fleetpull 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.
- fleetpull/__init__.py +36 -0
- fleetpull/api/__init__.py +32 -0
- fleetpull/api/auth_ingress.py +216 -0
- fleetpull/api/catalog.py +139 -0
- fleetpull/api/fetch.py +229 -0
- fleetpull/api/identity.py +90 -0
- fleetpull/api/sync.py +735 -0
- fleetpull/cli.py +146 -0
- fleetpull/config/__init__.py +47 -0
- fleetpull/config/base.py +18 -0
- fleetpull/config/example.py +90 -0
- fleetpull/config/geotab.py +78 -0
- fleetpull/config/http.py +32 -0
- fleetpull/config/loading.py +195 -0
- fleetpull/config/logger.py +139 -0
- fleetpull/config/providers.py +520 -0
- fleetpull/config/rate_limit.py +50 -0
- fleetpull/config/resolution.py +156 -0
- fleetpull/config/retry.py +69 -0
- fleetpull/config/root.py +157 -0
- fleetpull/config/sections.py +146 -0
- fleetpull/endpoints/__init__.py +24 -0
- fleetpull/endpoints/geotab/__init__.py +12 -0
- fleetpull/endpoints/geotab/_requests.py +427 -0
- fleetpull/endpoints/geotab/annotation_logs.py +73 -0
- fleetpull/endpoints/geotab/audits.py +70 -0
- fleetpull/endpoints/geotab/devices.py +79 -0
- fleetpull/endpoints/geotab/driver_changes.py +73 -0
- fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
- fleetpull/endpoints/geotab/dvir_logs.py +76 -0
- fleetpull/endpoints/geotab/exception_events.py +119 -0
- fleetpull/endpoints/geotab/fault_data.py +72 -0
- fleetpull/endpoints/geotab/fill_ups.py +79 -0
- fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
- fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
- fleetpull/endpoints/geotab/log_records.py +74 -0
- fleetpull/endpoints/geotab/media_files.py +74 -0
- fleetpull/endpoints/geotab/shipment_logs.py +71 -0
- fleetpull/endpoints/geotab/status_data.py +71 -0
- fleetpull/endpoints/geotab/text_messages.py +77 -0
- fleetpull/endpoints/geotab/trips.py +102 -0
- fleetpull/endpoints/geotab/users.py +81 -0
- fleetpull/endpoints/motive/__init__.py +12 -0
- fleetpull/endpoints/motive/_spec_builders.py +94 -0
- fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
- fleetpull/endpoints/motive/driving_periods.py +85 -0
- fleetpull/endpoints/motive/groups.py +61 -0
- fleetpull/endpoints/motive/idle_events.py +94 -0
- fleetpull/endpoints/motive/users.py +64 -0
- fleetpull/endpoints/motive/vehicle_locations.py +170 -0
- fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
- fleetpull/endpoints/motive/vehicles.py +106 -0
- fleetpull/endpoints/registry.py +280 -0
- fleetpull/endpoints/samsara/__init__.py +12 -0
- fleetpull/endpoints/samsara/_spec_builders.py +202 -0
- fleetpull/endpoints/samsara/addresses.py +77 -0
- fleetpull/endpoints/samsara/asset_locations.py +217 -0
- fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
- fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
- fleetpull/endpoints/samsara/drivers.py +169 -0
- fleetpull/endpoints/samsara/engine_states.py +114 -0
- fleetpull/endpoints/samsara/gps_readings.py +113 -0
- fleetpull/endpoints/samsara/idling_events.py +195 -0
- fleetpull/endpoints/samsara/odometer_readings.py +116 -0
- fleetpull/endpoints/samsara/trips.py +217 -0
- fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
- fleetpull/endpoints/samsara/vehicles.py +121 -0
- fleetpull/endpoints/shared/__init__.py +57 -0
- fleetpull/endpoints/shared/base.py +529 -0
- fleetpull/endpoints/shared/request_shape.py +227 -0
- fleetpull/endpoints/shared/resume.py +74 -0
- fleetpull/endpoints/shared/spec_builders.py +59 -0
- fleetpull/endpoints/shared/sync_mode.py +159 -0
- fleetpull/endpoints/shared/url_paths.py +149 -0
- fleetpull/exceptions.py +320 -0
- fleetpull/incremental/__init__.py +27 -0
- fleetpull/incremental/cursor.py +66 -0
- fleetpull/incremental/resolution.py +152 -0
- fleetpull/incremental/seed.py +53 -0
- fleetpull/incremental/window.py +94 -0
- fleetpull/logger/__init__.py +5 -0
- fleetpull/logger/setup.py +145 -0
- fleetpull/model_contract/__init__.py +6 -0
- fleetpull/model_contract/coercions.py +33 -0
- fleetpull/model_contract/response.py +45 -0
- fleetpull/models/__init__.py +7 -0
- fleetpull/models/geotab/__init__.py +166 -0
- fleetpull/models/geotab/annotation_log.py +109 -0
- fleetpull/models/geotab/audit.py +56 -0
- fleetpull/models/geotab/device.py +217 -0
- fleetpull/models/geotab/driver_change.py +102 -0
- fleetpull/models/geotab/duty_status_log.py +200 -0
- fleetpull/models/geotab/dvir_log.py +184 -0
- fleetpull/models/geotab/exception_event.py +135 -0
- fleetpull/models/geotab/fault_data.py +168 -0
- fleetpull/models/geotab/fill_up.py +189 -0
- fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
- fleetpull/models/geotab/fuel_tax_detail.py +150 -0
- fleetpull/models/geotab/log_record.py +68 -0
- fleetpull/models/geotab/media_file.py +133 -0
- fleetpull/models/geotab/shared.py +221 -0
- fleetpull/models/geotab/shipment_log.py +105 -0
- fleetpull/models/geotab/status_data.py +108 -0
- fleetpull/models/geotab/text_message.py +125 -0
- fleetpull/models/geotab/trip.py +162 -0
- fleetpull/models/geotab/user.py +187 -0
- fleetpull/models/motive/__init__.py +43 -0
- fleetpull/models/motive/driver_idle_rollup.py +109 -0
- fleetpull/models/motive/driving_period.py +82 -0
- fleetpull/models/motive/group.py +49 -0
- fleetpull/models/motive/idle_event.py +77 -0
- fleetpull/models/motive/shared.py +192 -0
- fleetpull/models/motive/user.py +217 -0
- fleetpull/models/motive/vehicle.py +162 -0
- fleetpull/models/motive/vehicle_location.py +127 -0
- fleetpull/models/motive/vehicle_utilization.py +128 -0
- fleetpull/models/samsara/__init__.py +108 -0
- fleetpull/models/samsara/address.py +149 -0
- fleetpull/models/samsara/asset_location.py +131 -0
- fleetpull/models/samsara/driver.py +232 -0
- fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
- fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
- fleetpull/models/samsara/engine_state.py +92 -0
- fleetpull/models/samsara/gps_reading.py +146 -0
- fleetpull/models/samsara/idling_event.py +174 -0
- fleetpull/models/samsara/odometer_reading.py +90 -0
- fleetpull/models/samsara/trip.py +199 -0
- fleetpull/models/samsara/vehicle.py +167 -0
- fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
- fleetpull/network/__init__.py +9 -0
- fleetpull/network/auth/__init__.py +15 -0
- fleetpull/network/auth/authenticate.py +271 -0
- fleetpull/network/auth/manager.py +223 -0
- fleetpull/network/auth/models.py +57 -0
- fleetpull/network/auth/strategies.py +169 -0
- fleetpull/network/classifiers/__init__.py +11 -0
- fleetpull/network/classifiers/geotab.py +145 -0
- fleetpull/network/classifiers/motive.py +79 -0
- fleetpull/network/classifiers/samsara.py +80 -0
- fleetpull/network/client/__init__.py +25 -0
- fleetpull/network/client/page.py +32 -0
- fleetpull/network/client/profile.py +27 -0
- fleetpull/network/client/registry.py +103 -0
- fleetpull/network/client/registry_base.py +150 -0
- fleetpull/network/client/runtime.py +44 -0
- fleetpull/network/client/transport.py +381 -0
- fleetpull/network/contract/__init__.py +50 -0
- fleetpull/network/contract/auth.py +48 -0
- fleetpull/network/contract/classifier.py +189 -0
- fleetpull/network/contract/envelope_fetcher.py +33 -0
- fleetpull/network/contract/envelopes.py +189 -0
- fleetpull/network/contract/outcome.py +43 -0
- fleetpull/network/contract/page_decoder.py +101 -0
- fleetpull/network/contract/request.py +97 -0
- fleetpull/network/decoders/__init__.py +40 -0
- fleetpull/network/decoders/_window_stamp.py +78 -0
- fleetpull/network/decoders/geotab.py +309 -0
- fleetpull/network/decoders/motive.py +204 -0
- fleetpull/network/decoders/motive_reports.py +114 -0
- fleetpull/network/decoders/samsara.py +343 -0
- fleetpull/network/decoders/samsara_reports.py +119 -0
- fleetpull/network/decoders/single_page.py +55 -0
- fleetpull/network/limits/__init__.py +13 -0
- fleetpull/network/limits/bucket_math.py +58 -0
- fleetpull/network/limits/limiter.py +153 -0
- fleetpull/network/limits/registry.py +96 -0
- fleetpull/network/posture/__init__.py +6 -0
- fleetpull/network/posture/client_options.py +96 -0
- fleetpull/network/retry/__init__.py +13 -0
- fleetpull/network/retry/decision.py +155 -0
- fleetpull/network/tls/__init__.py +5 -0
- fleetpull/network/tls/truststore_context.py +35 -0
- fleetpull/orchestrator/__init__.py +30 -0
- fleetpull/orchestrator/backfill.py +127 -0
- fleetpull/orchestrator/batch.py +155 -0
- fleetpull/orchestrator/bisection.py +271 -0
- fleetpull/orchestrator/drivers.py +350 -0
- fleetpull/orchestrator/entry.py +347 -0
- fleetpull/orchestrator/executors.py +128 -0
- fleetpull/orchestrator/fanout.py +163 -0
- fleetpull/orchestrator/feed_drive.py +266 -0
- fleetpull/orchestrator/metadata_projection.py +195 -0
- fleetpull/orchestrator/outcome.py +53 -0
- fleetpull/orchestrator/recording.py +85 -0
- fleetpull/orchestrator/resume.py +143 -0
- fleetpull/orchestrator/roster_harvest.py +108 -0
- fleetpull/orchestrator/roster_refresh.py +363 -0
- fleetpull/orchestrator/runner.py +262 -0
- fleetpull/orchestrator/shape_resolution.py +221 -0
- fleetpull/orchestrator/spine.py +166 -0
- fleetpull/orchestrator/streaming.py +153 -0
- fleetpull/orchestrator/unit_loop.py +273 -0
- fleetpull/orchestrator/watermark_drive.py +455 -0
- fleetpull/paths/__init__.py +13 -0
- fleetpull/paths/datasets.py +38 -0
- fleetpull/paths/partitions.py +41 -0
- fleetpull/paths/resolution.py +98 -0
- fleetpull/polars_typing/__init__.py +20 -0
- fleetpull/py.typed +0 -0
- fleetpull/records/__init__.py +19 -0
- fleetpull/records/convert.py +53 -0
- fleetpull/records/dataframe.py +63 -0
- fleetpull/records/event_time.py +70 -0
- fleetpull/records/fields.py +190 -0
- fleetpull/records/flatten.py +54 -0
- fleetpull/records/roster_members.py +74 -0
- fleetpull/records/schema.py +73 -0
- fleetpull/records/validation.py +62 -0
- fleetpull/resources/__init__.py +10 -0
- fleetpull/resources/config.example.yaml +222 -0
- fleetpull/roster/__init__.py +16 -0
- fleetpull/roster/definition.py +46 -0
- fleetpull/roster/key.py +40 -0
- fleetpull/roster/registry.py +93 -0
- fleetpull/state/__init__.py +36 -0
- fleetpull/state/cursors.py +417 -0
- fleetpull/state/database.py +463 -0
- fleetpull/state/migrations.py +430 -0
- fleetpull/state/reconcile.py +127 -0
- fleetpull/state/rosters.py +159 -0
- fleetpull/state/run_ledger.py +572 -0
- fleetpull/state/work_units.py +648 -0
- fleetpull/storage/__init__.py +30 -0
- fleetpull/storage/append.py +187 -0
- fleetpull/storage/atomic.py +161 -0
- fleetpull/storage/files.py +176 -0
- fleetpull/storage/frames.py +98 -0
- fleetpull/storage/metadata.py +161 -0
- fleetpull/storage/partitioned.py +205 -0
- fleetpull/storage/pruning.py +169 -0
- fleetpull/storage/read.py +35 -0
- fleetpull/storage/result.py +34 -0
- fleetpull/storage/single_file.py +130 -0
- fleetpull/storage/splitting.py +88 -0
- fleetpull/storage/staging.py +178 -0
- fleetpull/storage/writers.py +145 -0
- fleetpull/timing/__init__.py +21 -0
- fleetpull/timing/canon.py +92 -0
- fleetpull/timing/clock.py +192 -0
- fleetpull/timing/codec.py +122 -0
- fleetpull/timing/sleeper.py +65 -0
- fleetpull/vocabulary/__init__.py +16 -0
- fleetpull/vocabulary/json_types.py +20 -0
- fleetpull/vocabulary/provider.py +29 -0
- fleetpull/vocabulary/quota_scope.py +47 -0
- fleetpull/vocabulary/response_category.py +32 -0
- fleetpull-0.1.0.dist-info/METADATA +248 -0
- fleetpull-0.1.0.dist-info/RECORD +252 -0
- fleetpull-0.1.0.dist-info/WHEEL +5 -0
- fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
- fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
- fleetpull-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# src/fleetpull/records/__init__.py
|
|
2
|
+
"""The records layer: validate response records and shape them into typed
|
|
3
|
+
Polars DataFrames. Per-record validation (``validate_records``) and the
|
|
4
|
+
model-to-DataFrame conversion (``models_to_dataframe``) build the frame; the
|
|
5
|
+
frame readers ``latest_event_time`` (the watermark candidate) and
|
|
6
|
+
``extract_roster_members`` (the feeder's fan-out keys) observe it. The driver
|
|
7
|
+
composes validation with the network client."""
|
|
8
|
+
|
|
9
|
+
from fleetpull.records.convert import models_to_dataframe
|
|
10
|
+
from fleetpull.records.event_time import latest_event_time
|
|
11
|
+
from fleetpull.records.roster_members import extract_roster_members
|
|
12
|
+
from fleetpull.records.validation import validate_records
|
|
13
|
+
|
|
14
|
+
__all__: list[str] = [
|
|
15
|
+
'extract_roster_members',
|
|
16
|
+
'latest_event_time',
|
|
17
|
+
'models_to_dataframe',
|
|
18
|
+
'validate_records',
|
|
19
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# src/fleetpull/records/convert.py
|
|
2
|
+
"""The records composition: validated model instances to a DataFrame.
|
|
3
|
+
|
|
4
|
+
Ties the stage's pure steps together -- derive the schema from the model
|
|
5
|
+
class, flatten every instance against the shared field walk, build the
|
|
6
|
+
frame with the explicit schema, and normalize empty strings. Per-record
|
|
7
|
+
validation (raw dict to model) is separate (``records/validation.py``);
|
|
8
|
+
composing it with the network client is the driver's job, since records
|
|
9
|
+
does not import the client.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import polars as pl
|
|
16
|
+
|
|
17
|
+
from fleetpull.model_contract import ResponseModel
|
|
18
|
+
from fleetpull.records.dataframe import build_dataframe, normalize_empty_strings
|
|
19
|
+
from fleetpull.records.fields import iter_flat_fields
|
|
20
|
+
from fleetpull.records.flatten import flatten_record
|
|
21
|
+
from fleetpull.records.schema import derive_schema
|
|
22
|
+
|
|
23
|
+
__all__: list[str] = ['models_to_dataframe']
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def models_to_dataframe(
|
|
27
|
+
records: Sequence[ResponseModel], model_class: type[ResponseModel]
|
|
28
|
+
) -> pl.DataFrame:
|
|
29
|
+
"""Build a typed, flattened DataFrame from validated model instances.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
records: The validated model instances to materialize. An empty
|
|
33
|
+
sequence yields a zero-row frame with the model's full schema.
|
|
34
|
+
model_class: The model class whose fields define the schema;
|
|
35
|
+
passed explicitly so an empty ``records`` still derives the
|
|
36
|
+
full column set.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A DataFrame with one row per record, flattened columns in
|
|
40
|
+
declaration order, derived dtypes, and empty strings normalized to
|
|
41
|
+
null.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
TypeError: If any field annotation cannot be mapped to a dtype.
|
|
45
|
+
"""
|
|
46
|
+
schema: dict[str, pl.DataType] = derive_schema(model_class)
|
|
47
|
+
flat_fields = tuple(iter_flat_fields(model_class))
|
|
48
|
+
# typing-justified: flattened rows carry heterogeneous model-field values
|
|
49
|
+
rows: list[dict[str, Any]] = [
|
|
50
|
+
flatten_record(record=record, flat_fields=flat_fields) for record in records
|
|
51
|
+
]
|
|
52
|
+
dataframe: pl.DataFrame = build_dataframe(rows=rows, schema=schema)
|
|
53
|
+
return normalize_empty_strings(dataframe)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# src/fleetpull/records/dataframe.py
|
|
2
|
+
"""DataFrame construction and missing-value normalization.
|
|
3
|
+
|
|
4
|
+
Builds a Polars DataFrame from flat rows using an explicit derived schema
|
|
5
|
+
(construct-with-schema, never infer-then-cast), and normalizes empty
|
|
6
|
+
strings to null at the DataFrame boundary -- the models preserve ``""``
|
|
7
|
+
faithfully from the wire, but the frame uses one uniform missing-value
|
|
8
|
+
representation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
__all__: list[str] = ['build_dataframe', 'normalize_empty_strings']
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_dataframe(
|
|
20
|
+
# typing-justified: row values are heterogeneous model-field values
|
|
21
|
+
rows: Sequence[dict[str, Any]],
|
|
22
|
+
schema: dict[str, pl.DataType],
|
|
23
|
+
) -> pl.DataFrame:
|
|
24
|
+
"""Construct a typed DataFrame from flat rows and a derived schema.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
rows: The flattened ``{column: value}`` rows. An empty sequence is
|
|
28
|
+
valid and yields a zero-row frame with the right columns and
|
|
29
|
+
dtypes.
|
|
30
|
+
schema: The ``{column: dtype}`` map from ``derive_schema``;
|
|
31
|
+
supplies both the column set/order and the dtypes.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A DataFrame whose columns match the schema in declaration order,
|
|
35
|
+
each at its derived dtype.
|
|
36
|
+
"""
|
|
37
|
+
if not rows:
|
|
38
|
+
return pl.DataFrame(schema=schema)
|
|
39
|
+
return pl.DataFrame(rows, schema=schema)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_empty_strings(dataframe: pl.DataFrame) -> pl.DataFrame:
|
|
43
|
+
"""Replace ``""`` with null on every String column.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
dataframe: The frame to normalize.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The frame with empty strings nulled on String columns; other
|
|
50
|
+
dtypes are untouched (they cannot hold an empty string).
|
|
51
|
+
"""
|
|
52
|
+
string_columns: list[str] = [
|
|
53
|
+
name for name, dtype in dataframe.schema.items() if dtype == pl.String()
|
|
54
|
+
]
|
|
55
|
+
if not string_columns:
|
|
56
|
+
return dataframe
|
|
57
|
+
return dataframe.with_columns(
|
|
58
|
+
pl.when(pl.col(column).str.len_chars() == 0)
|
|
59
|
+
.then(None)
|
|
60
|
+
.otherwise(pl.col(column))
|
|
61
|
+
.alias(column)
|
|
62
|
+
for column in string_columns
|
|
63
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# src/fleetpull/records/event_time.py
|
|
2
|
+
"""Event-time observation over a records frame: the latest event timestamp.
|
|
3
|
+
|
|
4
|
+
The watermark candidate a successful watermark fetch produces -- the maximum
|
|
5
|
+
value of the endpoint's event-time column in the fetched records (DESIGN §5,
|
|
6
|
+
observed-data-only). After a fetch is persisted, the orchestrator reads this
|
|
7
|
+
maximum and, if it advances the stored watermark, commits it; the monotonic guard
|
|
8
|
+
and the wrapping into a ``DateWatermark`` are the orchestrator's and the state
|
|
9
|
+
layer's, not this function's -- it returns the raw ``datetime`` so it stays
|
|
10
|
+
ignorant of the cursor type.
|
|
11
|
+
|
|
12
|
+
A pure leaf over the records frame -- Polars, stdlib ``datetime``, and the timing
|
|
13
|
+
face's ``ensure_utc``. This is the one place a Python ``datetime`` is
|
|
14
|
+
materialized out of a Polars frame into domain code, so it is a
|
|
15
|
+
canonicalization ingress: Polars tags the extracted value
|
|
16
|
+
``zoneinfo.ZoneInfo('UTC')``, not ``datetime.UTC``, and ``ensure_utc``
|
|
17
|
+
converts it so the interior's strict identity guards hold (the canonical-UTC
|
|
18
|
+
doctrine, ``timing/canon.py``). It reads a finished records frame (records'
|
|
19
|
+
output) rather than building one, which is why it sits beside the construction
|
|
20
|
+
modules rather than in ``incremental`` (a deliberately stdlib-only, Polars-free
|
|
21
|
+
leaf) or ``state`` (which the DESIGN keeps free of frame knowledge -- the
|
|
22
|
+
caller hands it the computed value).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
|
|
27
|
+
import polars as pl
|
|
28
|
+
|
|
29
|
+
from fleetpull.timing import ensure_utc
|
|
30
|
+
|
|
31
|
+
__all__: list[str] = ['latest_event_time']
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def latest_event_time(frame: pl.DataFrame, event_time_column: str) -> datetime | None:
|
|
35
|
+
"""The maximum value of a records frame's event-time column, canonical UTC.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
frame: The fetched, validated records frame. ``event_time_column`` must be
|
|
39
|
+
a UTC datetime column.
|
|
40
|
+
event_time_column: Name of the UTC datetime column to take the maximum of
|
|
41
|
+
(e.g. ``'located_at'``).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The latest (maximum) timestamp in ``event_time_column`` as canonical UTC
|
|
45
|
+
(``tzinfo is datetime.UTC``, by construction via ``ensure_utc``), or
|
|
46
|
+
``None`` when the frame is empty (no observed events, hence no watermark
|
|
47
|
+
advance).
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
polars.exceptions.ColumnNotFoundError: ``event_time_column`` is absent
|
|
51
|
+
from ``frame`` -- a caller bug, surfaced unguarded by Polars.
|
|
52
|
+
TypeError: ``event_time_column``'s maximum is present but not a
|
|
53
|
+
``datetime`` (e.g. the column is a date or numeric), which would make
|
|
54
|
+
an invalid watermark -- raised loud rather than returned.
|
|
55
|
+
ValueError: ``event_time_column`` is a naive datetime column (from
|
|
56
|
+
``ensure_utc`` -- an unzoned event time is ambiguous, never assumed
|
|
57
|
+
UTC).
|
|
58
|
+
|
|
59
|
+
Side Effects:
|
|
60
|
+
None -- pure function.
|
|
61
|
+
"""
|
|
62
|
+
maximum = frame.get_column(event_time_column).max()
|
|
63
|
+
if maximum is None:
|
|
64
|
+
return None
|
|
65
|
+
if not isinstance(maximum, datetime):
|
|
66
|
+
raise TypeError(
|
|
67
|
+
f'expected a datetime maximum for column {event_time_column!r}, '
|
|
68
|
+
f'got {type(maximum).__name__}'
|
|
69
|
+
)
|
|
70
|
+
return ensure_utc(maximum)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# src/fleetpull/records/fields.py
|
|
2
|
+
"""The shared field walk: classify each model field's annotation and
|
|
3
|
+
enumerate the flat leaf columns a model produces.
|
|
4
|
+
|
|
5
|
+
Schema derivation and record flattening both consume this one walk, so
|
|
6
|
+
the column NAME a field produces (the type side) and the value PULLED for
|
|
7
|
+
it (the value side) can never drift -- they are computed from a single
|
|
8
|
+
traversal. Nested models flatten with double-underscore-joined column
|
|
9
|
+
names; top-level fields keep their bare name.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import enum
|
|
13
|
+
import types
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import date, datetime, timedelta
|
|
17
|
+
from typing import Any, Union, get_args, get_origin
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
__all__: list[str] = [
|
|
22
|
+
'FieldKind',
|
|
23
|
+
'FlatField',
|
|
24
|
+
'classify_annotation',
|
|
25
|
+
'iter_flat_fields',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Column-name join between a nested model and its child. Double, not
|
|
29
|
+
# single: field names already contain single underscores, so a single
|
|
30
|
+
# separator is ambiguous about the level boundary and lets a top-level
|
|
31
|
+
# field collide with a nested one. Module-private Final.
|
|
32
|
+
_NESTING_JOIN: str = '__'
|
|
33
|
+
|
|
34
|
+
# The closed scalar set. datetime resolves to a tz-aware microsecond
|
|
35
|
+
# dtype downstream and timedelta to a microsecond duration; the others
|
|
36
|
+
# to their obvious Polars scalars.
|
|
37
|
+
_SCALAR_TYPES: frozenset[type] = frozenset(
|
|
38
|
+
{int, float, str, bool, date, datetime, timedelta}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FieldKind(enum.Enum):
|
|
43
|
+
"""How a resolved field annotation maps to a column.
|
|
44
|
+
|
|
45
|
+
A closed set the walk and the schema both dispatch over.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
SCALAR = enum.auto()
|
|
49
|
+
ENUM = enum.auto()
|
|
50
|
+
LIST_OF_SCALAR = enum.auto()
|
|
51
|
+
NESTED_MODEL = enum.auto()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class FlatField:
|
|
56
|
+
"""One leaf column a model produces.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
column: The flattened column name (nested levels double-underscore
|
|
60
|
+
joined; a top-level field keeps its bare name).
|
|
61
|
+
kind: The leaf's classification (never NESTED_MODEL -- nesting is
|
|
62
|
+
resolved away into the column path).
|
|
63
|
+
resolved: The resolved leaf type -- the scalar ``type`` for SCALAR,
|
|
64
|
+
the ``Enum`` subclass for ENUM, the inner scalar ``type`` for
|
|
65
|
+
LIST_OF_SCALAR.
|
|
66
|
+
path: The attribute-access path from the root model to this leaf,
|
|
67
|
+
used to pull the value.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
column: str
|
|
71
|
+
kind: FieldKind
|
|
72
|
+
resolved: type
|
|
73
|
+
path: tuple[str, ...]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def classify_annotation(
|
|
77
|
+
# typing-justified: annotation forms (unions, aliases) are not `type` values
|
|
78
|
+
annotation: Any,
|
|
79
|
+
owning_model_name: str,
|
|
80
|
+
field_name: str,
|
|
81
|
+
) -> tuple[FieldKind, type]:
|
|
82
|
+
"""Reduce a field annotation to a kind and its resolved leaf type.
|
|
83
|
+
|
|
84
|
+
Unwraps ``Optional`` / ``T | None``; rejects multi-arm unions. A
|
|
85
|
+
nested ``BaseModel`` is NESTED_MODEL (the walk recurses); an ``Enum``
|
|
86
|
+
subclass is ENUM; one of the closed scalars is SCALAR; ``list[scalar]``
|
|
87
|
+
is LIST_OF_SCALAR. Anything else -- ``Any``, ``dict``, ``Literal``,
|
|
88
|
+
a list of models, an untyped ``list`` -- is a derivation gap and
|
|
89
|
+
raises (fail fast; there is no override path yet).
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
annotation: The Pydantic ``FieldInfo.annotation`` to classify.
|
|
93
|
+
owning_model_name: The model class name, for error messages.
|
|
94
|
+
field_name: The field name, for error messages.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
``(kind, resolved)`` -- the nested model class for NESTED_MODEL,
|
|
98
|
+
the enum class for ENUM, the scalar type for SCALAR, the inner
|
|
99
|
+
scalar type for LIST_OF_SCALAR.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
TypeError: When the annotation does not resolve to a supported
|
|
103
|
+
kind.
|
|
104
|
+
"""
|
|
105
|
+
# typing-justified: union arms are arbitrary annotation forms
|
|
106
|
+
non_none: list[Any] = _strip_none_from_union(annotation)
|
|
107
|
+
if len(non_none) != 1:
|
|
108
|
+
raise TypeError(
|
|
109
|
+
f'{owning_model_name}.{field_name}: cannot derive a column from '
|
|
110
|
+
f'{annotation!r} -- a single non-None type is required, '
|
|
111
|
+
f'found {len(non_none)}.'
|
|
112
|
+
)
|
|
113
|
+
candidate: Any = non_none[0] # typing-justified: an arbitrary annotation form
|
|
114
|
+
|
|
115
|
+
if isinstance(candidate, type) and issubclass(candidate, BaseModel):
|
|
116
|
+
return (FieldKind.NESTED_MODEL, candidate)
|
|
117
|
+
if isinstance(candidate, type) and issubclass(candidate, enum.Enum):
|
|
118
|
+
return (FieldKind.ENUM, candidate)
|
|
119
|
+
if candidate in _SCALAR_TYPES:
|
|
120
|
+
return (FieldKind.SCALAR, candidate)
|
|
121
|
+
if get_origin(candidate) is list:
|
|
122
|
+
# typing-justified: get_args yields arbitrary annotation forms (typeshed)
|
|
123
|
+
inner: tuple[Any, ...] = get_args(candidate)
|
|
124
|
+
if len(inner) == 1 and inner[0] in _SCALAR_TYPES:
|
|
125
|
+
return (FieldKind.LIST_OF_SCALAR, inner[0])
|
|
126
|
+
raise TypeError(
|
|
127
|
+
f'{owning_model_name}.{field_name}: cannot derive a column from '
|
|
128
|
+
f'{annotation!r} -- only list[scalar] is supported, not lists '
|
|
129
|
+
f'of models or untyped lists.'
|
|
130
|
+
)
|
|
131
|
+
raise TypeError(
|
|
132
|
+
f'{owning_model_name}.{field_name}: cannot derive a column from '
|
|
133
|
+
f'{annotation!r} -- unsupported annotation (no schema-override path '
|
|
134
|
+
f'exists yet; model the shape or narrow the type).'
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def iter_flat_fields(model_class: type[BaseModel]) -> Iterator[FlatField]:
|
|
139
|
+
"""Yield one ``FlatField`` per leaf column the model produces.
|
|
140
|
+
|
|
141
|
+
Recurses into nested models, double-underscore joining each level into
|
|
142
|
+
the column name and extending the attribute path. Top-level scalar
|
|
143
|
+
fields keep their bare name and a single-element path.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
model_class: The model class to walk.
|
|
147
|
+
|
|
148
|
+
Yields:
|
|
149
|
+
One ``FlatField`` per leaf, in declaration order (depth-first).
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
TypeError: Propagated from ``classify_annotation`` for any field
|
|
153
|
+
whose annotation cannot be resolved.
|
|
154
|
+
"""
|
|
155
|
+
yield from _walk(model_class, name_prefix=(), path_prefix=())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _walk(
|
|
159
|
+
model_class: type[BaseModel],
|
|
160
|
+
name_prefix: tuple[str, ...],
|
|
161
|
+
path_prefix: tuple[str, ...],
|
|
162
|
+
) -> Iterator[FlatField]:
|
|
163
|
+
"""Depth-first leaf walk; see :func:`iter_flat_fields`."""
|
|
164
|
+
for field_name, field_info in model_class.model_fields.items():
|
|
165
|
+
kind, resolved = classify_annotation(
|
|
166
|
+
annotation=field_info.annotation,
|
|
167
|
+
owning_model_name=model_class.__name__,
|
|
168
|
+
field_name=field_name,
|
|
169
|
+
)
|
|
170
|
+
path: tuple[str, ...] = (*path_prefix, field_name)
|
|
171
|
+
if kind is FieldKind.NESTED_MODEL:
|
|
172
|
+
yield from _walk(
|
|
173
|
+
model_class=resolved,
|
|
174
|
+
name_prefix=(*name_prefix, field_name),
|
|
175
|
+
path_prefix=path,
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
column: str = _NESTING_JOIN.join((*name_prefix, field_name))
|
|
179
|
+
yield FlatField(column=column, kind=kind, resolved=resolved, path=path)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# typing-justified: annotation forms (unions, aliases) in, annotation forms out
|
|
183
|
+
def _strip_none_from_union(annotation: Any) -> list[Any]:
|
|
184
|
+
"""Return the non-``NoneType`` arms of a union, else ``[annotation]``."""
|
|
185
|
+
origin: Any = get_origin(annotation) # typing-justified: typeshed returns Any
|
|
186
|
+
if origin is Union or origin is types.UnionType:
|
|
187
|
+
return [
|
|
188
|
+
argument for argument in get_args(annotation) if argument is not type(None)
|
|
189
|
+
]
|
|
190
|
+
return [annotation]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# src/fleetpull/records/flatten.py
|
|
2
|
+
"""Record flattening: a model instance to a flat ``{column: value}`` row.
|
|
3
|
+
|
|
4
|
+
The value side of the shared field walk -- it pulls each leaf's value by
|
|
5
|
+
the attribute path the same walk produced, so column names and values are
|
|
6
|
+
guaranteed aligned. A ``None`` nested block yields ``None`` for all of its
|
|
7
|
+
leaf columns; an enum value is reduced to its plain string.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from fleetpull.records.fields import FieldKind, FlatField
|
|
16
|
+
|
|
17
|
+
__all__: list[str] = ['flatten_record']
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def flatten_record(
|
|
21
|
+
record: BaseModel, flat_fields: Iterable[FlatField]
|
|
22
|
+
) -> dict[str, Any]: # typing-justified: model-field values are heterogeneous
|
|
23
|
+
"""Pull one flat row of values from a model instance.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
record: The validated model instance to flatten.
|
|
27
|
+
flat_fields: The leaf fields (from ``iter_flat_fields`` on the
|
|
28
|
+
record's class), reused across every record of a batch.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A ``{column: value}`` row. A missing (``None``) nested block
|
|
32
|
+
yields ``None`` for each of its columns; enum values reduce to
|
|
33
|
+
``str``; scalars, datetimes, and list values pass through.
|
|
34
|
+
"""
|
|
35
|
+
row: dict[str, Any] = {} # typing-justified: heterogeneous model-field values
|
|
36
|
+
for flat_field in flat_fields:
|
|
37
|
+
value: Any = _pull(record, flat_field.path) # typing-justified: see _pull
|
|
38
|
+
if value is not None and flat_field.kind is FieldKind.ENUM:
|
|
39
|
+
# All current enums are StrEnum, so str(member) is the wire
|
|
40
|
+
# value ('active'), not 'VehicleStatus.ACTIVE'.
|
|
41
|
+
value = str(value)
|
|
42
|
+
row[flat_field.column] = value
|
|
43
|
+
return row
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# typing-justified: getattr walks arbitrary model fields; the value is untyped
|
|
47
|
+
def _pull(record: BaseModel, path: tuple[str, ...]) -> Any:
|
|
48
|
+
"""Walk the attribute path; short-circuit to ``None`` on a null block."""
|
|
49
|
+
current: Any = record # typing-justified: intermediate attribute-walk value
|
|
50
|
+
for attribute in path:
|
|
51
|
+
if current is None:
|
|
52
|
+
return None
|
|
53
|
+
current = getattr(current, attribute)
|
|
54
|
+
return current
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# src/fleetpull/records/roster_members.py
|
|
2
|
+
"""Roster-member extraction over a records frame: a column's distinct values as
|
|
3
|
+
strings.
|
|
4
|
+
|
|
5
|
+
The roster members a feeder listing produces -- the distinct values of one column of
|
|
6
|
+
the feeder's validated frame, stringified for use as roster members and URL-path
|
|
7
|
+
fan-out values. The refresh path reads this off the listed-and-validated feeder frame
|
|
8
|
+
and hands the members to ``reconcile``; mapping a member to anything else (a VIN) is
|
|
9
|
+
never needed -- the value is opaque.
|
|
10
|
+
|
|
11
|
+
A pure-ish leaf over the records frame -- Polars, stdlib logging, nothing internal;
|
|
12
|
+
it reads a finished records frame rather than building one, so it sits with the
|
|
13
|
+
records-layer extractors. A missing column raises ``ValueError`` -- a wiring bug
|
|
14
|
+
(the roster definition names a column the feeder frame lacks) the exception
|
|
15
|
+
hierarchy leaves to stdlib. Null and empty-string values are *filtered, loudly*
|
|
16
|
+
rather than raised: both are unfetchable members by construction (a null carries
|
|
17
|
+
no id; an empty string renders an unbuildable URL path), and excluding one garbage
|
|
18
|
+
record beats converting it into an outage for every other member's fan-out. The
|
|
19
|
+
filter logs a warning with the column and counts, so a provider emitting garbage
|
|
20
|
+
ids is visible without being fatal.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
|
|
25
|
+
import polars as pl
|
|
26
|
+
|
|
27
|
+
__all__: list[str] = ['extract_roster_members']
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract_roster_members(frame: pl.DataFrame, column: str) -> set[str]:
|
|
33
|
+
"""The distinct fetchable values of a frame column, as strings.
|
|
34
|
+
|
|
35
|
+
Reads ``column`` and returns its distinct values stringified (a numeric id
|
|
36
|
+
becomes its decimal string, ready for a roster member and a URL-path value).
|
|
37
|
+
A roster is an unordered set, so the result is a ``set`` and extraction order
|
|
38
|
+
is not preserved. Null and empty-string values are filtered with a warning --
|
|
39
|
+
unfetchable by construction, excluded rather than fatal -- so the returned
|
|
40
|
+
members are exactly the fan-out-usable ones.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
frame: The fetched, validated feeder frame.
|
|
44
|
+
column: Name of the column whose distinct values are the members (e.g.
|
|
45
|
+
``'vehicle_id'``).
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The distinct non-null, non-empty values of ``column`` as a set of
|
|
49
|
+
strings; an empty set when the frame is empty or holds no usable value.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: ``column`` is absent from ``frame`` -- a wiring bug (the
|
|
53
|
+
roster definition names a column the feeder frame lacks), left to
|
|
54
|
+
stdlib rather than the operational hierarchy, which this leaf cannot
|
|
55
|
+
give provider context.
|
|
56
|
+
|
|
57
|
+
Side Effects:
|
|
58
|
+
Logs a warning when null or empty-string values were filtered.
|
|
59
|
+
"""
|
|
60
|
+
if column not in frame.columns:
|
|
61
|
+
raise ValueError(f'roster source column {column!r} is not in the frame')
|
|
62
|
+
series = frame.get_column(column)
|
|
63
|
+
null_row_count = series.null_count()
|
|
64
|
+
stringified = series.drop_nulls().cast(pl.String)
|
|
65
|
+
empty_row_count = int((stringified.str.len_chars() == 0).sum())
|
|
66
|
+
if null_row_count or empty_row_count:
|
|
67
|
+
logger.warning(
|
|
68
|
+
'filtered unfetchable members from roster source column %r: '
|
|
69
|
+
'%d null and %d empty-string value(s) excluded',
|
|
70
|
+
column,
|
|
71
|
+
null_row_count,
|
|
72
|
+
empty_row_count,
|
|
73
|
+
)
|
|
74
|
+
return {value for value in stringified.unique().to_list() if value != ''}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# src/fleetpull/records/schema.py
|
|
2
|
+
"""Polars schema derivation: a model class to a ``{column: dtype}`` map.
|
|
3
|
+
|
|
4
|
+
Walks the shared field enumeration (``records/fields.py``) and maps each
|
|
5
|
+
leaf to a Polars dtype: ``int``/``float``/``str``/``bool`` to their
|
|
6
|
+
obvious scalars, ``date`` to a calendar-date column (a DATE-ONLY wire
|
|
7
|
+
value carries no instant to recover -- the Motive users ``joined_at``
|
|
8
|
+
precedent), ``datetime`` to tz-aware microsecond UTC, ``timedelta``
|
|
9
|
+
to a microsecond ``Duration``, enums to ``String``, ``list[scalar]`` to
|
|
10
|
+
``List`` of the inner dtype. Depends only on the model class, not on any
|
|
11
|
+
data, so the schema is computable before a record is fetched. A leaf the
|
|
12
|
+
map cannot place raises (fail fast); there is no override path yet.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from datetime import date, datetime, timedelta
|
|
16
|
+
|
|
17
|
+
import polars as pl
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from fleetpull.records.fields import FieldKind, FlatField, iter_flat_fields
|
|
21
|
+
|
|
22
|
+
__all__: list[str] = ['derive_schema']
|
|
23
|
+
|
|
24
|
+
# The closed scalar -> Polars dtype map. datetime is tz-aware microsecond
|
|
25
|
+
# UTC and timedelta a microsecond Duration, so every temporal column
|
|
26
|
+
# carries a uniform, parquet-friendly type.
|
|
27
|
+
_SCALAR_TO_POLARS: dict[type, pl.DataType] = {
|
|
28
|
+
int: pl.Int64(),
|
|
29
|
+
float: pl.Float64(),
|
|
30
|
+
str: pl.String(),
|
|
31
|
+
bool: pl.Boolean(),
|
|
32
|
+
date: pl.Date(),
|
|
33
|
+
datetime: pl.Datetime(time_unit='us', time_zone='UTC'),
|
|
34
|
+
timedelta: pl.Duration(time_unit='us'),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def derive_schema(model_class: type[BaseModel]) -> dict[str, pl.DataType]:
|
|
39
|
+
"""Derive the Polars schema for a model's flattened columns.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
model_class: The model whose flattened leaves define the columns.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
An insertion-ordered ``{column: dtype}`` map in field declaration
|
|
46
|
+
order. Scalars, datetimes, and timedeltas map directly; enums map
|
|
47
|
+
to ``pl.String``; ``list[scalar]`` maps to ``pl.List`` of the
|
|
48
|
+
inner scalar's dtype.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
TypeError: If a leaf annotation has no dtype mapping.
|
|
52
|
+
ValueError: If two leaves resolve to one column name -- a
|
|
53
|
+
structural impossibility the double-underscore join prevents,
|
|
54
|
+
raised as a guard that should never fire.
|
|
55
|
+
"""
|
|
56
|
+
schema: dict[str, pl.DataType] = {}
|
|
57
|
+
for flat_field in iter_flat_fields(model_class):
|
|
58
|
+
if flat_field.column in schema:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f'{model_class.__name__}: duplicate flattened column '
|
|
61
|
+
f'{flat_field.column!r} -- two fields resolve to one name.'
|
|
62
|
+
)
|
|
63
|
+
schema[flat_field.column] = _leaf_to_dtype(flat_field)
|
|
64
|
+
return schema
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _leaf_to_dtype(flat_field: FlatField) -> pl.DataType:
|
|
68
|
+
"""Map one resolved leaf to its Polars dtype."""
|
|
69
|
+
if flat_field.kind is FieldKind.ENUM:
|
|
70
|
+
return pl.String()
|
|
71
|
+
if flat_field.kind is FieldKind.LIST_OF_SCALAR:
|
|
72
|
+
return pl.List(_SCALAR_TO_POLARS[flat_field.resolved])
|
|
73
|
+
return _SCALAR_TO_POLARS[flat_field.resolved]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# src/fleetpull/records/validation.py
|
|
2
|
+
"""Per-record validation: raw response dicts to typed model instances.
|
|
3
|
+
|
|
4
|
+
Validates each record dict into the endpoint's response model. Pydantic's
|
|
5
|
+
lax coercion (the model is non-strict) lands wire types; field-level
|
|
6
|
+
wire-cleaning, when an endpoint needs it, lives on the model as a
|
|
7
|
+
``field_validator(mode='before')``, not here. Validation fails fast and
|
|
8
|
+
loud on the first bad record, with safe-to-log context.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from fleetpull.exceptions import ProviderResponseError
|
|
16
|
+
from fleetpull.model_contract import ResponseModel
|
|
17
|
+
from fleetpull.vocabulary import JsonObject
|
|
18
|
+
|
|
19
|
+
__all__: list[str] = ['validate_records']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_records[ModelT: ResponseModel](
|
|
23
|
+
records: Sequence[JsonObject], model_class: type[ModelT]
|
|
24
|
+
) -> list[ModelT]:
|
|
25
|
+
"""Validate raw record dicts into typed model instances.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
records: The raw record objects (a page's ``records``).
|
|
29
|
+
model_class: The response model to validate each record into --
|
|
30
|
+
bound to ``ResponseModel`` so every validated record carries the
|
|
31
|
+
package's response-model config policy.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The validated model instances, one per input record, in order.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ProviderResponseError: On the first record that fails validation,
|
|
38
|
+
naming the model, the record's position, and which fields
|
|
39
|
+
failed and how -- never the raw field values, so the error is
|
|
40
|
+
safe to log.
|
|
41
|
+
"""
|
|
42
|
+
validated: list[ModelT] = []
|
|
43
|
+
for index, record in enumerate(records):
|
|
44
|
+
try:
|
|
45
|
+
validated.append(model_class.model_validate(record))
|
|
46
|
+
except ValidationError as error:
|
|
47
|
+
raise ProviderResponseError(
|
|
48
|
+
detail=(
|
|
49
|
+
f'{model_class.__name__} record {index} failed '
|
|
50
|
+
f'validation: {_safe_summary(error)}'
|
|
51
|
+
)
|
|
52
|
+
) from None
|
|
53
|
+
return validated
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _safe_summary(error: ValidationError) -> str:
|
|
57
|
+
"""Summarize a ValidationError without exposing raw field values."""
|
|
58
|
+
parts: list[str] = [
|
|
59
|
+
f'{".".join(str(item) for item in entry["loc"])} ({entry["type"]})'
|
|
60
|
+
for entry in error.errors()
|
|
61
|
+
]
|
|
62
|
+
return '; '.join(parts)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# src/fleetpull/resources/__init__.py
|
|
2
|
+
"""Packaged data resources shipped inside the wheel.
|
|
3
|
+
|
|
4
|
+
Holds ``config.example.yaml`` -- the annotated starter configuration a
|
|
5
|
+
user materializes with ``fleetpull init-config`` (read through
|
|
6
|
+
``importlib.resources`` so it resolves in both a wheel and an editable
|
|
7
|
+
install). This package carries data, not code; it exports nothing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__all__: list[str] = []
|