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 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
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m lintle`` to run the CLI."""
2
+
3
+ import sys
4
+
5
+ from lintle.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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))