eolnorm 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. eolnorm-0.2.0/.gitattributes +1 -0
  2. eolnorm-0.2.0/.github/FUNDING.yml +1 -0
  3. eolnorm-0.2.0/.gitignore +16 -0
  4. eolnorm-0.2.0/CHANGELOG.md +37 -0
  5. eolnorm-0.2.0/CONTRIBUTING.md +19 -0
  6. eolnorm-0.2.0/LICENSE +21 -0
  7. eolnorm-0.2.0/PKG-INFO +215 -0
  8. eolnorm-0.2.0/README.md +178 -0
  9. eolnorm-0.2.0/pyproject.toml +67 -0
  10. eolnorm-0.2.0/requirements-dev.txt +3 -0
  11. eolnorm-0.2.0/scripts/check.ps1 +16 -0
  12. eolnorm-0.2.0/scripts/fetch_vectors.py +35 -0
  13. eolnorm-0.2.0/scripts/release.ps1 +28 -0
  14. eolnorm-0.2.0/src/eolnorm/__init__.py +26 -0
  15. eolnorm-0.2.0/src/eolnorm/__main__.py +7 -0
  16. eolnorm-0.2.0/src/eolnorm/_atomic.py +58 -0
  17. eolnorm-0.2.0/src/eolnorm/_binary.py +45 -0
  18. eolnorm-0.2.0/src/eolnorm/_cli.py +107 -0
  19. eolnorm-0.2.0/src/eolnorm/_content.py +90 -0
  20. eolnorm-0.2.0/src/eolnorm/_eol.py +83 -0
  21. eolnorm-0.2.0/src/eolnorm/_files.py +87 -0
  22. eolnorm-0.2.0/src/eolnorm/_kinds.py +71 -0
  23. eolnorm-0.2.0/src/eolnorm/_stream.py +61 -0
  24. eolnorm-0.2.0/src/eolnorm/py.typed +0 -0
  25. eolnorm-0.2.0/tests/test_atomic.py +112 -0
  26. eolnorm-0.2.0/tests/test_binary.py +59 -0
  27. eolnorm-0.2.0/tests/test_boundaries.py +82 -0
  28. eolnorm-0.2.0/tests/test_cli.py +177 -0
  29. eolnorm-0.2.0/tests/test_contract.py +39 -0
  30. eolnorm-0.2.0/tests/test_docs.py +95 -0
  31. eolnorm-0.2.0/tests/test_e2e_wheel.py +89 -0
  32. eolnorm-0.2.0/tests/test_eol.py +153 -0
  33. eolnorm-0.2.0/tests/test_files.py +192 -0
  34. eolnorm-0.2.0/tests/test_git.py +107 -0
  35. eolnorm-0.2.0/tests/test_stream.py +108 -0
  36. eolnorm-0.2.0/tests/test_typing.py +87 -0
  37. eolnorm-0.2.0/tests/vectors/unicode/MANIFEST.txt +8 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1 @@
1
+ buy_me_a_coffee: jlianesglrs
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Third-party data, fetched by scripts/fetch_vectors.py (see its MANIFEST.txt)
15
+ tests/vectors/unicode/*
16
+ !tests/vectors/unicode/MANIFEST.txt
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0]
10
+
11
+ ### Added
12
+
13
+ - `breaks=` on every function: `"unicode"` adds the UAX #14 mandatory breaks (VT, FF, NEL,
14
+ LS, PS) and `"splitlines"` adds the three information separators, matching
15
+ `str.splitlines`. `Kind` and `Breaks` name the endings and the sets.
16
+ - `counts`, `detect` and `normalize` accept `bytes` (ASCII set) and return the same type.
17
+ - `keep_lone_cr=True` leaves a CR that is not followed by LF alone, as git does for paths
18
+ with the `text` attribute.
19
+ - `Normalizer` for streams: a CRLF split across chunks stays one ending; running counts.
20
+ - `is_binary`, using git's text=auto statistics.
21
+ - `scan_file` and `normalize_file` with `FileReport`: byte order marks (UTF-8, UTF-16,
22
+ UTF-32) are detected and kept, an explicit `encoding` covers other codecs, binary files
23
+ are skipped, and the rewrite is atomic (temporary file, fsync, mode kept, one rename,
24
+ symlinks followed, read-only files refused). `dry_run=True` only reports.
25
+ - The `eolnorm` command (also `python -m eolnorm`) with `--to`, `--check`, `--keep-lone-cr`,
26
+ `--breaks`, `--encoding` and `-` for standard input, which follows the same byte order
27
+ mark and binary rules as files.
28
+
29
+ ### Changed
30
+
31
+ - `Detected` also covers the new kinds. `to` and `breaks` values are checked up front with
32
+ a clear `ValueError`, and a value that is neither `str` nor `bytes` raises `TypeError`.
33
+
34
+ ## [0.1.0]
35
+
36
+ - First release: `detect`, `normalize` and `counts` for line endings in text, with CRLF
37
+ treated as a single unit.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/eolnorm/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ Open an issue first if the change is large or shifts the public API.
eolnorm-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
eolnorm-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.5
2
+ Name: eolnorm
3
+ Version: 0.2.0
4
+ Summary: Detect, normalize and count line endings in text, bytes, streams and files: git-exact, Unicode-aware, atomic rewrites, zero dependencies
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/eolnorm
7
+ Project-URL: Issues, https://github.com/JaviLianes8/eolnorm/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/eolnorm/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: crlf,eol,gitattributes,line-endings,newline,normalize,unicode
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Software Development :: Libraries
26
+ Classifier: Topic :: Text Processing
27
+ Classifier: Topic :: Utilities
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.10
30
+ Provides-Extra: dev
31
+ Requires-Dist: build; extra == 'dev'
32
+ Requires-Dist: mypy; extra == 'dev'
33
+ Requires-Dist: pytest; extra == 'dev'
34
+ Requires-Dist: ruff; extra == 'dev'
35
+ Requires-Dist: twine; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # eolnorm
39
+
40
+ Detect, normalize and count line endings in text, bytes, streams and files. It can match
41
+ git's `text` conversion byte for byte, knows the Unicode line boundaries, rewrites files
42
+ atomically, and has no dependencies.
43
+
44
+ ## Install
45
+
46
+ ```
47
+ pip install eolnorm
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ```python
53
+ from eolnorm import counts, detect, normalize
54
+
55
+ print(detect("a\r\nb\r\n"))
56
+ # crlf
57
+ print(detect("a\nb\r\n"))
58
+ # mixed
59
+ print(repr(normalize("a\r\nb\rc\n")))
60
+ # 'a\nb\nc\n'
61
+ print(counts("a\r\nb\r\nc\n"))
62
+ # {'lf': 1, 'crlf': 2, 'cr': 0}
63
+ ```
64
+
65
+ `detect` returns a `Detected` value: the single kind present, `"mixed"` or `"none"`.
66
+ `normalize` converts to a `Style` (`"lf"`, the default, `"crlf"` or `"cr"`). CRLF is always
67
+ one ending, never a CR plus an LF.
68
+
69
+ ### Bytes, and what git stores
70
+
71
+ ```python
72
+ from eolnorm import normalize
73
+
74
+ print(normalize(b"caf\xe9\r\nold\rmac\n"))
75
+ # b'caf\xe9\nold\nmac\n'
76
+ print(normalize(b"a\rb\r\n", keep_lone_cr=True))
77
+ # b'a\rb\n'
78
+ ```
79
+
80
+ `bytes` in, `bytes` out: CR and LF are the same bytes in every ASCII-compatible encoding
81
+ (UTF-8, latin-1, cp1252 and so on), so nothing is decoded and every other byte is kept.
82
+
83
+ `keep_lone_cr=True` leaves a CR that is not followed by LF alone. That is exactly what git
84
+ does for a path with the `text` attribute: `normalize(data, "lf", keep_lone_cr=True)` is
85
+ the blob `git add` stores under `text eol=lf`, and `normalize(blob, "crlf",
86
+ keep_lone_cr=True)` is the file `git checkout` writes under `eol=crlf`. The test suite runs
87
+ the installed git on hundreds of samples to prove it.
88
+
89
+ ### Unicode line boundaries
90
+
91
+ ```python
92
+ from eolnorm import counts, detect, normalize
93
+
94
+ text = "page one\x0cpage two\x85end"
95
+ print(detect(text))
96
+ # none
97
+ print(detect(text, breaks="unicode"))
98
+ # mixed
99
+ print(repr(normalize(text, "lf", breaks="unicode")))
100
+ # 'page one\npage two\nend'
101
+ print(counts("a\x1eb", breaks="splitlines")["rs"])
102
+ # 1
103
+ ```
104
+
105
+ `breaks` picks the boundary set (a `Breaks` value). Each recognised ending has a `Kind` name.
106
+
107
+ | `breaks` | Endings | Same as |
108
+ | --- | --- | --- |
109
+ | `"ascii"` (default) | `lf`, `crlf`, `cr` | universal newlines |
110
+ | `"unicode"` | adds `vt`, `ff`, `nel` (U+0085), `ls` (U+2028), `ps` (U+2029) | UAX #14 mandatory breaks (classes BK, CR, LF, NL) |
111
+ | `"splitlines"` | adds `fs`, `gs`, `rs` (U+001C to U+001E) | `str.splitlines` |
112
+
113
+ `counts` always returns every kind of the chosen set, in that order. The two Unicode sets
114
+ need decoded text: passing `bytes` with them raises `ValueError`. Note that they convert form
115
+ feeds and vertical tabs too, so page breaks in `^L`-paginated sources become newlines; keep
116
+ the default set for such files.
117
+
118
+ ### Streams
119
+
120
+ ```python
121
+ from eolnorm import Normalizer
122
+
123
+ stream = Normalizer("lf")
124
+ out = stream.feed(b"one\r") + stream.feed(b"\ntwo\r") + stream.feed(b"", final=True)
125
+ print(out)
126
+ # b'one\ntwo\n'
127
+ print(stream.counts, stream.detected)
128
+ # {'lf': 0, 'crlf': 1, 'cr': 1} mixed
129
+ ```
130
+
131
+ `Normalizer` takes the same options as `normalize`. A chunk ending in CR is held back until
132
+ the next chunk shows whether an LF follows, so a CRLF split across two reads is still one
133
+ ending. Pass `final=True` with the last chunk. All chunks of one stream are `str` or all are
134
+ `bytes`.
135
+
136
+ ### Files
137
+
138
+ ```python
139
+ import codecs
140
+ import tempfile
141
+ from pathlib import Path
142
+
143
+ from eolnorm import is_binary, normalize_file, scan_file
144
+
145
+ path = Path(tempfile.mkdtemp()) / "notes.txt"
146
+ path.write_bytes(codecs.BOM_UTF16_LE + "one\r\ntwo\r\n".encode("utf-16-le"))
147
+ report = normalize_file(path, "lf")
148
+ print(report.encoding, report.bom, report.detected, report.changed)
149
+ # utf-16-le True crlf True
150
+ print(scan_file(path).detected)
151
+ # lf
152
+ print(normalize_file(path, "crlf", dry_run=True).changed)
153
+ # True
154
+ print(is_binary(b"\x89PNG\r\n\x1a\n\x00"))
155
+ # True
156
+ ```
157
+
158
+ `normalize_file` and `scan_file` return a `FileReport` (`path`, `binary`, `encoding`, `bom`,
159
+ `counts`, `detected`, `changed`; the counts describe the file before any change).
160
+
161
+ - A UTF-8, UTF-16 or UTF-32 byte order mark selects the codec, and the same mark is written
162
+ back. Without a mark the bytes are handled undecoded (ASCII set) or as UTF-8 (Unicode
163
+ sets). `encoding=` names a codec instead, for example `"utf-16-le"` for a file without a
164
+ mark. A named UTF codec still honours its own marks; generic `"utf-16"` and `"utf-32"`
165
+ without a mark are refused because the byte order is unknown. Any other codec (say
166
+ `"cp1252"`) decodes every byte as text, even bytes that look like a mark.
167
+ - Binary files are reported and never changed. `is_binary` uses git's text=auto
168
+ statistics: a NUL byte, or fewer than 128 printable bytes per control byte, means binary.
169
+ Git also treats a lone CR as binary; `is_binary` does not, so old Mac files can be fixed.
170
+ The check also runs with an explicit ASCII-compatible `encoding`; it is skipped only for
171
+ codecs such as UTF-16, whose text is full of NUL bytes.
172
+ - The rewrite is atomic: the new bytes go to a temporary file in the same folder, are
173
+ flushed to disk, get the original's permission bits, and replace the original in one
174
+ rename. A symlink is followed and stays a symlink. A file whose bytes would not change
175
+ is not touched, and `dry_run=True` only reports. A read-only file is refused with
176
+ `PermissionError` on every system. Being a new file, the result does not keep hard links,
177
+ the owner and group, or Windows attributes and access lists of the original.
178
+
179
+ ### Command line
180
+
181
+ ```
182
+ $ eolnorm notes.txt logo.png
183
+ notes.txt: crlf (crlf=2)
184
+ logo.png: binary
185
+ $ eolnorm --to lf --check notes.txt
186
+ would fix notes.txt
187
+ $ eolnorm --to lf notes.txt
188
+ fixed notes.txt
189
+ $ eolnorm --to crlf - < in.txt > out.txt
190
+ ```
191
+
192
+ Options: `--to {lf,crlf,cr}`, `--check` (with `--to`: write nothing, exit 1 if a file would
193
+ change, handy in a pre-commit hook), `--keep-lone-cr`, `--breaks {ascii,unicode,splitlines}`
194
+ and `--encoding`. `-` filters standard input to standard output with the same rules as for
195
+ files (a byte order mark is kept, binary input passes through unchanged). Exit code 2 means bad
196
+ usage or a file that could not be read or decoded; the other files are still processed.
197
+ `python -m eolnorm` works too.
198
+
199
+ ## Why
200
+
201
+ The standard library resolves line endings only while decoding a file in universal newline
202
+ mode. There is no helper to inspect, convert or count the endings of data already in
203
+ memory, to do it on bytes without decoding, to match git's rules, or to fix a file in
204
+ place safely. `eolify` is a compiled extension focused on fast conversion; `eolnorm` is pure
205
+ Python and covers detection, counting, Unicode boundaries, streams and files.
206
+
207
+ ## License
208
+
209
+ MIT. See [LICENSE](LICENSE).
210
+
211
+ ## Links
212
+
213
+ - Website: https://lianes8studio.com
214
+ - GitHub: https://github.com/JaviLianes8
215
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,178 @@
1
+ # eolnorm
2
+
3
+ Detect, normalize and count line endings in text, bytes, streams and files. It can match
4
+ git's `text` conversion byte for byte, knows the Unicode line boundaries, rewrites files
5
+ atomically, and has no dependencies.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pip install eolnorm
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from eolnorm import counts, detect, normalize
17
+
18
+ print(detect("a\r\nb\r\n"))
19
+ # crlf
20
+ print(detect("a\nb\r\n"))
21
+ # mixed
22
+ print(repr(normalize("a\r\nb\rc\n")))
23
+ # 'a\nb\nc\n'
24
+ print(counts("a\r\nb\r\nc\n"))
25
+ # {'lf': 1, 'crlf': 2, 'cr': 0}
26
+ ```
27
+
28
+ `detect` returns a `Detected` value: the single kind present, `"mixed"` or `"none"`.
29
+ `normalize` converts to a `Style` (`"lf"`, the default, `"crlf"` or `"cr"`). CRLF is always
30
+ one ending, never a CR plus an LF.
31
+
32
+ ### Bytes, and what git stores
33
+
34
+ ```python
35
+ from eolnorm import normalize
36
+
37
+ print(normalize(b"caf\xe9\r\nold\rmac\n"))
38
+ # b'caf\xe9\nold\nmac\n'
39
+ print(normalize(b"a\rb\r\n", keep_lone_cr=True))
40
+ # b'a\rb\n'
41
+ ```
42
+
43
+ `bytes` in, `bytes` out: CR and LF are the same bytes in every ASCII-compatible encoding
44
+ (UTF-8, latin-1, cp1252 and so on), so nothing is decoded and every other byte is kept.
45
+
46
+ `keep_lone_cr=True` leaves a CR that is not followed by LF alone. That is exactly what git
47
+ does for a path with the `text` attribute: `normalize(data, "lf", keep_lone_cr=True)` is
48
+ the blob `git add` stores under `text eol=lf`, and `normalize(blob, "crlf",
49
+ keep_lone_cr=True)` is the file `git checkout` writes under `eol=crlf`. The test suite runs
50
+ the installed git on hundreds of samples to prove it.
51
+
52
+ ### Unicode line boundaries
53
+
54
+ ```python
55
+ from eolnorm import counts, detect, normalize
56
+
57
+ text = "page one\x0cpage two\x85end"
58
+ print(detect(text))
59
+ # none
60
+ print(detect(text, breaks="unicode"))
61
+ # mixed
62
+ print(repr(normalize(text, "lf", breaks="unicode")))
63
+ # 'page one\npage two\nend'
64
+ print(counts("a\x1eb", breaks="splitlines")["rs"])
65
+ # 1
66
+ ```
67
+
68
+ `breaks` picks the boundary set (a `Breaks` value). Each recognised ending has a `Kind` name.
69
+
70
+ | `breaks` | Endings | Same as |
71
+ | --- | --- | --- |
72
+ | `"ascii"` (default) | `lf`, `crlf`, `cr` | universal newlines |
73
+ | `"unicode"` | adds `vt`, `ff`, `nel` (U+0085), `ls` (U+2028), `ps` (U+2029) | UAX #14 mandatory breaks (classes BK, CR, LF, NL) |
74
+ | `"splitlines"` | adds `fs`, `gs`, `rs` (U+001C to U+001E) | `str.splitlines` |
75
+
76
+ `counts` always returns every kind of the chosen set, in that order. The two Unicode sets
77
+ need decoded text: passing `bytes` with them raises `ValueError`. Note that they convert form
78
+ feeds and vertical tabs too, so page breaks in `^L`-paginated sources become newlines; keep
79
+ the default set for such files.
80
+
81
+ ### Streams
82
+
83
+ ```python
84
+ from eolnorm import Normalizer
85
+
86
+ stream = Normalizer("lf")
87
+ out = stream.feed(b"one\r") + stream.feed(b"\ntwo\r") + stream.feed(b"", final=True)
88
+ print(out)
89
+ # b'one\ntwo\n'
90
+ print(stream.counts, stream.detected)
91
+ # {'lf': 0, 'crlf': 1, 'cr': 1} mixed
92
+ ```
93
+
94
+ `Normalizer` takes the same options as `normalize`. A chunk ending in CR is held back until
95
+ the next chunk shows whether an LF follows, so a CRLF split across two reads is still one
96
+ ending. Pass `final=True` with the last chunk. All chunks of one stream are `str` or all are
97
+ `bytes`.
98
+
99
+ ### Files
100
+
101
+ ```python
102
+ import codecs
103
+ import tempfile
104
+ from pathlib import Path
105
+
106
+ from eolnorm import is_binary, normalize_file, scan_file
107
+
108
+ path = Path(tempfile.mkdtemp()) / "notes.txt"
109
+ path.write_bytes(codecs.BOM_UTF16_LE + "one\r\ntwo\r\n".encode("utf-16-le"))
110
+ report = normalize_file(path, "lf")
111
+ print(report.encoding, report.bom, report.detected, report.changed)
112
+ # utf-16-le True crlf True
113
+ print(scan_file(path).detected)
114
+ # lf
115
+ print(normalize_file(path, "crlf", dry_run=True).changed)
116
+ # True
117
+ print(is_binary(b"\x89PNG\r\n\x1a\n\x00"))
118
+ # True
119
+ ```
120
+
121
+ `normalize_file` and `scan_file` return a `FileReport` (`path`, `binary`, `encoding`, `bom`,
122
+ `counts`, `detected`, `changed`; the counts describe the file before any change).
123
+
124
+ - A UTF-8, UTF-16 or UTF-32 byte order mark selects the codec, and the same mark is written
125
+ back. Without a mark the bytes are handled undecoded (ASCII set) or as UTF-8 (Unicode
126
+ sets). `encoding=` names a codec instead, for example `"utf-16-le"` for a file without a
127
+ mark. A named UTF codec still honours its own marks; generic `"utf-16"` and `"utf-32"`
128
+ without a mark are refused because the byte order is unknown. Any other codec (say
129
+ `"cp1252"`) decodes every byte as text, even bytes that look like a mark.
130
+ - Binary files are reported and never changed. `is_binary` uses git's text=auto
131
+ statistics: a NUL byte, or fewer than 128 printable bytes per control byte, means binary.
132
+ Git also treats a lone CR as binary; `is_binary` does not, so old Mac files can be fixed.
133
+ The check also runs with an explicit ASCII-compatible `encoding`; it is skipped only for
134
+ codecs such as UTF-16, whose text is full of NUL bytes.
135
+ - The rewrite is atomic: the new bytes go to a temporary file in the same folder, are
136
+ flushed to disk, get the original's permission bits, and replace the original in one
137
+ rename. A symlink is followed and stays a symlink. A file whose bytes would not change
138
+ is not touched, and `dry_run=True` only reports. A read-only file is refused with
139
+ `PermissionError` on every system. Being a new file, the result does not keep hard links,
140
+ the owner and group, or Windows attributes and access lists of the original.
141
+
142
+ ### Command line
143
+
144
+ ```
145
+ $ eolnorm notes.txt logo.png
146
+ notes.txt: crlf (crlf=2)
147
+ logo.png: binary
148
+ $ eolnorm --to lf --check notes.txt
149
+ would fix notes.txt
150
+ $ eolnorm --to lf notes.txt
151
+ fixed notes.txt
152
+ $ eolnorm --to crlf - < in.txt > out.txt
153
+ ```
154
+
155
+ Options: `--to {lf,crlf,cr}`, `--check` (with `--to`: write nothing, exit 1 if a file would
156
+ change, handy in a pre-commit hook), `--keep-lone-cr`, `--breaks {ascii,unicode,splitlines}`
157
+ and `--encoding`. `-` filters standard input to standard output with the same rules as for
158
+ files (a byte order mark is kept, binary input passes through unchanged). Exit code 2 means bad
159
+ usage or a file that could not be read or decoded; the other files are still processed.
160
+ `python -m eolnorm` works too.
161
+
162
+ ## Why
163
+
164
+ The standard library resolves line endings only while decoding a file in universal newline
165
+ mode. There is no helper to inspect, convert or count the endings of data already in
166
+ memory, to do it on bytes without decoding, to match git's rules, or to fix a file in
167
+ place safely. `eolify` is a compiled extension focused on fast conversion; `eolnorm` is pure
168
+ Python and covers detection, counting, Unicode boundaries, streams and files.
169
+
170
+ ## License
171
+
172
+ MIT. See [LICENSE](LICENSE).
173
+
174
+ ## Links
175
+
176
+ - Website: https://lianes8studio.com
177
+ - GitHub: https://github.com/JaviLianes8
178
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "eolnorm"
7
+ version = "0.2.0"
8
+ description = "Detect, normalize and count line endings in text, bytes, streams and files: git-exact, Unicode-aware, atomic rewrites, zero dependencies"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["line-endings", "eol", "crlf", "newline", "normalize", "gitattributes", "unicode"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Software Development :: Libraries",
27
+ "Topic :: Text Processing",
28
+ "Topic :: Utilities",
29
+ "Typing :: Typed",
30
+ ]
31
+ dependencies = []
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
35
+
36
+ [project.scripts]
37
+ eolnorm = "eolnorm._cli:main"
38
+
39
+ [project.urls]
40
+ Homepage = "https://lianes8studio.com"
41
+ Repository = "https://github.com/JaviLianes8/eolnorm"
42
+ Issues = "https://github.com/JaviLianes8/eolnorm/issues"
43
+ Changelog = "https://github.com/JaviLianes8/eolnorm/blob/main/CHANGELOG.md"
44
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
45
+ Funding = "https://buymeacoffee.com/jlianesglrs"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/eolnorm"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ exclude = ["IMPROVEMENTS-round.md", "tests/vectors/unicode/*.txt", "!tests/vectors/unicode/MANIFEST.txt"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py310"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
59
+
60
+ [tool.mypy]
61
+ python_version = "3.10"
62
+ strict = true
63
+
64
+ [tool.pytest.ini_options]
65
+ testpaths = ["tests"]
66
+ pythonpath = ["src"]
67
+ addopts = "-q"
@@ -0,0 +1,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -0,0 +1,16 @@
1
+ # Local quality gate. Run before every commit. No CI runs in the cloud.
2
+ # $ErrorActionPreference does NOT stop on a native command's non-zero exit, so each tool's
3
+ # exit code is checked explicitly: a red gate must fail, never pass silently.
4
+ $ErrorActionPreference = "Stop"
5
+
6
+ function Invoke-Step($label, [scriptblock]$cmd) {
7
+ Write-Host "== $label =="
8
+ & $cmd
9
+ if ($LASTEXITCODE -ne 0) { Write-Host "FAILED: $label (exit $LASTEXITCODE)"; exit $LASTEXITCODE }
10
+ }
11
+
12
+ Invoke-Step "ruff format" { ruff format --check . }
13
+ Invoke-Step "ruff lint" { ruff check . }
14
+ Invoke-Step "mypy" { mypy src tests }
15
+ Invoke-Step "pytest" { pytest }
16
+ Write-Host "gate green"
@@ -0,0 +1,35 @@
1
+ """Fetch the Unicode data files listed in tests/vectors/unicode/MANIFEST.txt.
2
+
3
+ The files are third-party and are not committed. Each download is checked against the size
4
+ and SHA-256 in the manifest; a mismatch aborts without keeping the file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import sys
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ FOLDER = Path(__file__).resolve().parents[1] / "tests" / "vectors" / "unicode"
15
+
16
+
17
+ def main() -> int:
18
+ lines = (FOLDER / "MANIFEST.txt").read_text(encoding="utf-8").splitlines()
19
+ base = next(line for line in lines if line.startswith("# URL: "))[len("# URL: ") :]
20
+ for line in lines:
21
+ if not line or line.startswith("#"):
22
+ continue
23
+ digest, size, name = line.split()
24
+ with urllib.request.urlopen(base.replace("<name>", name), timeout=60) as response:
25
+ data = response.read()
26
+ if len(data) != int(size) or hashlib.sha256(data).hexdigest() != digest:
27
+ print(f"{name}: size or sha256 does not match the manifest", file=sys.stderr)
28
+ return 1
29
+ (FOLDER / name).write_bytes(data)
30
+ print(f"{name}: ok")
31
+ return 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
@@ -0,0 +1,28 @@
1
+ # Build and upload to PyPI. Run scripts/check.ps1 first.
2
+ # The name must not already exist on PyPI; the token is read from a secret store, never git.
3
+ $ErrorActionPreference = "Stop"
4
+
5
+ # Works on both Windows PowerShell 5.1 and pwsh 7 (generic catch, no PS7-only types).
6
+ $taken = $true
7
+ try {
8
+ Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/eolnorm/json" | Out-Null
9
+ } catch {
10
+ if ($_.Exception.Response.StatusCode.value__ -eq 404) { $taken = $false } else { throw }
11
+ }
12
+ if ($taken) { throw "The name 'eolnorm' already exists on PyPI. Choose another before publishing." }
13
+
14
+ $token = $env:PYPI_TOKEN
15
+ if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
16
+ $token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
17
+ }
18
+ if (-not $token) {
19
+ throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
20
+ }
21
+
22
+ Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
23
+ python -m build
24
+ python -m twine check dist/*
25
+
26
+ $env:TWINE_USERNAME = "__token__"
27
+ $env:TWINE_PASSWORD = $token
28
+ try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }
@@ -0,0 +1,26 @@
1
+ """Detect, normalize and count line endings in text, bytes, streams and files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._binary import is_binary
6
+ from ._eol import counts, detect, normalize
7
+ from ._files import FileReport, normalize_file, scan_file
8
+ from ._kinds import Breaks, Detected, Kind, Style
9
+ from ._stream import Normalizer
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ __all__ = [
14
+ "Breaks",
15
+ "Detected",
16
+ "FileReport",
17
+ "Kind",
18
+ "Normalizer",
19
+ "Style",
20
+ "counts",
21
+ "detect",
22
+ "is_binary",
23
+ "normalize",
24
+ "normalize_file",
25
+ "scan_file",
26
+ ]