threshio 0.1.0__py3-none-win_arm64.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 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)