lintle 0.1.1__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.
- lintle/__init__.py +17 -0
- lintle/__main__.py +8 -0
- lintle/cli.py +441 -0
- lintle/pipeline.py +205 -0
- lintle/repair.py +140 -0
- lintle/report.py +197 -0
- lintle/tle.py +217 -0
- lintle-0.1.1.dist-info/METADATA +251 -0
- lintle-0.1.1.dist-info/RECORD +12 -0
- lintle-0.1.1.dist-info/WHEEL +4 -0
- lintle-0.1.1.dist-info/entry_points.txt +2 -0
- lintle-0.1.1.dist-info/licenses/LICENSE +21 -0
lintle/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""lintle — validator and cleaner for Two-Line Element (TLE) corpus files."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _dist_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = _dist_version("lintle")
|
|
8
|
+
except PackageNotFoundError: # source checkout that was never installed
|
|
9
|
+
__version__ = "0.0.0+local"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def stem(filename):
|
|
13
|
+
"""Return a filename without its trailing ``.txt`` extension.
|
|
14
|
+
|
|
15
|
+
``"tle2022.txt"`` -> ``"tle2022"``; other names are returned unchanged.
|
|
16
|
+
"""
|
|
17
|
+
return filename[:-4] if filename.endswith(".txt") else filename
|
lintle/__main__.py
ADDED
lintle/cli.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Command-line interface: ``lintle validate`` and ``lintle clean``."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import multiprocessing
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from lintle import __version__, pipeline, report
|
|
17
|
+
|
|
18
|
+
_DEFAULT_SOURCE = "data/source"
|
|
19
|
+
_DEFAULT_OUTPUT = "data/output"
|
|
20
|
+
|
|
21
|
+
_EPILOG = """\
|
|
22
|
+
Examples:
|
|
23
|
+
lintle validate audit data/source/ (read-only)
|
|
24
|
+
lintle clean clean data/source/ -> data/output/
|
|
25
|
+
lintle validate file.txt audit a single file
|
|
26
|
+
lintle clean dir1 dir2 --jobs 4 clean multiple directories
|
|
27
|
+
lintle clean data/raw --out-dir build write to a custom location
|
|
28
|
+
lintle validate --report json emit a machine-readable summary
|
|
29
|
+
|
|
30
|
+
Exit codes:
|
|
31
|
+
0 no records quarantined — every defect repaired
|
|
32
|
+
1 at least one record was quarantined
|
|
33
|
+
2 operational error (missing input, disk shortfall, file failure)
|
|
34
|
+
130 interrupted (Ctrl-C)
|
|
35
|
+
|
|
36
|
+
See `lintle <command> --help` for command-specific options.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def discover_paths(paths):
|
|
41
|
+
"""Expand each entry in ``paths``: a directory becomes its sorted
|
|
42
|
+
``tle*.txt`` files (excluding ``*.cleaned.txt`` / ``*.broken.txt`` tool
|
|
43
|
+
output); a file is passed through unchanged. Nonexistent entries are
|
|
44
|
+
dropped — callers should validate inputs with :func:`check_paths` first.
|
|
45
|
+
"""
|
|
46
|
+
result = []
|
|
47
|
+
for path in paths:
|
|
48
|
+
if os.path.isdir(path):
|
|
49
|
+
for name in sorted(os.listdir(path)):
|
|
50
|
+
if (
|
|
51
|
+
name.startswith("tle")
|
|
52
|
+
and name.endswith(".txt")
|
|
53
|
+
and not name.endswith(".cleaned.txt")
|
|
54
|
+
and not name.endswith(".broken.txt")
|
|
55
|
+
):
|
|
56
|
+
result.append(os.path.join(path, name))
|
|
57
|
+
elif os.path.isfile(path):
|
|
58
|
+
result.append(path)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_paths(paths, using_default):
|
|
63
|
+
"""Return a user-facing error string if any ``paths`` entry is missing
|
|
64
|
+
or unreadable, else ``None``. ``using_default`` tailors the message for
|
|
65
|
+
the case where the user passed no paths at all and the default
|
|
66
|
+
(``data/source``) is what's missing.
|
|
67
|
+
"""
|
|
68
|
+
missing = [p for p in paths if not os.path.exists(p)]
|
|
69
|
+
if missing:
|
|
70
|
+
if using_default:
|
|
71
|
+
return (
|
|
72
|
+
f"default input directory {_DEFAULT_SOURCE!r} does not exist.\n"
|
|
73
|
+
f" pass one or more files or directories on the command line,\n"
|
|
74
|
+
f" or create {_DEFAULT_SOURCE}/ and put your tle*.txt files there.\n"
|
|
75
|
+
f" run 'lintle --help' for usage and examples."
|
|
76
|
+
)
|
|
77
|
+
if len(missing) == 1:
|
|
78
|
+
return f"no such file or directory: {missing[0]!r}"
|
|
79
|
+
joined = ", ".join(repr(p) for p in missing)
|
|
80
|
+
return f"no such files or directories: {joined}"
|
|
81
|
+
unreadable = [p for p in paths if not os.access(p, os.R_OK)]
|
|
82
|
+
if unreadable:
|
|
83
|
+
joined = ", ".join(repr(p) for p in unreadable)
|
|
84
|
+
return f"permission denied: {joined}"
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_parser():
|
|
89
|
+
"""Build the ``lintle`` argument parser."""
|
|
90
|
+
parser = argparse.ArgumentParser(
|
|
91
|
+
prog="lintle",
|
|
92
|
+
description=(
|
|
93
|
+
"Validate and clean Two-Line Element (TLE) corpus files exported "
|
|
94
|
+
"from space-track.org."
|
|
95
|
+
),
|
|
96
|
+
epilog=_EPILOG,
|
|
97
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"-V",
|
|
101
|
+
"--version",
|
|
102
|
+
action="version",
|
|
103
|
+
version=f"%(prog)s {__version__}",
|
|
104
|
+
)
|
|
105
|
+
subparsers = parser.add_subparsers(
|
|
106
|
+
dest="command",
|
|
107
|
+
required=True,
|
|
108
|
+
metavar="{validate,clean}",
|
|
109
|
+
title="commands",
|
|
110
|
+
)
|
|
111
|
+
for name, help_text, description in (
|
|
112
|
+
(
|
|
113
|
+
"validate",
|
|
114
|
+
"audit files and report defects (writes nothing)",
|
|
115
|
+
"Audit TLE files against the spec and report every defect "
|
|
116
|
+
"(checksum mismatches, wrong length, orphan lines, etc.) "
|
|
117
|
+
"without modifying anything.",
|
|
118
|
+
),
|
|
119
|
+
(
|
|
120
|
+
"clean",
|
|
121
|
+
"write cleaned files and quarantine sidecars",
|
|
122
|
+
"Apply validated repairs and write cleaned files plus a per-file "
|
|
123
|
+
"quarantine sidecar to --out-dir; emit a corpus-wide report.md.",
|
|
124
|
+
),
|
|
125
|
+
):
|
|
126
|
+
sub = subparsers.add_parser(
|
|
127
|
+
name,
|
|
128
|
+
help=help_text,
|
|
129
|
+
description=description,
|
|
130
|
+
epilog=_EPILOG,
|
|
131
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
132
|
+
)
|
|
133
|
+
sub.add_argument(
|
|
134
|
+
"paths",
|
|
135
|
+
nargs="*",
|
|
136
|
+
default=None,
|
|
137
|
+
metavar="PATH",
|
|
138
|
+
help=(
|
|
139
|
+
f"files or directories to process "
|
|
140
|
+
f"(default: {_DEFAULT_SOURCE}). "
|
|
141
|
+
"Directories are globbed for tle*.txt."
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
sub.add_argument(
|
|
145
|
+
"--out-dir",
|
|
146
|
+
default=_DEFAULT_OUTPUT,
|
|
147
|
+
metavar="DIR",
|
|
148
|
+
help=f"destination for cleaned/broken files (default: {_DEFAULT_OUTPUT})",
|
|
149
|
+
)
|
|
150
|
+
sub.add_argument(
|
|
151
|
+
"--jobs",
|
|
152
|
+
type=int,
|
|
153
|
+
default=os.cpu_count() or 1,
|
|
154
|
+
metavar="N",
|
|
155
|
+
help="number of files to process in parallel (default: CPU count)",
|
|
156
|
+
)
|
|
157
|
+
sub.add_argument(
|
|
158
|
+
"--report",
|
|
159
|
+
choices=["text", "json"],
|
|
160
|
+
default="text",
|
|
161
|
+
help="summary output format (default: text)",
|
|
162
|
+
)
|
|
163
|
+
return parser
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _check_disk_space(out_dir, files):
|
|
167
|
+
"""Return an error string if ``out_dir`` lacks room for cleaned +
|
|
168
|
+
broken output (roughly twice the total input size), else ``None``.
|
|
169
|
+
"""
|
|
170
|
+
needed = sum(os.path.getsize(f) for f in files) * 2
|
|
171
|
+
free = shutil.disk_usage(out_dir).free
|
|
172
|
+
if free < needed:
|
|
173
|
+
return (
|
|
174
|
+
f"insufficient disk space in {out_dir}: "
|
|
175
|
+
f"need ~{needed:,} bytes, have {free:,}"
|
|
176
|
+
)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _ignore_sigint():
|
|
181
|
+
"""Worker-process initializer: ignore Ctrl-C in the worker.
|
|
182
|
+
|
|
183
|
+
Without this, a terminal Ctrl-C is delivered to every worker too, and
|
|
184
|
+
each one prints its own ``KeyboardInterrupt`` traceback. Making the
|
|
185
|
+
parent the only process that sees the interrupt keeps shutdown tidy —
|
|
186
|
+
it catches it once and terminates the workers itself.
|
|
187
|
+
"""
|
|
188
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _terminate_workers(executor):
|
|
192
|
+
"""SIGTERM every pool worker so Ctrl-C need not wait on in-flight files.
|
|
193
|
+
|
|
194
|
+
``ProcessPoolExecutor`` offers no public "stop now": leaving its
|
|
195
|
+
``with`` block runs ``shutdown(wait=True)``, which blocks until every
|
|
196
|
+
running task finishes — and one TLE corpus file can take minutes.
|
|
197
|
+
Terminating the worker processes directly makes Ctrl-C feel immediate.
|
|
198
|
+
"""
|
|
199
|
+
processes = getattr(executor, "_processes", None) or {}
|
|
200
|
+
for proc in list(processes.values()):
|
|
201
|
+
proc.terminate()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _format_elapsed(seconds):
|
|
205
|
+
"""Render an elapsed duration as ``M:SS`` (or ``H:MM:SS`` past an hour)."""
|
|
206
|
+
hours, rem = divmod(int(seconds), 3600)
|
|
207
|
+
minutes, secs = divmod(rem, 60)
|
|
208
|
+
if hours:
|
|
209
|
+
return f"{hours}:{minutes:02d}:{secs:02d}"
|
|
210
|
+
return f"{minutes}:{secs:02d}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _ProgressDisplay:
|
|
214
|
+
"""A live, single-line progress indicator for a parallel run.
|
|
215
|
+
|
|
216
|
+
On a terminal, a background thread repaints one self-overwriting line
|
|
217
|
+
— spinner, elapsed time, files completed, and the running total of
|
|
218
|
+
records processed (workers stream their counts in over the progress
|
|
219
|
+
queue). When stderr is not a TTY (a pipe or a log file) there is no
|
|
220
|
+
spinner: one plain line is printed per completed file instead, so
|
|
221
|
+
redirected output stays readable.
|
|
222
|
+
|
|
223
|
+
Used as a context manager: the thread starts on entry and is stopped,
|
|
224
|
+
with the live line cleared, on exit.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
228
|
+
_REFRESH = 0.1 # seconds between repaints — ~10 fps: smooth but cheap
|
|
229
|
+
|
|
230
|
+
def __init__(self, total_files, progress_queue):
|
|
231
|
+
self._total_files = total_files
|
|
232
|
+
self._queue = progress_queue
|
|
233
|
+
self._live = sys.stderr.isatty()
|
|
234
|
+
self._records = 0
|
|
235
|
+
self._files_done = 0
|
|
236
|
+
self._frame = 0
|
|
237
|
+
self._start = time.monotonic()
|
|
238
|
+
self._lock = threading.Lock()
|
|
239
|
+
self._stop = threading.Event()
|
|
240
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
241
|
+
|
|
242
|
+
def __enter__(self):
|
|
243
|
+
self._thread.start()
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, *_exc):
|
|
247
|
+
self._stop.set()
|
|
248
|
+
self._thread.join()
|
|
249
|
+
if self._live:
|
|
250
|
+
sys.stderr.write("\r\x1b[K")
|
|
251
|
+
sys.stderr.flush()
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
def file_done(self, stats):
|
|
255
|
+
"""Count a finished file; off a TTY, log its one-line summary."""
|
|
256
|
+
done = self._advance()
|
|
257
|
+
if not self._live:
|
|
258
|
+
self.log(
|
|
259
|
+
f"[{done}/{self._total_files}] {stats.src_name} — "
|
|
260
|
+
f"{stats.clean_count:,} clean, "
|
|
261
|
+
f"{stats.quarantined_count:,} quarantined"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def file_failed(self, path, exc):
|
|
265
|
+
"""Count a file that could not be processed and log the error."""
|
|
266
|
+
done = self._advance()
|
|
267
|
+
self.log(f"[{done}/{self._total_files}] error processing {path}: {exc!r}")
|
|
268
|
+
|
|
269
|
+
def log(self, message):
|
|
270
|
+
"""Print a line without the live status line swallowing it."""
|
|
271
|
+
with self._lock:
|
|
272
|
+
if self._live:
|
|
273
|
+
sys.stderr.write("\r\x1b[K")
|
|
274
|
+
sys.stderr.write(message + "\n")
|
|
275
|
+
sys.stderr.flush()
|
|
276
|
+
|
|
277
|
+
def _advance(self):
|
|
278
|
+
with self._lock:
|
|
279
|
+
self._files_done += 1
|
|
280
|
+
return self._files_done
|
|
281
|
+
|
|
282
|
+
def _run(self):
|
|
283
|
+
# The display is cosmetic: never let a broken progress queue (e.g.
|
|
284
|
+
# its manager gone mid-shutdown) crash this thread with a traceback.
|
|
285
|
+
with contextlib.suppress(Exception):
|
|
286
|
+
while not self._stop.is_set():
|
|
287
|
+
self._drain()
|
|
288
|
+
if self._live:
|
|
289
|
+
self._render()
|
|
290
|
+
self._stop.wait(self._REFRESH)
|
|
291
|
+
self._drain()
|
|
292
|
+
|
|
293
|
+
def _drain(self):
|
|
294
|
+
"""Fold every queued record-count delta into the running total."""
|
|
295
|
+
batch = 0
|
|
296
|
+
with contextlib.suppress(queue.Empty):
|
|
297
|
+
while True:
|
|
298
|
+
batch += self._queue.get_nowait()
|
|
299
|
+
if batch:
|
|
300
|
+
with self._lock:
|
|
301
|
+
self._records += batch
|
|
302
|
+
|
|
303
|
+
def _render(self):
|
|
304
|
+
with self._lock:
|
|
305
|
+
frame = self._SPINNER[self._frame % len(self._SPINNER)]
|
|
306
|
+
self._frame += 1
|
|
307
|
+
line = (
|
|
308
|
+
f"{frame} {_format_elapsed(time.monotonic() - self._start)} · "
|
|
309
|
+
f"{self._files_done}/{self._total_files} files · "
|
|
310
|
+
f"{self._records:,} records"
|
|
311
|
+
)
|
|
312
|
+
sys.stderr.write("\r\x1b[K" + line)
|
|
313
|
+
sys.stderr.flush()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main(argv=None):
|
|
317
|
+
"""Entry point for the ``lintle`` console script.
|
|
318
|
+
|
|
319
|
+
Returns the process exit code: ``0`` = no records quarantined;
|
|
320
|
+
``1`` = at least one record quarantined; ``2`` = operational error
|
|
321
|
+
(no input files, disk shortfall, or a file that failed to process);
|
|
322
|
+
``130`` = interrupted with Ctrl-C.
|
|
323
|
+
"""
|
|
324
|
+
args = build_parser().parse_args(argv)
|
|
325
|
+
|
|
326
|
+
# `args.paths` is None when the user passed nothing — fall back to the
|
|
327
|
+
# default source dir, and remember it so we can give a tailored error if
|
|
328
|
+
# that default doesn't exist on this machine.
|
|
329
|
+
using_default = not args.paths
|
|
330
|
+
paths = args.paths or [_DEFAULT_SOURCE]
|
|
331
|
+
|
|
332
|
+
if args.jobs < 1:
|
|
333
|
+
print(f"error: --jobs must be >= 1 (got {args.jobs})", file=sys.stderr)
|
|
334
|
+
return 2
|
|
335
|
+
|
|
336
|
+
path_error = check_paths(paths, using_default=using_default)
|
|
337
|
+
if path_error:
|
|
338
|
+
print(f"error: {path_error}", file=sys.stderr)
|
|
339
|
+
return 2
|
|
340
|
+
|
|
341
|
+
files = discover_paths(paths)
|
|
342
|
+
if not files:
|
|
343
|
+
dirs = [p for p in paths if os.path.isdir(p)]
|
|
344
|
+
if dirs:
|
|
345
|
+
joined = ", ".join(repr(d) for d in dirs)
|
|
346
|
+
print(
|
|
347
|
+
f"error: no tle*.txt files found in {joined}.\n"
|
|
348
|
+
" expected one or more files named tle*.txt "
|
|
349
|
+
"(excluding *.cleaned.txt / *.broken.txt).",
|
|
350
|
+
file=sys.stderr,
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
print("error: no input files found", file=sys.stderr)
|
|
354
|
+
return 2
|
|
355
|
+
|
|
356
|
+
if args.command == "clean":
|
|
357
|
+
os.makedirs(args.out_dir, exist_ok=True)
|
|
358
|
+
disk_error = _check_disk_space(args.out_dir, files)
|
|
359
|
+
if disk_error:
|
|
360
|
+
print(f"error: {disk_error}", file=sys.stderr)
|
|
361
|
+
return 2
|
|
362
|
+
|
|
363
|
+
print(
|
|
364
|
+
f"processing {len(files)} file(s) with {args.jobs} worker(s)...",
|
|
365
|
+
file=sys.stderr,
|
|
366
|
+
flush=True,
|
|
367
|
+
)
|
|
368
|
+
all_stats = []
|
|
369
|
+
failed_files = []
|
|
370
|
+
interrupted = False
|
|
371
|
+
|
|
372
|
+
# The executor runs without a `with` block deliberately: that block's
|
|
373
|
+
# __exit__ calls shutdown(wait=True), which on Ctrl-C would block until
|
|
374
|
+
# every in-flight corpus file finished. Instead the workers ignore
|
|
375
|
+
# SIGINT (so only this process sees it) and, on interrupt, we terminate
|
|
376
|
+
# them outright. A manager queue carries record counts back for display.
|
|
377
|
+
with multiprocessing.Manager() as manager:
|
|
378
|
+
progress_queue = manager.Queue()
|
|
379
|
+
executor = concurrent.futures.ProcessPoolExecutor(
|
|
380
|
+
max_workers=args.jobs, initializer=_ignore_sigint
|
|
381
|
+
)
|
|
382
|
+
try:
|
|
383
|
+
futures = {
|
|
384
|
+
executor.submit(
|
|
385
|
+
pipeline.process_file,
|
|
386
|
+
path,
|
|
387
|
+
args.out_dir,
|
|
388
|
+
args.command,
|
|
389
|
+
progress_queue,
|
|
390
|
+
): path
|
|
391
|
+
for path in files
|
|
392
|
+
}
|
|
393
|
+
with _ProgressDisplay(len(files), progress_queue) as progress:
|
|
394
|
+
for future in concurrent.futures.as_completed(futures):
|
|
395
|
+
path = futures[future]
|
|
396
|
+
try:
|
|
397
|
+
stats = future.result()
|
|
398
|
+
except Exception as exc:
|
|
399
|
+
progress.file_failed(path, exc)
|
|
400
|
+
failed_files.append(path)
|
|
401
|
+
else:
|
|
402
|
+
all_stats.append(stats)
|
|
403
|
+
progress.file_done(stats)
|
|
404
|
+
except KeyboardInterrupt:
|
|
405
|
+
# Ignore any further Ctrl-C so the shutdown itself cannot be
|
|
406
|
+
# interrupted half-way (which is what left it hung before).
|
|
407
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
408
|
+
interrupted = True
|
|
409
|
+
_terminate_workers(executor)
|
|
410
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
411
|
+
print("interrupted — workers stopped", file=sys.stderr, flush=True)
|
|
412
|
+
else:
|
|
413
|
+
executor.shutdown(wait=True)
|
|
414
|
+
|
|
415
|
+
if interrupted:
|
|
416
|
+
return 130
|
|
417
|
+
|
|
418
|
+
all_stats.sort(key=lambda stats: stats.src_name)
|
|
419
|
+
|
|
420
|
+
# A `clean` run writes a Markdown run report to the out-dir root.
|
|
421
|
+
report_path = None
|
|
422
|
+
if args.command == "clean" and all_stats:
|
|
423
|
+
report_path = os.path.join(args.out_dir, "report.md")
|
|
424
|
+
report.write_run_report(report_path, all_stats)
|
|
425
|
+
|
|
426
|
+
if args.report == "json":
|
|
427
|
+
print(json.dumps([report.summary_dict(s) for s in all_stats], indent=2))
|
|
428
|
+
else:
|
|
429
|
+
for stats in all_stats:
|
|
430
|
+
print(report.format_summary(stats))
|
|
431
|
+
if args.command == "validate" and stats.rejects:
|
|
432
|
+
print(report.format_reject_lines(stats))
|
|
433
|
+
if report_path:
|
|
434
|
+
print(f"\nrun report: {report_path}")
|
|
435
|
+
|
|
436
|
+
# A file that could not be processed is an operational error (spec §10),
|
|
437
|
+
# and that outranks the quarantined-record signal.
|
|
438
|
+
if failed_files:
|
|
439
|
+
return 2
|
|
440
|
+
total_quarantined = sum(s.quarantined_count for s in all_stats)
|
|
441
|
+
return 1 if total_quarantined else 0
|
lintle/pipeline.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Streaming I/O: read a file, pair lines into records, route them."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import dataclasses
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from lintle import repair, report, stem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclasses.dataclass
|
|
11
|
+
class RecordCandidate:
|
|
12
|
+
"""A line-1 / line-2 pair, with their 1-indexed source line numbers."""
|
|
13
|
+
|
|
14
|
+
raw_line1: bytes
|
|
15
|
+
raw_line2: bytes
|
|
16
|
+
src1: int
|
|
17
|
+
src2: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass
|
|
21
|
+
class Orphan:
|
|
22
|
+
"""A line that could not be paired into a record."""
|
|
23
|
+
|
|
24
|
+
raw_line: bytes
|
|
25
|
+
src: int
|
|
26
|
+
category: str
|
|
27
|
+
reason: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def iter_records(path):
|
|
31
|
+
"""Yield ``RecordCandidate`` / ``Orphan`` items streamed from ``path``.
|
|
32
|
+
|
|
33
|
+
The file is read in binary so ``\\r`` and stray bytes are observed
|
|
34
|
+
exactly. Blank, whitespace-only, and CR-only lines are dropped.
|
|
35
|
+
Pairing is prefix-driven and resynchronises on every ``1 `` line, so
|
|
36
|
+
one missing line cannot cascade into a run of mispaired records.
|
|
37
|
+
"""
|
|
38
|
+
held = None # (raw_bytes, line_number) of a line-1 awaiting its line-2
|
|
39
|
+
|
|
40
|
+
with open(path, "rb") as handle:
|
|
41
|
+
for lineno, raw in enumerate(handle, start=1):
|
|
42
|
+
line = raw.rstrip(b"\n")
|
|
43
|
+
if line.strip(b" \t\r") == b"":
|
|
44
|
+
continue # blank, whitespace-only, or CR-only line — dropped
|
|
45
|
+
|
|
46
|
+
prefix = line[:2]
|
|
47
|
+
if prefix == b"1 ":
|
|
48
|
+
if held is not None:
|
|
49
|
+
yield Orphan(
|
|
50
|
+
held[0],
|
|
51
|
+
held[1],
|
|
52
|
+
"orphan-line",
|
|
53
|
+
"orphan line 1: followed by another line 1",
|
|
54
|
+
)
|
|
55
|
+
held = (line, lineno)
|
|
56
|
+
elif prefix == b"2 ":
|
|
57
|
+
if held is not None:
|
|
58
|
+
yield RecordCandidate(held[0], line, held[1], lineno)
|
|
59
|
+
held = None
|
|
60
|
+
else:
|
|
61
|
+
yield Orphan(
|
|
62
|
+
line,
|
|
63
|
+
lineno,
|
|
64
|
+
"orphan-line",
|
|
65
|
+
"orphan line 2: no preceding line 1",
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
if held is not None:
|
|
69
|
+
yield Orphan(
|
|
70
|
+
held[0],
|
|
71
|
+
held[1],
|
|
72
|
+
"orphan-line",
|
|
73
|
+
"orphan line 1: followed by a non-TLE line",
|
|
74
|
+
)
|
|
75
|
+
held = None
|
|
76
|
+
yield Orphan(
|
|
77
|
+
line,
|
|
78
|
+
lineno,
|
|
79
|
+
"bad-prefix",
|
|
80
|
+
"line does not start with '1 ' or '2 '",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if held is not None:
|
|
84
|
+
yield Orphan(held[0], held[1], "orphan-line", "orphan line 1 at end of file")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def process_file(src_path, out_dir, mode, progress_queue=None, progress_every=25_000):
|
|
88
|
+
"""Process one source file and return its ``report.FileStats``.
|
|
89
|
+
|
|
90
|
+
``mode`` is ``"validate"`` (audit only — writes nothing) or ``"clean"``
|
|
91
|
+
(also writes ``cleaned/<name>.cleaned.txt`` and
|
|
92
|
+
``broken/<name>.broken.txt`` under ``out_dir``). The cleaned file is
|
|
93
|
+
written to a temp file and atomically renamed, so an interrupted run
|
|
94
|
+
never leaves a half-written output.
|
|
95
|
+
|
|
96
|
+
When ``progress_queue`` is given, the count of newly processed records
|
|
97
|
+
is pushed to it every ``progress_every`` records — and once more when
|
|
98
|
+
the file ends — so the caller can render live progress. With no queue
|
|
99
|
+
(or ``progress_every`` set to 0) no progress is reported.
|
|
100
|
+
"""
|
|
101
|
+
src_name = os.path.basename(src_path)
|
|
102
|
+
stats = report.FileStats(src_name=src_name)
|
|
103
|
+
|
|
104
|
+
cleaned_handle = None
|
|
105
|
+
cleaned_tmp = None
|
|
106
|
+
cleaned_path = None
|
|
107
|
+
if mode == "clean":
|
|
108
|
+
cleaned_dir = os.path.join(out_dir, "cleaned")
|
|
109
|
+
os.makedirs(cleaned_dir, exist_ok=True)
|
|
110
|
+
cleaned_path = os.path.join(cleaned_dir, stem(src_name) + ".cleaned.txt")
|
|
111
|
+
# Deterministic temp name (not tempfile.mkstemp): a killed run leaves
|
|
112
|
+
# at most one .partial per file, which the next run truncates — no
|
|
113
|
+
# random-name debris accumulates. open() also honours the umask
|
|
114
|
+
# (typically 0644), whereas mkstemp would force owner-only 0600.
|
|
115
|
+
cleaned_tmp = cleaned_path + ".partial"
|
|
116
|
+
# SIM115: the handle is long-lived across the record loop and is
|
|
117
|
+
# closed in the `finally` below — a `with` block does not fit.
|
|
118
|
+
cleaned_handle = open( # noqa: SIM115
|
|
119
|
+
cleaned_tmp, "w", encoding="ascii", newline="\n"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
completed = False
|
|
123
|
+
try:
|
|
124
|
+
for candidate in iter_records(src_path):
|
|
125
|
+
stats.total_records += 1
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
progress_queue is not None
|
|
129
|
+
and progress_every
|
|
130
|
+
and stats.total_records % progress_every == 0
|
|
131
|
+
):
|
|
132
|
+
progress_queue.put(progress_every)
|
|
133
|
+
|
|
134
|
+
if isinstance(candidate, Orphan):
|
|
135
|
+
_record_reject(
|
|
136
|
+
stats,
|
|
137
|
+
candidate.category,
|
|
138
|
+
candidate.reason,
|
|
139
|
+
[candidate.raw_line],
|
|
140
|
+
[candidate.src],
|
|
141
|
+
)
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
result = repair.process_record(
|
|
146
|
+
candidate.raw_line1,
|
|
147
|
+
candidate.src1,
|
|
148
|
+
candidate.raw_line2,
|
|
149
|
+
candidate.src2,
|
|
150
|
+
)
|
|
151
|
+
except Exception as exc: # one bad record must not kill the run
|
|
152
|
+
_record_reject(
|
|
153
|
+
stats,
|
|
154
|
+
"internal-error",
|
|
155
|
+
f"internal-error: {exc!r}",
|
|
156
|
+
[candidate.raw_line1, candidate.raw_line2],
|
|
157
|
+
[candidate.src1, candidate.src2],
|
|
158
|
+
)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if isinstance(result, repair.Accepted):
|
|
162
|
+
stats.clean_count += 1
|
|
163
|
+
for fix in result.fixes:
|
|
164
|
+
stats.fix_counts[fix] = stats.fix_counts.get(fix, 0) + 1
|
|
165
|
+
if cleaned_handle is not None:
|
|
166
|
+
cleaned_handle.write(result.line1 + "\n")
|
|
167
|
+
cleaned_handle.write(result.line2 + "\n")
|
|
168
|
+
else:
|
|
169
|
+
_record_reject(
|
|
170
|
+
stats,
|
|
171
|
+
result.category,
|
|
172
|
+
result.reason,
|
|
173
|
+
result.raw_lines,
|
|
174
|
+
result.source_lines,
|
|
175
|
+
)
|
|
176
|
+
# Push the trailing partial batch so the caller's tally is exact.
|
|
177
|
+
if progress_queue is not None and progress_every:
|
|
178
|
+
remainder = stats.total_records % progress_every
|
|
179
|
+
if remainder:
|
|
180
|
+
progress_queue.put(remainder)
|
|
181
|
+
completed = True
|
|
182
|
+
finally:
|
|
183
|
+
if cleaned_handle is not None:
|
|
184
|
+
cleaned_handle.close()
|
|
185
|
+
# On any failure, discard the partial temp file — never publish a
|
|
186
|
+
# half-written .cleaned.txt and never leak the .tmp behind.
|
|
187
|
+
if cleaned_tmp is not None and not completed:
|
|
188
|
+
with contextlib.suppress(OSError):
|
|
189
|
+
os.unlink(cleaned_tmp)
|
|
190
|
+
|
|
191
|
+
if mode == "clean":
|
|
192
|
+
os.replace(cleaned_tmp, cleaned_path)
|
|
193
|
+
broken_dir = os.path.join(out_dir, "broken")
|
|
194
|
+
os.makedirs(broken_dir, exist_ok=True)
|
|
195
|
+
broken_path = os.path.join(broken_dir, stem(src_name) + ".broken.txt")
|
|
196
|
+
report.write_broken_file(broken_path, src_name, stats)
|
|
197
|
+
|
|
198
|
+
return stats
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _record_reject(stats, category, reason, raw_lines, source_lines):
|
|
202
|
+
"""Tally one quarantined record into ``stats``."""
|
|
203
|
+
stats.quarantined_count += 1
|
|
204
|
+
stats.reject_categories[category] = stats.reject_categories.get(category, 0) + 1
|
|
205
|
+
stats.rejects.append(report.RejectEntry(raw_lines, source_lines, reason))
|