eolnorm 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
eolnorm/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run the command line tool with ``python -m eolnorm``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._cli import main
6
+
7
+ raise SystemExit(main())
eolnorm/_atomic.py ADDED
@@ -0,0 +1,58 @@
1
+ """Replace a file's contents atomically: readers see the old bytes or the new, never half."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import errno
7
+ import os
8
+ import shutil
9
+ import stat
10
+ import tempfile
11
+
12
+
13
+ def write_atomic(path: str, data: bytes) -> None:
14
+ # Rewrite the target of a symlink, not the link itself, and stay on its filesystem so
15
+ # the final rename cannot fall back to a copy.
16
+ real = os.path.realpath(path)
17
+ folder = os.path.dirname(real)
18
+ # Windows refuses to replace a read-only file while POSIX would replace it silently;
19
+ # refuse everywhere so the outcome does not depend on the system.
20
+ if not os.access(real, os.W_OK):
21
+ raise PermissionError(errno.EACCES, "file is read-only", path)
22
+ fd, temporary = tempfile.mkstemp(dir=folder, prefix=".eolnorm-", suffix=".tmp")
23
+ try:
24
+ with os.fdopen(fd, "wb") as handle:
25
+ handle.write(data)
26
+ handle.flush()
27
+ os.fsync(handle.fileno())
28
+ shutil.copymode(real, temporary)
29
+ os.replace(temporary, real)
30
+ except BaseException:
31
+ _discard(temporary)
32
+ raise
33
+ _sync_folder(folder)
34
+
35
+
36
+ def _discard(temporary: str) -> None:
37
+ # copymode may have made the temporary file read-only, which blocks deleting it on
38
+ # Windows.
39
+ with contextlib.suppress(OSError):
40
+ os.chmod(temporary, stat.S_IWRITE | stat.S_IREAD)
41
+ with contextlib.suppress(OSError):
42
+ os.unlink(temporary)
43
+
44
+
45
+ def _sync_folder(folder: str) -> None:
46
+ # On POSIX the rename is durable only once the directory entry is flushed too.
47
+ if not hasattr(os, "O_DIRECTORY"):
48
+ return
49
+ try:
50
+ descriptor = os.open(folder, os.O_RDONLY | os.O_DIRECTORY)
51
+ except OSError:
52
+ return
53
+ try:
54
+ os.fsync(descriptor)
55
+ except OSError:
56
+ pass
57
+ finally:
58
+ os.close(descriptor)
eolnorm/_binary.py ADDED
@@ -0,0 +1,45 @@
1
+ """Tell text from binary the way git does, and recognise byte order marks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+
7
+ # Git counts DEL and the control bytes as non-printable, except BS, HT, ESC and FF (and
8
+ # CR and LF, which are neutral). Bytes from 0x80 up count as printable.
9
+ _NONPRINTABLE = bytes(b for b in range(0x20) if b not in b"\b\t\n\r\x1b\x0c") + b"\x7f"
10
+
11
+ # Longest first: the UTF-32 LE mark starts with the UTF-16 LE one.
12
+ BOMS: tuple[tuple[bytes, str], ...] = (
13
+ (codecs.BOM_UTF32_LE, "utf-32-le"),
14
+ (codecs.BOM_UTF32_BE, "utf-32-be"),
15
+ (codecs.BOM_UTF8, "utf-8"),
16
+ (codecs.BOM_UTF16_LE, "utf-16-le"),
17
+ (codecs.BOM_UTF16_BE, "utf-16-be"),
18
+ )
19
+
20
+
21
+ def is_binary(data: bytes) -> bool:
22
+ """Apply git's text=auto statistics: True when the bytes look binary.
23
+
24
+ A NUL byte, or fewer than 128 printable bytes for each non-printable one, means
25
+ binary. CR and LF are neutral, BS, HT, ESC and FF are printable, DEL and the other
26
+ control bytes are not, and a Ctrl-Z as the very last byte is not held against the
27
+ data. Unlike git, a lone CR alone does not make the data binary.
28
+ """
29
+ if not isinstance(data, bytes):
30
+ raise TypeError(f"expected bytes, got {type(data).__name__}")
31
+ if b"\x00" in data:
32
+ return True
33
+ nonprintable = len(data) - len(data.translate(None, _NONPRINTABLE))
34
+ printable = len(data) - nonprintable - data.count(b"\n") - data.count(b"\r")
35
+ if data.endswith(b"\x1a"):
36
+ nonprintable -= 1
37
+ return (printable >> 7) < nonprintable
38
+
39
+
40
+ def sniff_bom(data: bytes) -> tuple[bytes, str] | None:
41
+ """Return the byte order mark at the start of ``data`` and its codec, if any."""
42
+ for bom, codec in BOMS:
43
+ if data.startswith(bom):
44
+ return bom, codec
45
+ return None
eolnorm/_cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """Command line tool: report line endings, check them, or fix them in place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from ._content import decode, fix
10
+ from ._files import FileReport, normalize_file, scan_file
11
+
12
+
13
+ def _parser() -> argparse.ArgumentParser:
14
+ from . import __version__
15
+
16
+ parser = argparse.ArgumentParser(
17
+ prog="eolnorm",
18
+ description="Report line endings, or convert them with --to. "
19
+ "Use - to filter standard input to standard output.",
20
+ )
21
+ parser.add_argument("paths", nargs="+", metavar="PATH")
22
+ parser.add_argument("--to", choices=["lf", "crlf", "cr"], help="convert to this style")
23
+ parser.add_argument(
24
+ "--check", action="store_true", help="with --to, change nothing; exit 1 if a file would"
25
+ )
26
+ parser.add_argument(
27
+ "--keep-lone-cr", action="store_true", help="leave a CR not followed by LF alone"
28
+ )
29
+ parser.add_argument("--breaks", choices=["ascii", "unicode", "splitlines"], default="ascii")
30
+ parser.add_argument(
31
+ "--encoding", help="decode with this codec (binary files are still skipped)"
32
+ )
33
+ parser.add_argument("--version", action="version", version=f"eolnorm {__version__}")
34
+ return parser
35
+
36
+
37
+ def _describe(report: FileReport) -> str:
38
+ if report.binary:
39
+ return f"{report.path}: binary"
40
+ present = ", ".join(f"{name}={count}" for name, count in report.counts.items() if count)
41
+ return f"{report.path}: {report.detected}" + (f" ({present})" if present else "")
42
+
43
+
44
+ def _filter(args: argparse.Namespace) -> None:
45
+ # The same rules as for files: a byte order mark picks and keeps the codec, binary
46
+ # input passes through untouched.
47
+ content = decode(sys.stdin.buffer.read(), args.breaks, args.encoding)
48
+ if content.body is None:
49
+ print("eolnorm: -: binary, passed through unchanged", file=sys.stderr)
50
+ sys.stdout.buffer.write(fix(content, args.to, args.breaks, args.keep_lone_cr))
51
+ sys.stdout.flush()
52
+
53
+
54
+ def _readable(stream: object) -> None:
55
+ # A path the console cannot encode must not turn a finished fix into a reported failure.
56
+ reconfigure = getattr(stream, "reconfigure", None)
57
+ if reconfigure is not None:
58
+ reconfigure(errors="backslashreplace")
59
+
60
+
61
+ def _one(path: str, args: argparse.Namespace) -> bool:
62
+ """Handle one path; return True when it was or would be changed."""
63
+ if args.to is None:
64
+ print(_describe(scan_file(path, breaks=args.breaks, encoding=args.encoding)))
65
+ return False
66
+ report = normalize_file(
67
+ path,
68
+ args.to,
69
+ breaks=args.breaks,
70
+ keep_lone_cr=args.keep_lone_cr,
71
+ encoding=args.encoding,
72
+ dry_run=args.check,
73
+ )
74
+ if report.binary:
75
+ print(f"skipped {path}: binary")
76
+ elif report.changed:
77
+ print(f"{'would fix' if args.check else 'fixed'} {path}")
78
+ return report.changed
79
+
80
+
81
+ def main(argv: Sequence[str] | None = None) -> int:
82
+ """Run the tool. Exit 0 on success, 1 when --check finds work, 2 on bad input."""
83
+ args = _parser().parse_args(argv)
84
+ for flag, used in (("--check", args.check), ("--keep-lone-cr", args.keep_lone_cr)):
85
+ if used and args.to is None:
86
+ _parser().error(f"{flag} needs --to")
87
+ _readable(sys.stdout)
88
+ _readable(sys.stderr)
89
+ if "-" in args.paths:
90
+ if args.to is None or args.check or len(args.paths) > 1:
91
+ _parser().error("- filters standard input and needs --to, alone, without --check")
92
+ try:
93
+ _filter(args)
94
+ except ValueError as error:
95
+ print(f"eolnorm: -: {error}", file=sys.stderr)
96
+ return 2
97
+ return 0
98
+ failed = pending = False
99
+ for path in args.paths:
100
+ try:
101
+ pending |= _one(path, args)
102
+ except (OSError, UnicodeError, ValueError) as error:
103
+ print(f"eolnorm: {path}: {error}", file=sys.stderr)
104
+ failed = True
105
+ if failed:
106
+ return 2
107
+ return 1 if pending and args.check else 0
eolnorm/_content.py ADDED
@@ -0,0 +1,90 @@
1
+ """Turn raw bytes into something to normalize (and back), honouring marks and codecs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ from dataclasses import dataclass
7
+
8
+ from ._binary import BOMS, is_binary, sniff_bom
9
+ from ._eol import normalize
10
+ from ._kinds import Breaks, Style
11
+
12
+ # Codec names (as codecs.lookup spells them) that agree with each byte order mark.
13
+ _AGREES = {
14
+ "utf-8": {"utf-8", "utf-8-sig"},
15
+ "utf-16-le": {"utf-16", "utf-16-le"},
16
+ "utf-16-be": {"utf-16", "utf-16-be"},
17
+ "utf-32-le": {"utf-32", "utf-32-le"},
18
+ "utf-32-be": {"utf-32", "utf-32-be"},
19
+ }
20
+ _SAMPLE = "\r\n a"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Content:
25
+ raw: bytes
26
+ prefix: bytes
27
+ body: str | bytes | None # None: binary, left alone
28
+ codec: str | None
29
+
30
+
31
+ def _codec(encoding: object) -> str:
32
+ if not isinstance(encoding, str):
33
+ raise TypeError(f"encoding must be a str or None, got {type(encoding).__name__}")
34
+ try:
35
+ return codecs.lookup(encoding).name
36
+ except LookupError:
37
+ raise ValueError(f"unknown encoding: {encoding!r}") from None
38
+
39
+
40
+ def _mark(raw: bytes, wanted: str | None) -> tuple[bytes, str] | None:
41
+ if wanted is None:
42
+ return sniff_bom(raw)
43
+ # A named codec only looks for its own marks: "utf-16" data starting with U+0000 is not
44
+ # UTF-32, and a cp1252 file may start with the bytes of a mark.
45
+ for bom, codec in BOMS:
46
+ if wanted in _AGREES[codec] and raw.startswith(bom):
47
+ return bom, codec
48
+ return None
49
+
50
+
51
+ def _ascii_compatible(codec: str) -> bool:
52
+ try:
53
+ return _SAMPLE.encode(codec) == _SAMPLE.encode("ascii")
54
+ except (UnicodeError, LookupError, TypeError):
55
+ return False
56
+
57
+
58
+ def decode(raw: bytes, breaks: Breaks, encoding: str | None) -> Content:
59
+ """Split ``raw`` into a kept prefix (a byte order mark) and a body to normalize."""
60
+ wanted = None if encoding is None else _codec(encoding)
61
+ found = _mark(raw, wanted)
62
+ if found is not None:
63
+ bom, codec = found
64
+ if codec == "utf-8" and breaks == "ascii":
65
+ return Content(raw, bom, raw[len(bom) :], codec)
66
+ return Content(raw, bom, raw[len(bom) :].decode(codec), codec)
67
+ if wanted in ("utf-16", "utf-32"):
68
+ raise ValueError(f"no byte order mark: name the byte order, {wanted}-le or {wanted}-be")
69
+ if wanted is not None:
70
+ # Without a mark utf-8-sig decodes like utf-8, but encoding with it would add one.
71
+ codec = "utf-8" if wanted == "utf-8-sig" else wanted
72
+ if _ascii_compatible(codec) and is_binary(raw):
73
+ return Content(raw, b"", None, None)
74
+ return Content(raw, b"", raw.decode(codec), codec)
75
+ if is_binary(raw):
76
+ return Content(raw, b"", None, None)
77
+ if breaks == "ascii":
78
+ return Content(raw, b"", raw, None)
79
+ return Content(raw, b"", raw.decode("utf-8"), "utf-8")
80
+
81
+
82
+ def fix(content: Content, to: Style, breaks: Breaks, keep_lone_cr: bool) -> bytes:
83
+ """The normalized bytes, with the same prefix and codec; binary comes back unchanged."""
84
+ body = content.body
85
+ if body is None:
86
+ return content.raw
87
+ if isinstance(body, bytes):
88
+ return content.prefix + normalize(body, to, keep_lone_cr=keep_lone_cr)
89
+ fixed = normalize(body, to, breaks=breaks, keep_lone_cr=keep_lone_cr)
90
+ return content.prefix + fixed.encode(str(content.codec))
eolnorm/_eol.py ADDED
@@ -0,0 +1,83 @@
1
+ """Detect, normalize and count line endings in text or bytes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import AnyStr, cast
6
+
7
+ from ._kinds import (
8
+ BYTES_PATTERN,
9
+ Breaks,
10
+ Detected,
11
+ Style,
12
+ check_breaks,
13
+ check_style,
14
+ kinds,
15
+ name_of,
16
+ sequence,
17
+ text_pattern,
18
+ )
19
+
20
+
21
+ def _check(text: object, breaks: object) -> Breaks:
22
+ checked = check_breaks(breaks)
23
+ if isinstance(text, bytes):
24
+ if checked != "ascii":
25
+ raise ValueError(f"breaks={checked!r} needs decoded text, not bytes")
26
+ elif not isinstance(text, str):
27
+ raise TypeError(f"expected str or bytes, got {type(text).__name__}")
28
+ return checked
29
+
30
+
31
+ def counts(text: str | bytes, *, breaks: Breaks = "ascii") -> dict[str, int]:
32
+ """Count each line ending, treating CRLF as one unit rather than CR plus LF.
33
+
34
+ The keys are the kinds of the chosen boundary set, always all present, in a fixed
35
+ order: ``lf``, ``crlf``, ``cr``, then any others the set adds.
36
+ """
37
+ breaks = _check(text, breaks)
38
+ tally: dict[str, int] = dict.fromkeys(kinds(breaks), 0)
39
+ if isinstance(text, bytes):
40
+ for found in BYTES_PATTERN.finditer(text):
41
+ tally[name_of(found.group().decode("ascii"))] += 1
42
+ else:
43
+ for match in text_pattern(breaks).finditer(text):
44
+ tally[name_of(match.group())] += 1
45
+ return tally
46
+
47
+
48
+ def detected_from(tally: dict[str, int]) -> Detected:
49
+ present = [name for name, count in tally.items() if count]
50
+ if not present:
51
+ return "none"
52
+ if len(present) == 1:
53
+ return cast(Detected, present[0])
54
+ return "mixed"
55
+
56
+
57
+ def detect(text: str | bytes, *, breaks: Breaks = "ascii") -> Detected:
58
+ """Report the line-ending style: the single kind present, "mixed", or "none"."""
59
+ return detected_from(counts(text, breaks=breaks))
60
+
61
+
62
+ def normalize(
63
+ text: AnyStr, to: Style = "lf", *, breaks: Breaks = "ascii", keep_lone_cr: bool = False
64
+ ) -> AnyStr:
65
+ """Convert every line ending of the boundary set to the chosen style.
66
+
67
+ A lone CR is converted too unless ``keep_lone_cr`` is true, which matches what git
68
+ does for a path with the ``text`` attribute: only a CR that precedes an LF is part of
69
+ an ending. ``bytes`` in, ``bytes`` out.
70
+ """
71
+ breaks = _check(text, breaks)
72
+ target = sequence(check_style(to))
73
+ if not isinstance(keep_lone_cr, bool):
74
+ raise TypeError("keep_lone_cr must be a bool")
75
+ if isinstance(text, bytes):
76
+ raw = target.encode("ascii")
77
+ if keep_lone_cr:
78
+ return BYTES_PATTERN.sub(lambda m: m.group() if m.group() == b"\r" else raw, text)
79
+ return BYTES_PATTERN.sub(raw, text)
80
+ pattern = text_pattern(breaks)
81
+ if keep_lone_cr:
82
+ return pattern.sub(lambda m: m.group() if m.group() == "\r" else target, text)
83
+ return pattern.sub(target, text)
eolnorm/_files.py ADDED
@@ -0,0 +1,87 @@
1
+ """Inspect and normalize files: binary detection, byte order marks, atomic rewrite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Union
9
+
10
+ from ._atomic import write_atomic
11
+ from ._content import Content, decode, fix
12
+ from ._eol import counts, detected_from
13
+ from ._kinds import Breaks, Detected, Style, check_breaks, check_style, kinds
14
+
15
+ StrPath = Union[str, "os.PathLike[str]"]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class FileReport:
20
+ """What a file holds and whether normalizing it changed it (or would, in a dry run).
21
+
22
+ ``encoding`` names the codec in play: the one a byte order mark or the caller chose, or
23
+ utf-8 for the Unicode sets. It is None when no codec was involved, which is exact for
24
+ any ASCII-compatible encoding. For a binary file nothing is counted or changed.
25
+ """
26
+
27
+ path: str
28
+ binary: bool
29
+ encoding: str | None
30
+ bom: bool
31
+ counts: dict[str, int] = field(hash=False)
32
+ detected: Detected
33
+ changed: bool = False
34
+
35
+
36
+ def _load(path: StrPath, breaks: Breaks, encoding: str | None) -> Content:
37
+ return decode(Path(path).read_bytes(), breaks, encoding)
38
+
39
+
40
+ def _report(path: StrPath, content: Content, breaks: Breaks, changed: bool) -> FileReport:
41
+ tally: dict[str, int] = dict.fromkeys(kinds(breaks), 0)
42
+ if content.body is not None:
43
+ tally = counts(content.body, breaks=breaks)
44
+ return FileReport(
45
+ path=os.fspath(path),
46
+ binary=content.body is None,
47
+ encoding=content.codec,
48
+ bom=bool(content.prefix),
49
+ counts=tally,
50
+ detected=detected_from(tally),
51
+ changed=changed,
52
+ )
53
+
54
+
55
+ def scan_file(
56
+ path: StrPath, *, breaks: Breaks = "ascii", encoding: str | None = None
57
+ ) -> FileReport:
58
+ """Read a file and report its line endings without writing anything."""
59
+ breaks = check_breaks(breaks)
60
+ return _report(path, _load(path, breaks, encoding), breaks, changed=False)
61
+
62
+
63
+ def normalize_file(
64
+ path: StrPath,
65
+ to: Style = "lf",
66
+ *,
67
+ breaks: Breaks = "ascii",
68
+ keep_lone_cr: bool = False,
69
+ encoding: str | None = None,
70
+ dry_run: bool = False,
71
+ ) -> FileReport:
72
+ """Normalize a file in place, atomically, keeping its byte order mark and encoding.
73
+
74
+ A binary file is left alone. A file whose bytes would not change is not rewritten.
75
+ With ``dry_run`` the report says whether it would change and nothing is written. The
76
+ counts describe the file as it was before.
77
+ """
78
+ breaks = check_breaks(breaks)
79
+ to = check_style(to)
80
+ if not isinstance(keep_lone_cr, bool):
81
+ raise TypeError("keep_lone_cr must be a bool")
82
+ content = _load(path, breaks, encoding)
83
+ output = fix(content, to, breaks, keep_lone_cr)
84
+ changed = output != content.raw
85
+ if changed and not dry_run:
86
+ write_atomic(os.fspath(path), output)
87
+ return _report(path, content, breaks, changed=changed)
eolnorm/_kinds.py ADDED
@@ -0,0 +1,71 @@
1
+ """Line-ending kinds, the boundary sets that group them and the patterns that find them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Literal
7
+
8
+ Style = Literal["lf", "crlf", "cr"]
9
+ Kind = Literal["lf", "crlf", "cr", "vt", "ff", "fs", "gs", "rs", "nel", "ls", "ps"]
10
+ Detected = Kind | Literal["mixed", "none"]
11
+ Breaks = Literal["ascii", "unicode", "splitlines"]
12
+
13
+ # Single-character kinds in report order. CRLF is the only two-character ending.
14
+ _CHARS: dict[str, Kind] = {
15
+ "\n": "lf",
16
+ "\r": "cr",
17
+ "\x0b": "vt",
18
+ "\x0c": "ff",
19
+ "\x1c": "fs",
20
+ "\x1d": "gs",
21
+ "\x1e": "rs",
22
+ "\x85": "nel",
23
+ "\u2028": "ls",
24
+ "\u2029": "ps",
25
+ }
26
+ # UAX #14: BK (VT, FF, LS, PS), CR, LF and NL are the mandatory breaks. str.splitlines
27
+ # also splits on the three information separators FS, GS and RS.
28
+ _SETS: dict[Breaks, str] = {
29
+ "ascii": "\n\r",
30
+ "unicode": "\n\r\x0b\x0c\x85\u2028\u2029",
31
+ "splitlines": "\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029",
32
+ }
33
+ _SEQUENCES: dict[Style, str] = {"lf": "\n", "crlf": "\r\n", "cr": "\r"}
34
+
35
+
36
+ def check_breaks(breaks: object) -> Breaks:
37
+ if not isinstance(breaks, str) or breaks not in _SETS:
38
+ raise ValueError(f"unknown boundary set: {breaks!r}")
39
+ return breaks
40
+
41
+
42
+ def check_style(to: object) -> Style:
43
+ if not isinstance(to, str) or to not in _SEQUENCES:
44
+ raise ValueError(f"unknown style: {to!r}")
45
+ return to
46
+
47
+
48
+ def kinds(breaks: Breaks) -> tuple[Kind, ...]:
49
+ """The kinds a boundary set recognises: lf, crlf, cr first, then the rest."""
50
+ names = [_CHARS[char] for char in _SETS[breaks]]
51
+ return ("lf", "crlf", "cr", *names[2:])
52
+
53
+
54
+ def name_of(ending: str) -> Kind:
55
+ return "crlf" if ending == "\r\n" else _CHARS[ending]
56
+
57
+
58
+ def sequence(to: Style) -> str:
59
+ return _SEQUENCES[to]
60
+
61
+
62
+ def _build(chars: str) -> re.Pattern[str]:
63
+ return re.compile("\r\n|[" + re.escape(chars) + "]")
64
+
65
+
66
+ _TEXT_PATTERNS = {breaks: _build(chars) for breaks, chars in _SETS.items()}
67
+ BYTES_PATTERN = re.compile(rb"\r\n|[\r\n]")
68
+
69
+
70
+ def text_pattern(breaks: Breaks) -> re.Pattern[str]:
71
+ return _TEXT_PATTERNS[breaks]
eolnorm/_stream.py ADDED
@@ -0,0 +1,61 @@
1
+ """Normalize a stream chunk by chunk, keeping CRLF whole across chunk borders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import AnyStr, cast
6
+
7
+ from ._eol import counts, detected_from, normalize
8
+ from ._kinds import Breaks, Detected, Style, check_breaks, check_style, kinds
9
+
10
+
11
+ class Normalizer:
12
+ """Incremental `normalize` with running `counts`, for text or bytes chunks.
13
+
14
+ A chunk that ends in CR is held back by one character until the next chunk shows
15
+ whether an LF follows. Pass ``final=True`` with the last chunk (it may be empty) to
16
+ release it. All chunks of one stream must be the same type, ``str`` or ``bytes``.
17
+ """
18
+
19
+ def __init__(
20
+ self, to: Style = "lf", *, breaks: Breaks = "ascii", keep_lone_cr: bool = False
21
+ ) -> None:
22
+ if not isinstance(keep_lone_cr, bool):
23
+ raise TypeError("keep_lone_cr must be a bool")
24
+ self._to = check_style(to)
25
+ self._breaks = check_breaks(breaks)
26
+ self._keep = keep_lone_cr
27
+ self._tally: dict[str, int] = dict.fromkeys(kinds(self._breaks), 0)
28
+ self._pending: str | bytes = ""
29
+ self._kind: type | None = None
30
+
31
+ def feed(self, chunk: AnyStr, final: bool = False) -> AnyStr:
32
+ """Return the normalized output that is safe to emit after this chunk."""
33
+ if not isinstance(chunk, (str, bytes)):
34
+ raise TypeError(f"expected str or bytes, got {type(chunk).__name__}")
35
+ if isinstance(chunk, bytes) and self._breaks != "ascii":
36
+ # Checked before any state changes, so a rejected chunk leaves the stream usable.
37
+ raise ValueError(f"breaks={self._breaks!r} needs decoded text, not bytes")
38
+ if self._kind is None:
39
+ self._kind = str if isinstance(chunk, str) else bytes
40
+ self._pending = chunk[:0]
41
+ elif not isinstance(chunk, self._kind):
42
+ raise TypeError(f"this stream carries {self._kind.__name__}, not a mix")
43
+ buffer = cast(AnyStr, self._pending) + chunk
44
+ hold = buffer.endswith("\r") if isinstance(buffer, str) else buffer.endswith(b"\r")
45
+ if hold and not final:
46
+ buffer, self._pending = buffer[:-1], buffer[-1:]
47
+ else:
48
+ self._pending = buffer[:0]
49
+ for name, count in counts(buffer, breaks=self._breaks).items():
50
+ self._tally[name] += count
51
+ return normalize(buffer, self._to, breaks=self._breaks, keep_lone_cr=self._keep)
52
+
53
+ @property
54
+ def counts(self) -> dict[str, int]:
55
+ """Endings seen so far in the input; a held-back CR is not counted yet."""
56
+ return dict(self._tally)
57
+
58
+ @property
59
+ def detected(self) -> Detected:
60
+ """`detect` over the input seen so far."""
61
+ return detected_from(self._tally)
eolnorm/py.typed ADDED
File without changes
@@ -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,16 @@
1
+ eolnorm/__init__.py,sha256=5ZkGgkAro_jBpYjgUHKZOhUGXyYVkBv0db9KXDUe0wM,564
2
+ eolnorm/__main__.py,sha256=QJUgHCRkBs6i9De7gGjiXlj4jFCe1xtVJ51BF0WjNUs,146
3
+ eolnorm/_atomic.py,sha256=htJN3Fy2QVBfwZGzOjfYFzBMuglZ6Uyd9kW3rzEQj9I,1855
4
+ eolnorm/_binary.py,sha256=BxeupqBFKBblp5ZMlbSxAxZupKRBuBe4G4NawN_RiiQ,1780
5
+ eolnorm/_cli.py,sha256=ijmrBugWtGnamVv9Ksh4DIwyQudFMUWGQc7u9sn-UcE,4022
6
+ eolnorm/_content.py,sha256=KbuGoS0MHdRodlNIPHRh-CI40bLFbKGYhw9RYPAVtlg,3347
7
+ eolnorm/_eol.py,sha256=vMkfCj3jjZYApEUxWNqK9_oKBCqGgZ8ceSTGy0N1ZH4,2838
8
+ eolnorm/_files.py,sha256=nFelGObF8RtAjG9_EvfwEdXlA4_qoTVNt3YefBmVPLY,2916
9
+ eolnorm/_kinds.py,sha256=CcorMUla6V-abkeQoYdFLL3pxr5Y71_85b0nQVZNT1E,2100
10
+ eolnorm/_stream.py,sha256=QIY5LdUUpmc1gL7-JmB43s7LOToJVHGuLobxgiQtkek,2743
11
+ eolnorm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ eolnorm-0.2.0.dist-info/METADATA,sha256=qE377MmzxzTpz5Rhpe6xdF-o-ba6MQaofAztRMQck9c,8352
13
+ eolnorm-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ eolnorm-0.2.0.dist-info/entry_points.txt,sha256=1TPYvjKAz8EwZFuxYBNh1IcgsW0TQ5vPrwkARRRDCSg,46
15
+ eolnorm-0.2.0.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
16
+ eolnorm-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ eolnorm = eolnorm._cli:main
@@ -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.