datakrit 2.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.
datakrit/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """Datakrit: validate and explicitly clean CSV files with declared number and date formats.
2
+
3
+ The public API is the set of names in ``__all__`` (see docs/STABILITY.md), mainly::
4
+
5
+ from datakrit import check_file, clean_file, export_clean, load_profile
6
+ from datakrit import profile_to_toml, run_batch, select_inputs
7
+
8
+ See docs/ARCHITECTURE.md for the result model and exceptions.
9
+ """
10
+
11
+ __version__ = "2.1.1"
12
+
13
+ from .api import CheckResult, CleanResult, ScanInfo, check_file, clean_file, export_clean
14
+ from .batch import BatchInput, BatchResult, FileOutcome, run_batch, select_inputs
15
+ from .errors import DatakritError, InputError, OutputError, ProfileError
16
+ from .normalization import FieldChange, RemovedRecord
17
+ from .output import ArtifactOutcome
18
+ from .reader import FatalError
19
+ from .schema import (
20
+ ColumnSpec,
21
+ OutputSettings,
22
+ Overrides,
23
+ Profile,
24
+ Settings,
25
+ SourceSettings,
26
+ load_profile,
27
+ profile_to_toml,
28
+ )
29
+ from .validation import Issue
30
+
31
+ __all__ = [
32
+ "ArtifactOutcome",
33
+ "BatchInput",
34
+ "BatchResult",
35
+ "CheckResult",
36
+ "CleanResult",
37
+ "ColumnSpec",
38
+ "DatakritError",
39
+ "FatalError",
40
+ "FieldChange",
41
+ "FileOutcome",
42
+ "InputError",
43
+ "Issue",
44
+ "OutputError",
45
+ "OutputSettings",
46
+ "Overrides",
47
+ "Profile",
48
+ "ProfileError",
49
+ "RemovedRecord",
50
+ "ScanInfo",
51
+ "Settings",
52
+ "SourceSettings",
53
+ "__version__",
54
+ "check_file",
55
+ "clean_file",
56
+ "export_clean",
57
+ "load_profile",
58
+ "profile_to_toml",
59
+ "run_batch",
60
+ "select_inputs",
61
+ ]
datakrit/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
datakrit/api.py ADDED
@@ -0,0 +1,367 @@
1
+ """Public entry points shared by the CLI and library users.
2
+
3
+ Core functions never print or exit. Configuration and I/O problems raise
4
+ ``ProfileError``/``InputError``/``OutputError``; data problems are returned as issues.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, replace
10
+ from pathlib import Path
11
+
12
+ from . import __version__
13
+ from .errors import OutputError
14
+ from .normalization import FieldChange, RemovedRecord, transform
15
+ from .output import (
16
+ OUTPUT_CSV,
17
+ OUTPUT_PROFILE,
18
+ ArtifactOutcome,
19
+ encode_text,
20
+ render_csv,
21
+ validate_artifact_paths,
22
+ write_artifact,
23
+ )
24
+ from .reader import FatalError, Table, read_csv
25
+ from .schema import (
26
+ Overrides,
27
+ Profile,
28
+ Settings,
29
+ load_profile,
30
+ output_profile,
31
+ profile_to_toml,
32
+ resolve_settings,
33
+ )
34
+ from .validation import ERROR, FATAL, WARNING, Issue, validate_table
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ScanInfo:
39
+ """``complete`` is false when a fatal error stopped reading; counts cover what was read."""
40
+
41
+ complete: bool
42
+ data_records: int
43
+ physical_lines: int
44
+ blank_lines: int
45
+ bom: bool
46
+ fatal: FatalError | None
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class CheckResult:
51
+ """Source validation result. ``exit_code``: 0 valid, 1 source errors, 2 incomplete scan."""
52
+
53
+ source_path: str
54
+ profile_path: str | None
55
+ settings: Settings
56
+ scan: ScanInfo
57
+ issues: tuple[Issue, ...]
58
+ # Absolute paths captured when the files were read; used to protect them on export
59
+ # even if the working directory changes later.
60
+ source_file: Path | None = None
61
+ profile_file: Path | None = None
62
+ header: tuple[str, ...] | None = None
63
+
64
+ @property
65
+ def errors(self) -> int:
66
+ return sum(i.severity == ERROR for i in self.issues)
67
+
68
+ @property
69
+ def warnings(self) -> int:
70
+ return sum(i.severity == WARNING for i in self.issues)
71
+
72
+ @property
73
+ def exit_code(self) -> int:
74
+ if not self.scan.complete:
75
+ return 2
76
+ return 1 if self.errors else 0
77
+
78
+ @property
79
+ def ok(self) -> bool:
80
+ return self.exit_code == 0
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class CleanResult:
85
+ """Clean/dry-run result. ``exit_code`` reflects remaining *output* errors (0/1), or 2 when
86
+ the scan is incomplete. ``rows`` are the cleaned records in source order."""
87
+
88
+ source_path: str
89
+ profile_path: str | None
90
+ settings: Settings
91
+ scan: ScanInfo
92
+ header: tuple[str, ...] | None
93
+ rows: tuple[tuple[str, ...], ...]
94
+ source_issues: tuple[Issue, ...]
95
+ output_issues: tuple[Issue, ...]
96
+ changes: tuple[FieldChange, ...]
97
+ removed: tuple[RemovedRecord, ...]
98
+ output_profile: Profile
99
+ source_file: Path | None = None
100
+ profile_file: Path | None = None
101
+
102
+ @property
103
+ def source_errors(self) -> int:
104
+ """Errors of the source file, before the selected transformations."""
105
+ return sum(i.severity == ERROR for i in self.source_issues)
106
+
107
+ @property
108
+ def source_warnings(self) -> int:
109
+ return sum(i.severity == WARNING for i in self.source_issues)
110
+
111
+ @property
112
+ def output_errors(self) -> int:
113
+ """Errors that remain in the cleaned result; they block the export."""
114
+ return sum(i.severity == ERROR for i in self.output_issues)
115
+
116
+ @property
117
+ def output_warnings(self) -> int:
118
+ return sum(i.severity == WARNING for i in self.output_issues)
119
+
120
+ @property
121
+ def export_ready(self) -> bool:
122
+ return self.scan.complete and self.header is not None and self.output_errors == 0
123
+
124
+ @property
125
+ def export_blocked_reason(self) -> str | None:
126
+ """Why the result cannot be exported, with the next step; ``None`` when it can."""
127
+ if self.export_ready:
128
+ return None
129
+ fatal = self.scan.fatal
130
+ if fatal is not None:
131
+ where = f" at line {fatal.line}" if fatal.line is not None else ""
132
+ return (
133
+ f"the scan is incomplete ({fatal.code}{where}), so the file was not fully "
134
+ "read and nothing can be exported. Fix the file structure or encoding (or "
135
+ "the delimiter/encoding settings), then run the command again"
136
+ )
137
+ if self.header is None:
138
+ return (
139
+ "the file has no header row, so there is nothing to export. Provide a CSV "
140
+ "file whose first row contains the column names"
141
+ )
142
+ return (
143
+ f"{self.output_errors} output error(s) remain. Fix the reported values in the "
144
+ "source file or select a transformation that repairs them, then run clean again"
145
+ )
146
+
147
+ @property
148
+ def exit_code(self) -> int:
149
+ if not self.scan.complete:
150
+ return 2
151
+ return 0 if self.export_ready else 1
152
+
153
+ @property
154
+ def ok(self) -> bool:
155
+ """True when the cleaned result can be exported (exit code 0)."""
156
+ return self.exit_code == 0
157
+
158
+ def output_profile_toml(self) -> str:
159
+ """The effective output profile as TOML text."""
160
+ name = Path(self.source_path).name
161
+ header = (
162
+ f"Generated by datakrit {__version__} from the cleaned output of {name}.\n"
163
+ "It describes the cleaned file; use it with `datakrit check` or `datakrit clean`."
164
+ )
165
+ return profile_to_toml(self.output_profile, header)
166
+
167
+ def csv_text(self) -> str:
168
+ """The cleaned CSV serialized with the output delimiter and line ending."""
169
+ if self.header is None:
170
+ raise OutputError("there is no header to export")
171
+ out = self.settings.output
172
+ return render_csv(self.header, self.rows, out.delimiter, out.line_ending)
173
+
174
+
175
+ def _profile(profile: Profile | str | Path | None) -> Profile | None:
176
+ if profile is None or isinstance(profile, Profile):
177
+ return profile
178
+ return load_profile(profile)
179
+
180
+
181
+ def _profile_file(profile: Profile | None) -> Path | None:
182
+ if profile is None or profile.resolved_path is None:
183
+ return None
184
+ return Path(profile.resolved_path)
185
+
186
+
187
+ def _read(path: str | Path, settings: Settings) -> Table:
188
+ src = settings.source
189
+ return read_csv(path, src.delimiter, src.encoding, src.max_field_chars)
190
+
191
+
192
+ def _scan(table: Table) -> ScanInfo:
193
+ return ScanInfo(
194
+ table.complete,
195
+ len(table.records),
196
+ table.physical_lines,
197
+ len(table.blank_lines),
198
+ table.bom,
199
+ table.fatal,
200
+ )
201
+
202
+
203
+ FATAL_REMEDIES = {
204
+ "decode_error": "encoding",
205
+ "malformed_csv": "quoting",
206
+ "field_too_large": "max_field_chars",
207
+ }
208
+
209
+
210
+ def _fatal_issue(fatal: FatalError, stage: str) -> Issue:
211
+ return Issue(
212
+ fatal.code,
213
+ FATAL,
214
+ fatal.message[:1].upper() + fatal.message[1:] + ".",
215
+ "Fix the file structure or encoding (or adjust the profile), then run the command again. "
216
+ "Records after this point were not scanned.",
217
+ stage,
218
+ line_start=fatal.line,
219
+ line_end=fatal.line,
220
+ remedy=FATAL_REMEDIES.get(fatal.code),
221
+ )
222
+
223
+
224
+ def check_file(
225
+ path: str | Path,
226
+ profile: Profile | str | Path | None = None,
227
+ *,
228
+ overrides: Overrides | None = None,
229
+ ) -> CheckResult:
230
+ """Validate a CSV file against source rules without modifying it."""
231
+ loaded = _profile(profile)
232
+ settings = resolve_settings(loaded, overrides)
233
+ table = _read(path, settings)
234
+ issues = validate_table(table, settings, "source")
235
+ if table.fatal:
236
+ issues.append(_fatal_issue(table.fatal, "source"))
237
+ return CheckResult(
238
+ str(path),
239
+ loaded.path if loaded else None,
240
+ settings,
241
+ _scan(table),
242
+ tuple(issues),
243
+ source_file=Path(path).resolve(),
244
+ profile_file=_profile_file(loaded),
245
+ header=table.header,
246
+ )
247
+
248
+
249
+ def clean_file(
250
+ path: str | Path,
251
+ profile: Profile | str | Path | None = None,
252
+ *,
253
+ overrides: Overrides | None = None,
254
+ ) -> CleanResult:
255
+ """Validate, apply selected transformations in memory, and validate the result.
256
+
257
+ Nothing is written; use ``export_clean`` to write the cleaned CSV.
258
+ """
259
+ loaded = _profile(profile)
260
+ settings = resolve_settings(loaded, overrides)
261
+ table = _read(path, settings)
262
+ source_issues = validate_table(table, settings, "source")
263
+ changes: list[FieldChange] = []
264
+ removed: list[RemovedRecord] = []
265
+ rows: tuple[tuple[str, ...], ...] = ()
266
+ if table.fatal is not None: # the scan is incomplete
267
+ source_issues.append(_fatal_issue(table.fatal, "source"))
268
+ output_issues: list[Issue] = []
269
+ elif table.header is None:
270
+ output_issues = validate_table(table, settings, "output", include_blank_lines=False)
271
+ else:
272
+ result = transform(table.header, table.records, settings)
273
+ changes, removed = result.changes, result.removed
274
+ rows = tuple(r.fields for r in result.records)
275
+ cleaned = Table(
276
+ header=table.header,
277
+ header_line=table.header_line,
278
+ records=result.records,
279
+ physical_lines=table.physical_lines,
280
+ bom=table.bom,
281
+ )
282
+ output_issues = validate_table(
283
+ cleaned,
284
+ settings,
285
+ "output",
286
+ include_blank_lines=False,
287
+ unconverted=result.unconverted,
288
+ )
289
+
290
+ removed_ids = {r.record for r in removed}
291
+ output_keys = {(i.code, i.record, i.column) for i in output_issues}
292
+ source_keys = {(i.code, i.record, i.column) for i in source_issues}
293
+ statused = []
294
+ for issue in source_issues:
295
+ if not table.complete:
296
+ status = "unresolved"
297
+ elif issue.record is not None and issue.record in removed_ids:
298
+ status = "removed"
299
+ elif (issue.code, issue.record, issue.column) in output_keys:
300
+ status = "unresolved"
301
+ else:
302
+ status = "resolved"
303
+ statused.append(replace(issue, status=status))
304
+ introduced = [
305
+ replace(i, introduced=(i.code, i.record, i.column) not in source_keys)
306
+ for i in output_issues
307
+ ]
308
+ return CleanResult(
309
+ source_path=str(path),
310
+ profile_path=loaded.path if loaded else None,
311
+ settings=settings,
312
+ scan=_scan(table),
313
+ header=table.header,
314
+ rows=rows,
315
+ source_issues=tuple(statused),
316
+ output_issues=tuple(introduced),
317
+ changes=tuple(changes),
318
+ removed=tuple(removed),
319
+ output_profile=output_profile(settings),
320
+ source_file=Path(path).resolve(),
321
+ profile_file=_profile_file(loaded),
322
+ )
323
+
324
+
325
+ def protected_inputs(result: CheckResult | CleanResult) -> dict[str, Path]:
326
+ protected = {"input CSV": result.source_file or Path(result.source_path).resolve()}
327
+ if result.profile_file is not None:
328
+ protected["input profile"] = result.profile_file
329
+ elif result.profile_path:
330
+ protected["input profile"] = Path(result.profile_path).resolve()
331
+ return protected
332
+
333
+
334
+ def export_clean(
335
+ result: CleanResult,
336
+ output: str | Path,
337
+ *,
338
+ output_profile: str | Path | None = None,
339
+ overwrite: bool = False,
340
+ ) -> tuple[ArtifactOutcome, ...]:
341
+ """Write the cleaned CSV (and optionally its output profile) after safety checks.
342
+
343
+ Raises ``OutputError`` if output errors remain, a path aliases an input or another
344
+ artifact, a file exists without ``overwrite``, or a write fails. Each file is replaced
345
+ atomically; if the profile write fails after the CSV was written, the error's
346
+ ``outcomes`` record that the CSV was written.
347
+ """
348
+ if not result.export_ready:
349
+ raise OutputError(f"export blocked: {result.export_blocked_reason}")
350
+ output = Path(output)
351
+ profile = Path(output_profile) if output_profile is not None else None
352
+ validate_artifact_paths(
353
+ protected_inputs(result), {OUTPUT_CSV: output, OUTPUT_PROFILE: profile}, overwrite
354
+ )
355
+ out = result.settings.output
356
+ csv_bytes = encode_text(result.csv_text(), out.encoding, "output CSV")
357
+ profile_bytes = (
358
+ encode_text(result.output_profile_toml(), "utf-8", "output profile") if profile else None
359
+ )
360
+ outcomes = [write_artifact(output, csv_bytes, overwrite, OUTPUT_CSV)]
361
+ if profile is not None and profile_bytes is not None:
362
+ try:
363
+ outcomes.append(write_artifact(profile, profile_bytes, overwrite, OUTPUT_PROFILE))
364
+ except OutputError as exc:
365
+ outcomes.append(ArtifactOutcome(OUTPUT_PROFILE, str(profile), "failed", str(exc)))
366
+ raise OutputError(str(exc), tuple(outcomes)) from None
367
+ return tuple(outcomes)