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 +61 -0
- datakrit/__main__.py +3 -0
- datakrit/api.py +367 -0
- datakrit/batch.py +593 -0
- datakrit/cli.py +992 -0
- datakrit/errors.py +32 -0
- datakrit/formats.py +203 -0
- datakrit/normalization.py +190 -0
- datakrit/output.py +190 -0
- datakrit/profile_init.py +445 -0
- datakrit/py.typed +0 -0
- datakrit/reader.py +209 -0
- datakrit/reporting.py +683 -0
- datakrit/schema.py +691 -0
- datakrit/shortcuts.py +363 -0
- datakrit/ui.py +894 -0
- datakrit/ui_static/app.css +619 -0
- datakrit/ui_static/app.js +2161 -0
- datakrit/ui_static/index.html +489 -0
- datakrit/ui_static/logo-mark.png +0 -0
- datakrit/ui_static/theme.js +31 -0
- datakrit/update.py +167 -0
- datakrit/validation.py +452 -0
- datakrit-2.1.1.dist-info/METADATA +717 -0
- datakrit-2.1.1.dist-info/RECORD +29 -0
- datakrit-2.1.1.dist-info/WHEEL +4 -0
- datakrit-2.1.1.dist-info/entry_points.txt +5 -0
- datakrit-2.1.1.dist-info/licenses/LICENSE +21 -0
- datakrit-2.1.1.dist-info/licenses/NOTICE.md +84 -0
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
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)
|