cfb-data 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cfb_data/__init__.py +234 -0
- cfb_data/_dataframes.py +279 -0
- cfb_data/_executor.py +129 -0
- cfb_data/_parquet.py +197 -0
- cfb_data/_request_rules.py +41 -0
- cfb_data/_requests.py +36 -0
- cfb_data/_tabular.py +676 -0
- cfb_data/_transport.py +452 -0
- cfb_data/adjusted_metrics/__init__.py +29 -0
- cfb_data/adjusted_metrics/models/__init__.py +1 -0
- cfb_data/adjusted_metrics/models/pydantic/__init__.py +29 -0
- cfb_data/adjusted_metrics/models/pydantic/requests.py +45 -0
- cfb_data/adjusted_metrics/models/pydantic/responses.py +88 -0
- cfb_data/adjusted_metrics/resource.py +220 -0
- cfb_data/base/__init__.py +6 -0
- cfb_data/base/types.py +112 -0
- cfb_data/betting/__init__.py +15 -0
- cfb_data/betting/models/__init__.py +1 -0
- cfb_data/betting/models/pydantic/__init__.py +6 -0
- cfb_data/betting/models/pydantic/requests.py +35 -0
- cfb_data/betting/models/pydantic/responses.py +62 -0
- cfb_data/betting/resource.py +91 -0
- cfb_data/client.py +305 -0
- cfb_data/coaches/__init__.py +57 -0
- cfb_data/coaches/models/__init__.py +1 -0
- cfb_data/coaches/models/pydantic/__init__.py +57 -0
- cfb_data/coaches/models/pydantic/requests.py +75 -0
- cfb_data/coaches/models/pydantic/responses.py +261 -0
- cfb_data/coaches/resource.py +216 -0
- cfb_data/conferences/__init__.py +23 -0
- cfb_data/conferences/models/__init__.py +1 -0
- cfb_data/conferences/models/pydantic/__init__.py +23 -0
- cfb_data/conferences/models/pydantic/requests.py +69 -0
- cfb_data/conferences/models/pydantic/responses.py +64 -0
- cfb_data/conferences/resource.py +175 -0
- cfb_data/draft/__init__.py +19 -0
- cfb_data/draft/models/__init__.py +1 -0
- cfb_data/draft/models/pydantic/__init__.py +12 -0
- cfb_data/draft/models/pydantic/requests.py +18 -0
- cfb_data/draft/models/pydantic/responses.py +65 -0
- cfb_data/draft/resource.py +132 -0
- cfb_data/drives/__init__.py +13 -0
- cfb_data/drives/models/__init__.py +1 -0
- cfb_data/drives/models/pydantic/__init__.py +17 -0
- cfb_data/drives/models/pydantic/requests.py +42 -0
- cfb_data/drives/models/pydantic/responses.py +46 -0
- cfb_data/drives/resource.py +94 -0
- cfb_data/enums.py +93 -0
- cfb_data/errors.py +234 -0
- cfb_data/games/__init__.py +42 -0
- cfb_data/games/models/__init__.py +1 -0
- cfb_data/games/models/pydantic/__init__.py +106 -0
- cfb_data/games/models/pydantic/requests.py +266 -0
- cfb_data/games/models/pydantic/responses.py +495 -0
- cfb_data/games/resource.py +486 -0
- cfb_data/info/__init__.py +25 -0
- cfb_data/info/models/__init__.py +1 -0
- cfb_data/info/models/pydantic/__init__.py +23 -0
- cfb_data/info/models/pydantic/requests.py +18 -0
- cfb_data/info/models/pydantic/responses.py +103 -0
- cfb_data/info/resource.py +88 -0
- cfb_data/metrics/__init__.py +53 -0
- cfb_data/metrics/models/__init__.py +1 -0
- cfb_data/metrics/models/pydantic/__init__.py +49 -0
- cfb_data/metrics/models/pydantic/requests.py +121 -0
- cfb_data/metrics/models/pydantic/responses.py +182 -0
- cfb_data/metrics/resource.py +371 -0
- cfb_data/players/__init__.py +44 -0
- cfb_data/players/models/__init__.py +1 -0
- cfb_data/players/models/pydantic/__init__.py +41 -0
- cfb_data/players/models/pydantic/requests.py +70 -0
- cfb_data/players/models/pydantic/responses.py +171 -0
- cfb_data/players/resource.py +258 -0
- cfb_data/playoffs/__init__.py +41 -0
- cfb_data/playoffs/models/__init__.py +1 -0
- cfb_data/playoffs/models/pydantic/__init__.py +37 -0
- cfb_data/playoffs/models/pydantic/requests.py +30 -0
- cfb_data/playoffs/models/pydantic/responses.py +173 -0
- cfb_data/playoffs/resource.py +149 -0
- cfb_data/plays/__init__.py +43 -0
- cfb_data/plays/models/__init__.py +1 -0
- cfb_data/plays/models/pydantic/__init__.py +35 -0
- cfb_data/plays/models/pydantic/requests.py +85 -0
- cfb_data/plays/models/pydantic/responses.py +231 -0
- cfb_data/plays/resource.py +249 -0
- cfb_data/py.typed +0 -0
- cfb_data/rankings/__init__.py +16 -0
- cfb_data/rankings/models/__init__.py +1 -0
- cfb_data/rankings/models/pydantic/__init__.py +6 -0
- cfb_data/rankings/models/pydantic/requests.py +36 -0
- cfb_data/rankings/models/pydantic/responses.py +42 -0
- cfb_data/rankings/resource.py +89 -0
- cfb_data/ratings/__init__.py +59 -0
- cfb_data/ratings/models/__init__.py +1 -0
- cfb_data/ratings/models/pydantic/__init__.py +55 -0
- cfb_data/ratings/models/pydantic/requests.py +87 -0
- cfb_data/ratings/models/pydantic/responses.py +215 -0
- cfb_data/ratings/resource.py +342 -0
- cfb_data/recruiting/__init__.py +26 -0
- cfb_data/recruiting/models/__init__.py +1 -0
- cfb_data/recruiting/models/pydantic/__init__.py +23 -0
- cfb_data/recruiting/models/pydantic/requests.py +69 -0
- cfb_data/recruiting/models/pydantic/responses.py +70 -0
- cfb_data/recruiting/resource.py +180 -0
- cfb_data/retry.py +49 -0
- cfb_data/stats/__init__.py +69 -0
- cfb_data/stats/models/__init__.py +1 -0
- cfb_data/stats/models/pydantic/__init__.py +65 -0
- cfb_data/stats/models/pydantic/requests.py +167 -0
- cfb_data/stats/models/pydantic/responses.py +291 -0
- cfb_data/stats/resource.py +400 -0
- cfb_data/teams/__init__.py +40 -0
- cfb_data/teams/models/__init__.py +1 -0
- cfb_data/teams/models/pydantic/__init__.py +26 -0
- cfb_data/teams/models/pydantic/requests.py +96 -0
- cfb_data/teams/models/pydantic/responses.py +116 -0
- cfb_data/teams/resource.py +270 -0
- cfb_data/venues/__init__.py +6 -0
- cfb_data/venues/models/__init__.py +1 -0
- cfb_data/venues/models/pydantic/__init__.py +5 -0
- cfb_data/venues/models/pydantic/responses.py +24 -0
- cfb_data/venues/resource.py +51 -0
- cfb_data-0.4.1.dist-info/METADATA +414 -0
- cfb_data-0.4.1.dist-info/RECORD +127 -0
- cfb_data-0.4.1.dist-info/WHEEL +5 -0
- cfb_data-0.4.1.dist-info/licenses/LICENSE +21 -0
- cfb_data-0.4.1.dist-info/top_level.txt +1 -0
cfb_data/_tabular.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""Build canonical Arrow tables from validated tabular response models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import types
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
from typing import (
|
|
14
|
+
Annotated,
|
|
15
|
+
Final,
|
|
16
|
+
Literal,
|
|
17
|
+
Union,
|
|
18
|
+
cast,
|
|
19
|
+
get_args,
|
|
20
|
+
get_origin,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
import pyarrow as pa
|
|
24
|
+
from pydantic import BaseModel, TypeAdapter
|
|
25
|
+
|
|
26
|
+
_LogicalKind = Literal[
|
|
27
|
+
"integer",
|
|
28
|
+
"float",
|
|
29
|
+
"boolean",
|
|
30
|
+
"string",
|
|
31
|
+
"scalar",
|
|
32
|
+
"datetime",
|
|
33
|
+
"struct",
|
|
34
|
+
"list",
|
|
35
|
+
]
|
|
36
|
+
_ScalarKind = Literal["string", "integer", "float"]
|
|
37
|
+
|
|
38
|
+
_STORAGE_VERSION: Final = "1"
|
|
39
|
+
_STORAGE_VERSION_KEY: Final = b"cfb_data.storage_version"
|
|
40
|
+
_ROW_MODEL_KEY: Final = b"cfb_data.row_model"
|
|
41
|
+
_SCHEMA_DIGEST_KEY: Final = b"cfb_data.logical_schema_sha256"
|
|
42
|
+
_WRITER_VERSION_KEY: Final = b"cfb_data.writer_version"
|
|
43
|
+
_SCALAR_ENCODING: Final = "tagged_struct_v1"
|
|
44
|
+
_SCALAR_FIELDS: Final = (
|
|
45
|
+
"kind",
|
|
46
|
+
"string_value",
|
|
47
|
+
"integer_value",
|
|
48
|
+
"float_value",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class _LogicalType:
|
|
54
|
+
"""Describe one recursively representable table value."""
|
|
55
|
+
|
|
56
|
+
kind: _LogicalKind
|
|
57
|
+
nullable: bool = False
|
|
58
|
+
fields: tuple[_LogicalField, ...] = ()
|
|
59
|
+
item: _LogicalType | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class _LogicalField:
|
|
64
|
+
"""Bind a declared field name to its recursive logical type."""
|
|
65
|
+
|
|
66
|
+
name: str
|
|
67
|
+
type: _LogicalType
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class _LogicalSchema:
|
|
72
|
+
"""Preserve the declared field order of one Pydantic row model."""
|
|
73
|
+
|
|
74
|
+
fields: tuple[_LogicalField, ...]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _UnsupportedTableAnnotationError(TypeError):
|
|
78
|
+
"""Report an annotation that has no backend-neutral representation."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _CanonicalTableMetadataError(ValueError):
|
|
82
|
+
"""Report incompatible or incomplete cfb-data table metadata."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _CanonicalTableSchemaError(ValueError):
|
|
86
|
+
"""Report a physical Arrow schema that violates the logical schema."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _ScalarEncodingError(ValueError):
|
|
90
|
+
"""Report a heterogeneous scalar that violates its tagged encoding."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _logical_schema(row_model: type[BaseModel]) -> _LogicalSchema:
|
|
94
|
+
"""Derive an ordered recursive schema from a Pydantic model declaration.
|
|
95
|
+
|
|
96
|
+
:param row_model: Pydantic model defining one tabular row.
|
|
97
|
+
:return: Backend-neutral recursive table schema.
|
|
98
|
+
:raises _UnsupportedTableAnnotationError: If a field cannot be tabularized.
|
|
99
|
+
"""
|
|
100
|
+
return _LogicalSchema(fields=_model_fields(row_model, active_models=frozenset()))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _model_fields(
|
|
104
|
+
row_model: type[BaseModel],
|
|
105
|
+
*,
|
|
106
|
+
active_models: frozenset[type[BaseModel]],
|
|
107
|
+
) -> tuple[_LogicalField, ...]:
|
|
108
|
+
"""Derive fields while rejecting recursive model cycles explicitly."""
|
|
109
|
+
if row_model in active_models:
|
|
110
|
+
raise _UnsupportedTableAnnotationError(
|
|
111
|
+
f"Recursive model {row_model.__name__} cannot be tabularized"
|
|
112
|
+
)
|
|
113
|
+
next_active = active_models | {row_model}
|
|
114
|
+
fields: list[_LogicalField] = []
|
|
115
|
+
for name, field_info in row_model.model_fields.items():
|
|
116
|
+
annotation = field_info.annotation
|
|
117
|
+
if annotation is None:
|
|
118
|
+
raise _UnsupportedTableAnnotationError(
|
|
119
|
+
f"Field {row_model.__name__}.{name} has no annotation"
|
|
120
|
+
)
|
|
121
|
+
fields.append(
|
|
122
|
+
_LogicalField(
|
|
123
|
+
name=name,
|
|
124
|
+
type=_logical_type(annotation, active_models=next_active),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return tuple(fields)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _logical_type(
|
|
131
|
+
annotation: object,
|
|
132
|
+
*,
|
|
133
|
+
active_models: frozenset[type[BaseModel]],
|
|
134
|
+
) -> _LogicalType:
|
|
135
|
+
"""Map one supported annotation to its recursive logical type."""
|
|
136
|
+
origin = get_origin(annotation)
|
|
137
|
+
if origin is Annotated:
|
|
138
|
+
annotated_args = get_args(annotation)
|
|
139
|
+
if not annotated_args:
|
|
140
|
+
raise _UnsupportedTableAnnotationError("Empty Annotated type")
|
|
141
|
+
return _logical_type(annotated_args[0], active_models=active_models)
|
|
142
|
+
|
|
143
|
+
union_origin = types.UnionType if isinstance(annotation, types.UnionType) else None
|
|
144
|
+
if origin is Union or union_origin is types.UnionType:
|
|
145
|
+
union_args = get_args(annotation)
|
|
146
|
+
non_none = tuple(item for item in union_args if item is not type(None))
|
|
147
|
+
if len(union_args) == 3 and set(union_args) == {str, int, float}:
|
|
148
|
+
return _LogicalType("scalar")
|
|
149
|
+
if len(non_none) != 1 or len(non_none) == len(union_args):
|
|
150
|
+
raise _UnsupportedTableAnnotationError(
|
|
151
|
+
f"Only T | None unions are supported, received {annotation!r}"
|
|
152
|
+
)
|
|
153
|
+
return replace(
|
|
154
|
+
_logical_type(non_none[0], active_models=active_models),
|
|
155
|
+
nullable=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if annotation is bool:
|
|
159
|
+
return _LogicalType("boolean")
|
|
160
|
+
if annotation is int:
|
|
161
|
+
return _LogicalType("integer")
|
|
162
|
+
if annotation is float:
|
|
163
|
+
return _LogicalType("float")
|
|
164
|
+
if annotation is str:
|
|
165
|
+
return _LogicalType("string")
|
|
166
|
+
if annotation is datetime:
|
|
167
|
+
return _LogicalType("datetime")
|
|
168
|
+
|
|
169
|
+
if isinstance(annotation, type) and issubclass(annotation, StrEnum):
|
|
170
|
+
return _LogicalType("string")
|
|
171
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
172
|
+
return _LogicalType(
|
|
173
|
+
"struct",
|
|
174
|
+
fields=_model_fields(annotation, active_models=active_models),
|
|
175
|
+
)
|
|
176
|
+
if origin is list:
|
|
177
|
+
list_args = get_args(annotation)
|
|
178
|
+
if len(list_args) != 1:
|
|
179
|
+
raise _UnsupportedTableAnnotationError(
|
|
180
|
+
f"List annotation must have one item type: {annotation!r}"
|
|
181
|
+
)
|
|
182
|
+
item_annotation = next(iter(list_args))
|
|
183
|
+
return _LogicalType(
|
|
184
|
+
"list",
|
|
185
|
+
item=_logical_type(item_annotation, active_models=active_models),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
raise _UnsupportedTableAnnotationError(
|
|
189
|
+
f"Unsupported table annotation: {annotation!r}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _arrow_table_from_models[ModelT: BaseModel](
|
|
194
|
+
*,
|
|
195
|
+
row_model: type[ModelT],
|
|
196
|
+
models: Sequence[ModelT],
|
|
197
|
+
) -> pa.Table:
|
|
198
|
+
"""Return the canonical Arrow table for validated model rows.
|
|
199
|
+
|
|
200
|
+
:param row_model: Authoritative model defining one row.
|
|
201
|
+
:param models: Validated rows in source order.
|
|
202
|
+
:return: Arrow table with exact recursive schema and storage metadata.
|
|
203
|
+
:raises TypeError: If a row or value violates the logical model type.
|
|
204
|
+
:raises ValueError: If a value violates a logical table invariant.
|
|
205
|
+
"""
|
|
206
|
+
logical_schema = _logical_schema(row_model)
|
|
207
|
+
records = _records_from_models(models, row_model, logical_schema)
|
|
208
|
+
storage_records = [
|
|
209
|
+
_encode_storage_struct(record, logical_schema.fields) for record in records
|
|
210
|
+
]
|
|
211
|
+
return pa.Table.from_pylist(
|
|
212
|
+
storage_records,
|
|
213
|
+
schema=_expected_arrow_schema(row_model, logical_schema=logical_schema),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _models_from_arrow_table[ModelT: BaseModel](
|
|
218
|
+
*,
|
|
219
|
+
row_model: type[ModelT],
|
|
220
|
+
response_adapter: TypeAdapter[list[ModelT]],
|
|
221
|
+
table: pa.Table,
|
|
222
|
+
) -> list[ModelT]:
|
|
223
|
+
"""Validate and return models decoded from a canonical Arrow table.
|
|
224
|
+
|
|
225
|
+
:param row_model: Expected authoritative row model.
|
|
226
|
+
:param response_adapter: Pydantic adapter for a list of expected rows.
|
|
227
|
+
:param table: Canonical Arrow table to decode.
|
|
228
|
+
:return: Fully Pydantic-validated rows in table order.
|
|
229
|
+
:raises _CanonicalTableMetadataError: If cfb-data metadata is incompatible.
|
|
230
|
+
:raises _CanonicalTableSchemaError: If the Arrow schema is incompatible.
|
|
231
|
+
:raises _ScalarEncodingError: If a tagged scalar is malformed.
|
|
232
|
+
:raises pydantic.ValidationError: If decoded rows violate the model contract.
|
|
233
|
+
"""
|
|
234
|
+
records = _logical_records_from_arrow_table(row_model=row_model, table=table)
|
|
235
|
+
return response_adapter.validate_python(records)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _logical_records_from_arrow_table(
|
|
239
|
+
*,
|
|
240
|
+
row_model: type[BaseModel],
|
|
241
|
+
table: pa.Table,
|
|
242
|
+
) -> list[dict[str, object]]:
|
|
243
|
+
"""Decode canonical storage values into backend-neutral logical records.
|
|
244
|
+
|
|
245
|
+
:param row_model: Expected authoritative row model.
|
|
246
|
+
:param table: Canonical Arrow table to decode.
|
|
247
|
+
:return: Python records preserving row order and logical scalar values.
|
|
248
|
+
:raises _CanonicalTableMetadataError: If cfb-data metadata is incompatible.
|
|
249
|
+
:raises _CanonicalTableSchemaError: If the Arrow schema is incompatible.
|
|
250
|
+
:raises _ScalarEncodingError: If a tagged scalar is malformed.
|
|
251
|
+
"""
|
|
252
|
+
logical_schema = _logical_schema(row_model)
|
|
253
|
+
_assert_canonical_arrow_table(
|
|
254
|
+
row_model=row_model,
|
|
255
|
+
table=table,
|
|
256
|
+
logical_schema=logical_schema,
|
|
257
|
+
)
|
|
258
|
+
return [
|
|
259
|
+
_decode_storage_struct(record, logical_schema.fields)
|
|
260
|
+
for record in table.to_pylist()
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _assert_canonical_arrow_table(
|
|
265
|
+
*,
|
|
266
|
+
row_model: type[BaseModel],
|
|
267
|
+
table: pa.Table,
|
|
268
|
+
logical_schema: _LogicalSchema | None = None,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Verify physical schema and cfb-data metadata for an expected row model.
|
|
271
|
+
|
|
272
|
+
:param row_model: Expected authoritative row model.
|
|
273
|
+
:param table: Arrow table to inspect without decoding row values.
|
|
274
|
+
:param logical_schema: Previously derived schema, if already available.
|
|
275
|
+
:raises _CanonicalTableMetadataError: If required metadata is incompatible.
|
|
276
|
+
:raises _CanonicalTableSchemaError: If the physical schema differs.
|
|
277
|
+
"""
|
|
278
|
+
schema = logical_schema or _logical_schema(row_model)
|
|
279
|
+
expected = _expected_arrow_schema(row_model, logical_schema=schema)
|
|
280
|
+
if not table.schema.remove_metadata().equals(expected.remove_metadata()):
|
|
281
|
+
raise _CanonicalTableSchemaError(
|
|
282
|
+
"Arrow table does not match the expected physical schema"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
metadata = table.schema.metadata
|
|
286
|
+
if metadata is None:
|
|
287
|
+
raise _CanonicalTableMetadataError("Arrow table has no cfb-data metadata")
|
|
288
|
+
|
|
289
|
+
expected_metadata = expected.metadata
|
|
290
|
+
if expected_metadata is None:
|
|
291
|
+
raise AssertionError("Expected Arrow schema metadata is missing")
|
|
292
|
+
for key in (_STORAGE_VERSION_KEY, _ROW_MODEL_KEY, _SCHEMA_DIGEST_KEY):
|
|
293
|
+
if metadata.get(key) != expected_metadata[key]:
|
|
294
|
+
raise _CanonicalTableMetadataError(
|
|
295
|
+
"Arrow table has incompatible cfb-data metadata"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
writer_version = metadata.get(_WRITER_VERSION_KEY)
|
|
299
|
+
if writer_version is None:
|
|
300
|
+
raise _CanonicalTableMetadataError("Arrow table has no writer version")
|
|
301
|
+
try:
|
|
302
|
+
decoded_writer_version = writer_version.decode("utf-8")
|
|
303
|
+
except UnicodeDecodeError as exc:
|
|
304
|
+
raise _CanonicalTableMetadataError(
|
|
305
|
+
"Arrow table writer version is not UTF-8"
|
|
306
|
+
) from exc
|
|
307
|
+
if not decoded_writer_version:
|
|
308
|
+
raise _CanonicalTableMetadataError("Arrow table writer version is empty")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _expected_arrow_schema(
|
|
312
|
+
row_model: type[BaseModel],
|
|
313
|
+
*,
|
|
314
|
+
logical_schema: _LogicalSchema | None = None,
|
|
315
|
+
) -> pa.Schema:
|
|
316
|
+
"""Return the canonical physical schema and metadata for a row model."""
|
|
317
|
+
schema = logical_schema or _logical_schema(row_model)
|
|
318
|
+
return pa.schema(
|
|
319
|
+
[_arrow_field(field) for field in schema.fields],
|
|
320
|
+
metadata=_storage_metadata(row_model, schema),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _arrow_field(field: _LogicalField) -> pa.Field[pa.DataType]:
|
|
325
|
+
"""Return one Arrow field with recursive logical nullability."""
|
|
326
|
+
return pa.field(
|
|
327
|
+
field.name,
|
|
328
|
+
_arrow_type(field.type),
|
|
329
|
+
nullable=field.type.nullable,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _arrow_type(logical_type: _LogicalType) -> pa.DataType:
|
|
334
|
+
"""Return the canonical Arrow data type for one logical type."""
|
|
335
|
+
if logical_type.kind == "integer":
|
|
336
|
+
return pa.int64()
|
|
337
|
+
if logical_type.kind == "float":
|
|
338
|
+
return pa.float64()
|
|
339
|
+
if logical_type.kind == "boolean":
|
|
340
|
+
return pa.bool_()
|
|
341
|
+
if logical_type.kind == "string":
|
|
342
|
+
return pa.string()
|
|
343
|
+
if logical_type.kind == "datetime":
|
|
344
|
+
return pa.timestamp("us", tz="UTC")
|
|
345
|
+
if logical_type.kind == "scalar":
|
|
346
|
+
return pa.struct(
|
|
347
|
+
[
|
|
348
|
+
pa.field("kind", pa.string(), nullable=False),
|
|
349
|
+
pa.field("string_value", pa.string(), nullable=True),
|
|
350
|
+
pa.field("integer_value", pa.binary(), nullable=True),
|
|
351
|
+
pa.field("float_value", pa.float64(), nullable=True),
|
|
352
|
+
]
|
|
353
|
+
)
|
|
354
|
+
if logical_type.kind == "struct":
|
|
355
|
+
return pa.struct([_arrow_field(field) for field in logical_type.fields])
|
|
356
|
+
if logical_type.kind == "list" and logical_type.item is not None:
|
|
357
|
+
return pa.list_(
|
|
358
|
+
pa.field(
|
|
359
|
+
"element",
|
|
360
|
+
_arrow_type(logical_type.item),
|
|
361
|
+
nullable=logical_type.item.nullable,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
raise AssertionError(f"Unreachable logical kind: {logical_type.kind}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _records_from_models[ModelT: BaseModel](
|
|
368
|
+
models: Sequence[ModelT],
|
|
369
|
+
row_model: type[ModelT],
|
|
370
|
+
schema: _LogicalSchema,
|
|
371
|
+
) -> list[dict[str, object]]:
|
|
372
|
+
"""Dump validated models in Python mode and normalize logical values."""
|
|
373
|
+
records: list[dict[str, object]] = []
|
|
374
|
+
for model in models:
|
|
375
|
+
if not isinstance(model, row_model):
|
|
376
|
+
raise TypeError(
|
|
377
|
+
f"Expected {row_model.__name__}, received {type(model).__name__}"
|
|
378
|
+
)
|
|
379
|
+
raw: object = model.model_dump(mode="python", by_alias=False)
|
|
380
|
+
records.append(_normalize_struct(raw, schema.fields))
|
|
381
|
+
return records
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _normalize_struct(
|
|
385
|
+
value: object,
|
|
386
|
+
fields: tuple[_LogicalField, ...],
|
|
387
|
+
) -> dict[str, object]:
|
|
388
|
+
"""Normalize a model-derived mapping in declared field order."""
|
|
389
|
+
mapping = _checked_mapping(value, fields)
|
|
390
|
+
return {
|
|
391
|
+
field.name: _normalize_value(mapping[field.name], field.type)
|
|
392
|
+
for field in fields
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _normalize_value(value: object, logical_type: _LogicalType) -> object:
|
|
397
|
+
"""Normalize one model-derived value against its logical type."""
|
|
398
|
+
if value is None:
|
|
399
|
+
if logical_type.nullable:
|
|
400
|
+
return None
|
|
401
|
+
raise TypeError("Non-nullable logical value is null")
|
|
402
|
+
|
|
403
|
+
if logical_type.kind == "boolean":
|
|
404
|
+
if not isinstance(value, bool):
|
|
405
|
+
raise TypeError("Boolean logical value has the wrong type")
|
|
406
|
+
return value
|
|
407
|
+
if logical_type.kind == "integer":
|
|
408
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
409
|
+
raise TypeError("Integer logical value has the wrong type")
|
|
410
|
+
return value
|
|
411
|
+
if logical_type.kind == "float":
|
|
412
|
+
if not isinstance(value, float):
|
|
413
|
+
raise TypeError("Float logical value has the wrong type")
|
|
414
|
+
return value
|
|
415
|
+
if logical_type.kind == "string":
|
|
416
|
+
if isinstance(value, StrEnum):
|
|
417
|
+
return value.value
|
|
418
|
+
if not isinstance(value, str):
|
|
419
|
+
raise TypeError("String logical value has the wrong type")
|
|
420
|
+
return value
|
|
421
|
+
if logical_type.kind == "scalar":
|
|
422
|
+
if isinstance(value, bool) or not isinstance(value, str | int | float):
|
|
423
|
+
raise TypeError("Heterogeneous scalar value has the wrong type")
|
|
424
|
+
return value
|
|
425
|
+
if logical_type.kind == "datetime":
|
|
426
|
+
if not isinstance(value, datetime):
|
|
427
|
+
raise TypeError("Datetime logical value has the wrong type")
|
|
428
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
429
|
+
raise ValueError("Datetime logical values must be timezone-aware")
|
|
430
|
+
return value.astimezone(UTC)
|
|
431
|
+
if logical_type.kind == "struct":
|
|
432
|
+
return _normalize_struct(value, logical_type.fields)
|
|
433
|
+
if logical_type.kind == "list":
|
|
434
|
+
if not isinstance(value, list) or logical_type.item is None:
|
|
435
|
+
raise TypeError("List logical value has the wrong type")
|
|
436
|
+
return [_normalize_value(item, logical_type.item) for item in value]
|
|
437
|
+
raise AssertionError(f"Unreachable logical kind: {logical_type.kind}")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _encode_storage_struct(
|
|
441
|
+
value: object,
|
|
442
|
+
fields: tuple[_LogicalField, ...],
|
|
443
|
+
) -> dict[str, object]:
|
|
444
|
+
"""Encode one normalized logical struct for canonical Arrow storage."""
|
|
445
|
+
mapping = _checked_mapping(value, fields)
|
|
446
|
+
return {
|
|
447
|
+
field.name: _encode_storage_value(mapping[field.name], field.type)
|
|
448
|
+
for field in fields
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _encode_storage_value(value: object, logical_type: _LogicalType) -> object:
|
|
453
|
+
"""Encode one logical value into its canonical Arrow storage value."""
|
|
454
|
+
if value is None:
|
|
455
|
+
if logical_type.nullable:
|
|
456
|
+
return None
|
|
457
|
+
raise TypeError("Non-nullable storage value is null")
|
|
458
|
+
if logical_type.kind == "scalar":
|
|
459
|
+
return _encode_scalar(value)
|
|
460
|
+
if logical_type.kind == "struct":
|
|
461
|
+
return _encode_storage_struct(value, logical_type.fields)
|
|
462
|
+
if logical_type.kind == "list":
|
|
463
|
+
if not isinstance(value, list) or logical_type.item is None:
|
|
464
|
+
raise TypeError("List storage value has the wrong type")
|
|
465
|
+
return [_encode_storage_value(item, logical_type.item) for item in value]
|
|
466
|
+
return _normalize_value(value, logical_type)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _encode_scalar(value: object) -> dict[str, object]:
|
|
470
|
+
"""Encode a heterogeneous scalar without losing its concrete Python type."""
|
|
471
|
+
if isinstance(value, str):
|
|
472
|
+
kind: _ScalarKind = "string"
|
|
473
|
+
selected_field = "string_value"
|
|
474
|
+
elif isinstance(value, int) and not isinstance(value, bool):
|
|
475
|
+
kind = "integer"
|
|
476
|
+
selected_field = "integer_value"
|
|
477
|
+
value = _encode_integer(value)
|
|
478
|
+
elif isinstance(value, float):
|
|
479
|
+
kind = "float"
|
|
480
|
+
selected_field = "float_value"
|
|
481
|
+
else:
|
|
482
|
+
raise TypeError("Heterogeneous scalar storage value has the wrong type")
|
|
483
|
+
|
|
484
|
+
encoded: dict[str, object] = {
|
|
485
|
+
"kind": kind,
|
|
486
|
+
"string_value": None,
|
|
487
|
+
"integer_value": None,
|
|
488
|
+
"float_value": None,
|
|
489
|
+
}
|
|
490
|
+
encoded[selected_field] = value
|
|
491
|
+
return encoded
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _encode_integer(value: int) -> bytes:
|
|
495
|
+
"""Encode an arbitrary integer as canonical signed big-endian bytes.
|
|
496
|
+
|
|
497
|
+
:param value: Integer to preserve without a fixed-width bound.
|
|
498
|
+
:return: Minimal two's-complement byte representation.
|
|
499
|
+
"""
|
|
500
|
+
if value >= 0:
|
|
501
|
+
byte_count = max(1, (value.bit_length() + 8) // 8)
|
|
502
|
+
else:
|
|
503
|
+
byte_count = max(1, ((~value).bit_length() + 8) // 8)
|
|
504
|
+
return value.to_bytes(byte_count, byteorder="big", signed=True)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _decode_storage_struct(
|
|
508
|
+
value: object,
|
|
509
|
+
fields: tuple[_LogicalField, ...],
|
|
510
|
+
) -> dict[str, object]:
|
|
511
|
+
"""Decode one canonical Arrow struct into logical Python values."""
|
|
512
|
+
mapping = _checked_mapping(value, fields)
|
|
513
|
+
return {
|
|
514
|
+
field.name: _decode_storage_value(mapping[field.name], field.type)
|
|
515
|
+
for field in fields
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _decode_storage_value(value: object, logical_type: _LogicalType) -> object:
|
|
520
|
+
"""Decode one canonical Arrow value into its logical Python value."""
|
|
521
|
+
if value is None:
|
|
522
|
+
if logical_type.nullable:
|
|
523
|
+
return None
|
|
524
|
+
raise TypeError("Non-nullable storage value is null")
|
|
525
|
+
if logical_type.kind == "scalar":
|
|
526
|
+
return _decode_scalar(value)
|
|
527
|
+
if logical_type.kind == "struct":
|
|
528
|
+
return _decode_storage_struct(value, logical_type.fields)
|
|
529
|
+
if logical_type.kind == "list":
|
|
530
|
+
if not isinstance(value, list) or logical_type.item is None:
|
|
531
|
+
raise TypeError("List storage value has the wrong type")
|
|
532
|
+
return [_decode_storage_value(item, logical_type.item) for item in value]
|
|
533
|
+
return _normalize_value(value, logical_type)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _decode_scalar(value: object) -> str | int | float:
|
|
537
|
+
"""Decode and validate one tagged heterogeneous scalar."""
|
|
538
|
+
if not isinstance(value, Mapping):
|
|
539
|
+
raise _ScalarEncodingError("Tagged scalar must be a mapping")
|
|
540
|
+
mapping = cast(Mapping[object, object], value)
|
|
541
|
+
if set(mapping) != set(_SCALAR_FIELDS):
|
|
542
|
+
raise _ScalarEncodingError("Tagged scalar fields are invalid")
|
|
543
|
+
|
|
544
|
+
kind = mapping["kind"]
|
|
545
|
+
if kind not in {"string", "integer", "float"}:
|
|
546
|
+
raise _ScalarEncodingError("Tagged scalar kind is invalid")
|
|
547
|
+
field_by_kind = {
|
|
548
|
+
"string": "string_value",
|
|
549
|
+
"integer": "integer_value",
|
|
550
|
+
"float": "float_value",
|
|
551
|
+
}
|
|
552
|
+
selected_field = field_by_kind[kind]
|
|
553
|
+
populated_fields = [
|
|
554
|
+
field_name
|
|
555
|
+
for field_name in _SCALAR_FIELDS[1:]
|
|
556
|
+
if mapping[field_name] is not None
|
|
557
|
+
]
|
|
558
|
+
if populated_fields != [selected_field]:
|
|
559
|
+
raise _ScalarEncodingError("Tagged scalar value slots are invalid")
|
|
560
|
+
|
|
561
|
+
selected_value = mapping[selected_field]
|
|
562
|
+
if kind == "string" and isinstance(selected_value, str):
|
|
563
|
+
return selected_value
|
|
564
|
+
if kind == "integer" and isinstance(selected_value, bytes):
|
|
565
|
+
integer_value = int.from_bytes(selected_value, byteorder="big", signed=True)
|
|
566
|
+
if _encode_integer(integer_value) == selected_value:
|
|
567
|
+
return integer_value
|
|
568
|
+
raise _ScalarEncodingError("Tagged scalar integer is not canonical")
|
|
569
|
+
if kind == "float" and isinstance(selected_value, float):
|
|
570
|
+
return selected_value
|
|
571
|
+
raise _ScalarEncodingError("Tagged scalar value type does not match its kind")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _checked_mapping(
|
|
575
|
+
value: object,
|
|
576
|
+
fields: tuple[_LogicalField, ...],
|
|
577
|
+
) -> Mapping[object, object]:
|
|
578
|
+
"""Return a mapping whose keys exactly match the declared struct fields."""
|
|
579
|
+
if not isinstance(value, Mapping):
|
|
580
|
+
raise TypeError("Struct value must be a mapping")
|
|
581
|
+
mapping = cast(Mapping[object, object], value)
|
|
582
|
+
expected_names = tuple(field.name for field in fields)
|
|
583
|
+
if set(mapping) != set(expected_names):
|
|
584
|
+
raise ValueError("Struct keys do not match the logical schema")
|
|
585
|
+
return mapping
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _storage_metadata(
|
|
589
|
+
row_model: type[BaseModel],
|
|
590
|
+
schema: _LogicalSchema,
|
|
591
|
+
) -> dict[bytes | str, bytes | str]:
|
|
592
|
+
"""Return deterministic namespaced metadata for a canonical table."""
|
|
593
|
+
return {
|
|
594
|
+
_STORAGE_VERSION_KEY: _STORAGE_VERSION.encode("ascii"),
|
|
595
|
+
_ROW_MODEL_KEY: _row_model_identifier(row_model).encode("utf-8"),
|
|
596
|
+
_SCHEMA_DIGEST_KEY: _logical_schema_digest(schema).encode("ascii"),
|
|
597
|
+
_WRITER_VERSION_KEY: _installed_package_version().encode("utf-8"),
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _row_model_identifier(row_model: type[BaseModel]) -> str:
|
|
602
|
+
"""Return the stable module-qualified identity stored with a row model."""
|
|
603
|
+
return f"{row_model.__module__}:{row_model.__qualname__}"
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _logical_schema_digest(schema: _LogicalSchema) -> str:
|
|
607
|
+
"""Return a stable digest of logical shape, order, and nullability."""
|
|
608
|
+
payload = json.dumps(
|
|
609
|
+
_logical_schema_payload(schema),
|
|
610
|
+
ensure_ascii=True,
|
|
611
|
+
separators=(",", ":"),
|
|
612
|
+
sort_keys=True,
|
|
613
|
+
).encode("utf-8")
|
|
614
|
+
return hashlib.sha256(payload).hexdigest()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _logical_schema_payload(schema: _LogicalSchema) -> dict[str, object]:
|
|
618
|
+
"""Return the deterministic JSON-compatible form of a logical schema."""
|
|
619
|
+
return {
|
|
620
|
+
"fields": [
|
|
621
|
+
{"name": field.name, "type": _logical_type_payload(field.type)}
|
|
622
|
+
for field in schema.fields
|
|
623
|
+
]
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _logical_type_payload(logical_type: _LogicalType) -> dict[str, object]:
|
|
628
|
+
"""Return the deterministic JSON-compatible form of a logical type."""
|
|
629
|
+
payload: dict[str, object] = {
|
|
630
|
+
"kind": logical_type.kind,
|
|
631
|
+
"nullable": logical_type.nullable,
|
|
632
|
+
}
|
|
633
|
+
if logical_type.kind == "scalar":
|
|
634
|
+
payload["encoding"] = {
|
|
635
|
+
"name": _SCALAR_ENCODING,
|
|
636
|
+
"fields": [
|
|
637
|
+
{"name": "kind", "kind": "string", "nullable": False},
|
|
638
|
+
{
|
|
639
|
+
"name": "string_value",
|
|
640
|
+
"kind": "string",
|
|
641
|
+
"nullable": True,
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
"name": "integer_value",
|
|
645
|
+
"kind": "binary",
|
|
646
|
+
"nullable": True,
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
"name": "float_value",
|
|
650
|
+
"kind": "float",
|
|
651
|
+
"nullable": True,
|
|
652
|
+
},
|
|
653
|
+
],
|
|
654
|
+
"kind_to_value_field": [
|
|
655
|
+
["string", "string_value"],
|
|
656
|
+
["integer", "integer_value"],
|
|
657
|
+
["float", "float_value"],
|
|
658
|
+
],
|
|
659
|
+
"populated_value_slots": 1,
|
|
660
|
+
}
|
|
661
|
+
if logical_type.kind == "struct":
|
|
662
|
+
payload["fields"] = [
|
|
663
|
+
{"name": field.name, "type": _logical_type_payload(field.type)}
|
|
664
|
+
for field in logical_type.fields
|
|
665
|
+
]
|
|
666
|
+
if logical_type.kind == "list" and logical_type.item is not None:
|
|
667
|
+
payload["item"] = _logical_type_payload(logical_type.item)
|
|
668
|
+
return payload
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _installed_package_version() -> str:
|
|
672
|
+
"""Return package metadata version without duplicating pyproject state."""
|
|
673
|
+
try:
|
|
674
|
+
return version("cfb-data")
|
|
675
|
+
except PackageNotFoundError:
|
|
676
|
+
return "unknown"
|