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,529 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/shared/base.py
|
|
2
|
+
"""The endpoints-layer binding: the ``EndpointDefinition`` and the types it composes.
|
|
3
|
+
|
|
4
|
+
An ``EndpointDefinition`` is the single source of truth per endpoint — a frozen,
|
|
5
|
+
keyword-only dataclass that composes one implementation per behavioral axis (the
|
|
6
|
+
``SpecBuilder`` and the ``PageDecoder``) plus the per-endpoint facts the generic
|
|
7
|
+
machinery reads (DESIGN §11). It is a thin declarative binding, not a fat base
|
|
8
|
+
class: the network layer already owns auth, pagination, classification, and
|
|
9
|
+
parsing as separate strategies, so the only work that remains on the endpoint is
|
|
10
|
+
its spec-builder.
|
|
11
|
+
|
|
12
|
+
This module ships the binding, the two Protocols it defines (``SpecBuilder``
|
|
13
|
+
and ``CompletenessCheck``; the ``PageDecoder`` it composes is imported from the
|
|
14
|
+
contract), and the ``ResumeValue`` alias; the declaration families the binding
|
|
15
|
+
composes live in their own modules -- the ``RequestShape`` union in
|
|
16
|
+
``request_shape.py``, ``StorageKind`` and the ``SyncMode`` union
|
|
17
|
+
(``SnapshotMode`` / ``WatermarkMode`` / ``FeedMode``) in ``sync_mode.py``. The
|
|
18
|
+
``event_time_column`` the watermark and the partitioned layouts read
|
|
19
|
+
(§3/§5) now ships here on the binding, validated at construction; the records
|
|
20
|
+
``schema_overrides`` hatch (§9) is the one contract piece still deferred.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import date, datetime
|
|
26
|
+
from typing import Any, Protocol, get_args
|
|
27
|
+
|
|
28
|
+
from fleetpull.endpoints.shared.request_shape import (
|
|
29
|
+
BatchedRosterFanOut,
|
|
30
|
+
BisectedWindowFetch,
|
|
31
|
+
ParamSweep,
|
|
32
|
+
RequestShape,
|
|
33
|
+
RosterFanOut,
|
|
34
|
+
SingleFetch,
|
|
35
|
+
)
|
|
36
|
+
from fleetpull.endpoints.shared.sync_mode import (
|
|
37
|
+
FeedMode,
|
|
38
|
+
SnapshotMode,
|
|
39
|
+
StorageKind,
|
|
40
|
+
SyncMode,
|
|
41
|
+
WatermarkMode,
|
|
42
|
+
)
|
|
43
|
+
from fleetpull.incremental import DateWindow, FeedSeed, FeedToken
|
|
44
|
+
from fleetpull.model_contract import ResponseModel
|
|
45
|
+
from fleetpull.network.contract import EnvelopeFetcher, PageDecoder, RequestSpec
|
|
46
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
47
|
+
|
|
48
|
+
__all__: list[str] = [
|
|
49
|
+
'CompletenessCheck',
|
|
50
|
+
'EndpointDefinition',
|
|
51
|
+
'ResumeValue',
|
|
52
|
+
'SpecBuilder',
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# The resume value a spec-builder consumes: a DateWindow (watermark, from the
|
|
57
|
+
# resume resolver), a FeedToken (feed, the stored token), a FeedSeed (feed, the
|
|
58
|
+
# cold-start anchor on the tokenless first run), or None — meaning no resume at
|
|
59
|
+
# all, i.e. a snapshot every run. Named here with SpecBuilder because it is the
|
|
60
|
+
# spec-builder's input contract; the resolver returns stay narrower per arm
|
|
61
|
+
# (DateWindow | None for the watermark resolver, FeedResume for the feed one).
|
|
62
|
+
type ResumeValue = DateWindow | FeedToken | FeedSeed | None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SpecBuilder(Protocol):
|
|
66
|
+
"""
|
|
67
|
+
Builds the first request for an endpoint — the one genuine per-endpoint behavior.
|
|
68
|
+
|
|
69
|
+
Builds only the first request (URL, base params, and the resume injection);
|
|
70
|
+
the page decoder produces every request after it. This is where the canonical
|
|
71
|
+
half-open ``DateWindow`` (§4) is translated to the provider's own request
|
|
72
|
+
convention. A plain Protocol (not ``@runtime_checkable``): it is only composed
|
|
73
|
+
into and called through the stateless ``EndpointDefinition``, never verified
|
|
74
|
+
dynamically.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def build_spec(
|
|
78
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
79
|
+
) -> RequestSpec:
|
|
80
|
+
"""
|
|
81
|
+
Build the endpoint's first request.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
resume: The resume value to inject — a ``DateWindow`` (watermark),
|
|
85
|
+
a ``FeedToken`` or ``FeedSeed`` (feed), or ``None`` (snapshot).
|
|
86
|
+
member_values: The per-chain member binding the request shape
|
|
87
|
+
supplies — an empty mapping for a single-chain run, one
|
|
88
|
+
``{member_key: member}`` entry per fan-out or sweep chain. The
|
|
89
|
+
spec builder owns the interpretation: a URL-path placeholder
|
|
90
|
+
value for a roster fan-out (e.g. a per-vehicle locations
|
|
91
|
+
endpoint), a query-parameter value for a param sweep.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The first ``RequestSpec``; the page decoder builds every request after it.
|
|
95
|
+
"""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CompletenessCheck(Protocol):
|
|
100
|
+
"""
|
|
101
|
+
A provider-reported expected count fired beside a snapshot harvest.
|
|
102
|
+
|
|
103
|
+
The truth instrument behind the single-fetch driver's verified
|
|
104
|
+
harvest (an endpoint that silently caps its listing needs an
|
|
105
|
+
independent count to prove the walk lost nothing -- GeoTab's
|
|
106
|
+
``GetCountOf`` beside the capped ``Get`` is the first case). An
|
|
107
|
+
implementation fires its count request through the SAME open client
|
|
108
|
+
the harvest used, so auth, the limiter (token-per-attempt on the
|
|
109
|
+
given scope), and the classifier all apply -- typed against the
|
|
110
|
+
contract's one-method ``EnvelopeFetcher``, which ``TransportClient``
|
|
111
|
+
satisfies structurally, so this binding layer never imports the
|
|
112
|
+
client. A plain Protocol (not
|
|
113
|
+
``@runtime_checkable``): it is only declared on the stateless
|
|
114
|
+
``EndpointDefinition`` and called by the driver, never verified
|
|
115
|
+
dynamically.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def expected_count(self, client: EnvelopeFetcher, quota_scope: str) -> int:
|
|
119
|
+
"""
|
|
120
|
+
Return the provider-reported count of the harvested entity.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
client: The open transport client the harvest ran on (its
|
|
124
|
+
single-request ``fetch_envelope`` surface).
|
|
125
|
+
quota_scope: The endpoint's rate-limit scope key -- the
|
|
126
|
+
count request spends from the same budget as the data
|
|
127
|
+
pages.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The provider's expected entity count.
|
|
131
|
+
"""
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
136
|
+
class EndpointDefinition[ModelT: ResponseModel]:
|
|
137
|
+
"""
|
|
138
|
+
The single source of truth per endpoint: a frozen binding, one strategy per axis.
|
|
139
|
+
|
|
140
|
+
A frozen, keyword-only dataclass generic over its per-record response model,
|
|
141
|
+
composing one implementation per behavioral axis (``spec_builder``,
|
|
142
|
+
``page_decoder`` — swappable implementations) plus the per-endpoint facts. It
|
|
143
|
+
does no work itself except through its ``spec_builder``.
|
|
144
|
+
|
|
145
|
+
Never subclassed per provider. Variation lives in the composed strategies, not
|
|
146
|
+
in subclasses — subclassing would re-braid the per-provider variation the
|
|
147
|
+
network layer already pulled apart into separate strategies, recreating the
|
|
148
|
+
predecessor's tangle. ``if endpoint.name == 'vehicle_locations':`` in the client
|
|
149
|
+
is the failure mode; the fix is always a swapped-in strategy, never a branch or
|
|
150
|
+
a subclass.
|
|
151
|
+
|
|
152
|
+
Generic over ``ModelT`` (bound ``ResponseModel``). ``response_model`` is
|
|
153
|
+
``type[ModelT]``, so an ``EndpointDefinition[Vehicle]`` carries the concrete
|
|
154
|
+
model type forward to a typed ``iter_records`` (§10). ``ModelT`` appears only in
|
|
155
|
+
that output position, so a heterogeneous collection of definitions uses the base
|
|
156
|
+
bound, ``EndpointDefinition[ResponseModel]``. ``response_model`` is the
|
|
157
|
+
per-record model (e.g. ``Vehicle``), not a full-response wrapper — the
|
|
158
|
+
``page_decoder`` owns the envelope, so full-response wrapper models are never
|
|
159
|
+
modeled, only the per-record ones.
|
|
160
|
+
|
|
161
|
+
Keyword-only because positional fields here are an error waiting to happen. The
|
|
162
|
+
``event_time_column`` the watermark and date-partitioning read (§3/§5) now
|
|
163
|
+
attaches here, validated against the response model at construction; the
|
|
164
|
+
records ``schema_overrides`` / ``coercion_overrides`` hatch (§9) is the one
|
|
165
|
+
excluded concern still deferred.
|
|
166
|
+
|
|
167
|
+
Attributes:
|
|
168
|
+
provider: The provider this endpoint belongs to.
|
|
169
|
+
name: The endpoint's name (e.g. ``'vehicles'``).
|
|
170
|
+
spec_builder: Builds the first request (the one per-endpoint behavior).
|
|
171
|
+
page_decoder: Interprets each response envelope — the page's records and
|
|
172
|
+
its pagination verdict, from one validated view.
|
|
173
|
+
response_model: The per-record response model type.
|
|
174
|
+
quota_scope: Which token bucket this endpoint spends from.
|
|
175
|
+
storage_kind: The §3 storage layout (single file vs date-partitioned) —
|
|
176
|
+
layout only; merge semantics follow ``sync_mode``.
|
|
177
|
+
sync_mode: The sync-mode declaration (``SnapshotMode`` / ``WatermarkMode``
|
|
178
|
+
/ ``FeedMode``) — drives resume and write semantics.
|
|
179
|
+
event_time_column: The response model's UTC datetime field the watermark
|
|
180
|
+
and the partitioned layouts read (§3/§5) — e.g. ``'located_at'``.
|
|
181
|
+
``None`` for endpoints with no event-time dimension (every
|
|
182
|
+
snapshot). Required for ``WatermarkMode`` / ``DATE_PARTITIONED`` /
|
|
183
|
+
``APPEND_LOG`` (the feed cell routes each record into its event
|
|
184
|
+
date's partition by it), forbidden for snapshots; validated against
|
|
185
|
+
``response_model`` at construction.
|
|
186
|
+
request_shape: The request-cardinality declaration — exactly one
|
|
187
|
+
``RequestShape`` member, so mutual exclusion between patterns is
|
|
188
|
+
structural. Defaults to ``SingleFetch()`` (one chain), keeping
|
|
189
|
+
single-chain leaves undeclared; ``RosterFanOut`` fans one chain per
|
|
190
|
+
roster member, ``BatchedRosterFanOut`` one per comma-joined
|
|
191
|
+
roster batch, ``ParamSweep`` one per declared query-param value,
|
|
192
|
+
``BisectedWindowFetch`` fetches each unit window whole and halves
|
|
193
|
+
on overflow. Resolved to a request driver by the orchestrator's
|
|
194
|
+
shape resolution; semantic sync-mode pairings are validated here at
|
|
195
|
+
construction.
|
|
196
|
+
completeness_check: The provider-reported-count truth check the
|
|
197
|
+
single-fetch driver fires after the harvest streams (``None``
|
|
198
|
+
for endpoints with no silent cap to guard against).
|
|
199
|
+
Snapshot-mode, ``SingleFetch``-shaped endpoints only -- an
|
|
200
|
+
expected-count comparison is meaningful only against one complete
|
|
201
|
+
listing, which a windowed (partial) or per-member (fan-out /
|
|
202
|
+
sweep) run never is; any other declaration is a wiring error
|
|
203
|
+
rejected at construction.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
provider: Provider
|
|
207
|
+
name: str
|
|
208
|
+
spec_builder: SpecBuilder
|
|
209
|
+
page_decoder: PageDecoder
|
|
210
|
+
response_model: type[ModelT]
|
|
211
|
+
quota_scope: QuotaScope
|
|
212
|
+
storage_kind: StorageKind
|
|
213
|
+
sync_mode: SyncMode
|
|
214
|
+
event_time_column: str | None = None
|
|
215
|
+
request_shape: RequestShape = field(default_factory=SingleFetch)
|
|
216
|
+
completeness_check: CompletenessCheck | None = None
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def required_event_time_column(self) -> str:
|
|
220
|
+
"""The declared event-time column, narrowed for cells that require one.
|
|
221
|
+
|
|
222
|
+
Construction validation already guarantees a ``WatermarkMode``,
|
|
223
|
+
``DATE_PARTITIONED``, or ``APPEND_LOG`` binding declares the column
|
|
224
|
+
(``_validate_event_time_column``), so a ``None`` here is impossible on
|
|
225
|
+
any constructed binding -- this property states that narrowing once
|
|
226
|
+
for every consumer instead of a per-call-site ``is None`` raise.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The declared ``event_time_column``.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
RuntimeError: ``event_time_column`` is ``None`` -- construction
|
|
233
|
+
validation was bypassed, a wiring bug surfaced loudly.
|
|
234
|
+
"""
|
|
235
|
+
if self.event_time_column is None:
|
|
236
|
+
raise RuntimeError(
|
|
237
|
+
f'{self.provider.value}.{self.name}: event_time_column is None '
|
|
238
|
+
f'on a cell that requires one -- construction validation '
|
|
239
|
+
f'should have rejected this binding'
|
|
240
|
+
)
|
|
241
|
+
return self.event_time_column
|
|
242
|
+
|
|
243
|
+
def __post_init__(self) -> None:
|
|
244
|
+
"""Validate the binding's storage / sync / event-time / shape coherence.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
ValueError: The storage-kind / sync-mode pairing is invalid; the
|
|
248
|
+
event-time column is required-but-missing or forbidden-but-present
|
|
249
|
+
or names no field on the response model; or the declared request
|
|
250
|
+
shape (or the completeness check against it) fails a semantic
|
|
251
|
+
sync-mode pairing.
|
|
252
|
+
TypeError: ``event_time_column`` names a non-date-like field.
|
|
253
|
+
|
|
254
|
+
Side Effects:
|
|
255
|
+
None -- reads fields and may raise.
|
|
256
|
+
"""
|
|
257
|
+
self._validate_storage_sync_pairing()
|
|
258
|
+
self._validate_event_time_column()
|
|
259
|
+
self._validate_request_shape()
|
|
260
|
+
self._validate_completeness_check()
|
|
261
|
+
|
|
262
|
+
def _validate_request_shape(self) -> None:
|
|
263
|
+
"""Reject a request shape (or its check pairing) outside its semantics.
|
|
264
|
+
|
|
265
|
+
Mutual exclusion between cardinality patterns is structural (one
|
|
266
|
+
``request_shape`` field); what remains here are the semantic
|
|
267
|
+
pairings. The roster-backed shapes (``RosterFanOut``,
|
|
268
|
+
``BatchedRosterFanOut``) require their roster to share the
|
|
269
|
+
endpoint's provider -- ``Sync`` (§7) runs one queue per provider,
|
|
270
|
+
and cross-queue independence rests on rosters never crossing
|
|
271
|
+
providers (within a queue, the feeder barrier and the refresh
|
|
272
|
+
coordinator's per-key single-flight serialize roster writes).
|
|
273
|
+
``BisectedWindowFetch`` recursively narrows a resume
|
|
274
|
+
window, so it requires a windowed (``WatermarkMode``),
|
|
275
|
+
date-partitioned endpoint. A ``completeness_check`` requires
|
|
276
|
+
``SnapshotMode`` AND ``SingleFetch`` -- an expected-count comparison
|
|
277
|
+
is meaningful only against one complete listing, and a windowed
|
|
278
|
+
harvest is deliberately partial while a fan-out or sweep run is
|
|
279
|
+
per-member. ``ParamSweep`` composes with ``SnapshotMode`` (the sweep
|
|
280
|
+
union is the full current listing); windowed sweep composition is
|
|
281
|
+
untested against any provider, so non-snapshot sweeps are rejected
|
|
282
|
+
loudly for now.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
ValueError: A pairing above is violated.
|
|
286
|
+
|
|
287
|
+
Side Effects:
|
|
288
|
+
None.
|
|
289
|
+
"""
|
|
290
|
+
match self.request_shape:
|
|
291
|
+
case RosterFanOut() | BatchedRosterFanOut() if (
|
|
292
|
+
self.request_shape.roster.provider is not self.provider
|
|
293
|
+
):
|
|
294
|
+
# Sync runs one queue per provider; the cross-queue
|
|
295
|
+
# independence argument (§7) rests on rosters never
|
|
296
|
+
# crossing providers -- a cross-provider roster would let
|
|
297
|
+
# two queues reconcile one roster's rows concurrently,
|
|
298
|
+
# outside the reach of either queue's feeder barrier.
|
|
299
|
+
# Both roster-backed shapes carry the invariant.
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f'{self.provider.value}.{self.name}: '
|
|
302
|
+
f'{type(self.request_shape).__name__} roster '
|
|
303
|
+
f'{self.request_shape.roster.provider.value}/'
|
|
304
|
+
f'{self.request_shape.roster.name} crosses the provider '
|
|
305
|
+
f'boundary -- a roster and its consumer share one provider.'
|
|
306
|
+
)
|
|
307
|
+
case BisectedWindowFetch():
|
|
308
|
+
if not isinstance(self.sync_mode, WatermarkMode) or (
|
|
309
|
+
self.storage_kind is not StorageKind.DATE_PARTITIONED
|
|
310
|
+
):
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f'{self.provider.value}.{self.name}: BisectedWindowFetch '
|
|
313
|
+
f'requires WatermarkMode and DATE_PARTITIONED storage, '
|
|
314
|
+
f'got {type(self.sync_mode).__name__} / '
|
|
315
|
+
f'{self.storage_kind}.'
|
|
316
|
+
)
|
|
317
|
+
case ParamSweep() if not isinstance(self.sync_mode, SnapshotMode):
|
|
318
|
+
# Reopen note: a windowed sweep (one chain per value per
|
|
319
|
+
# window) is coherent on paper but untested against any
|
|
320
|
+
# provider's window-matching semantics -- widen this pairing
|
|
321
|
+
# only with a probed consumer, not speculatively.
|
|
322
|
+
raise ValueError(
|
|
323
|
+
f'{self.provider.value}.{self.name}: ParamSweep requires '
|
|
324
|
+
f'SnapshotMode, got {type(self.sync_mode).__name__} -- '
|
|
325
|
+
f'windowed sweep composition is unprobed.'
|
|
326
|
+
)
|
|
327
|
+
case _:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
def _validate_storage_sync_pairing(self) -> None:
|
|
331
|
+
"""Reject an invalid storage-kind / sync-mode pairing.
|
|
332
|
+
|
|
333
|
+
A snapshot has no event-time dimension to partition on, so
|
|
334
|
+
``DATE_PARTITIONED`` is structurally unexecutable for it -- there is no
|
|
335
|
+
column for ``split_by_date`` to split on (DESIGN §3). The feed pairing
|
|
336
|
+
is exclusive in BOTH directions: ``FeedMode`` requires ``APPEND_LOG``
|
|
337
|
+
(append-only is the feed stream's one write semantic — any other
|
|
338
|
+
layout would delete or replace what the stored-as-emitted contract
|
|
339
|
+
must keep), and ``APPEND_LOG`` requires ``FeedMode`` (a windowed or
|
|
340
|
+
snapshot semantic run against an accumulate-only layout would either
|
|
341
|
+
never clear its window or grow a snapshot without bound).
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
ValueError: The endpoint is a snapshot with a non-``SINGLE``
|
|
345
|
+
layout, a feed with a non-``APPEND_LOG`` layout, or an
|
|
346
|
+
``APPEND_LOG`` layout on a non-feed mode.
|
|
347
|
+
|
|
348
|
+
Side Effects:
|
|
349
|
+
None.
|
|
350
|
+
"""
|
|
351
|
+
if (
|
|
352
|
+
isinstance(self.sync_mode, SnapshotMode)
|
|
353
|
+
and self.storage_kind is not StorageKind.SINGLE
|
|
354
|
+
):
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f'{self.provider.value}.{self.name}: SnapshotMode requires '
|
|
357
|
+
f'storage_kind SINGLE, got {self.storage_kind}.'
|
|
358
|
+
)
|
|
359
|
+
if (
|
|
360
|
+
isinstance(self.sync_mode, FeedMode)
|
|
361
|
+
and self.storage_kind is not StorageKind.APPEND_LOG
|
|
362
|
+
):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f'{self.provider.value}.{self.name}: FeedMode requires '
|
|
365
|
+
f'storage_kind APPEND_LOG, got {self.storage_kind}.'
|
|
366
|
+
)
|
|
367
|
+
if self.storage_kind is StorageKind.APPEND_LOG and not isinstance(
|
|
368
|
+
self.sync_mode, FeedMode
|
|
369
|
+
):
|
|
370
|
+
raise ValueError(
|
|
371
|
+
f'{self.provider.value}.{self.name}: APPEND_LOG requires '
|
|
372
|
+
f'FeedMode, got {type(self.sync_mode).__name__}.'
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def _validate_event_time_column(self) -> None:
|
|
376
|
+
"""Validate the event-time column against sync mode, layout, and model.
|
|
377
|
+
|
|
378
|
+
Snapshots forbid it (no event-time dimension); ``WatermarkMode``,
|
|
379
|
+
``DATE_PARTITIONED``, and ``APPEND_LOG`` require it (the watermark
|
|
380
|
+
reads it, and both partitioned layouts route rows into ``date=``
|
|
381
|
+
partitions by it). When present it must name a date-like field on the
|
|
382
|
+
response model, caught here at construction rather than mid-persist,
|
|
383
|
+
where ``split_by_date`` or ``latest_event_time`` would otherwise fail
|
|
384
|
+
on a non-temporal column after the fetch is already spent (DESIGN
|
|
385
|
+
§3/§5).
|
|
386
|
+
|
|
387
|
+
Raises:
|
|
388
|
+
ValueError: The column is required-but-missing, forbidden-but-present,
|
|
389
|
+
or names no field on the response model.
|
|
390
|
+
TypeError: The named field is not date-like.
|
|
391
|
+
|
|
392
|
+
Side Effects:
|
|
393
|
+
None.
|
|
394
|
+
"""
|
|
395
|
+
is_snapshot = isinstance(self.sync_mode, SnapshotMode)
|
|
396
|
+
requires_event_time = isinstance(self.sync_mode, WatermarkMode) or (
|
|
397
|
+
self.storage_kind in (StorageKind.DATE_PARTITIONED, StorageKind.APPEND_LOG)
|
|
398
|
+
)
|
|
399
|
+
if is_snapshot and self.event_time_column is not None:
|
|
400
|
+
raise ValueError(
|
|
401
|
+
f'{self.provider.value}.{self.name}: snapshot endpoints have no '
|
|
402
|
+
f'event-time dimension, so event_time_column must be None.'
|
|
403
|
+
)
|
|
404
|
+
if requires_event_time and self.event_time_column is None:
|
|
405
|
+
raise ValueError(
|
|
406
|
+
f'{self.provider.value}.{self.name}: WatermarkMode, '
|
|
407
|
+
f'DATE_PARTITIONED, or APPEND_LOG requires an '
|
|
408
|
+
f'event_time_column.'
|
|
409
|
+
)
|
|
410
|
+
if self.event_time_column is not None:
|
|
411
|
+
self._require_date_like_field(self.event_time_column)
|
|
412
|
+
|
|
413
|
+
def _validate_completeness_check(self) -> None:
|
|
414
|
+
"""Reject a completeness check declared outside snapshot single-fetch.
|
|
415
|
+
|
|
416
|
+
An expected-count comparison is meaningful only against a complete
|
|
417
|
+
listing fetched as one chain: a snapshot's single chain fetches the
|
|
418
|
+
entity's full current state, so the provider's count and the harvest
|
|
419
|
+
describe the same population. A windowed harvest is deliberately
|
|
420
|
+
partial (one window of an unbounded history) and a fan-out or sweep
|
|
421
|
+
run is per-member -- on either, the comparison would be counting
|
|
422
|
+
different things. Both declarations are wiring bugs, rejected here at
|
|
423
|
+
the declaration seam so the driver layer never needs to reason about
|
|
424
|
+
it.
|
|
425
|
+
|
|
426
|
+
Raises:
|
|
427
|
+
ValueError: The check is declared on a non-snapshot or
|
|
428
|
+
non-``SingleFetch`` endpoint.
|
|
429
|
+
|
|
430
|
+
Side Effects:
|
|
431
|
+
None.
|
|
432
|
+
"""
|
|
433
|
+
if self.completeness_check is None:
|
|
434
|
+
return
|
|
435
|
+
if not isinstance(self.sync_mode, SnapshotMode):
|
|
436
|
+
raise ValueError(
|
|
437
|
+
f'{self.provider.value}.{self.name}: a completeness_check '
|
|
438
|
+
f'requires SnapshotMode -- an expected-count comparison is '
|
|
439
|
+
f'meaningful only against a complete listing, and a windowed '
|
|
440
|
+
f'harvest is deliberately partial.'
|
|
441
|
+
)
|
|
442
|
+
if not isinstance(self.request_shape, SingleFetch):
|
|
443
|
+
raise ValueError(
|
|
444
|
+
f'{self.provider.value}.{self.name}: a completeness_check '
|
|
445
|
+
f'requires the SingleFetch request shape, got '
|
|
446
|
+
f'{type(self.request_shape).__name__} -- a fan-out or sweep '
|
|
447
|
+
f'run is per-member, not the one complete listing an expected '
|
|
448
|
+
f'count describes.'
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def _require_date_like_field(self, column: str) -> None:
|
|
452
|
+
"""Require ``column`` to name a date-like field on the response model.
|
|
453
|
+
|
|
454
|
+
Validated as a top-level Pydantic field name: the records flatten
|
|
455
|
+
preserves top-level field names as column names, so a top-level
|
|
456
|
+
event-time field's column name equals its field name. A nested event-time
|
|
457
|
+
field is not yet supported and would not resolve here (deferred until an
|
|
458
|
+
endpoint needs one).
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
column: The event-time column name to check against the model.
|
|
462
|
+
|
|
463
|
+
Raises:
|
|
464
|
+
ValueError: ``column`` names no field on ``response_model``.
|
|
465
|
+
TypeError: The field is annotated as neither ``date`` nor ``datetime``
|
|
466
|
+
(nullable forms included).
|
|
467
|
+
|
|
468
|
+
Side Effects:
|
|
469
|
+
None.
|
|
470
|
+
"""
|
|
471
|
+
model_fields = self.response_model.model_fields
|
|
472
|
+
if column not in model_fields:
|
|
473
|
+
valid_names = ', '.join(sorted(model_fields))
|
|
474
|
+
raise ValueError(
|
|
475
|
+
f'{self.provider.value}.{self.name}: event_time_column '
|
|
476
|
+
f'{column!r} is not a field on {self.response_model.__name__}. '
|
|
477
|
+
f'Fields: {valid_names}.'
|
|
478
|
+
)
|
|
479
|
+
annotation = model_fields[column].annotation
|
|
480
|
+
if not _is_date_like_annotation(annotation):
|
|
481
|
+
raise TypeError(
|
|
482
|
+
f'{self.provider.value}.{self.name}: event_time_column '
|
|
483
|
+
f'{column!r} must be date-like, got annotation {annotation!r}.'
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# A field annotation is an arbitrary type form (a class, a PEP 604 union, ...):
|
|
488
|
+
# typing-justified: Any is the honest input type for annotation inspection
|
|
489
|
+
def _is_date_like_annotation(annotation: Any) -> bool:
|
|
490
|
+
"""Whether an annotation resolves to ``date`` or ``datetime``.
|
|
491
|
+
|
|
492
|
+
Nullable forms count: a provider's ``datetime | None`` timestamp is still a
|
|
493
|
+
valid event-time field. Other unions and non-temporal types do not.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
annotation: The field annotation to inspect.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
``True`` if the annotation is ``date``, ``datetime``, or a two-arm
|
|
500
|
+
optional of either.
|
|
501
|
+
|
|
502
|
+
Side Effects:
|
|
503
|
+
None.
|
|
504
|
+
"""
|
|
505
|
+
return _unwrap_optional(annotation) in {date, datetime}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# typing-justified: annotation forms (unions, aliases) in, annotation forms out
|
|
509
|
+
def _unwrap_optional(annotation: Any) -> Any:
|
|
510
|
+
"""Strip ``None`` from a two-arm optional annotation, else return it as is.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
annotation: The annotation to inspect.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
The sole non-``None`` arm of a two-arm ``X | None`` union; otherwise
|
|
517
|
+
``annotation`` unchanged. A union of more than two arms passes through, so
|
|
518
|
+
a genuinely ambiguous union is not treated as date-like.
|
|
519
|
+
|
|
520
|
+
Side Effects:
|
|
521
|
+
None.
|
|
522
|
+
"""
|
|
523
|
+
union_args = get_args(annotation)
|
|
524
|
+
if not union_args:
|
|
525
|
+
return annotation
|
|
526
|
+
non_none_args = [arg for arg in union_args if arg is not type(None)]
|
|
527
|
+
if len(non_none_args) == 1 and len(non_none_args) != len(union_args):
|
|
528
|
+
return non_none_args[0]
|
|
529
|
+
return annotation
|