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.
- eolnorm-0.2.0/.gitattributes +1 -0
- eolnorm-0.2.0/.github/FUNDING.yml +1 -0
- eolnorm-0.2.0/.gitignore +16 -0
- eolnorm-0.2.0/CHANGELOG.md +37 -0
- eolnorm-0.2.0/CONTRIBUTING.md +19 -0
- eolnorm-0.2.0/LICENSE +21 -0
- eolnorm-0.2.0/PKG-INFO +215 -0
- eolnorm-0.2.0/README.md +178 -0
- eolnorm-0.2.0/pyproject.toml +67 -0
- eolnorm-0.2.0/requirements-dev.txt +3 -0
- eolnorm-0.2.0/scripts/check.ps1 +16 -0
- eolnorm-0.2.0/scripts/fetch_vectors.py +35 -0
- eolnorm-0.2.0/scripts/release.ps1 +28 -0
- eolnorm-0.2.0/src/eolnorm/__init__.py +26 -0
- eolnorm-0.2.0/src/eolnorm/__main__.py +7 -0
- eolnorm-0.2.0/src/eolnorm/_atomic.py +58 -0
- eolnorm-0.2.0/src/eolnorm/_binary.py +45 -0
- eolnorm-0.2.0/src/eolnorm/_cli.py +107 -0
- eolnorm-0.2.0/src/eolnorm/_content.py +90 -0
- eolnorm-0.2.0/src/eolnorm/_eol.py +83 -0
- eolnorm-0.2.0/src/eolnorm/_files.py +87 -0
- eolnorm-0.2.0/src/eolnorm/_kinds.py +71 -0
- eolnorm-0.2.0/src/eolnorm/_stream.py +61 -0
- eolnorm-0.2.0/src/eolnorm/py.typed +0 -0
- eolnorm-0.2.0/tests/test_atomic.py +112 -0
- eolnorm-0.2.0/tests/test_binary.py +59 -0
- eolnorm-0.2.0/tests/test_boundaries.py +82 -0
- eolnorm-0.2.0/tests/test_cli.py +177 -0
- eolnorm-0.2.0/tests/test_contract.py +39 -0
- eolnorm-0.2.0/tests/test_docs.py +95 -0
- eolnorm-0.2.0/tests/test_e2e_wheel.py +89 -0
- eolnorm-0.2.0/tests/test_eol.py +153 -0
- eolnorm-0.2.0/tests/test_files.py +192 -0
- eolnorm-0.2.0/tests/test_git.py +107 -0
- eolnorm-0.2.0/tests/test_stream.py +108 -0
- eolnorm-0.2.0/tests/test_typing.py +87 -0
- 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
|
eolnorm-0.2.0/.gitignore
ADDED
|
@@ -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/
|
eolnorm-0.2.0/README.md
ADDED
|
@@ -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,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
|
+
]
|