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,227 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/shared/request_shape.py
|
|
2
|
+
"""Request cardinality as one closed axis: the ``RequestShape`` union.
|
|
3
|
+
|
|
4
|
+
How one endpoint run decomposes into request chains is a single concept
|
|
5
|
+
and a single closed choice, exactly like ``SyncMode`` -- so it is one
|
|
6
|
+
tagged union, not a field per pattern. ``EndpointDefinition`` declares
|
|
7
|
+
exactly one member on ``request_shape``; the orchestrator's shape
|
|
8
|
+
resolution (``orchestrator/shape_resolution.py``) matches over the union
|
|
9
|
+
to pick the request driver. A future cardinality pattern is a new union
|
|
10
|
+
member plus its resolution arm -- the definition's field set never
|
|
11
|
+
changes for one again (unified 2026-07-20; DESIGN section 14 carries the
|
|
12
|
+
decision record).
|
|
13
|
+
|
|
14
|
+
Members: ``SingleFetch`` (one chain -- the default), ``RosterFanOut``
|
|
15
|
+
(one chain per roster member), ``BatchedRosterFanOut`` (one chain per
|
|
16
|
+
fixed-size batch of roster members, the batch comma-joined into one
|
|
17
|
+
query-parameter value), ``BisectedWindowFetch`` (the unit window
|
|
18
|
+
fetched whole, halved adaptively on the capped-response overflow
|
|
19
|
+
signal), and ``ParamSweep`` (one chain per declared query-parameter
|
|
20
|
+
value).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import timedelta
|
|
25
|
+
|
|
26
|
+
from fleetpull.roster import RosterKey
|
|
27
|
+
|
|
28
|
+
__all__: list[str] = [
|
|
29
|
+
'BatchedRosterFanOut',
|
|
30
|
+
'BisectedWindowFetch',
|
|
31
|
+
'ParamSweep',
|
|
32
|
+
'RequestShape',
|
|
33
|
+
'RosterFanOut',
|
|
34
|
+
'SingleFetch',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class SingleFetch:
|
|
40
|
+
"""One request chain: the default request shape, a marker.
|
|
41
|
+
|
|
42
|
+
The endpoint's whole run is one chain -- the spec-builder's first
|
|
43
|
+
request plus every page the decoder walks. Declared implicitly:
|
|
44
|
+
``EndpointDefinition.request_shape`` defaults to this marker, so a
|
|
45
|
+
single-chain leaf declares nothing.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class RosterFanOut:
|
|
51
|
+
"""One request chain per roster member.
|
|
52
|
+
|
|
53
|
+
The shape for endpoints that fan a request out over per-entity keys
|
|
54
|
+
(the per-vehicle ``vehicle_locations`` endpoint). Names only a
|
|
55
|
+
``RosterKey``: the consumer knows *that* a roster of its keys
|
|
56
|
+
exists, never where those keys come from. The source endpoint and
|
|
57
|
+
column -- and so the feeder -- live in the ``RosterDefinition`` the
|
|
58
|
+
``RosterRegistry`` holds, keyed by that ``RosterKey``; the
|
|
59
|
+
orchestration entry reads the members from the ``RosterStore``, also
|
|
60
|
+
keyed by it. That indirection keeps the consumer ignorant of the
|
|
61
|
+
feeder: ``vehicle_locations`` references
|
|
62
|
+
``RosterKey(MOTIVE, 'vehicle_ids')`` and nothing about ``vehicles``.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
roster: The roster supplying this endpoint's fan-out members --
|
|
66
|
+
the opaque handle; the source endpoint and column live in
|
|
67
|
+
the registry's ``RosterDefinition``, not here.
|
|
68
|
+
member_key: The key under which each member lands in the
|
|
69
|
+
spec-builder's ``member_values``. The spec builder owns the
|
|
70
|
+
interpretation; for URL-path endpoints it is the path
|
|
71
|
+
template placeholder (e.g. ``'vehicle_id'`` for
|
|
72
|
+
``'/v3/vehicle_locations/{vehicle_id}'``), which the strict
|
|
73
|
+
renderer enforces at request build.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
roster: RosterKey
|
|
77
|
+
member_key: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class BatchedRosterFanOut:
|
|
82
|
+
"""One request chain per fixed-size batch of roster members.
|
|
83
|
+
|
|
84
|
+
The shape for surfaces that REQUIRE a member-id filter and enforce a
|
|
85
|
+
batch cap on it (first consumer: Samsara asset_locations, where an
|
|
86
|
+
id-less request is a loud HTTP 400 and 51+ comma-joined ids another
|
|
87
|
+
-- the API-enforced 50-id cap, captured 2026-07-20). The roster
|
|
88
|
+
indirection is ``RosterFanOut``'s verbatim: only the ``RosterKey``
|
|
89
|
+
is named here, and the shape resolution reads the membership through
|
|
90
|
+
the same refresh-then-read machinery path.
|
|
91
|
+
|
|
92
|
+
The batch is TRANSPORT PACKING only: each chain fetches a batch's
|
|
93
|
+
records in one walk, and every record self-identifies its member
|
|
94
|
+
(per-record asset attribution on the wire), so no member attribution
|
|
95
|
+
rides on the request mapping -- unlike a ``RosterFanOut`` chain,
|
|
96
|
+
whose responses may not echo the requested member. Batches are
|
|
97
|
+
deterministic: members are sorted before chunking, so identical
|
|
98
|
+
rosters always produce identical batches.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
roster: The roster supplying the members packed into batches --
|
|
102
|
+
the opaque handle, exactly ``RosterFanOut.roster``.
|
|
103
|
+
member_key: The key each batch's comma-joined member string
|
|
104
|
+
binds under in the spec-builder's ``member_values`` -- for a
|
|
105
|
+
query-parameter surface, the verbatim wire parameter (e.g.
|
|
106
|
+
``'ids'``). The spec builder owns the interpretation.
|
|
107
|
+
batch_size: The maximum members per chain -- the provider's
|
|
108
|
+
API-enforced cap, declared from probe evidence. At least 1;
|
|
109
|
+
a roster smaller than one batch fans a single chain.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
roster: RosterKey
|
|
113
|
+
member_key: str
|
|
114
|
+
batch_size: int
|
|
115
|
+
|
|
116
|
+
def __post_init__(self) -> None:
|
|
117
|
+
"""Reject a batch size that could not pack a request.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ValueError: ``batch_size`` is less than 1 -- a zero or
|
|
121
|
+
negative batch packs nothing and would silently fetch
|
|
122
|
+
nothing, a declaration bug rejected at construction.
|
|
123
|
+
|
|
124
|
+
Side Effects:
|
|
125
|
+
None -- reads fields and may raise.
|
|
126
|
+
"""
|
|
127
|
+
if self.batch_size < 1:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f'BatchedRosterFanOut({self.roster.name!r}): batch_size must '
|
|
130
|
+
f'be >= 1, got {self.batch_size} -- a batch that packs no '
|
|
131
|
+
f'members is a declaration bug, not an empty fan-out.'
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class BisectedWindowFetch:
|
|
137
|
+
"""The unit window fetched whole, halved adaptively on overflow.
|
|
138
|
+
|
|
139
|
+
The shape for a capped, unsortable Get endpoint (GeoTab
|
|
140
|
+
ExceptionEvent: the silent 5,000 cap plus id-sort rejected outright
|
|
141
|
+
-- DESIGN section 8, captured 2026-07-15): fetch the window whole; a
|
|
142
|
+
response of exactly the declared limit is the overflow signal;
|
|
143
|
+
discard it, halve the window, recurse; a floor-width window still
|
|
144
|
+
coming back full fails loudly. Executed by the orchestrator's
|
|
145
|
+
``BisectingWindowDriver`` -- this declaration carries the provider
|
|
146
|
+
facts the provider-agnostic driver cannot know.
|
|
147
|
+
|
|
148
|
+
Attributes:
|
|
149
|
+
results_limit: The per-request record limit the endpoint's spec
|
|
150
|
+
builder writes; a response of exactly this many records is
|
|
151
|
+
the overflow signal. Sound only where the provider's silent
|
|
152
|
+
cap is Captured at or above this value for the entity type
|
|
153
|
+
(a lower cap would make every page look partial and overflow
|
|
154
|
+
undetectable).
|
|
155
|
+
floor: The minimum window width. A floor-width window still
|
|
156
|
+
returning ``results_limit`` records raises loudly -- the
|
|
157
|
+
data is denser than windowed fetching can enumerate, or more
|
|
158
|
+
than ``results_limit`` records overlap a single instant,
|
|
159
|
+
which no window width resolves.
|
|
160
|
+
event_time_wire_key: The raw wire key carrying each record's
|
|
161
|
+
owning timestamp (e.g. ``'activeFrom'``) -- pre-model, so the
|
|
162
|
+
driver can assign each record to exactly one leaf window
|
|
163
|
+
under overlap-matched retrieval instead of leaning on
|
|
164
|
+
write-time dedup for correctness.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
results_limit: int
|
|
168
|
+
floor: timedelta
|
|
169
|
+
event_time_wire_key: str
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class ParamSweep:
|
|
174
|
+
"""One request chain per declared query-parameter value.
|
|
175
|
+
|
|
176
|
+
The shape for endpoints whose provider partitions the population
|
|
177
|
+
behind a mandatory closed-enum filter with no all-values request
|
|
178
|
+
(first consumer: Samsara drivers, where the default listing is only
|
|
179
|
+
the active set). The union of the sweeps is the endpoint's one
|
|
180
|
+
complete dataset -- the sweep is completeness machinery, never a
|
|
181
|
+
member filter.
|
|
182
|
+
|
|
183
|
+
Attributes:
|
|
184
|
+
param: The member key the spec builder merges as a query
|
|
185
|
+
parameter -- each sweep value lands in ``member_values``
|
|
186
|
+
under this key.
|
|
187
|
+
values: The closed, ordered value set, one chain each. Never
|
|
188
|
+
empty and never duplicated -- either is a wiring bug
|
|
189
|
+
rejected at construction.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
param: str
|
|
193
|
+
values: tuple[str, ...]
|
|
194
|
+
|
|
195
|
+
def __post_init__(self) -> None:
|
|
196
|
+
"""Reject a sweep that could not be a complete dataset.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ValueError: ``values`` is empty (a sweep over nothing fetches
|
|
200
|
+
nothing and would silently emit an empty dataset) or
|
|
201
|
+
carries a duplicate (the same partition fetched twice is
|
|
202
|
+
a declaration typo, not a wider sweep).
|
|
203
|
+
|
|
204
|
+
Side Effects:
|
|
205
|
+
None -- reads fields and may raise.
|
|
206
|
+
"""
|
|
207
|
+
if not self.values:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f'ParamSweep({self.param!r}): values must not be empty -- '
|
|
210
|
+
f'a sweep over nothing is a wiring bug, not an empty dataset.'
|
|
211
|
+
)
|
|
212
|
+
if len(set(self.values)) != len(self.values):
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f'ParamSweep({self.param!r}): values carry a duplicate -- '
|
|
215
|
+
f'each declared value is one partition of the population, '
|
|
216
|
+
f'fetched exactly once.'
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# The endpoint's request-cardinality declaration (config): the shape
|
|
221
|
+
# resolution matches on it to pick the request driver -- SingleFetch ->
|
|
222
|
+
# SingleRequestDriver, RosterFanOut / BatchedRosterFanOut / ParamSweep ->
|
|
223
|
+
# FanOutRequestDriver, BisectedWindowFetch -> BisectingWindowDriver. One
|
|
224
|
+
# declared member per endpoint; mutual exclusion is structural.
|
|
225
|
+
type RequestShape = (
|
|
226
|
+
SingleFetch | RosterFanOut | BatchedRosterFanOut | BisectedWindowFetch | ParamSweep
|
|
227
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/shared/resume.py
|
|
2
|
+
"""The shared resume-value type guards.
|
|
3
|
+
|
|
4
|
+
Every incremental spec-builder's first act is the same proof: the
|
|
5
|
+
``ResumeValue`` it was handed is the shape its endpoint always resumes from
|
|
6
|
+
-- the ``DateWindow`` for a watermark endpoint, a ``FeedToken`` or
|
|
7
|
+
``FeedSeed`` for a feed endpoint -- and anything else is a wiring bug that
|
|
8
|
+
must fail loudly before a request is built. The guards are pure type
|
|
9
|
+
narrowings with no provider-specific behavior, so they live on the shared
|
|
10
|
+
surface -- placement here couples nothing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from fleetpull.endpoints.shared.base import ResumeValue
|
|
14
|
+
from fleetpull.incremental import DateWindow, FeedResume, FeedSeed, FeedToken
|
|
15
|
+
|
|
16
|
+
__all__: list[str] = ['require_date_window', 'require_feed_resume']
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def require_date_window(resume: ResumeValue, requirer: str) -> DateWindow:
|
|
20
|
+
"""Narrow a resume value to the ``DateWindow`` a watermark run carries.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
resume: The resume value handed to a spec-builder -- a
|
|
24
|
+
``DateWindow`` for a watermark endpoint; any other value is a
|
|
25
|
+
wiring bug.
|
|
26
|
+
requirer: The name blamed in the error, conventionally the
|
|
27
|
+
calling builder's class name.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
``resume``, narrowed to ``DateWindow``.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
TypeError: ``resume`` is not a ``DateWindow``.
|
|
34
|
+
|
|
35
|
+
Side Effects:
|
|
36
|
+
None.
|
|
37
|
+
"""
|
|
38
|
+
if not isinstance(resume, DateWindow):
|
|
39
|
+
raise TypeError(
|
|
40
|
+
f'{requirer} requires a DateWindow resume, got {type(resume).__name__}.'
|
|
41
|
+
)
|
|
42
|
+
return resume
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def require_feed_resume(resume: ResumeValue, requirer: str) -> FeedResume:
|
|
46
|
+
"""Narrow a resume value to the seed-or-token a feed run carries.
|
|
47
|
+
|
|
48
|
+
A feed endpoint always resumes from something -- the cold-start
|
|
49
|
+
``FeedSeed`` on the tokenless first run, the stored ``FeedToken`` on
|
|
50
|
+
every run after -- so ``None`` (like any other shape) is a wiring bug,
|
|
51
|
+
not a bootstrap case.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
resume: The resume value handed to a spec-builder -- a ``FeedSeed``
|
|
55
|
+
or ``FeedToken`` for a feed endpoint; any other value is a
|
|
56
|
+
wiring bug.
|
|
57
|
+
requirer: The name blamed in the error, conventionally the
|
|
58
|
+
calling builder's class name.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
``resume``, narrowed to ``FeedSeed | FeedToken``.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
TypeError: ``resume`` is neither a ``FeedSeed`` nor a ``FeedToken``.
|
|
65
|
+
|
|
66
|
+
Side Effects:
|
|
67
|
+
None.
|
|
68
|
+
"""
|
|
69
|
+
if not isinstance(resume, FeedSeed | FeedToken):
|
|
70
|
+
raise TypeError(
|
|
71
|
+
f'{requirer} requires a FeedSeed or FeedToken resume, '
|
|
72
|
+
f'got {type(resume).__name__}.'
|
|
73
|
+
)
|
|
74
|
+
return resume
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/shared/spec_builders.py
|
|
2
|
+
"""Shared spec-builders: SpecBuilder implementations with no per-provider
|
|
3
|
+
or per-endpoint behavior.
|
|
4
|
+
|
|
5
|
+
A snapshot endpoint's first request is just ``GET base_url + path``: it
|
|
6
|
+
translates no resume value (``SnapshotMode`` always passes
|
|
7
|
+
``resume=None``) and binds no member, so its spec-builder carries
|
|
8
|
+
no provider- or endpoint-specific logic and is shared across every
|
|
9
|
+
snapshot binding. Per-provider resume translation -- watermark windows,
|
|
10
|
+
feed tokens -- lives in dedicated builders beside their bindings, never
|
|
11
|
+
here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from fleetpull.endpoints.shared.base import ResumeValue
|
|
18
|
+
from fleetpull.network.contract import HttpMethod, RequestSpec
|
|
19
|
+
|
|
20
|
+
__all__: list[str] = ['StaticGetSpecBuilder']
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class StaticGetSpecBuilder:
|
|
25
|
+
"""Build a fixed ``GET base_url + path`` first request.
|
|
26
|
+
|
|
27
|
+
The spec-builder for endpoints with no resume value and no per-chain
|
|
28
|
+
member binding (single-chain snapshots). Both ``build_spec`` arguments
|
|
29
|
+
are accepted to satisfy the ``SpecBuilder`` protocol but are
|
|
30
|
+
intentionally unused: a snapshot resumes from nothing, and a
|
|
31
|
+
single-chain endpoint binds no member.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
base_url: Root of the provider API, trailing-slash-normalized by
|
|
35
|
+
the provider config so a leading-slash path joins directly.
|
|
36
|
+
path: The endpoint's leading-slash request path (e.g.
|
|
37
|
+
``'/v1/vehicles'``).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
base_url: str
|
|
41
|
+
path: str
|
|
42
|
+
|
|
43
|
+
def build_spec(
|
|
44
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
45
|
+
) -> RequestSpec:
|
|
46
|
+
"""Build the fixed first request.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
resume: Accepted to satisfy the protocol; unused -- a snapshot
|
|
50
|
+
resumes from nothing.
|
|
51
|
+
member_values: Accepted to satisfy the protocol; unused -- a
|
|
52
|
+
single-chain endpoint binds no member.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A credential-less ``GET`` for ``base_url + path``. Auth headers
|
|
56
|
+
are layered on by the client's ``ProviderProfile``; pagination
|
|
57
|
+
parameters are injected by the page decoder's ``first_request``.
|
|
58
|
+
"""
|
|
59
|
+
return RequestSpec(method=HttpMethod.GET, url=f'{self.base_url}{self.path}')
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/shared/sync_mode.py
|
|
2
|
+
"""The sync-mode and storage-layout declaration family.
|
|
3
|
+
|
|
4
|
+
The two declared axes every ``EndpointDefinition`` carries (the
|
|
5
|
+
``request_shape.py`` family precedent -- one declaration family per file):
|
|
6
|
+
``StorageKind`` is the §3 storage *layout* (where the bytes live), and the
|
|
7
|
+
``SyncMode`` union (``SnapshotMode`` / ``WatermarkMode`` / ``FeedMode``) is
|
|
8
|
+
the resume-and-write *semantic* (how a run resumes and what its write does to
|
|
9
|
+
the data). The two are orthogonal; the valid pairings are validated on the
|
|
10
|
+
binding (``base.py``) at construction.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
|
|
17
|
+
__all__: list[str] = [
|
|
18
|
+
'FeedMode',
|
|
19
|
+
'SnapshotMode',
|
|
20
|
+
'StorageKind',
|
|
21
|
+
'SyncMode',
|
|
22
|
+
'WatermarkMode',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StorageKind(StrEnum):
|
|
27
|
+
"""
|
|
28
|
+
The §3 storage *layout* an endpoint declares: one parquet file vs hive
|
|
29
|
+
partitions. Layout only — *where* the bytes live, not how they merge.
|
|
30
|
+
|
|
31
|
+
``SINGLE`` is one ``data.parquet``; ``DATE_PARTITIONED`` is hive
|
|
32
|
+
``date=YYYY-MM-DD`` partitions; ``APPEND_LOG`` is hive ``date=`` partitions
|
|
33
|
+
holding numbered ``part-NNNNN.parquet`` files that only ever accumulate —
|
|
34
|
+
the feed arm's append-only layout, where nothing is ever deleted or
|
|
35
|
+
replaced. The caller dispatches on it to pick the storage path —
|
|
36
|
+
read-the-whole-file for ``SINGLE``, touch-only-overlapping-partitions for
|
|
37
|
+
``DATE_PARTITIONED``, append-new-parts-only for ``APPEND_LOG``. What that
|
|
38
|
+
write *does* to the data — full-replace, delete-by-window-then-append, or
|
|
39
|
+
append — is the ``SyncMode``'s concern, not the layout's; the two are
|
|
40
|
+
orthogonal axes the storage layer combines (``APPEND_LOG`` pairs only with
|
|
41
|
+
``FeedMode``, validated at construction: append-only is the feed stream's
|
|
42
|
+
write semantic, and every windowed or snapshot semantic would corrupt an
|
|
43
|
+
accumulate-only layout).
|
|
44
|
+
|
|
45
|
+
It lives here beside the binding, not in ``vocabulary/``: unlike
|
|
46
|
+
``QuotaScope``
|
|
47
|
+
(which config validates against, so it must sit in a leaf config can import),
|
|
48
|
+
``StorageKind`` has no low-layer consumer — it travels with the
|
|
49
|
+
``EndpointDefinition`` it configures.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
SINGLE = 'single'
|
|
53
|
+
DATE_PARTITIONED = 'date_partitioned'
|
|
54
|
+
APPEND_LOG = 'append_log'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class SnapshotMode:
|
|
59
|
+
"""
|
|
60
|
+
Snapshot sync declaration (config): a marker carrying no configuration.
|
|
61
|
+
|
|
62
|
+
The endpoint re-fetches its full current-state dataset every run and has no
|
|
63
|
+
resume — its spec-builder always receives ``resume=None`` (no window, no
|
|
64
|
+
token). Its write semantic is *full replacement* of the endpoint's current-
|
|
65
|
+
state dataset. A marker member of ``SyncMode``. Snapshot has no event-time
|
|
66
|
+
dimension to partition on, so a snapshot endpoint must be laid out
|
|
67
|
+
``SINGLE`` — ``EndpointDefinition`` enforces that pairing at construction,
|
|
68
|
+
since ``DATE_PARTITIONED`` would have no event-time column to split on.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class WatermarkMode:
|
|
74
|
+
"""
|
|
75
|
+
Watermark sync declaration (config): the late-arrival lookback margin.
|
|
76
|
+
|
|
77
|
+
The endpoint's sync *declaration*, distinct from the runtime
|
|
78
|
+
``IncrementalCursor`` *state* in ``incremental/``: this configures how a fetch
|
|
79
|
+
resumes; the cursor is what it resumes from. Its write semantic is
|
|
80
|
+
*delete-by-window, then append* — the refetched window is cleared and replaced,
|
|
81
|
+
so late arrivals and in-window corrections land cleanly. ``lookback`` is the
|
|
82
|
+
margin the resume resolver subtracts from the stored watermark (§4) so late-
|
|
83
|
+
arriving records inside it are re-fetched; the resolver then floors the
|
|
84
|
+
start to its UTC midnight, so a lookback of N days re-covers N whole days
|
|
85
|
+
before the watermark's day. ``cutoff`` is the complementary
|
|
86
|
+
trailing-edge holdback: the window's end is held back this far from the clock
|
|
87
|
+
so a still-arriving day is never frozen as a complete partition. Both express
|
|
88
|
+
one physical concern -- provider data latency -- from opposite ends, so both
|
|
89
|
+
are sourced from the provider config (``lookback_days`` / ``cutoff_days``),
|
|
90
|
+
not defaulted on the mode.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
lookback: How far before the watermark each resume re-fetches, to recover
|
|
94
|
+
records that landed after their event-time day.
|
|
95
|
+
cutoff: How far the window's end is held back from the clock, so the most
|
|
96
|
+
recent written partition is always a complete day. Day-granular; zero
|
|
97
|
+
adds no holdback beyond the resolver's own date alignment.
|
|
98
|
+
fixed_unit_days: The endpoint's fixed work-unit width in whole days, or
|
|
99
|
+
``None`` (the default) to tile at ``sync.backfill_chunk_days``. Set
|
|
100
|
+
only by endpoints whose rows are per-request-window rollups: the
|
|
101
|
+
provider aggregates over exactly the requested window, so the unit
|
|
102
|
+
width is part of the ROW'S MEANING and must never float with user
|
|
103
|
+
configuration — the Samsara fuel-energy probe proved day rollups are
|
|
104
|
+
NOT a lossless decomposition of wider windows (summing two adjacent
|
|
105
|
+
day windows reproduced the two-day rollup on only 178 of 267
|
|
106
|
+
vehicles; DESIGN §8, 2026-07-21). When set, the window planner tiles
|
|
107
|
+
this endpoint's resume window into units of exactly this many days,
|
|
108
|
+
ignoring ``sync.backfill_chunk_days``; the config knob remains the
|
|
109
|
+
default for every endpoint that leaves this ``None``. Validated
|
|
110
|
+
>= 1 at construction.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
lookback: timedelta
|
|
114
|
+
cutoff: timedelta
|
|
115
|
+
fixed_unit_days: int | None = None
|
|
116
|
+
|
|
117
|
+
def __post_init__(self) -> None:
|
|
118
|
+
"""Validate the fixed unit width when one is declared.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: ``fixed_unit_days`` is set but not >= 1 — a
|
|
122
|
+
zero-or-negative unit width can tile nothing.
|
|
123
|
+
|
|
124
|
+
Side Effects:
|
|
125
|
+
None -- reads fields and may raise.
|
|
126
|
+
"""
|
|
127
|
+
if self.fixed_unit_days is not None and self.fixed_unit_days < 1:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f'fixed_unit_days must be >= 1 when set, got {self.fixed_unit_days}.'
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class FeedMode:
|
|
135
|
+
"""
|
|
136
|
+
Feed sync declaration (config): a marker carrying no configuration.
|
|
137
|
+
|
|
138
|
+
The feed arm needs no config — its resume value is the stored ``FeedToken``
|
|
139
|
+
used directly (no lookback, no window), or a ``FeedSeed`` from the sync-wide
|
|
140
|
+
cold-start anchor on the tokenless first run. Its write semantic is
|
|
141
|
+
*append-only*: the feed is a forward-only version stream stored as emitted
|
|
142
|
+
— every run appends new numbered part files into the event-date partitions
|
|
143
|
+
its records belong to, and nothing is ever deleted or replaced (DESIGN §4).
|
|
144
|
+
Re-emitted versions and crash-window duplicates land as new rows; the
|
|
145
|
+
consumer reconciles calculated feeds by ``(id, max version)`` and active
|
|
146
|
+
feeds by ``id``. A ``FeedMode`` endpoint therefore requires the
|
|
147
|
+
``APPEND_LOG`` layout and an ``event_time_column`` (the records' own event
|
|
148
|
+
dates route the partitions), both validated at construction. A marker
|
|
149
|
+
member of ``SyncMode``, distinct from the runtime ``FeedToken`` cursor
|
|
150
|
+
state in ``incremental/``.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# The endpoint's sync-mode declaration (config): the caller matches on it to drive
|
|
155
|
+
# both resume and write semantics — SnapshotMode -> no resume + full replace,
|
|
156
|
+
# WatermarkMode -> resume resolver + delete-by-window-then-append, FeedMode -> the
|
|
157
|
+
# stored token (or cold-start seed) + append-only. Storage layout (StorageKind) is
|
|
158
|
+
# the orthogonal axis.
|
|
159
|
+
type SyncMode = SnapshotMode | WatermarkMode | FeedMode
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/shared/url_paths.py
|
|
2
|
+
"""Strict URL-path template rendering for endpoint fan-out paths.
|
|
3
|
+
|
|
4
|
+
A pure leaf -- stdlib only, imports nothing internal. It renders a small, closed
|
|
5
|
+
template dialect: a URL path may carry named ``{placeholder}`` segments and
|
|
6
|
+
nothing else. Python's general ``str.format`` grammar (format specs, conversions,
|
|
7
|
+
attribute/item access, ``{{`` escapes) is deliberately not borrowed -- defining
|
|
8
|
+
the tiny dialect we want is less surface than subtracting the features we do not
|
|
9
|
+
from a large one.
|
|
10
|
+
|
|
11
|
+
Strict both ways and loud on any mismatch: the placeholder set must equal the
|
|
12
|
+
supplied-value key set (a missing or unused key is a binding/fan-out bug), no
|
|
13
|
+
value may be empty, and a template with malformed braces is rejected. Substituted
|
|
14
|
+
values are URL-encoded as a single path segment (``quote(..., safe='')``), so a
|
|
15
|
+
value like ``'abc/123'`` becomes ``'abc%2F123'`` and can never restructure the
|
|
16
|
+
path. That encoding is also load-bearing for safety: because an encoded value can
|
|
17
|
+
contain neither ``{`` nor ``}``, the literal placeholder replacement below can
|
|
18
|
+
never have a substituted value forge a second placeholder.
|
|
19
|
+
|
|
20
|
+
Errors are ``UrlPathTemplateError`` (a ``ValueError`` subclass defined here,
|
|
21
|
+
importing nothing internal) -- a template/key mismatch is a programmer bug with no
|
|
22
|
+
user input in the loop, so it raises and stays stdlib-typed, exactly as the timing
|
|
23
|
+
codec does.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from urllib.parse import quote
|
|
29
|
+
|
|
30
|
+
__all__: list[str] = ['UrlPathTemplateError', 'render_url_path_template']
|
|
31
|
+
|
|
32
|
+
_PLACEHOLDER_PATTERN = re.compile(r'\{([A-Za-z_][A-Za-z0-9_]*)\}')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UrlPathTemplateError(ValueError):
|
|
36
|
+
"""Raised when a URL-path template or its supplied values are invalid."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_url_path_template(path_template: str, path_values: Mapping[str, str]) -> str:
|
|
40
|
+
"""Render a strict URL-path template, URL-encoding each substituted value.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
path_template: A URL path with zero or more named ``{placeholder}``
|
|
44
|
+
segments (e.g. ``'/v3/vehicle_locations/{vehicle_id}'``). Only named
|
|
45
|
+
placeholders are supported; format specs, conversions, attribute or
|
|
46
|
+
item access, and escaped braces are not.
|
|
47
|
+
path_values: The substitution values. Its key set must exactly equal the
|
|
48
|
+
template's placeholder set, and no value may be empty.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The rendered path with every placeholder replaced by its URL-encoded
|
|
52
|
+
(single-segment) value. A template with no placeholders and an empty
|
|
53
|
+
mapping is returned unchanged.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
UrlPathTemplateError: The template has malformed or unsupported braces;
|
|
57
|
+
the value keys do not exactly match the placeholders (missing or
|
|
58
|
+
unused keys); or a supplied value is empty.
|
|
59
|
+
|
|
60
|
+
Side Effects:
|
|
61
|
+
None -- pure function.
|
|
62
|
+
"""
|
|
63
|
+
placeholder_names = _extract_placeholder_names(path_template)
|
|
64
|
+
|
|
65
|
+
expected = set(placeholder_names)
|
|
66
|
+
supplied = set(path_values)
|
|
67
|
+
if expected != supplied:
|
|
68
|
+
raise UrlPathTemplateError(
|
|
69
|
+
_mismatch_message(
|
|
70
|
+
path_template=path_template,
|
|
71
|
+
missing=expected - supplied,
|
|
72
|
+
unused=supplied - expected,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
rendered = path_template
|
|
77
|
+
for placeholder_name in placeholder_names:
|
|
78
|
+
value = path_values[placeholder_name]
|
|
79
|
+
if value == '':
|
|
80
|
+
raise UrlPathTemplateError(
|
|
81
|
+
f'URL path value {placeholder_name!r} must not be empty.'
|
|
82
|
+
)
|
|
83
|
+
# safe='' encodes every reserved character, so the value is a single
|
|
84
|
+
# inert path segment. The literal replace below relies on this: an
|
|
85
|
+
# encoded value contains no braces, so it cannot forge a placeholder.
|
|
86
|
+
rendered = rendered.replace(f'{{{placeholder_name}}}', quote(value, safe=''))
|
|
87
|
+
return rendered
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _extract_placeholder_names(path_template: str) -> tuple[str, ...]:
|
|
91
|
+
"""Extract a template's placeholder names in first-seen order.
|
|
92
|
+
|
|
93
|
+
A repeated placeholder is returned once -- it must take a single value. Any
|
|
94
|
+
brace left after removing every valid placeholder means the template has
|
|
95
|
+
malformed or unsupported brace syntax, which raises.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
path_template: The template to inspect.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The distinct placeholder names, in first-seen order.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
UrlPathTemplateError: The template contains malformed or unsupported brace
|
|
105
|
+
syntax (a dangling brace, or a non-named-placeholder use).
|
|
106
|
+
|
|
107
|
+
Side Effects:
|
|
108
|
+
None -- pure function.
|
|
109
|
+
"""
|
|
110
|
+
residual = _PLACEHOLDER_PATTERN.sub('', path_template)
|
|
111
|
+
if '{' in residual or '}' in residual:
|
|
112
|
+
raise UrlPathTemplateError(
|
|
113
|
+
'URL path template has malformed or unsupported brace syntax: '
|
|
114
|
+
f"{path_template!r}. Only named placeholders like '{{vehicle_id}}' "
|
|
115
|
+
'are supported.'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
seen: set[str] = set()
|
|
119
|
+
ordered_names: list[str] = []
|
|
120
|
+
for match in _PLACEHOLDER_PATTERN.finditer(path_template):
|
|
121
|
+
name = match.group(1)
|
|
122
|
+
if name not in seen:
|
|
123
|
+
seen.add(name)
|
|
124
|
+
ordered_names.append(name)
|
|
125
|
+
return tuple(ordered_names)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _mismatch_message(path_template: str, missing: set[str], unused: set[str]) -> str:
|
|
129
|
+
"""Build a precise placeholder/value mismatch message.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
path_template: The template being rendered.
|
|
133
|
+
missing: Placeholder names the template needs but the values omit.
|
|
134
|
+
unused: Value keys the caller supplied but the template never uses.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
A human-readable validation message naming the missing and unused keys.
|
|
138
|
+
|
|
139
|
+
Side Effects:
|
|
140
|
+
None -- pure function.
|
|
141
|
+
"""
|
|
142
|
+
parts = [f'URL path values do not match placeholders for {path_template!r}.']
|
|
143
|
+
if missing:
|
|
144
|
+
names = ', '.join(repr(name) for name in sorted(missing))
|
|
145
|
+
parts.append(f'Missing: {names}.')
|
|
146
|
+
if unused:
|
|
147
|
+
names = ', '.join(repr(name) for name in sorted(unused))
|
|
148
|
+
parts.append(f'Unused: {names}.')
|
|
149
|
+
return ' '.join(parts)
|