threshio 0.1.0__py3-none-win_amd64.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.
- threshio/__init__.py +1108 -0
- threshio/libthresh.dll +0 -0
- threshio/py.typed +0 -0
- threshio-0.1.0.dist-info/METADATA +260 -0
- threshio-0.1.0.dist-info/RECORD +8 -0
- threshio-0.1.0.dist-info/WHEEL +4 -0
- threshio-0.1.0.dist-info/licenses/LICENSE +202 -0
- threshio-0.1.0.dist-info/licenses/NOTICE +92 -0
threshio/__init__.py
ADDED
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
"""Know a delimited file before your code reads it.
|
|
2
|
+
|
|
3
|
+
A CSV, TSV or pipe-delimited export arrives from somewhere you do not
|
|
4
|
+
control. threshio says how it is written (its dialect), what its columns
|
|
5
|
+
hold (a schema it infers), and whether its rows fit the schema you expect,
|
|
6
|
+
row by row and value by value, with a stable code for everything that is
|
|
7
|
+
wrong. Then it hands the rows that fit, and the rows that do not, to
|
|
8
|
+
pyarrow, polars or DuckDB without a copy.
|
|
9
|
+
|
|
10
|
+
>>> import threshio
|
|
11
|
+
>>> schema = threshio.infer("data/harvest.csv").schema
|
|
12
|
+
>>> report = threshio.validate("data/harvest_dirty.csv", schema)
|
|
13
|
+
>>> print(report)
|
|
14
|
+
fail data/harvest_dirty.csv 30 rows 4 rejected
|
|
15
|
+
>>> import pyarrow
|
|
16
|
+
>>> table = pyarrow.table(threshio.read("data/harvest.csv", schema))
|
|
17
|
+
|
|
18
|
+
A file's own trouble (it is missing, its dialect is not settled, a value
|
|
19
|
+
does not fit) is in what a call returns, not an exception. Exceptions are
|
|
20
|
+
for a call made wrongly, a schema refused, a read that failed partway, a
|
|
21
|
+
cancel, and the two calls whose point is to stop: check() and read().
|
|
22
|
+
|
|
23
|
+
These are bindings to libthresh, the C library of thresh
|
|
24
|
+
(https://github.com/kmoneil/thresh), which ships inside this package.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import ctypes
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, NoReturn, Optional, Tuple, Union
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ArgumentError",
|
|
38
|
+
"Cancel",
|
|
39
|
+
"ColumnReport",
|
|
40
|
+
"Count",
|
|
41
|
+
"Description",
|
|
42
|
+
"Dialect",
|
|
43
|
+
"Error",
|
|
44
|
+
"FileFailed",
|
|
45
|
+
"FileOutcome",
|
|
46
|
+
"Inference",
|
|
47
|
+
"Interrupted",
|
|
48
|
+
"Problem",
|
|
49
|
+
"ReadError",
|
|
50
|
+
"Reader",
|
|
51
|
+
"RejectedLine",
|
|
52
|
+
"Report",
|
|
53
|
+
"RowError",
|
|
54
|
+
"Sample",
|
|
55
|
+
"Schema",
|
|
56
|
+
"SchemaColumn",
|
|
57
|
+
"SchemaError",
|
|
58
|
+
"Sniff",
|
|
59
|
+
"Stats",
|
|
60
|
+
"check",
|
|
61
|
+
"columns",
|
|
62
|
+
"describe",
|
|
63
|
+
"infer",
|
|
64
|
+
"read",
|
|
65
|
+
"sniff",
|
|
66
|
+
"validate",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
70
|
+
SchemaLike = Union["Schema", Mapping[str, Any], str, "os.PathLike[str]"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# The library and its structs, as include/thresh.h declares them.
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Options(ctypes.Structure):
|
|
78
|
+
_fields_ = [
|
|
79
|
+
("size", ctypes.c_size_t),
|
|
80
|
+
("flags", ctypes.c_uint32),
|
|
81
|
+
("quote", ctypes.c_int32),
|
|
82
|
+
("delimiter", ctypes.c_uint8),
|
|
83
|
+
("escape", ctypes.c_uint8),
|
|
84
|
+
("newline", ctypes.c_uint8),
|
|
85
|
+
("header", ctypes.c_uint8),
|
|
86
|
+
("comment", ctypes.c_uint8),
|
|
87
|
+
("ragged", ctypes.c_uint8),
|
|
88
|
+
("encoding", ctypes.c_char_p),
|
|
89
|
+
("nulls", ctypes.POINTER(ctypes.c_char_p)),
|
|
90
|
+
("nulls_len", ctypes.c_size_t),
|
|
91
|
+
("sample_bytes", ctypes.c_uint64),
|
|
92
|
+
("sample_rows", ctypes.c_uint64),
|
|
93
|
+
("threshold", ctypes.c_double),
|
|
94
|
+
("jobs", ctypes.c_uint32),
|
|
95
|
+
("cancel", ctypes.POINTER(ctypes.c_uint8)),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _ArrowSchema(ctypes.Structure):
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_ArrowSchema._fields_ = [
|
|
104
|
+
("format", ctypes.c_void_p),
|
|
105
|
+
("name", ctypes.c_void_p),
|
|
106
|
+
("metadata", ctypes.c_void_p),
|
|
107
|
+
("flags", ctypes.c_int64),
|
|
108
|
+
("n_children", ctypes.c_int64),
|
|
109
|
+
("children", ctypes.c_void_p),
|
|
110
|
+
("dictionary", ctypes.c_void_p),
|
|
111
|
+
("release", ctypes.CFUNCTYPE(None, ctypes.POINTER(_ArrowSchema))),
|
|
112
|
+
("private_data", ctypes.c_void_p),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _ArrowArrayStream(ctypes.Structure):
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_StreamPtr = ctypes.POINTER(_ArrowArrayStream)
|
|
121
|
+
_ArrowArrayStream._fields_ = [
|
|
122
|
+
("get_schema", ctypes.CFUNCTYPE(ctypes.c_int, _StreamPtr, ctypes.POINTER(_ArrowSchema))),
|
|
123
|
+
("get_next", ctypes.c_void_p),
|
|
124
|
+
("get_last_error", ctypes.CFUNCTYPE(ctypes.c_char_p, _StreamPtr)),
|
|
125
|
+
("release", ctypes.CFUNCTYPE(None, _StreamPtr)),
|
|
126
|
+
("private_data", ctypes.c_void_p),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load() -> ctypes.CDLL:
|
|
131
|
+
path = os.environ.get("THRESHIO_LIBRARY")
|
|
132
|
+
if not path:
|
|
133
|
+
name = {"win32": "libthresh.dll", "darwin": "libthresh.dylib"}.get(sys.platform, "libthresh.so")
|
|
134
|
+
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), name)
|
|
135
|
+
lib = ctypes.CDLL(path)
|
|
136
|
+
P = ctypes.POINTER
|
|
137
|
+
h, s, out = ctypes.c_void_p, ctypes.c_char_p, P(ctypes.c_void_p)
|
|
138
|
+
sigs = {
|
|
139
|
+
"thresh_version": ([], ctypes.c_char_p),
|
|
140
|
+
"thresh_new": ([], h),
|
|
141
|
+
"thresh_free": ([h], None),
|
|
142
|
+
"thresh_error": ([h], ctypes.c_char_p),
|
|
143
|
+
"thresh_string_free": ([ctypes.c_void_p], None),
|
|
144
|
+
"thresh_sniff": ([h, s, P(_Options), out], ctypes.c_int),
|
|
145
|
+
"thresh_infer": ([h, P(ctypes.c_char_p), ctypes.c_size_t, P(_Options), out], ctypes.c_int),
|
|
146
|
+
"thresh_columns": ([h, s, out], ctypes.c_int),
|
|
147
|
+
"thresh_validate": ([h, s, s, P(_Options), out], ctypes.c_int),
|
|
148
|
+
"thresh_describe": ([h, s, s, P(_Options), out], ctypes.c_int),
|
|
149
|
+
"thresh_read": ([h, s, s, P(_Options), ctypes.c_uint32, out, out], ctypes.c_int),
|
|
150
|
+
"thresh_reader_rows": ([h, ctypes.c_void_p], ctypes.c_int),
|
|
151
|
+
"thresh_reader_rejects": ([h, ctypes.c_void_p], ctypes.c_int),
|
|
152
|
+
"thresh_reader_report": ([h, out], ctypes.c_int),
|
|
153
|
+
"thresh_reader_error": ([h], ctypes.c_char_p),
|
|
154
|
+
"thresh_reader_free": ([h], None),
|
|
155
|
+
}
|
|
156
|
+
for fn_name, (args, res) in sigs.items():
|
|
157
|
+
fn = getattr(lib, fn_name)
|
|
158
|
+
fn.argtypes = args
|
|
159
|
+
fn.restype = res
|
|
160
|
+
return lib
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_lib = _load()
|
|
164
|
+
|
|
165
|
+
#: The version of thresh inside this package, which is the package's own.
|
|
166
|
+
__version__: str = _lib.thresh_version().decode()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Errors
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Error(Exception):
|
|
174
|
+
"""What threshio raises. A file's own trouble is a report instead,
|
|
175
|
+
except from check() and read(), which raise FileFailed."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ArgumentError(Error, ValueError):
|
|
179
|
+
"""A call made wrongly: an option out of range, a stream taken twice."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class SchemaError(Error, ValueError):
|
|
183
|
+
"""The schema was refused; the message says why."""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ReadError(Error, OSError):
|
|
187
|
+
"""Reading failed partway through, or a file could not be read for a
|
|
188
|
+
reason that is not the file's own."""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Interrupted(Error):
|
|
192
|
+
"""The call's Cancel was set."""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class FileFailed(Error):
|
|
196
|
+
"""check() found the file does not pass, or read() could not read it.
|
|
197
|
+
|
|
198
|
+
``report`` is the file's Report when there is one; ``problems`` says
|
|
199
|
+
why in either case.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, file: str, result: str, problems: List["Problem"], report: Optional["Report"] = None):
|
|
203
|
+
self.file = file
|
|
204
|
+
self.result = result
|
|
205
|
+
self.problems = problems
|
|
206
|
+
self.report = report
|
|
207
|
+
if problems:
|
|
208
|
+
why = problems[0].message
|
|
209
|
+
elif report is not None:
|
|
210
|
+
why = "%d of %d rows rejected" % (report.rows_rejected, report.rows)
|
|
211
|
+
else:
|
|
212
|
+
why = "no reason given"
|
|
213
|
+
super().__init__("%s: %s: %s" % (file, result, why))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _raise(status: int, message: Optional[bytes]) -> NoReturn:
|
|
217
|
+
text = (message or b"").decode("utf-8", "replace")
|
|
218
|
+
if status == 4:
|
|
219
|
+
raise MemoryError(text or "thresh: out of memory")
|
|
220
|
+
kind = {1: ArgumentError, 2: SchemaError, 3: ReadError, 5: Interrupted}.get(status, Error)
|
|
221
|
+
raise kind(text or "thresh: status %d" % status)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Options
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class Cancel:
|
|
229
|
+
"""Stops a call from another thread: pass ``cancel=c`` to any call (or
|
|
230
|
+
read()) and ``c.set()`` makes it raise Interrupted at its next batch.
|
|
231
|
+
|
|
232
|
+
>>> c = threshio.Cancel()
|
|
233
|
+
>>> threading.Timer(5.0, c.set).start()
|
|
234
|
+
>>> threshio.validate("huge.csv", schema, cancel=c)
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def __init__(self) -> None:
|
|
238
|
+
self._byte = ctypes.c_uint8(0)
|
|
239
|
+
|
|
240
|
+
def set(self) -> None:
|
|
241
|
+
self._byte.value = 1
|
|
242
|
+
|
|
243
|
+
def is_set(self) -> bool:
|
|
244
|
+
return self._byte.value != 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_ESCAPES = {"doubled": 1, "backslash": 2}
|
|
248
|
+
_NEWLINES = {"lf": 1, "crlf": 2, "cr": 3}
|
|
249
|
+
_RAGGED = {"pad": 1, "truncate": 2}
|
|
250
|
+
_ENCODINGS = ("utf8", "latin1", "cp1252")
|
|
251
|
+
_FLAGS = {"keep_empty": 1, "day_first": 2, "strict": 4, "shapes_only": 32, "shape_names": 64}
|
|
252
|
+
_NO_MMAP = 8
|
|
253
|
+
_THRESHOLD = 16
|
|
254
|
+
_OPTIONS = (
|
|
255
|
+
"delimiter quote escape newline header comment encoding nulls ragged sample_bytes sample_rows "
|
|
256
|
+
"threshold jobs keep_empty day_first strict mmap shapes_only shape_names cancel"
|
|
257
|
+
).split()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _byte(name: str, value: Any) -> int:
|
|
261
|
+
if isinstance(value, bytes) and len(value) == 1:
|
|
262
|
+
return value[0]
|
|
263
|
+
if isinstance(value, str) and len(value) == 1 and ord(value) < 256:
|
|
264
|
+
return ord(value)
|
|
265
|
+
raise ValueError("%s: one character (one byte), as ',' or '\\t'; not %r" % (name, value))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _choice(name: str, value: Any, choices: Mapping[str, int]) -> int:
|
|
269
|
+
if isinstance(value, str) and value in choices:
|
|
270
|
+
return choices[value]
|
|
271
|
+
raise ValueError("%s: one of %s; not %r" % (name, ", ".join(choices), value))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _count(name: str, value: Any) -> int:
|
|
275
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
276
|
+
raise ValueError("%s: a whole number, 0 or more; not %r" % (name, value))
|
|
277
|
+
return value
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _flag(name: str, value: Any) -> bool:
|
|
281
|
+
if not isinstance(value, bool):
|
|
282
|
+
raise ValueError("%s: True or False; not %r" % (name, value))
|
|
283
|
+
return value
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class _Built:
|
|
287
|
+
"""A thresh_options and what its pointers point at, kept alive with it."""
|
|
288
|
+
|
|
289
|
+
def __init__(self, options: Mapping[str, Any]):
|
|
290
|
+
unknown = sorted(set(options) - set(_OPTIONS))
|
|
291
|
+
if unknown:
|
|
292
|
+
raise TypeError("unknown option %r; the options are %s" % (unknown[0], ", ".join(_OPTIONS)))
|
|
293
|
+
self.struct = _Options()
|
|
294
|
+
self.struct.size = ctypes.sizeof(_Options)
|
|
295
|
+
self.keep: List[Any] = []
|
|
296
|
+
for name, value in options.items():
|
|
297
|
+
if value is not None:
|
|
298
|
+
self._set(name, value)
|
|
299
|
+
|
|
300
|
+
def _set(self, name: str, value: Any) -> None:
|
|
301
|
+
o = self.struct
|
|
302
|
+
if name in ("delimiter", "comment"):
|
|
303
|
+
setattr(o, name, _byte(name, value))
|
|
304
|
+
elif name == "quote":
|
|
305
|
+
o.quote = -1 if value is False else _byte(name, value)
|
|
306
|
+
elif name == "escape":
|
|
307
|
+
o.escape = _choice(name, value, _ESCAPES)
|
|
308
|
+
elif name == "newline":
|
|
309
|
+
o.newline = _choice(name, value, _NEWLINES)
|
|
310
|
+
elif name == "ragged":
|
|
311
|
+
o.ragged = _choice(name, value, _RAGGED)
|
|
312
|
+
elif name == "header":
|
|
313
|
+
o.header = 1 if _flag(name, value) else 2
|
|
314
|
+
elif name == "encoding":
|
|
315
|
+
_choice(name, value, dict.fromkeys(_ENCODINGS, 0))
|
|
316
|
+
o.encoding = self._kept(value.encode())
|
|
317
|
+
elif name == "nulls":
|
|
318
|
+
self._nulls(value)
|
|
319
|
+
elif name in ("sample_bytes", "sample_rows", "jobs"):
|
|
320
|
+
setattr(o, name, _count(name, value))
|
|
321
|
+
elif name == "threshold":
|
|
322
|
+
self._threshold(value)
|
|
323
|
+
elif name == "mmap":
|
|
324
|
+
if not _flag(name, value):
|
|
325
|
+
o.flags |= _NO_MMAP
|
|
326
|
+
elif name == "cancel":
|
|
327
|
+
if not isinstance(value, Cancel):
|
|
328
|
+
raise ValueError("cancel: a threshio.Cancel; not %r" % (value,))
|
|
329
|
+
o.cancel = ctypes.pointer(self._kept(value)._byte)
|
|
330
|
+
elif _flag(name, value):
|
|
331
|
+
o.flags |= _FLAGS[name]
|
|
332
|
+
|
|
333
|
+
def _kept(self, value: Any) -> Any:
|
|
334
|
+
self.keep.append(value)
|
|
335
|
+
return value
|
|
336
|
+
|
|
337
|
+
def _nulls(self, value: Any) -> None:
|
|
338
|
+
if isinstance(value, (str, bytes)) or not all(isinstance(v, str) for v in value):
|
|
339
|
+
raise ValueError("nulls: a list of strings, as ['', 'NA']; not %r" % (value,))
|
|
340
|
+
items = [v.encode() for v in value]
|
|
341
|
+
array = (ctypes.c_char_p * len(items))(*items)
|
|
342
|
+
self.struct.nulls = ctypes.cast(self._kept(array), ctypes.POINTER(ctypes.c_char_p))
|
|
343
|
+
self.struct.nulls_len = len(items)
|
|
344
|
+
self._kept(items)
|
|
345
|
+
|
|
346
|
+
def _threshold(self, value: Any) -> None:
|
|
347
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 1:
|
|
348
|
+
raise ValueError("threshold: a fraction from 0 to 1; not %r" % (value,))
|
|
349
|
+
self.struct.threshold = float(value)
|
|
350
|
+
self.struct.flags |= _THRESHOLD
|
|
351
|
+
|
|
352
|
+
def pointer(self) -> Any:
|
|
353
|
+
return ctypes.byref(self.struct)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Calls
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _path(path: PathLike) -> bytes:
|
|
361
|
+
return os.fsencode(os.fspath(path))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _take(out: ctypes.c_void_p) -> Optional[str]:
|
|
365
|
+
if not out.value:
|
|
366
|
+
return None
|
|
367
|
+
try:
|
|
368
|
+
return ctypes.string_at(out.value).decode("utf-8")
|
|
369
|
+
finally:
|
|
370
|
+
_lib.thresh_string_free(out.value)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _call(fn: Callable[..., int], *args: Any) -> str:
|
|
374
|
+
"""One call on a handle of its own (so any thread may make one), its
|
|
375
|
+
returned string read and freed."""
|
|
376
|
+
t = _lib.thresh_new()
|
|
377
|
+
if not t:
|
|
378
|
+
raise MemoryError("thresh: out of memory")
|
|
379
|
+
try:
|
|
380
|
+
out = ctypes.c_void_p()
|
|
381
|
+
status = fn(t, *args, ctypes.byref(out))
|
|
382
|
+
text = _take(out)
|
|
383
|
+
if status != 0:
|
|
384
|
+
_raise(status, _lib.thresh_error(t))
|
|
385
|
+
return text or ""
|
|
386
|
+
finally:
|
|
387
|
+
_lib.thresh_free(t)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _schema_json(schema: SchemaLike) -> bytes:
|
|
391
|
+
if isinstance(schema, Schema):
|
|
392
|
+
return schema.to_json().encode()
|
|
393
|
+
if isinstance(schema, Mapping):
|
|
394
|
+
return json.dumps(schema).encode()
|
|
395
|
+
if isinstance(schema, str):
|
|
396
|
+
if schema.lstrip().startswith("{"):
|
|
397
|
+
return schema.encode()
|
|
398
|
+
if not schema.endswith(".json"):
|
|
399
|
+
return columns(schema).to_json().encode()
|
|
400
|
+
if isinstance(schema, (str, os.PathLike)):
|
|
401
|
+
with open(schema, "rb") as f:
|
|
402
|
+
return f.read()
|
|
403
|
+
raise TypeError("schema: a Schema, a dict, JSON text, a .json path, or columns as 'id:int64,kg:decimal(5,2)'; not %r" % (schema,))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def sniff(path: PathLike, **options: Any) -> Sniff:
|
|
407
|
+
"""How the file is written: its delimiter, quote, escape, line breaks,
|
|
408
|
+
whether row 1 is a header, and how sure thresh is.
|
|
409
|
+
|
|
410
|
+
A file that cannot be read, or whose dialect is not settled, gives a
|
|
411
|
+
Sniff whose ``problems`` say why, not an exception.
|
|
412
|
+
"""
|
|
413
|
+
o = _Built(options)
|
|
414
|
+
return Sniff._of(json.loads(_call(_lib.thresh_sniff, _path(path), o.pointer())))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def infer(*paths: PathLike, **options: Any) -> Inference:
|
|
418
|
+
"""The schema the files' values fit, from reading every row (or the
|
|
419
|
+
first ``sample_rows``), with what thresh found in each file. Several
|
|
420
|
+
files are read at once (``jobs``, default one a CPU core) and the
|
|
421
|
+
schema fits them all."""
|
|
422
|
+
if not paths:
|
|
423
|
+
raise TypeError("infer: give at least one path")
|
|
424
|
+
o = _Built(options)
|
|
425
|
+
encoded = [_path(p) for p in paths]
|
|
426
|
+
array = (ctypes.c_char_p * len(encoded))(*encoded)
|
|
427
|
+
return Inference._of(json.loads(_call(_lib.thresh_infer, array, len(encoded), o.pointer())))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def columns(spec: str) -> Schema:
|
|
431
|
+
"""A Schema from the inline form: ``"lug_id:int64,lug_kg:decimal(5,2)"``.
|
|
432
|
+
Every column nullable, the nulls ``""``."""
|
|
433
|
+
return Schema(json.loads(_call(_lib.thresh_columns, spec.encode())))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def validate(path: PathLike, schema: SchemaLike, **options: Any) -> Report:
|
|
437
|
+
"""The file read against the schema: every value checked, every row
|
|
438
|
+
that does not fit set aside with why. ``report.passed`` says whether
|
|
439
|
+
it passed; a file that fails, or cannot be read, is a Report too."""
|
|
440
|
+
o = _Built(options)
|
|
441
|
+
text = _call(_lib.thresh_validate, _path(path), _schema_json(schema), o.pointer())
|
|
442
|
+
return Report._of(json.loads(text))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def check(path: PathLike, schema: SchemaLike, **options: Any) -> Report:
|
|
446
|
+
"""validate(), for code that should not go on with a file that fails:
|
|
447
|
+
its Report when it passes, FileFailed (with the Report) when not."""
|
|
448
|
+
report = validate(path, schema, **options)
|
|
449
|
+
if not report.passed:
|
|
450
|
+
raise FileFailed(report.file, report.result, report.problems, report)
|
|
451
|
+
return report
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def describe(path: PathLike, schema: Optional[SchemaLike] = None, **options: Any) -> Description:
|
|
455
|
+
"""Everything thresh can say of the file in one bounded document: read
|
|
456
|
+
against the schema, or with none, against one inferred from it first.
|
|
457
|
+
With ``shapes_only=True`` it holds none of the file's values (digits
|
|
458
|
+
become 9, letters a), so it can go to an AI or into an issue."""
|
|
459
|
+
o = _Built(options)
|
|
460
|
+
text = _call(_lib.thresh_describe, _path(path), None if schema is None else _schema_json(schema), o.pointer())
|
|
461
|
+
return Description._of(json.loads(text))
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def read(path: PathLike, schema: Optional[SchemaLike] = None, *, rejects: bool = False, **options: Any) -> Reader:
|
|
465
|
+
"""The file opened to read its rows as Arrow: pass the Reader to
|
|
466
|
+
``pyarrow.table``, ``polars.DataFrame`` or anything that takes the
|
|
467
|
+
Arrow PyCapsule stream protocol. With no schema, one is inferred from
|
|
468
|
+
the file first. With ``rejects=True``, ``reader.rejects`` is the rows
|
|
469
|
+
that did not fit, the same way.
|
|
470
|
+
|
|
471
|
+
A file that cannot be read at all raises FileFailed, with its report.
|
|
472
|
+
"""
|
|
473
|
+
if schema is None:
|
|
474
|
+
inference = infer(path, **options)
|
|
475
|
+
if inference.schema is None:
|
|
476
|
+
outcome = inference.files[0]
|
|
477
|
+
raise FileFailed(outcome.file, outcome.result, outcome.problems + inference.problems)
|
|
478
|
+
schema = inference.schema
|
|
479
|
+
o = _Built(options)
|
|
480
|
+
t = _lib.thresh_new()
|
|
481
|
+
if not t:
|
|
482
|
+
raise MemoryError("thresh: out of memory")
|
|
483
|
+
try:
|
|
484
|
+
reader, out = ctypes.c_void_p(), ctypes.c_void_p()
|
|
485
|
+
want = 1 | (2 if rejects else 0)
|
|
486
|
+
status = _lib.thresh_read(t, _path(path), _schema_json(schema), o.pointer(), want, ctypes.byref(reader), ctypes.byref(out))
|
|
487
|
+
text = _take(out)
|
|
488
|
+
if status != 0:
|
|
489
|
+
_raise(status, _lib.thresh_error(t))
|
|
490
|
+
finally:
|
|
491
|
+
_lib.thresh_free(t)
|
|
492
|
+
if not reader.value:
|
|
493
|
+
report = Report._of(json.loads(text or "{}"))
|
|
494
|
+
raise FileFailed(report.file, report.result, report.problems, report)
|
|
495
|
+
return Reader(reader.value, rejects, o)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
# Arrow streams, handed out as PyCapsules
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
_capsule_new = ctypes.pythonapi.PyCapsule_New
|
|
503
|
+
_capsule_new.restype = ctypes.py_object
|
|
504
|
+
_capsule_new.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p]
|
|
505
|
+
_capsule_pointer = ctypes.PYFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p)(("PyCapsule_GetPointer", ctypes.pythonapi))
|
|
506
|
+
_raw_malloc = ctypes.pythonapi.PyMem_RawMalloc
|
|
507
|
+
_raw_malloc.restype = ctypes.c_void_p
|
|
508
|
+
_raw_malloc.argtypes = [ctypes.c_size_t]
|
|
509
|
+
_raw_free = ctypes.pythonapi.PyMem_RawFree
|
|
510
|
+
_raw_free.restype = None
|
|
511
|
+
_raw_free.argtypes = [ctypes.c_void_p]
|
|
512
|
+
|
|
513
|
+
# A capsule keeps a pointer to its name, so the names live as long as the
|
|
514
|
+
# module.
|
|
515
|
+
_STREAM = b"arrow_array_stream"
|
|
516
|
+
_SCHEMA = b"arrow_schema"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _zeroed(size: int) -> int:
|
|
520
|
+
ptr = _raw_malloc(size)
|
|
521
|
+
if not ptr:
|
|
522
|
+
raise MemoryError("out of memory for an Arrow struct")
|
|
523
|
+
ctypes.memset(ptr, 0, size)
|
|
524
|
+
return ptr
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _free_stream(ptr: int) -> None:
|
|
528
|
+
# A consumer that took the stream moved it out and left release NULL;
|
|
529
|
+
# one never taken is released here.
|
|
530
|
+
stream = _ArrowArrayStream.from_address(ptr)
|
|
531
|
+
if stream.release:
|
|
532
|
+
stream.release(ctypes.cast(ptr, _StreamPtr))
|
|
533
|
+
_raw_free(ptr)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _free_schema(ptr: int) -> None:
|
|
537
|
+
schema = _ArrowSchema.from_address(ptr)
|
|
538
|
+
if schema.release:
|
|
539
|
+
schema.release(ctypes.cast(ptr, ctypes.POINTER(_ArrowSchema)))
|
|
540
|
+
_raw_free(ptr)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@ctypes.CFUNCTYPE(None, ctypes.c_void_p)
|
|
544
|
+
def _drop_stream_capsule(capsule: int) -> None:
|
|
545
|
+
ptr = _capsule_pointer(capsule, _STREAM)
|
|
546
|
+
if ptr:
|
|
547
|
+
_free_stream(ptr)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@ctypes.CFUNCTYPE(None, ctypes.c_void_p)
|
|
551
|
+
def _drop_schema_capsule(capsule: int) -> None:
|
|
552
|
+
ptr = _capsule_pointer(capsule, _SCHEMA)
|
|
553
|
+
if ptr:
|
|
554
|
+
_free_schema(ptr)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _capsule(ptr: int, name: bytes, drop: Any, free: Callable[[int], None]) -> Any:
|
|
558
|
+
try:
|
|
559
|
+
return _capsule_new(ptr, name, ctypes.cast(drop, ctypes.c_void_p))
|
|
560
|
+
except BaseException:
|
|
561
|
+
free(ptr)
|
|
562
|
+
raise
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class _Stream:
|
|
566
|
+
"""One of a Reader's streams. It is taken from the reader when first
|
|
567
|
+
asked for, so its schema can be read (``__arrow_c_schema__``, which
|
|
568
|
+
DuckDB asks for before the rows) without using it up, and then handed
|
|
569
|
+
out once."""
|
|
570
|
+
|
|
571
|
+
def __init__(self, reader: "Reader", take: Callable[[int, int], int], what: str):
|
|
572
|
+
self._reader = reader
|
|
573
|
+
self._take = take
|
|
574
|
+
self._what = what
|
|
575
|
+
self._ptr: Optional[int] = None
|
|
576
|
+
self._handed = False
|
|
577
|
+
|
|
578
|
+
def __arrow_c_schema__(self) -> Any:
|
|
579
|
+
ptr = self._held()
|
|
580
|
+
stream = _ArrowArrayStream.from_address(ptr)
|
|
581
|
+
schema = _zeroed(ctypes.sizeof(_ArrowSchema))
|
|
582
|
+
status = stream.get_schema(ctypes.cast(ptr, _StreamPtr), ctypes.cast(schema, ctypes.POINTER(_ArrowSchema)))
|
|
583
|
+
if status != 0:
|
|
584
|
+
_raw_free(schema)
|
|
585
|
+
message = stream.get_last_error(ctypes.cast(ptr, _StreamPtr)) or b""
|
|
586
|
+
raise ReadError(message.decode("utf-8", "replace") or "the %s stream gave no schema" % self._what)
|
|
587
|
+
return _capsule(schema, _SCHEMA, _drop_schema_capsule, _free_schema)
|
|
588
|
+
|
|
589
|
+
def __arrow_c_stream__(self, requested_schema: Any = None) -> Any:
|
|
590
|
+
"""The stream. ``requested_schema`` is ignored: the schema decides."""
|
|
591
|
+
ptr = self._held()
|
|
592
|
+
self._ptr, self._handed = None, True
|
|
593
|
+
return _capsule(ptr, _STREAM, _drop_stream_capsule, _free_stream)
|
|
594
|
+
|
|
595
|
+
def _held(self) -> int:
|
|
596
|
+
if self._handed:
|
|
597
|
+
raise ArgumentError("the %s stream was taken already: a Reader is read once" % self._what)
|
|
598
|
+
if self._ptr is None:
|
|
599
|
+
reader = self._reader._live()
|
|
600
|
+
ptr = _zeroed(ctypes.sizeof(_ArrowArrayStream))
|
|
601
|
+
status = self._take(reader, ptr)
|
|
602
|
+
if status != 0:
|
|
603
|
+
_raw_free(ptr)
|
|
604
|
+
_raise(status, _lib.thresh_reader_error(reader))
|
|
605
|
+
self._ptr = ptr
|
|
606
|
+
return self._ptr
|
|
607
|
+
|
|
608
|
+
def _drop(self) -> None:
|
|
609
|
+
if self._ptr is not None:
|
|
610
|
+
_free_stream(self._ptr)
|
|
611
|
+
self._ptr = None
|
|
612
|
+
|
|
613
|
+
def __del__(self) -> None:
|
|
614
|
+
if getattr(self, "_ptr", None) is not None:
|
|
615
|
+
self._drop()
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
class Reader:
|
|
619
|
+
"""A file opened by read(). It is the rows' stream itself: pass it to
|
|
620
|
+
``pyarrow.table(reader)``, ``polars.DataFrame(reader)``, a DuckDB
|
|
621
|
+
query, or anything that takes the Arrow PyCapsule protocol. A Reader is
|
|
622
|
+
read once.
|
|
623
|
+
|
|
624
|
+
Use a Reader from one thread at a time. A stream taken from it is
|
|
625
|
+
independent of it: it may be read after the Reader is closed.
|
|
626
|
+
"""
|
|
627
|
+
|
|
628
|
+
def __init__(self, ptr: int, rejects: bool, options: _Built):
|
|
629
|
+
self._ptr: Optional[int] = ptr
|
|
630
|
+
self._options = options # its cancel byte must outlive the streams
|
|
631
|
+
self._rows = _Stream(self, _lib.thresh_reader_rows, "rows")
|
|
632
|
+
self._rejects = _Stream(self, _lib.thresh_reader_rejects, "rejects") if rejects else None
|
|
633
|
+
|
|
634
|
+
def __arrow_c_schema__(self) -> Any:
|
|
635
|
+
"""The rows' schema: a struct, a field a schema column."""
|
|
636
|
+
return self._rows.__arrow_c_schema__()
|
|
637
|
+
|
|
638
|
+
def __arrow_c_stream__(self, requested_schema: Any = None) -> Any:
|
|
639
|
+
"""The accepted rows, a struct array a batch, a field a schema
|
|
640
|
+
column. ``requested_schema`` is ignored: the schema decides."""
|
|
641
|
+
return self._rows.__arrow_c_stream__()
|
|
642
|
+
|
|
643
|
+
@property
|
|
644
|
+
def rejects(self) -> _Stream:
|
|
645
|
+
"""The rejected rows, a stream of their own: the file's fields as
|
|
646
|
+
text (null where a short row had none), then ``_row``, ``_errors``
|
|
647
|
+
(a list of {column, code}) and ``_extra``. Only with
|
|
648
|
+
``read(..., rejects=True)``."""
|
|
649
|
+
if self._rejects is None:
|
|
650
|
+
raise ArgumentError("read(..., rejects=True) to read the rejected rows")
|
|
651
|
+
return self._rejects
|
|
652
|
+
|
|
653
|
+
def report(self) -> Report:
|
|
654
|
+
"""The file's Report. What the streams have not read yet is read
|
|
655
|
+
first (and kept for them), so this can come before or after."""
|
|
656
|
+
out = ctypes.c_void_p()
|
|
657
|
+
status = _lib.thresh_reader_report(self._live(), ctypes.byref(out))
|
|
658
|
+
text = _take(out)
|
|
659
|
+
if status != 0:
|
|
660
|
+
_raise(status, _lib.thresh_reader_error(self._ptr))
|
|
661
|
+
return Report._of(json.loads(text or "{}"))
|
|
662
|
+
|
|
663
|
+
def close(self) -> None:
|
|
664
|
+
"""Frees the reader, and a stream asked for its schema and never
|
|
665
|
+
taken. Streams already taken stay readable."""
|
|
666
|
+
for s in (self._rows, self._rejects):
|
|
667
|
+
if s is not None:
|
|
668
|
+
s._drop()
|
|
669
|
+
if self._ptr:
|
|
670
|
+
_lib.thresh_reader_free(self._ptr)
|
|
671
|
+
self._ptr = None
|
|
672
|
+
|
|
673
|
+
def __enter__(self) -> "Reader":
|
|
674
|
+
return self
|
|
675
|
+
|
|
676
|
+
def __exit__(self, *exc: Any) -> None:
|
|
677
|
+
self.close()
|
|
678
|
+
|
|
679
|
+
def __del__(self) -> None:
|
|
680
|
+
if getattr(self, "_ptr", None):
|
|
681
|
+
self.close()
|
|
682
|
+
|
|
683
|
+
def _live(self) -> int:
|
|
684
|
+
if not self._ptr:
|
|
685
|
+
raise ArgumentError("the Reader is closed")
|
|
686
|
+
return self._ptr
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# ---------------------------------------------------------------------------
|
|
690
|
+
# What the calls return, from the JSON documents of docs/REPORT.md. Each
|
|
691
|
+
# keeps the whole document in ``raw``; a field a later thresh adds is
|
|
692
|
+
# there before it has an attribute.
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
class _Document:
|
|
696
|
+
raw: Dict[str, Any]
|
|
697
|
+
|
|
698
|
+
def to_json(self, indent: Optional[int] = 2) -> str:
|
|
699
|
+
"""The document as thresh wrote it (docs/REPORT.md), for code, a
|
|
700
|
+
file, or an AI."""
|
|
701
|
+
return json.dumps(self.raw, indent=indent, ensure_ascii=False)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _problems(items: Optional[Iterable[Mapping[str, Any]]]) -> List["Problem"]:
|
|
705
|
+
return [Problem._of(p) for p in items or ()]
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@dataclass(frozen=True)
|
|
709
|
+
class Problem:
|
|
710
|
+
"""Something thresh has to say about a file, with a stable ``code``
|
|
711
|
+
(docs/PROBLEMS.md), a sentence, the code's parameters, and the fix
|
|
712
|
+
when thresh knows one."""
|
|
713
|
+
|
|
714
|
+
code: str
|
|
715
|
+
message: str
|
|
716
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
717
|
+
suggest: Dict[str, Any] = field(default_factory=dict)
|
|
718
|
+
|
|
719
|
+
@classmethod
|
|
720
|
+
def _of(cls, d: Mapping[str, Any]) -> "Problem":
|
|
721
|
+
params = {k: v for k, v in d.items() if k not in ("code", "message", "suggest")}
|
|
722
|
+
return cls(d["code"], d["message"], params, dict(d.get("suggest") or {}))
|
|
723
|
+
|
|
724
|
+
@property
|
|
725
|
+
def fix(self) -> Dict[str, Any]:
|
|
726
|
+
"""thresh's suggested fix as this package's options: call again
|
|
727
|
+
with them, ``threshio.validate(path, schema, **problem.fix)``.
|
|
728
|
+
Empty when thresh knows no fix."""
|
|
729
|
+
out: Dict[str, Any] = {}
|
|
730
|
+
for key, value in self.suggest.items():
|
|
731
|
+
if key == "header":
|
|
732
|
+
out["header"] = value == "yes"
|
|
733
|
+
elif key == "quote":
|
|
734
|
+
out["quote"] = False if value == "none" else value
|
|
735
|
+
elif key == "encoding" and isinstance(value, list):
|
|
736
|
+
out["encoding"] = value[0]
|
|
737
|
+
else:
|
|
738
|
+
out[key] = value
|
|
739
|
+
return out
|
|
740
|
+
|
|
741
|
+
def __str__(self) -> str:
|
|
742
|
+
return self.message
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@dataclass(frozen=True)
|
|
746
|
+
class Dialect:
|
|
747
|
+
"""How a file is written, and how sure thresh is (``confidence`` is
|
|
748
|
+
high, medium or low)."""
|
|
749
|
+
|
|
750
|
+
delimiter: str
|
|
751
|
+
quote: Optional[str]
|
|
752
|
+
escape: str
|
|
753
|
+
newline: str
|
|
754
|
+
header: bool
|
|
755
|
+
confidence: str
|
|
756
|
+
score: float
|
|
757
|
+
comment: Optional[str] = None
|
|
758
|
+
runners_up: Tuple[Tuple[str, float], ...] = ()
|
|
759
|
+
|
|
760
|
+
@classmethod
|
|
761
|
+
def _of(cls, d: Optional[Mapping[str, Any]]) -> Optional["Dialect"]:
|
|
762
|
+
if d is None:
|
|
763
|
+
return None
|
|
764
|
+
runners = tuple((r["delimiter"], r["score"]) for r in d.get("runners_up", ()))
|
|
765
|
+
return cls(d["delimiter"], d["quote"], d["escape"], d["newline"], d["header"], d["confidence"], d["score"], d.get("comment"), runners)
|
|
766
|
+
|
|
767
|
+
@property
|
|
768
|
+
def options(self) -> Dict[str, Any]:
|
|
769
|
+
"""This dialect as options, to pin it for this file or the next
|
|
770
|
+
like it: ``threshio.validate(path, schema, **dialect.options)``."""
|
|
771
|
+
o: Dict[str, Any] = {
|
|
772
|
+
"delimiter": self.delimiter,
|
|
773
|
+
"quote": False if self.quote is None else self.quote,
|
|
774
|
+
"escape": self.escape,
|
|
775
|
+
"newline": self.newline,
|
|
776
|
+
"header": self.header,
|
|
777
|
+
}
|
|
778
|
+
if self.comment is not None:
|
|
779
|
+
o["comment"] = self.comment
|
|
780
|
+
return o
|
|
781
|
+
|
|
782
|
+
def __str__(self) -> str:
|
|
783
|
+
quote = "none" if self.quote is None else self.quote
|
|
784
|
+
return "delimiter=%s quote=%s escape=%s newline=%s header=%s confidence=%s" % (
|
|
785
|
+
_shown(self.delimiter),
|
|
786
|
+
quote,
|
|
787
|
+
self.escape,
|
|
788
|
+
self.newline,
|
|
789
|
+
"yes" if self.header else "no",
|
|
790
|
+
self.confidence,
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _shown(c: str) -> str:
|
|
795
|
+
return {"\t": "\\t", " ": "space"}.get(c, c)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@dataclass
|
|
799
|
+
class Sniff(_Document):
|
|
800
|
+
"""What sniff() found: the dialect, what is unusual, and what stops
|
|
801
|
+
the file (``problems``; empty when nothing does)."""
|
|
802
|
+
|
|
803
|
+
file: str
|
|
804
|
+
dialect: Optional[Dialect]
|
|
805
|
+
warnings: List[str]
|
|
806
|
+
problems: List[Problem]
|
|
807
|
+
raw: Dict[str, Any] = field(repr=False)
|
|
808
|
+
|
|
809
|
+
@classmethod
|
|
810
|
+
def _of(cls, d: Dict[str, Any]) -> "Sniff":
|
|
811
|
+
return cls(d["file"], Dialect._of(d.get("dialect")), list(d.get("warnings", ())), _problems(d.get("problems")), d)
|
|
812
|
+
|
|
813
|
+
@property
|
|
814
|
+
def ok(self) -> bool:
|
|
815
|
+
"""The dialect is settled and nothing stops the file."""
|
|
816
|
+
return self.dialect is not None and not self.problems
|
|
817
|
+
|
|
818
|
+
def __str__(self) -> str:
|
|
819
|
+
if self.dialect is None or self.problems:
|
|
820
|
+
return "%s: %s" % (self.file, self.problems[0].message if self.problems else "no dialect")
|
|
821
|
+
return "%s: %s" % (self.file, self.dialect)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
@dataclass(frozen=True)
|
|
825
|
+
class SchemaColumn:
|
|
826
|
+
"""One column of a Schema: ``raw`` has every field it was given."""
|
|
827
|
+
|
|
828
|
+
name: str
|
|
829
|
+
type: str
|
|
830
|
+
nullable: bool
|
|
831
|
+
raw: Dict[str, Any] = field(repr=False, compare=False)
|
|
832
|
+
|
|
833
|
+
def __str__(self) -> str:
|
|
834
|
+
return "%s %s%s" % (self.name, self.type, "" if self.nullable else " not null")
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
class Schema(_Document):
|
|
838
|
+
"""The columns a file should have, their types, nulls and formats, as
|
|
839
|
+
docs/REPORT.md's schema: what infer() writes, columns() builds, and
|
|
840
|
+
validate(), check(), describe() and read() take. A dict, JSON text or
|
|
841
|
+
a ``.json`` path is taken wherever a Schema is."""
|
|
842
|
+
|
|
843
|
+
def __init__(self, data: Mapping[str, Any]):
|
|
844
|
+
self.raw: Dict[str, Any] = json.loads(json.dumps(data))
|
|
845
|
+
if not isinstance(self.raw.get("columns"), list):
|
|
846
|
+
raise SchemaError("a schema has a list of columns")
|
|
847
|
+
|
|
848
|
+
@classmethod
|
|
849
|
+
def load(cls, path: PathLike) -> "Schema":
|
|
850
|
+
"""A schema from a JSON file, as infer --out writes one."""
|
|
851
|
+
with open(path, "rb") as f:
|
|
852
|
+
return cls(json.loads(f.read().decode("utf-8")))
|
|
853
|
+
|
|
854
|
+
@classmethod
|
|
855
|
+
def from_json(cls, text: str) -> "Schema":
|
|
856
|
+
return cls(json.loads(text))
|
|
857
|
+
|
|
858
|
+
def save(self, path: PathLike) -> None:
|
|
859
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
860
|
+
f.write(self.to_json() + "\n")
|
|
861
|
+
|
|
862
|
+
@property
|
|
863
|
+
def columns(self) -> List[SchemaColumn]:
|
|
864
|
+
return [SchemaColumn(c["name"], c["type"], c.get("nullable", True), c) for c in self.raw["columns"]]
|
|
865
|
+
|
|
866
|
+
@property
|
|
867
|
+
def names(self) -> List[str]:
|
|
868
|
+
return [c["name"] for c in self.raw["columns"]]
|
|
869
|
+
|
|
870
|
+
def __getitem__(self, name: str) -> SchemaColumn:
|
|
871
|
+
for c in self.columns:
|
|
872
|
+
if c.name == name:
|
|
873
|
+
return c
|
|
874
|
+
raise KeyError(name)
|
|
875
|
+
|
|
876
|
+
def with_column(self, name: str, **fields: Any) -> "Schema":
|
|
877
|
+
"""A copy with one column's fields set, as the schema's JSON names
|
|
878
|
+
them: ``schema.with_column("lug_id", nullable=False)``."""
|
|
879
|
+
data = json.loads(json.dumps(self.raw))
|
|
880
|
+
for c in data["columns"]:
|
|
881
|
+
if c["name"] == name:
|
|
882
|
+
c.update(fields)
|
|
883
|
+
return Schema(data)
|
|
884
|
+
raise KeyError(name)
|
|
885
|
+
|
|
886
|
+
def __eq__(self, other: object) -> bool:
|
|
887
|
+
return isinstance(other, Schema) and self.raw == other.raw
|
|
888
|
+
|
|
889
|
+
def __repr__(self) -> str:
|
|
890
|
+
return "Schema(%s)" % ", ".join(str(c) for c in self.columns)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
class Count(NamedTuple):
|
|
894
|
+
"""A value (or a shape) and how many times it was seen."""
|
|
895
|
+
|
|
896
|
+
value: str
|
|
897
|
+
count: int
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@dataclass
|
|
901
|
+
class Stats:
|
|
902
|
+
"""What a column's values are like, without reading them all."""
|
|
903
|
+
|
|
904
|
+
values: int
|
|
905
|
+
empty: int
|
|
906
|
+
length: Optional[Tuple[int, int]]
|
|
907
|
+
min: Optional[str]
|
|
908
|
+
max: Optional[str]
|
|
909
|
+
distinct: int
|
|
910
|
+
distinct_at_least: bool
|
|
911
|
+
top: List[Count]
|
|
912
|
+
top_estimated: bool
|
|
913
|
+
shapes: List[Count]
|
|
914
|
+
shapes_estimated: bool
|
|
915
|
+
sampled: Optional[int]
|
|
916
|
+
|
|
917
|
+
@classmethod
|
|
918
|
+
def _of(cls, d: Optional[Mapping[str, Any]]) -> Optional["Stats"]:
|
|
919
|
+
if d is None:
|
|
920
|
+
return None
|
|
921
|
+
length = d.get("length")
|
|
922
|
+
return cls(
|
|
923
|
+
d["values"],
|
|
924
|
+
d["empty"],
|
|
925
|
+
(length["min"], length["max"]) if length else None,
|
|
926
|
+
d.get("min"),
|
|
927
|
+
d.get("max"),
|
|
928
|
+
d["distinct"],
|
|
929
|
+
d.get("distinct_at_least", False),
|
|
930
|
+
[Count(t["value"], t["count"]) for t in d.get("top", ())],
|
|
931
|
+
d.get("top_estimated", False),
|
|
932
|
+
[Count(s["shape"], s["count"]) for s in d.get("shapes", ())],
|
|
933
|
+
d.get("shapes_estimated", False),
|
|
934
|
+
d.get("sampled"),
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
class Sample(NamedTuple):
|
|
939
|
+
"""A value that did not fit its column: the line its row starts on, the
|
|
940
|
+
value, and why (a reason code)."""
|
|
941
|
+
|
|
942
|
+
row: int
|
|
943
|
+
value: str
|
|
944
|
+
code: str
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
@dataclass
|
|
948
|
+
class ColumnReport:
|
|
949
|
+
"""One schema column of a Report: its nulls, the values that did not
|
|
950
|
+
fit, how much of it conforms, and what its values are like."""
|
|
951
|
+
|
|
952
|
+
name: str
|
|
953
|
+
type: str
|
|
954
|
+
nulls: int
|
|
955
|
+
mismatches: int
|
|
956
|
+
conformance: float
|
|
957
|
+
format: Optional[str]
|
|
958
|
+
ambiguous: Optional[int]
|
|
959
|
+
fits: List[str]
|
|
960
|
+
samples: List[Sample]
|
|
961
|
+
stats: Optional[Stats]
|
|
962
|
+
|
|
963
|
+
@classmethod
|
|
964
|
+
def _of(cls, d: Mapping[str, Any]) -> "ColumnReport":
|
|
965
|
+
samples = [Sample(s["row"], s["value"], s["code"]) for s in d.get("samples", ())]
|
|
966
|
+
return cls(
|
|
967
|
+
d["name"],
|
|
968
|
+
d["type"],
|
|
969
|
+
d["nulls"],
|
|
970
|
+
d["mismatches"],
|
|
971
|
+
d["conformance"],
|
|
972
|
+
d.get("format"),
|
|
973
|
+
d.get("ambiguous"),
|
|
974
|
+
list(d.get("fits", ())),
|
|
975
|
+
samples,
|
|
976
|
+
Stats._of(d.get("stats")),
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
class RowError(NamedTuple):
|
|
981
|
+
"""Why a row was rejected: the column (None for the whole row's error,
|
|
982
|
+
a ragged one say) and a reason code."""
|
|
983
|
+
|
|
984
|
+
column: Optional[str]
|
|
985
|
+
code: str
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
@dataclass
|
|
989
|
+
class RejectedLine:
|
|
990
|
+
"""A rejected row as the file has it, and why."""
|
|
991
|
+
|
|
992
|
+
row: int
|
|
993
|
+
text: str
|
|
994
|
+
length: Optional[int]
|
|
995
|
+
errors: List[RowError]
|
|
996
|
+
|
|
997
|
+
@classmethod
|
|
998
|
+
def _of(cls, d: Mapping[str, Any]) -> "RejectedLine":
|
|
999
|
+
errors = [RowError(e.get("column"), e["code"]) for e in d.get("errors", ())]
|
|
1000
|
+
return cls(d["row"], d["text"], d.get("length"), errors)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _report_fields(d: Dict[str, Any]) -> Dict[str, Any]:
|
|
1004
|
+
return dict(
|
|
1005
|
+
file=d["file"],
|
|
1006
|
+
result=d["result"],
|
|
1007
|
+
rows=d["rows"],
|
|
1008
|
+
bytes=d["bytes"],
|
|
1009
|
+
dialect=Dialect._of(d.get("dialect")),
|
|
1010
|
+
columns=[ColumnReport._of(c) for c in d.get("columns", ())],
|
|
1011
|
+
problems=_problems(d.get("problems")),
|
|
1012
|
+
warnings=list(d.get("warnings", ())),
|
|
1013
|
+
rows_rejected=d.get("rows_rejected", 0),
|
|
1014
|
+
rejected_sample=list(d.get("rejected_sample", ())),
|
|
1015
|
+
rejected_lines=[RejectedLine._of(r) for r in d.get("rejected_lines", ())],
|
|
1016
|
+
row_errors=dict(d.get("row_errors", {})),
|
|
1017
|
+
repeated_header_rows=list(d.get("repeated_header_rows", ())),
|
|
1018
|
+
raw=d,
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
@dataclass
|
|
1023
|
+
class Report(_Document):
|
|
1024
|
+
"""A file read against a schema (docs/REPORT.md, "The file report").
|
|
1025
|
+
``result`` is ``pass``, ``fail`` (it does not fit its schema) or
|
|
1026
|
+
``error`` (it could not be read, or its dialect not settled)."""
|
|
1027
|
+
|
|
1028
|
+
file: str
|
|
1029
|
+
result: str
|
|
1030
|
+
rows: int
|
|
1031
|
+
bytes: int
|
|
1032
|
+
dialect: Optional[Dialect]
|
|
1033
|
+
columns: List[ColumnReport]
|
|
1034
|
+
problems: List[Problem]
|
|
1035
|
+
warnings: List[str]
|
|
1036
|
+
rows_rejected: int
|
|
1037
|
+
rejected_sample: List[int]
|
|
1038
|
+
rejected_lines: List[RejectedLine]
|
|
1039
|
+
row_errors: Dict[str, int]
|
|
1040
|
+
repeated_header_rows: List[int]
|
|
1041
|
+
raw: Dict[str, Any] = field(repr=False)
|
|
1042
|
+
|
|
1043
|
+
@classmethod
|
|
1044
|
+
def _of(cls, d: Dict[str, Any]) -> "Report":
|
|
1045
|
+
return cls(**_report_fields(d))
|
|
1046
|
+
|
|
1047
|
+
@property
|
|
1048
|
+
def passed(self) -> bool:
|
|
1049
|
+
return self.result == "pass"
|
|
1050
|
+
|
|
1051
|
+
def column(self, name: str) -> ColumnReport:
|
|
1052
|
+
for c in self.columns:
|
|
1053
|
+
if c.name == name:
|
|
1054
|
+
return c
|
|
1055
|
+
raise KeyError(name)
|
|
1056
|
+
|
|
1057
|
+
def __str__(self) -> str:
|
|
1058
|
+
return "%s %s %d rows %d rejected" % (self.result, self.file, self.rows, self.rows_rejected)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
@dataclass
|
|
1062
|
+
class Description(Report):
|
|
1063
|
+
"""Everything thresh can say of a file (docs/REPORT.md, "A
|
|
1064
|
+
description"): a Report with every column's statistics, and what it
|
|
1065
|
+
was read as and against."""
|
|
1066
|
+
|
|
1067
|
+
encoding: str = "utf8"
|
|
1068
|
+
inferred: bool = False
|
|
1069
|
+
schema: Optional[Schema] = None
|
|
1070
|
+
notes: List[Problem] = field(default_factory=list)
|
|
1071
|
+
|
|
1072
|
+
@classmethod
|
|
1073
|
+
def _of(cls, d: Dict[str, Any]) -> "Description":
|
|
1074
|
+
schema = Schema(d["schema"]) if "schema" in d else None
|
|
1075
|
+
return cls(**_report_fields(d), encoding=d.get("encoding", "utf8"), inferred=d.get("inferred", False), schema=schema, notes=_problems(d.get("notes")))
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@dataclass
|
|
1079
|
+
class FileOutcome:
|
|
1080
|
+
"""One file of an infer() call: ``pass``, or ``error`` and why."""
|
|
1081
|
+
|
|
1082
|
+
file: str
|
|
1083
|
+
result: str
|
|
1084
|
+
problems: List[Problem]
|
|
1085
|
+
notes: List[Problem]
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
@dataclass
|
|
1089
|
+
class Inference(_Document):
|
|
1090
|
+
"""What infer() found: the schema the files fit, each file's outcome
|
|
1091
|
+
and notes, and how the files disagree (``problems``)."""
|
|
1092
|
+
|
|
1093
|
+
schema: Optional[Schema]
|
|
1094
|
+
files: List[FileOutcome]
|
|
1095
|
+
notes: List[Problem]
|
|
1096
|
+
problems: List[Problem]
|
|
1097
|
+
raw: Dict[str, Any] = field(repr=False)
|
|
1098
|
+
|
|
1099
|
+
@classmethod
|
|
1100
|
+
def _of(cls, d: Dict[str, Any]) -> "Inference":
|
|
1101
|
+
files = [FileOutcome(f["file"], f["result"], _problems(f.get("problems")), _problems(f.get("notes"))) for f in d.get("files", ())]
|
|
1102
|
+
schema = Schema(d["schema"]) if "schema" in d else None
|
|
1103
|
+
return cls(schema, files, _problems(d.get("notes")), _problems(d.get("problems")), d)
|
|
1104
|
+
|
|
1105
|
+
@property
|
|
1106
|
+
def ok(self) -> bool:
|
|
1107
|
+
"""A schema was inferred, every file could be read, and they agree."""
|
|
1108
|
+
return self.schema is not None and not self.problems and all(f.result == "pass" for f in self.files)
|