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.
Files changed (252) hide show
  1. fleetpull/__init__.py +36 -0
  2. fleetpull/api/__init__.py +32 -0
  3. fleetpull/api/auth_ingress.py +216 -0
  4. fleetpull/api/catalog.py +139 -0
  5. fleetpull/api/fetch.py +229 -0
  6. fleetpull/api/identity.py +90 -0
  7. fleetpull/api/sync.py +735 -0
  8. fleetpull/cli.py +146 -0
  9. fleetpull/config/__init__.py +47 -0
  10. fleetpull/config/base.py +18 -0
  11. fleetpull/config/example.py +90 -0
  12. fleetpull/config/geotab.py +78 -0
  13. fleetpull/config/http.py +32 -0
  14. fleetpull/config/loading.py +195 -0
  15. fleetpull/config/logger.py +139 -0
  16. fleetpull/config/providers.py +520 -0
  17. fleetpull/config/rate_limit.py +50 -0
  18. fleetpull/config/resolution.py +156 -0
  19. fleetpull/config/retry.py +69 -0
  20. fleetpull/config/root.py +157 -0
  21. fleetpull/config/sections.py +146 -0
  22. fleetpull/endpoints/__init__.py +24 -0
  23. fleetpull/endpoints/geotab/__init__.py +12 -0
  24. fleetpull/endpoints/geotab/_requests.py +427 -0
  25. fleetpull/endpoints/geotab/annotation_logs.py +73 -0
  26. fleetpull/endpoints/geotab/audits.py +70 -0
  27. fleetpull/endpoints/geotab/devices.py +79 -0
  28. fleetpull/endpoints/geotab/driver_changes.py +73 -0
  29. fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
  30. fleetpull/endpoints/geotab/dvir_logs.py +76 -0
  31. fleetpull/endpoints/geotab/exception_events.py +119 -0
  32. fleetpull/endpoints/geotab/fault_data.py +72 -0
  33. fleetpull/endpoints/geotab/fill_ups.py +79 -0
  34. fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
  35. fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
  36. fleetpull/endpoints/geotab/log_records.py +74 -0
  37. fleetpull/endpoints/geotab/media_files.py +74 -0
  38. fleetpull/endpoints/geotab/shipment_logs.py +71 -0
  39. fleetpull/endpoints/geotab/status_data.py +71 -0
  40. fleetpull/endpoints/geotab/text_messages.py +77 -0
  41. fleetpull/endpoints/geotab/trips.py +102 -0
  42. fleetpull/endpoints/geotab/users.py +81 -0
  43. fleetpull/endpoints/motive/__init__.py +12 -0
  44. fleetpull/endpoints/motive/_spec_builders.py +94 -0
  45. fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
  46. fleetpull/endpoints/motive/driving_periods.py +85 -0
  47. fleetpull/endpoints/motive/groups.py +61 -0
  48. fleetpull/endpoints/motive/idle_events.py +94 -0
  49. fleetpull/endpoints/motive/users.py +64 -0
  50. fleetpull/endpoints/motive/vehicle_locations.py +170 -0
  51. fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
  52. fleetpull/endpoints/motive/vehicles.py +106 -0
  53. fleetpull/endpoints/registry.py +280 -0
  54. fleetpull/endpoints/samsara/__init__.py +12 -0
  55. fleetpull/endpoints/samsara/_spec_builders.py +202 -0
  56. fleetpull/endpoints/samsara/addresses.py +77 -0
  57. fleetpull/endpoints/samsara/asset_locations.py +217 -0
  58. fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
  59. fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
  60. fleetpull/endpoints/samsara/drivers.py +169 -0
  61. fleetpull/endpoints/samsara/engine_states.py +114 -0
  62. fleetpull/endpoints/samsara/gps_readings.py +113 -0
  63. fleetpull/endpoints/samsara/idling_events.py +195 -0
  64. fleetpull/endpoints/samsara/odometer_readings.py +116 -0
  65. fleetpull/endpoints/samsara/trips.py +217 -0
  66. fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
  67. fleetpull/endpoints/samsara/vehicles.py +121 -0
  68. fleetpull/endpoints/shared/__init__.py +57 -0
  69. fleetpull/endpoints/shared/base.py +529 -0
  70. fleetpull/endpoints/shared/request_shape.py +227 -0
  71. fleetpull/endpoints/shared/resume.py +74 -0
  72. fleetpull/endpoints/shared/spec_builders.py +59 -0
  73. fleetpull/endpoints/shared/sync_mode.py +159 -0
  74. fleetpull/endpoints/shared/url_paths.py +149 -0
  75. fleetpull/exceptions.py +320 -0
  76. fleetpull/incremental/__init__.py +27 -0
  77. fleetpull/incremental/cursor.py +66 -0
  78. fleetpull/incremental/resolution.py +152 -0
  79. fleetpull/incremental/seed.py +53 -0
  80. fleetpull/incremental/window.py +94 -0
  81. fleetpull/logger/__init__.py +5 -0
  82. fleetpull/logger/setup.py +145 -0
  83. fleetpull/model_contract/__init__.py +6 -0
  84. fleetpull/model_contract/coercions.py +33 -0
  85. fleetpull/model_contract/response.py +45 -0
  86. fleetpull/models/__init__.py +7 -0
  87. fleetpull/models/geotab/__init__.py +166 -0
  88. fleetpull/models/geotab/annotation_log.py +109 -0
  89. fleetpull/models/geotab/audit.py +56 -0
  90. fleetpull/models/geotab/device.py +217 -0
  91. fleetpull/models/geotab/driver_change.py +102 -0
  92. fleetpull/models/geotab/duty_status_log.py +200 -0
  93. fleetpull/models/geotab/dvir_log.py +184 -0
  94. fleetpull/models/geotab/exception_event.py +135 -0
  95. fleetpull/models/geotab/fault_data.py +168 -0
  96. fleetpull/models/geotab/fill_up.py +189 -0
  97. fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
  98. fleetpull/models/geotab/fuel_tax_detail.py +150 -0
  99. fleetpull/models/geotab/log_record.py +68 -0
  100. fleetpull/models/geotab/media_file.py +133 -0
  101. fleetpull/models/geotab/shared.py +221 -0
  102. fleetpull/models/geotab/shipment_log.py +105 -0
  103. fleetpull/models/geotab/status_data.py +108 -0
  104. fleetpull/models/geotab/text_message.py +125 -0
  105. fleetpull/models/geotab/trip.py +162 -0
  106. fleetpull/models/geotab/user.py +187 -0
  107. fleetpull/models/motive/__init__.py +43 -0
  108. fleetpull/models/motive/driver_idle_rollup.py +109 -0
  109. fleetpull/models/motive/driving_period.py +82 -0
  110. fleetpull/models/motive/group.py +49 -0
  111. fleetpull/models/motive/idle_event.py +77 -0
  112. fleetpull/models/motive/shared.py +192 -0
  113. fleetpull/models/motive/user.py +217 -0
  114. fleetpull/models/motive/vehicle.py +162 -0
  115. fleetpull/models/motive/vehicle_location.py +127 -0
  116. fleetpull/models/motive/vehicle_utilization.py +128 -0
  117. fleetpull/models/samsara/__init__.py +108 -0
  118. fleetpull/models/samsara/address.py +149 -0
  119. fleetpull/models/samsara/asset_location.py +131 -0
  120. fleetpull/models/samsara/driver.py +232 -0
  121. fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
  122. fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
  123. fleetpull/models/samsara/engine_state.py +92 -0
  124. fleetpull/models/samsara/gps_reading.py +146 -0
  125. fleetpull/models/samsara/idling_event.py +174 -0
  126. fleetpull/models/samsara/odometer_reading.py +90 -0
  127. fleetpull/models/samsara/trip.py +199 -0
  128. fleetpull/models/samsara/vehicle.py +167 -0
  129. fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
  130. fleetpull/network/__init__.py +9 -0
  131. fleetpull/network/auth/__init__.py +15 -0
  132. fleetpull/network/auth/authenticate.py +271 -0
  133. fleetpull/network/auth/manager.py +223 -0
  134. fleetpull/network/auth/models.py +57 -0
  135. fleetpull/network/auth/strategies.py +169 -0
  136. fleetpull/network/classifiers/__init__.py +11 -0
  137. fleetpull/network/classifiers/geotab.py +145 -0
  138. fleetpull/network/classifiers/motive.py +79 -0
  139. fleetpull/network/classifiers/samsara.py +80 -0
  140. fleetpull/network/client/__init__.py +25 -0
  141. fleetpull/network/client/page.py +32 -0
  142. fleetpull/network/client/profile.py +27 -0
  143. fleetpull/network/client/registry.py +103 -0
  144. fleetpull/network/client/registry_base.py +150 -0
  145. fleetpull/network/client/runtime.py +44 -0
  146. fleetpull/network/client/transport.py +381 -0
  147. fleetpull/network/contract/__init__.py +50 -0
  148. fleetpull/network/contract/auth.py +48 -0
  149. fleetpull/network/contract/classifier.py +189 -0
  150. fleetpull/network/contract/envelope_fetcher.py +33 -0
  151. fleetpull/network/contract/envelopes.py +189 -0
  152. fleetpull/network/contract/outcome.py +43 -0
  153. fleetpull/network/contract/page_decoder.py +101 -0
  154. fleetpull/network/contract/request.py +97 -0
  155. fleetpull/network/decoders/__init__.py +40 -0
  156. fleetpull/network/decoders/_window_stamp.py +78 -0
  157. fleetpull/network/decoders/geotab.py +309 -0
  158. fleetpull/network/decoders/motive.py +204 -0
  159. fleetpull/network/decoders/motive_reports.py +114 -0
  160. fleetpull/network/decoders/samsara.py +343 -0
  161. fleetpull/network/decoders/samsara_reports.py +119 -0
  162. fleetpull/network/decoders/single_page.py +55 -0
  163. fleetpull/network/limits/__init__.py +13 -0
  164. fleetpull/network/limits/bucket_math.py +58 -0
  165. fleetpull/network/limits/limiter.py +153 -0
  166. fleetpull/network/limits/registry.py +96 -0
  167. fleetpull/network/posture/__init__.py +6 -0
  168. fleetpull/network/posture/client_options.py +96 -0
  169. fleetpull/network/retry/__init__.py +13 -0
  170. fleetpull/network/retry/decision.py +155 -0
  171. fleetpull/network/tls/__init__.py +5 -0
  172. fleetpull/network/tls/truststore_context.py +35 -0
  173. fleetpull/orchestrator/__init__.py +30 -0
  174. fleetpull/orchestrator/backfill.py +127 -0
  175. fleetpull/orchestrator/batch.py +155 -0
  176. fleetpull/orchestrator/bisection.py +271 -0
  177. fleetpull/orchestrator/drivers.py +350 -0
  178. fleetpull/orchestrator/entry.py +347 -0
  179. fleetpull/orchestrator/executors.py +128 -0
  180. fleetpull/orchestrator/fanout.py +163 -0
  181. fleetpull/orchestrator/feed_drive.py +266 -0
  182. fleetpull/orchestrator/metadata_projection.py +195 -0
  183. fleetpull/orchestrator/outcome.py +53 -0
  184. fleetpull/orchestrator/recording.py +85 -0
  185. fleetpull/orchestrator/resume.py +143 -0
  186. fleetpull/orchestrator/roster_harvest.py +108 -0
  187. fleetpull/orchestrator/roster_refresh.py +363 -0
  188. fleetpull/orchestrator/runner.py +262 -0
  189. fleetpull/orchestrator/shape_resolution.py +221 -0
  190. fleetpull/orchestrator/spine.py +166 -0
  191. fleetpull/orchestrator/streaming.py +153 -0
  192. fleetpull/orchestrator/unit_loop.py +273 -0
  193. fleetpull/orchestrator/watermark_drive.py +455 -0
  194. fleetpull/paths/__init__.py +13 -0
  195. fleetpull/paths/datasets.py +38 -0
  196. fleetpull/paths/partitions.py +41 -0
  197. fleetpull/paths/resolution.py +98 -0
  198. fleetpull/polars_typing/__init__.py +20 -0
  199. fleetpull/py.typed +0 -0
  200. fleetpull/records/__init__.py +19 -0
  201. fleetpull/records/convert.py +53 -0
  202. fleetpull/records/dataframe.py +63 -0
  203. fleetpull/records/event_time.py +70 -0
  204. fleetpull/records/fields.py +190 -0
  205. fleetpull/records/flatten.py +54 -0
  206. fleetpull/records/roster_members.py +74 -0
  207. fleetpull/records/schema.py +73 -0
  208. fleetpull/records/validation.py +62 -0
  209. fleetpull/resources/__init__.py +10 -0
  210. fleetpull/resources/config.example.yaml +222 -0
  211. fleetpull/roster/__init__.py +16 -0
  212. fleetpull/roster/definition.py +46 -0
  213. fleetpull/roster/key.py +40 -0
  214. fleetpull/roster/registry.py +93 -0
  215. fleetpull/state/__init__.py +36 -0
  216. fleetpull/state/cursors.py +417 -0
  217. fleetpull/state/database.py +463 -0
  218. fleetpull/state/migrations.py +430 -0
  219. fleetpull/state/reconcile.py +127 -0
  220. fleetpull/state/rosters.py +159 -0
  221. fleetpull/state/run_ledger.py +572 -0
  222. fleetpull/state/work_units.py +648 -0
  223. fleetpull/storage/__init__.py +30 -0
  224. fleetpull/storage/append.py +187 -0
  225. fleetpull/storage/atomic.py +161 -0
  226. fleetpull/storage/files.py +176 -0
  227. fleetpull/storage/frames.py +98 -0
  228. fleetpull/storage/metadata.py +161 -0
  229. fleetpull/storage/partitioned.py +205 -0
  230. fleetpull/storage/pruning.py +169 -0
  231. fleetpull/storage/read.py +35 -0
  232. fleetpull/storage/result.py +34 -0
  233. fleetpull/storage/single_file.py +130 -0
  234. fleetpull/storage/splitting.py +88 -0
  235. fleetpull/storage/staging.py +178 -0
  236. fleetpull/storage/writers.py +145 -0
  237. fleetpull/timing/__init__.py +21 -0
  238. fleetpull/timing/canon.py +92 -0
  239. fleetpull/timing/clock.py +192 -0
  240. fleetpull/timing/codec.py +122 -0
  241. fleetpull/timing/sleeper.py +65 -0
  242. fleetpull/vocabulary/__init__.py +16 -0
  243. fleetpull/vocabulary/json_types.py +20 -0
  244. fleetpull/vocabulary/provider.py +29 -0
  245. fleetpull/vocabulary/quota_scope.py +47 -0
  246. fleetpull/vocabulary/response_category.py +32 -0
  247. fleetpull-0.1.0.dist-info/METADATA +248 -0
  248. fleetpull-0.1.0.dist-info/RECORD +252 -0
  249. fleetpull-0.1.0.dist-info/WHEEL +5 -0
  250. fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
  251. fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
  252. 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)