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 +26 -0
- eolnorm/__main__.py +7 -0
- eolnorm/_atomic.py +58 -0
- eolnorm/_binary.py +45 -0
- eolnorm/_cli.py +107 -0
- eolnorm/_content.py +90 -0
- eolnorm/_eol.py +83 -0
- eolnorm/_files.py +87 -0
- eolnorm/_kinds.py +71 -0
- eolnorm/_stream.py +61 -0
- eolnorm/py.typed +0 -0
- eolnorm-0.2.0.dist-info/METADATA +215 -0
- eolnorm-0.2.0.dist-info/RECORD +16 -0
- eolnorm-0.2.0.dist-info/WHEEL +4 -0
- eolnorm-0.2.0.dist-info/entry_points.txt +2 -0
- eolnorm-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|