dstrack 0.1.0__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.
@@ -0,0 +1,241 @@
1
+ """Registry of reader classes, keyed by short name and by file extension.
2
+
3
+ Registration is the single extension point for readers. Three kinds of user
4
+ reach it by three different routes, and none of them should have to do the other
5
+ two's work:
6
+
7
+ - **From Python**, a caller who already has a reader instance hands it straight
8
+ to [SnapshotBuilder][dstrack.snapshot._builder.SnapshotBuilder]; the registry
9
+ is never consulted.
10
+ - **From an installed plugin package**, a reader self-registers through the
11
+ ``dstrack.readers`` entry-point group, so ``dstrack track data.parquet`` infers
12
+ it from the extension with nothing to type.
13
+ - **From an ad-hoc class** in the user's own project, which is not installed as a
14
+ plugin and so is named explicitly as ``"package.module:ClassName"`` (see
15
+ [dstrack.readers._resolve][]).
16
+
17
+ A registered class must satisfy both
18
+ [TabularReader][dstrack.readers._protocol.TabularReader] (how it reads) and
19
+ [ReaderFactory][dstrack.readers._protocol.ReaderFactory] (how it is built from a
20
+ path);
21
+ [check_reader_class()][dstrack.readers._registry.check_reader_class] enforces
22
+ that *before* the class is ever instantiated.
23
+ """
24
+
25
+ import inspect
26
+ import logging
27
+ from collections.abc import Sequence
28
+ from importlib.metadata import entry_points
29
+ from pathlib import Path
30
+ from typing import Final
31
+
32
+ from ._csv import CsvReader
33
+ from ._protocol import ReaderFactory, TabularReader
34
+
35
+ _log = logging.getLogger(__name__)
36
+
37
+ # Entry-point group an installed package declares to add a reader, e.g.
38
+ # [project.entry-points."dstrack.readers"]
39
+ # parquet = "dstrack_parquet:ParquetReader"
40
+ ENTRY_POINT_GROUP: Final[str] = "dstrack.readers"
41
+
42
+ # Short name (as typed for --reader) -> reader class.
43
+ _BY_NAME: dict[str, type[TabularReader]] = {}
44
+ # Lowercase file extension, leading dot included -> reader class.
45
+ _BY_EXTENSION: dict[str, type[TabularReader]] = {}
46
+
47
+ _plugins_loaded = False
48
+
49
+
50
+ def check_reader_class(obj: object, *, origin: str) -> type[TabularReader]:
51
+ """Validate that ``obj`` is a class usable as a reader, without calling it.
52
+
53
+ Both protocols are checked here, on the class, so that a class which merely
54
+ *looks* like a reader is rejected before its constructor runs on a user path.
55
+
56
+ Args:
57
+ obj: The candidate, typically freshly imported or freshly registered.
58
+ origin: How the caller referred to it (a spec, a name, an entry point),
59
+ used to make the error message actionable.
60
+
61
+ Returns:
62
+ ``obj`` itself, narrowed to a reader class.
63
+
64
+ Raises:
65
+ TypeError: If ``obj`` is not a class, or does not satisfy
66
+ [TabularReader][dstrack.readers._protocol.TabularReader] and
67
+ [ReaderFactory][dstrack.readers._protocol.ReaderFactory].
68
+ """
69
+ if not isinstance(obj, type):
70
+ raise TypeError(
71
+ f"{origin} resolved to {obj!r}, which is not a class. "
72
+ "A reader must be a class, not an instance or a function."
73
+ )
74
+ qualname = f"{obj.__module__}.{obj.__qualname__}"
75
+ if not issubclass(obj, TabularReader):
76
+ raise TypeError(
77
+ f"{qualname} (from {origin}) does not satisfy the TabularReader "
78
+ "protocol: it must define columns() and iter_batches()."
79
+ )
80
+ # Checked through an object-typed name: mypy assumes any class object
81
+ # already satisfies ReaderFactory, so narrowing from `object` is what keeps
82
+ # this branch live for the type checker as well as at runtime.
83
+ candidate: object = obj
84
+ if not isinstance(candidate, ReaderFactory):
85
+ raise TypeError(
86
+ f"{qualname} (from {origin}) does not satisfy the ReaderFactory "
87
+ "protocol: it must define a from_path(path) classmethod, which is "
88
+ "how dstrack builds a reader it knows only by name."
89
+ )
90
+ # Check that from_path is a class method
91
+ if not isinstance(inspect.getattr_static(obj, "from_path"), classmethod):
92
+ raise TypeError(
93
+ f"{qualname} (from {origin}) defines from_path, but not as a "
94
+ "classmethod: dstrack calls it on the class (MyReader.from_path(path)), "
95
+ "so it must be decorated with @classmethod."
96
+ )
97
+ return obj
98
+
99
+
100
+ def build_reader(reader_cls: type[TabularReader], path: Path) -> TabularReader:
101
+ """Instantiate a validated reader class for ``path``.
102
+
103
+ Args:
104
+ reader_cls: A class already validated through
105
+ [check_reader_class()][dstrack.readers._registry.check_reader_class].
106
+ path: Path to the dataset source.
107
+
108
+ Returns:
109
+ The reader instance.
110
+
111
+ Raises:
112
+ TypeError: If ``reader_cls`` was never validated and lacks ``from_path``,
113
+ or if its ``from_path`` returns something that is not a
114
+ [TabularReader][dstrack.readers._protocol.TabularReader].
115
+ """
116
+ factory: object = reader_cls
117
+ if not isinstance(factory, ReaderFactory):
118
+ raise TypeError(f"{reader_cls!r} is not an instance of {ReaderFactory}")
119
+ reader: object = factory.from_path(path)
120
+ if not isinstance(reader, TabularReader):
121
+ qualname = f"{reader_cls.__module__}.{reader_cls.__qualname__}"
122
+ raise TypeError(
123
+ f"{qualname}.from_path returned {reader!r}, which is not a "
124
+ "TabularReader: from_path must build an object that follows this protocol."
125
+ )
126
+ return reader
127
+
128
+
129
+ def register_reader(
130
+ reader_cls: type[TabularReader],
131
+ *,
132
+ name: str,
133
+ extensions: Sequence[str] | None = None,
134
+ ) -> None:
135
+ """Register a reader under a short name and the extensions it handles.
136
+
137
+ Args:
138
+ reader_cls: The reader class. Must satisfy both
139
+ [TabularReader][dstrack.readers._protocol.TabularReader] and
140
+ [ReaderFactory][dstrack.readers._protocol.ReaderFactory].
141
+ name: Short name, as typed for ``--reader`` (e.g. ``"csv"``).
142
+ extensions: Extensions this reader claims, leading dot included. When
143
+ omitted, the class's ``EXTENSIONS`` attribute is used, so a plugin
144
+ can declare its extensions once on the class and register through a
145
+ bare entry point.
146
+
147
+ Raises:
148
+ TypeError: If ``reader_cls`` does not satisfy both reader protocols.
149
+ ValueError: If ``name`` or any extension is already taken. Registration
150
+ never silently displaces an existing reader: two packages fighting
151
+ over ``.parquet`` is a conflict the user has to see, not one to
152
+ resolve by import order.
153
+ ValueError: If any extension is malformed. An extension is matched
154
+ against [Path.suffix][pathlib.PurePath.suffix], so it must be a
155
+ single leading-dot suffix such as ``".csv"``: a value like ``"csv"``
156
+ or ``".tar.gz"`` could never match and is rejected rather than
157
+ silently registered as dead weight.
158
+ """
159
+ check_reader_class(reader_cls, origin=f"reader {name!r}")
160
+
161
+ if extensions is None:
162
+ extensions = getattr(reader_cls, "EXTENSIONS", ())
163
+
164
+ if name in _BY_NAME:
165
+ raise ValueError(
166
+ f"Reader name {name!r} is already registered to "
167
+ f"{_BY_NAME[name].__module__}.{_BY_NAME[name].__qualname__}."
168
+ )
169
+
170
+ claimed = [ext.lower() for ext in extensions]
171
+ for ext in claimed:
172
+ # An extension is only ever compared against Path.suffix, which is a
173
+ # single leading-dot component (".csv").
174
+ if not ext.startswith(".") or ext == "." or Path(f"_{ext}").suffix != ext:
175
+ raise ValueError(
176
+ f"Extension {ext!r} (for reader {name!r}) is malformed: an "
177
+ "extension must be a single leading-dot suffix such as '.csv', "
178
+ "so that it can match Path.suffix."
179
+ )
180
+ for ext in claimed:
181
+ if ext in _BY_EXTENSION:
182
+ owner = _BY_EXTENSION[ext]
183
+ raise ValueError(
184
+ f"Extension {ext!r} is already registered to "
185
+ f"{owner.__module__}.{owner.__qualname__}."
186
+ )
187
+
188
+ _BY_NAME[name] = reader_cls
189
+ for ext in claimed:
190
+ _BY_EXTENSION[ext] = reader_cls
191
+
192
+
193
+ def _load_plugins() -> None:
194
+ """Register readers advertised by installed packages, once per process.
195
+
196
+ A plugin that fails to import or conflicts with an existing registration is
197
+ logged and skipped: one broken third-party package must not take down
198
+ `dstrack track` for a dataset that does not even use it.
199
+ """
200
+ global _plugins_loaded
201
+ if _plugins_loaded:
202
+ return
203
+ # Set before loading: a plugin that raises must not be retried on every
204
+ # subsequent lookup.
205
+ _plugins_loaded = True
206
+
207
+ for entry_point in entry_points(group=ENTRY_POINT_GROUP):
208
+ try:
209
+ register_reader(entry_point.load(), name=entry_point.name)
210
+ except Exception as e:
211
+ _log.warning(
212
+ f"Ignoring reader plugin {entry_point.name!r} "
213
+ f"({entry_point.value}): {e}"
214
+ )
215
+
216
+
217
+ def reader_class_for_name(name: str) -> type[TabularReader] | None:
218
+ """Look up a reader by its short name, or ``None`` if nothing claims it."""
219
+ _load_plugins()
220
+ return _BY_NAME.get(name)
221
+
222
+
223
+ def reader_class_for_extension(extension: str) -> type[TabularReader] | None:
224
+ """Look up a reader by file extension, or ``None`` if nothing claims it."""
225
+ _load_plugins()
226
+ return _BY_EXTENSION.get(extension.lower())
227
+
228
+
229
+ def available_readers() -> dict[str, type[TabularReader]]:
230
+ """Return all registered readers by short name, built-in and plugin alike."""
231
+ _load_plugins()
232
+ return dict(_BY_NAME)
233
+
234
+
235
+ def known_extensions() -> dict[str, type[TabularReader]]:
236
+ """Return all registered extensions and the reader class that claims each."""
237
+ _load_plugins()
238
+ return dict(_BY_EXTENSION)
239
+
240
+
241
+ register_reader(CsvReader, name="csv")
@@ -0,0 +1,140 @@
1
+ """Selection and loading of a reader for a given source.
2
+
3
+ A [TabularReader][dstrack.readers._protocol.TabularReader] is chosen one of three
4
+ ways, in order of how much the user has to type:
5
+
6
+ 1. Implicitly, from the source file's extension (``data.csv`` ->
7
+ [CsvReader][dstrack.readers._csv.CsvReader]).
8
+ Plugin readers registered through the ``dstrack.readers`` entry-point group
9
+ participate here too, so an installed reader needs nothing on the command line.
10
+ 2. By short name (``"csv"``), for when the extension is absent or misleading.
11
+ 3. By ``"package.module:ClassName"`` spec, which imports the class directly. This
12
+ is the escape hatch for a reader that lives in the user's own project and is
13
+ not installed as a plugin.
14
+
15
+ Security: a spec is arbitrary import-by-name, i.e. code execution. It is only
16
+ ever accepted from the invoking user (a ``--reader`` argument they typed), and
17
+ is never persisted into a snapshot nor read back out of one. See ADR-0003.
18
+ """
19
+
20
+ import importlib
21
+ from pathlib import Path
22
+
23
+ from ._protocol import TabularReader
24
+ from ._registry import (
25
+ available_readers,
26
+ build_reader,
27
+ check_reader_class,
28
+ known_extensions,
29
+ reader_class_for_extension,
30
+ reader_class_for_name,
31
+ )
32
+
33
+
34
+ def load_reader_class(spec: str) -> type[TabularReader]:
35
+ """Import a reader class from a ``"package.module:ClassName"`` spec.
36
+
37
+ The class is validated against both reader protocols before being returned,
38
+ so a mistyped spec that happens to name some other importable object fails
39
+ here rather than part-way through a snapshot.
40
+
41
+ Args:
42
+ spec: A ``"<module>:<class>"`` string. The module part is imported and
43
+ the class part is looked up on it.
44
+
45
+ Returns:
46
+ The referenced class (not an instance).
47
+
48
+ Raises:
49
+ ValueError: If ``spec`` is not of the form ``"module:ClassName"``, the
50
+ module cannot be imported, or the class is not found on it.
51
+ TypeError: If the referenced object is not a usable reader class.
52
+ """
53
+ module_name, sep, class_name = spec.partition(":")
54
+ if not sep or not module_name or not class_name:
55
+ raise ValueError(
56
+ f"Invalid reader spec {spec!r}. "
57
+ "Expected the form 'package.module:ClassName'."
58
+ )
59
+ try:
60
+ module = importlib.import_module(module_name)
61
+ except ImportError as e:
62
+ raise ValueError(
63
+ f"Could not import module {module_name!r} from reader spec {spec!r}."
64
+ ) from e
65
+ try:
66
+ obj = getattr(module, class_name)
67
+ except AttributeError as e:
68
+ raise ValueError(
69
+ f"Module {module_name!r} has no attribute {class_name!r} "
70
+ f"(from reader spec {spec!r})."
71
+ ) from e
72
+
73
+ return check_reader_class(obj, origin=f"reader spec {spec!r}")
74
+
75
+
76
+ def resolve_reader_class(
77
+ path: str | Path, *, reader: str | None = None
78
+ ) -> type[TabularReader]:
79
+ """Choose the reader class for ``path``, without instantiating it.
80
+
81
+ Args:
82
+ path: Path to the dataset source.
83
+ reader: Either a registered short name (``"csv"``) or a
84
+ ``"package.module:ClassName"`` spec, told apart by the ``":"``.
85
+ When omitted, the reader is inferred from ``path``'s extension.
86
+
87
+ Returns:
88
+ A validated reader class.
89
+
90
+ Raises:
91
+ ValueError: If ``reader`` names nothing known, a spec is malformed, or
92
+ no reader is registered for ``path``'s extension.
93
+ TypeError: If the resolved class is not a usable reader.
94
+ """
95
+ if reader is not None:
96
+ if ":" in reader:
97
+ return load_reader_class(reader)
98
+ reader_cls = reader_class_for_name(reader)
99
+ if reader_cls is None:
100
+ known = ", ".join(sorted(available_readers())) or "(none)"
101
+ raise ValueError(
102
+ f"Unknown reader {reader!r}. Available readers: {known}. "
103
+ "Pass 'package.module:ClassName' to use a reader that is not "
104
+ "installed as a plugin."
105
+ )
106
+ return reader_cls
107
+
108
+ path = Path(path)
109
+ extension = path.suffix.lower()
110
+ reader_cls = reader_class_for_extension(extension)
111
+ if reader_cls is None:
112
+ known = ", ".join(sorted(known_extensions())) or "(none)"
113
+ raise ValueError(
114
+ f"No reader registered for extension {extension!r} (path: {path}). "
115
+ f"Known extensions: {known}. Name a reader explicitly, either a "
116
+ "registered one or 'package.module:ClassName'."
117
+ )
118
+ return reader_cls
119
+
120
+
121
+ def resolve_reader(path: str | Path, *, reader: str | None = None) -> TabularReader:
122
+ """Build a reader for ``path``, explicitly named or inferred from its extension.
123
+
124
+ Args:
125
+ path: Path to the dataset source the reader will read.
126
+ reader: Optional short name or ``"package.module:ClassName"`` spec. When
127
+ omitted, the reader is inferred from ``path``'s file extension.
128
+
129
+ Returns:
130
+ An instantiated reader satisfying
131
+ [TabularReader][dstrack.readers._protocol.TabularReader].
132
+
133
+ Raises:
134
+ ValueError: If ``reader`` names nothing known, a spec is malformed, or
135
+ no reader is registered for ``path``'s extension.
136
+ TypeError: If the resolved class is not a usable reader.
137
+ """
138
+ path = Path(path)
139
+ reader_cls = resolve_reader_class(path, reader=reader)
140
+ return build_reader(reader_cls, path)
@@ -0,0 +1,299 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/leoyala/dstrack/schemas/snapshot_v1.json",
4
+ "title": "DatasetSnapshot",
5
+ "description": "A dstrack dataset snapshot. Captures semantic information about a tabular dataset at a point in time. See ADR-0001.",
6
+ "type": "object",
7
+ "required": [
8
+ "format_version",
9
+ "snapshot_id",
10
+ "created_at",
11
+ "created_by",
12
+ "dataset_name",
13
+ "dataset_path",
14
+ "source_type",
15
+ "source_hash",
16
+ "num_columns",
17
+ "columns",
18
+ "schema_hash",
19
+ "num_rows",
20
+ "column_stats",
21
+ "duplicate_row_fraction",
22
+ "near_duplicate_estimate",
23
+ "constant_columns",
24
+ "high_null_columns",
25
+ "row_minhash",
26
+ "row_hyperloglog"
27
+ ],
28
+ "additionalProperties": false,
29
+ "properties": {
30
+ "format_version": {
31
+ "type": "string",
32
+ "const": "1",
33
+ "description": "Schema version. Readers must reject snapshots with an unrecognised version."
34
+ },
35
+ "snapshot_id": {
36
+ "type": "string",
37
+ "format": "uuid",
38
+ "description": "Unique identifier for this snapshot."
39
+ },
40
+ "created_at": {
41
+ "type": "string",
42
+ "format": "date-time",
43
+ "description": "ISO-8601 timestamp of when the snapshot was taken."
44
+ },
45
+ "created_by": {
46
+ "type": "string",
47
+ "description": "User or process that created the snapshot."
48
+ },
49
+ "dataset_name": {
50
+ "type": "string",
51
+ "description": "Human-readable name for the dataset."
52
+ },
53
+ "dataset_path": {
54
+ "type": "string",
55
+ "description": "Source path or URI at snapshot time."
56
+ },
57
+ "source_type": {
58
+ "type": "string",
59
+ "examples": ["file", "directory", "database", "huggingface", "s3"],
60
+ "description": "Origin kind of the dataset source."
61
+ },
62
+ "source_hash": {
63
+ "type": "string",
64
+ "description": "Cryptographic hash of the raw source bytes, or a Merkle tree hash for directories."
65
+ },
66
+ "description": {
67
+ "type": "string",
68
+ "description": "Free-text description of what this dataset represents."
69
+ },
70
+ "tags": {
71
+ "type": "object",
72
+ "additionalProperties": { "type": "string" },
73
+ "description": "Arbitrary key-value labels for filtering and organisation."
74
+ },
75
+ "num_columns": {
76
+ "type": "integer",
77
+ "minimum": 0,
78
+ "description": "Total number of columns/features."
79
+ },
80
+ "columns": {
81
+ "type": "array",
82
+ "items": { "$ref": "#/$defs/ColumnDescriptor" },
83
+ "description": "Ordered list of column descriptors."
84
+ },
85
+ "schema_hash": {
86
+ "type": "string",
87
+ "description": "Hash of (name, dtype) tuples in column order. Changes whenever the schema changes."
88
+ },
89
+ "num_rows": {
90
+ "type": "integer",
91
+ "minimum": 0,
92
+ "description": "Total number of records."
93
+ },
94
+ "column_stats": {
95
+ "type": "object",
96
+ "additionalProperties": { "$ref": "#/$defs/ColumnStats" },
97
+ "description": "Per-column statistics keyed by column name. The concrete shape depends on the column dtype; users must branch on the corresponding entry in `columns`."
98
+ },
99
+ "duplicate_row_fraction": {
100
+ "type": "number",
101
+ "minimum": 0,
102
+ "maximum": 1,
103
+ "description": "Estimated fraction of exact-duplicate rows."
104
+ },
105
+ "near_duplicate_estimate": {
106
+ "type": "number",
107
+ "minimum": 0,
108
+ "maximum": 1,
109
+ "description": "Approximate fraction of near-duplicate rows via MinHash/LSH."
110
+ },
111
+ "constant_columns": {
112
+ "type": "array",
113
+ "items": { "type": "string" },
114
+ "description": "Names of columns with zero variance / a single unique value."
115
+ },
116
+ "high_null_columns": {
117
+ "type": "array",
118
+ "items": { "type": "string" },
119
+ "description": "Names of columns where null_fraction > 0.5."
120
+ },
121
+ "row_minhash": {
122
+ "type": "string",
123
+ "contentEncoding": "base64",
124
+ "description": "Base64-encoded MinHash signature of the row set. Enables Jaccard similarity between snapshots."
125
+ },
126
+ "row_hyperloglog": {
127
+ "type": "string",
128
+ "contentEncoding": "base64",
129
+ "description": "Base64-encoded HyperLogLog sketch for cardinality estimation of unique rows."
130
+ },
131
+ "parent_snapshot_id": {
132
+ "type": "string",
133
+ "format": "uuid",
134
+ "description": "ID of the preceding snapshot of the same dataset, if one exists."
135
+ },
136
+ "pipeline_stage": {
137
+ "type": "string",
138
+ "enum": ["raw", "cleaned", "featurized", "augmented", "sampled", "final"],
139
+ "description": "Stage in the data pipeline at which this snapshot was taken."
140
+ },
141
+ "transform_description": {
142
+ "type": "string",
143
+ "description": "Human-readable description of the changes applied since the parent snapshot."
144
+ }
145
+ },
146
+ "$defs": {
147
+ "ColumnDescriptor": {
148
+ "type": "object",
149
+ "required": ["name", "dtype", "nullable"],
150
+ "additionalProperties": false,
151
+ "properties": {
152
+ "name": {
153
+ "type": "string",
154
+ "description": "Column identifier."
155
+ },
156
+ "dtype": {
157
+ "type": "string",
158
+ "examples": [
159
+ "int8", "int16", "int32", "int64",
160
+ "float16", "float32", "float64",
161
+ "string", "bool", "datetime64", "bytes"
162
+ ],
163
+ "description": "Storage type of the column."
164
+ },
165
+ "nullable": {
166
+ "type": "boolean",
167
+ "description": "Whether null values are structurally allowed."
168
+ }
169
+ }
170
+ },
171
+ "ColumnStats": {
172
+ "oneOf": [
173
+ { "$ref": "#/$defs/NumericColumnStats" },
174
+ { "$ref": "#/$defs/StringColumnStats" },
175
+ { "$ref": "#/$defs/DatetimeColumnStats" }
176
+ ]
177
+ },
178
+ "NumericColumnStats": {
179
+ "description": "Statistics for int* and float* columns.",
180
+ "type": "object",
181
+ "required": [
182
+ "null_count", "null_fraction",
183
+ "mean", "std", "min", "max",
184
+ "percentiles", "histogram", "num_unique"
185
+ ],
186
+ "additionalProperties": false,
187
+ "properties": {
188
+ "null_count": { "type": "integer", "minimum": 0 },
189
+ "null_fraction": { "type": "number", "minimum": 0, "maximum": 1 },
190
+ "mean": { "type": "number" },
191
+ "std": { "type": "number", "minimum": 0 },
192
+ "min": { "type": "number" },
193
+ "max": { "type": "number" },
194
+ "percentiles": {
195
+ "type": "object",
196
+ "required": ["p5", "p25", "p50", "p75", "p95", "p99"],
197
+ "additionalProperties": false,
198
+ "properties": {
199
+ "p5": { "type": "number" },
200
+ "p25": { "type": "number" },
201
+ "p50": { "type": "number" },
202
+ "p75": { "type": "number" },
203
+ "p95": { "type": "number" },
204
+ "p99": { "type": "number" }
205
+ }
206
+ },
207
+ "histogram": {
208
+ "type": "object",
209
+ "required": ["bin_edges", "counts"],
210
+ "additionalProperties": false,
211
+ "properties": {
212
+ "bin_edges": {
213
+ "type": "array",
214
+ "items": { "type": "number" },
215
+ "description": "Length is num_bins + 1."
216
+ },
217
+ "counts": {
218
+ "type": "array",
219
+ "items": { "type": "integer", "minimum": 0 },
220
+ "description": "Length is num_bins."
221
+ }
222
+ }
223
+ },
224
+ "num_unique": {
225
+ "type": "integer",
226
+ "minimum": 0,
227
+ "description": "Exact count if cheap to compute, otherwise a sketch estimate."
228
+ }
229
+ }
230
+ },
231
+ "StringColumnStats": {
232
+ "description": "Statistics for string columns.",
233
+ "type": "object",
234
+ "required": [
235
+ "null_count", "null_fraction",
236
+ "num_unique", "top_values", "top_values_coverage",
237
+ "avg_char_length", "min_char_length", "max_char_length",
238
+ "avg_token_count", "min_token_count", "max_token_count"
239
+ ],
240
+ "additionalProperties": false,
241
+ "properties": {
242
+ "null_count": { "type": "integer", "minimum": 0 },
243
+ "null_fraction": { "type": "number", "minimum": 0, "maximum": 1 },
244
+ "num_unique": { "type": "integer", "minimum": 0, "description": "Cardinality." },
245
+ "top_values": {
246
+ "type": "object",
247
+ "additionalProperties": { "type": "integer", "minimum": 0 },
248
+ "description": "Top-K {value: count} pairs (default K=50)."
249
+ },
250
+ "top_values_coverage": {
251
+ "type": "number",
252
+ "minimum": 0,
253
+ "maximum": 1,
254
+ "description": "Fraction of rows covered by the top_values entries."
255
+ },
256
+ "avg_char_length": { "type": "number", "minimum": 0 },
257
+ "min_char_length": { "type": "integer", "minimum": 0 },
258
+ "max_char_length": { "type": "integer", "minimum": 0 },
259
+ "avg_token_count": {
260
+ "type": "number",
261
+ "minimum": 0,
262
+ "description": "Whitespace-split token count, averaged over non-null rows."
263
+ },
264
+ "min_token_count": { "type": "integer", "minimum": 0 },
265
+ "max_token_count": { "type": "integer", "minimum": 0 },
266
+ "detected_languages": {
267
+ "type": "object",
268
+ "additionalProperties": { "type": "number", "minimum": 0, "maximum": 1 },
269
+ "description": "Top detected BCP-47 language codes mapped to their row fraction."
270
+ }
271
+ }
272
+ },
273
+ "DatetimeColumnStats": {
274
+ "description": "Statistics for datetime64 columns.",
275
+ "type": "object",
276
+ "required": ["null_count", "null_fraction", "min", "max", "range_days"],
277
+ "additionalProperties": false,
278
+ "properties": {
279
+ "null_count": { "type": "integer", "minimum": 0 },
280
+ "null_fraction": { "type": "number", "minimum": 0, "maximum": 1 },
281
+ "min": {
282
+ "type": "string",
283
+ "format": "date-time",
284
+ "description": "ISO-8601 lower boundary."
285
+ },
286
+ "max": {
287
+ "type": "string",
288
+ "format": "date-time",
289
+ "description": "ISO-8601 upper boundary."
290
+ },
291
+ "range_days": {
292
+ "type": "number",
293
+ "minimum": 0,
294
+ "description": "(max - min) expressed in days."
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }