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,455 @@
1
+ # src/fleetpull/orchestrator/watermark_drive.py
2
+ """The watermark drive: the run executor's plan-and-drive unit-loop arm.
3
+
4
+ ``WatermarkDrive`` drives one watermark endpoint's run (DESIGN sections 4/5):
5
+ re-claim any incomplete work units and drive them (``backfill_unit_workers``
6
+ concurrently), then resolve the residual window exactly as before --
7
+ watermark less lookback (floored), else coverage frontier, else the
8
+ cold-start anchor, against the cutoff trailing edge -- plan it into
9
+ ``backfill_chunk_days`` units (or the endpoint's declared
10
+ ``fixed_unit_days``, which wins over config), and drive those. Every unit is
11
+ its own transaction: fetch the unit's window (the fan-out threads unchanged
12
+ within it), write parquet, record the ledger row, and mark the unit done
13
+ with its folded observation. The watermark advances on the PREFIX-ADVANCE
14
+ rule (2026-07-20): after each completion, to the maximum observation across
15
+ the contiguous done-prefix, through the cursor store's atomic forward-only
16
+ write -- so units may complete in any order and every persisted watermark is
17
+ still true at every instant; a crash resumes from the work-units ledger
18
+ instead of refetching the window. The pure resume decisions live in
19
+ ``orchestrator/resume.py``, the per-batch transform in
20
+ ``orchestrator/batch.py``, and the claim choreography in
21
+ ``orchestrator/unit_loop.py``, so the drive only orchestrates -- read state,
22
+ call pure functions, write state.
23
+ """
24
+
25
+ import logging
26
+ from collections.abc import Iterator, Sequence
27
+ from datetime import datetime, timedelta
28
+ from functools import partial
29
+
30
+ from fleetpull.endpoints.shared import EndpointDefinition, WatermarkMode
31
+ from fleetpull.exceptions import ConfigurationError
32
+ from fleetpull.incremental import (
33
+ DateWindow,
34
+ resolve_resume_start,
35
+ resolve_trailing_edge,
36
+ window_or_none,
37
+ )
38
+ from fleetpull.model_contract import ResponseModel
39
+ from fleetpull.orchestrator.backfill import plan_backfill_units
40
+ from fleetpull.orchestrator.batch import ProcessedBatch, WindowContext
41
+ from fleetpull.orchestrator.drivers import RequestDriver
42
+ from fleetpull.orchestrator.outcome import CaughtUp, Executed, RunOutcome
43
+ from fleetpull.orchestrator.recording import recorded_run
44
+ from fleetpull.orchestrator.resume import resolve_watermark_start
45
+ from fleetpull.orchestrator.spine import RunnerSpine
46
+ from fleetpull.orchestrator.streaming import (
47
+ BatchObserver,
48
+ drain_batches,
49
+ observe_batches,
50
+ stream_processed_batches,
51
+ )
52
+ from fleetpull.orchestrator.unit_loop import UnitCrew, drive_claimable_units
53
+ from fleetpull.storage import WriteResult
54
+ from fleetpull.timing import to_iso8601
55
+ from fleetpull.vocabulary import Provider
56
+
57
+ __all__: list[str] = ['WatermarkDrive']
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ def _window_context(
63
+ definition: EndpointDefinition[ResponseModel], window: DateWindow, now: datetime
64
+ ) -> WindowContext:
65
+ """Build the per-batch transform context, asserting the event-time column.
66
+
67
+ A watermark endpoint always declares an ``event_time_column`` (the endpoint
68
+ definition forbids otherwise); this narrows it for the type checker and fails
69
+ loudly if that invariant is ever broken.
70
+
71
+ Args:
72
+ definition: The watermark endpoint being run.
73
+ window: The run's half-open window.
74
+ now: The run instant (the future-event guard's bound).
75
+
76
+ Returns:
77
+ The ``WindowContext`` for ``process_batch``.
78
+
79
+ Raises:
80
+ ConfigurationError: The endpoint declares no event-time column.
81
+ """
82
+ event_time_column = definition.event_time_column
83
+ if event_time_column is None:
84
+ raise ConfigurationError(
85
+ 'watermark endpoint has no event_time_column',
86
+ provider=definition.provider.value,
87
+ endpoint=definition.name,
88
+ )
89
+ return WindowContext(window=window, now=now, event_time_column=event_time_column)
90
+
91
+
92
+ def _merge_executed(outcomes: Sequence[Executed]) -> Executed:
93
+ """Fold the driven units' outcomes into the invocation's one ``Executed``.
94
+
95
+ Counts sum; the pruned partitions concatenate in drive order (a residual
96
+ unit re-covering a leftover unit's dates may repeat one -- the report is
97
+ informational, never consumed as a set).
98
+
99
+ Args:
100
+ outcomes: The per-unit outcomes, in drive order; at least one.
101
+
102
+ Returns:
103
+ The aggregated ``Executed``.
104
+ """
105
+ observations = [
106
+ outcome.latest_observed
107
+ for outcome in outcomes
108
+ if outcome.latest_observed is not None
109
+ ]
110
+ return Executed(
111
+ records_fetched=sum(outcome.records_fetched for outcome in outcomes),
112
+ write=WriteResult(
113
+ rows_written=sum(outcome.write.rows_written for outcome in outcomes),
114
+ duplicates_dropped=sum(
115
+ outcome.write.duplicates_dropped for outcome in outcomes
116
+ ),
117
+ files_written=sum(outcome.write.files_written for outcome in outcomes),
118
+ deleted_partitions=tuple(
119
+ deleted_date
120
+ for outcome in outcomes
121
+ for deleted_date in outcome.write.deleted_partitions
122
+ ),
123
+ ),
124
+ latest_observed=max(observations) if observations else None,
125
+ )
126
+
127
+
128
+ class WatermarkDrive:
129
+ """Drives one watermark endpoint's run through the plan-and-drive loop.
130
+
131
+ Constructed once by ``EndpointRunner.__init__`` from the shared spine;
132
+ ``run`` takes the endpoint, its request driver, its mode, and the
133
+ optional per-frame observer.
134
+ """
135
+
136
+ def __init__(self, spine: RunnerSpine) -> None:
137
+ """
138
+ Args:
139
+ spine: The shared drive kit -- collaborators plus the runner's
140
+ writer-factory and projection seams.
141
+ """
142
+ self._spine = spine
143
+
144
+ def run(
145
+ self,
146
+ definition: EndpointDefinition[ResponseModel],
147
+ driver: RequestDriver,
148
+ mode: WatermarkMode,
149
+ observer: BatchObserver | None,
150
+ ) -> RunOutcome:
151
+ """Run the watermark arm: the plan-and-drive unit loop.
152
+
153
+ Incomplete units outrank the watermark: orphaned ``claimed`` units are
154
+ reset (an in-progress unit found at run start is by definition
155
+ orphaned -- fleetpull assumes a single driver per state database) and
156
+ every claimable unit is re-claimed and driven,
157
+ ``backfill_unit_workers`` at a time. Then the residual window is
158
+ resolved exactly as before and planned into units
159
+ (``_plan_residual_units``), and those are driven the same
160
+ way. Each unit commits
161
+ independently; the watermark advances per completion across the
162
+ contiguous done-prefix (the prefix-advance rule, so out-of-order
163
+ completions never overstate it); a failing unit returns to a
164
+ claimable state and fails the endpoint after in-flight siblings
165
+ finish. An invocation that drove nothing is ``CaughtUp``
166
+ -- the resume point had reached the trailing edge, or every planned
167
+ unit was already complete. After the last unit commits, the merged
168
+ outcome projects into ``metadata.json`` (post-commit, best-effort);
169
+ a ``CaughtUp`` invocation writes nothing.
170
+
171
+ Args:
172
+ definition: The watermark endpoint to run.
173
+ driver: The request driver supplying each unit's batches.
174
+ mode: The endpoint's watermark mode (lookback and cutoff).
175
+ observer: The optional per-frame hook, applied within every unit.
176
+
177
+ Returns:
178
+ ``Executed`` aggregated over the driven units, or ``CaughtUp``
179
+ when nothing was driven.
180
+
181
+ Raises:
182
+ ConfigurationError: A stored watermark dated after ``now`` (Guard A), a
183
+ cross-mode feed cursor on this endpoint, or a missing event-time
184
+ column.
185
+ FleetpullError: A fetch, validation, write, guard, or completion failure
186
+ -- the failed unit's run is recorded failed, the unit returns to a
187
+ claimable state, and the error re-raises.
188
+ """
189
+ provider = definition.provider
190
+ name = definition.name
191
+ state = self._spine.state
192
+ # The cursor guards (Guard A, cross-mode) fire before any unit
193
+ # drives; the residual resolution below re-derives this value after
194
+ # the leftover units have advanced the cursor.
195
+ resolve_watermark_start(
196
+ state.cursors.get_cursor(provider, name),
197
+ mode.lookback,
198
+ self._spine.clock.now_utc(),
199
+ provider,
200
+ name,
201
+ )
202
+ state.units.reset_claimed_to_pending(provider, name)
203
+ commit_prefix = partial(self._commit_watermark_prefix, provider, name)
204
+ # Heal a crash that landed between a unit's done-mark and its prefix
205
+ # commit: the prefix read is cheap and the advance is atomic and
206
+ # forward-only, so an up-to-date cursor makes this a no-op.
207
+ commit_prefix()
208
+ crew = UnitCrew(
209
+ queue=state.units,
210
+ provider=provider,
211
+ endpoint=name,
212
+ drive_unit=partial(self._drive_unit, definition, driver, observer),
213
+ commit_prefix=commit_prefix,
214
+ )
215
+ workers = self._spine.sync.backfill_unit_workers
216
+ outcomes = drive_claimable_units(crew, workers=workers)
217
+ residual = self._resolve_residual_window(definition, mode)
218
+ if residual is not None:
219
+ self._plan_residual_units(definition, mode, residual)
220
+ outcomes.extend(drive_claimable_units(crew, workers=workers))
221
+ if not outcomes:
222
+ logger.info('caught up: provider=%s endpoint=%s', provider.value, name)
223
+ return CaughtUp()
224
+ merged = _merge_executed(outcomes)
225
+ # The residual window is the run's resolved window; a run that only
226
+ # re-drove leftover units resolved none, and its projection carries a
227
+ # null window.
228
+ self._spine.projection.project(definition, merged, window=residual)
229
+ return merged
230
+
231
+ def _commit_watermark_prefix(self, provider: Provider, name: str) -> None:
232
+ """Advance the watermark across the contiguous done-prefix.
233
+
234
+ The prefix-advance rule's commit (DESIGN section 5, 2026-07-20):
235
+ read the maximum observation over the endpoint's contiguous
236
+ done-prefix and advance the watermark to it through the store's
237
+ atomic forward-only write. Invoked after every unit completion (and
238
+ once at run start, healing a crash between a done-mark and its
239
+ commit); concurrent invocations are safe -- the guard lives inside
240
+ the store's statement, so a stale prefix read can never write the
241
+ cursor backward.
242
+
243
+ Args:
244
+ provider: The provider whose watermark to commit.
245
+ name: The endpoint whose watermark to commit.
246
+
247
+ Side Effects:
248
+ May advance the endpoint's cursor row.
249
+ """
250
+ state = self._spine.state
251
+ observation = state.units.done_prefix_observation(provider, name)
252
+ if observation is not None:
253
+ state.cursors.advance_watermark_forward(provider, name, observation)
254
+
255
+ def _resolve_residual_window(
256
+ self,
257
+ definition: EndpointDefinition[ResponseModel],
258
+ mode: WatermarkMode,
259
+ ) -> DateWindow | None:
260
+ """Resolve the not-yet-planned residual window, exactly as the resume chain.
261
+
262
+ The same resolution the whole-window arm performed, run after the
263
+ leftover units have driven: the stored watermark less the lookback
264
+ margin (floored to its UTC midnight), else the coverage frontier,
265
+ else the cold-start anchor -- against the cutoff-held trailing edge.
266
+ Both bounds are midnight-aligned by construction, which is what makes
267
+ the result plannable into whole-day units.
268
+
269
+ Args:
270
+ definition: The watermark endpoint being run.
271
+ mode: The endpoint's watermark mode (lookback and cutoff).
272
+
273
+ Returns:
274
+ The residual ``DateWindow``, or ``None`` when the resume point
275
+ has reached the trailing edge (nothing new to plan).
276
+
277
+ Raises:
278
+ ConfigurationError: A stored watermark dated after ``now`` (Guard
279
+ A) or a cross-mode feed cursor (from
280
+ ``resolve_watermark_start``).
281
+ """
282
+ state = self._spine.state
283
+ now = self._spine.clock.now_utc()
284
+ end = resolve_trailing_edge(now, mode.cutoff)
285
+ stored = state.cursors.get_cursor(definition.provider, definition.name)
286
+ watermark_start = resolve_watermark_start(
287
+ stored, mode.lookback, now, definition.provider, definition.name
288
+ )
289
+ frontier = state.recorder.coverage_frontier(
290
+ definition.provider, definition.name
291
+ )
292
+ start = resolve_resume_start(
293
+ watermark_start, frontier, self._spine.sync.default_start_datetime
294
+ )
295
+ return window_or_none(start, end)
296
+
297
+ def _plan_residual_units(
298
+ self,
299
+ definition: EndpointDefinition[ResponseModel],
300
+ mode: WatermarkMode,
301
+ residual: DateWindow,
302
+ ) -> None:
303
+ """Tile the residual window into work units and enqueue them.
304
+
305
+ The residual-planning step of the plan-and-drive loop: release the
306
+ window's committed ``done`` units back to plannable (the
307
+ release-then-enqueue pairing -- the lookback margin deliberately
308
+ re-covers committed days, and day-aligned tiles re-tile onto
309
+ identical unit keys, which the idempotent enqueue would otherwise
310
+ collapse onto and never re-fetch), pick the unit width (the
311
+ endpoint's declared ``fixed_unit_days`` wins over config -- on a
312
+ window-grain rollup surface the unit width is part of the row's
313
+ meaning, so it must never float with ``sync.backfill_chunk_days``;
314
+ config remains the default for endpoints declaring ``None``), tile
315
+ the window into units, enqueue them, and narrate the plan.
316
+
317
+ This is the ONE sanctioned row-deletion site: the claim loop has
318
+ drained (a failing leftover aborts the run before planning), so
319
+ every released row is committed history, and the same call
320
+ re-tiles the released span hole-free -- the prefix rule's
321
+ gap-unreachability re-derivation (DESIGN §5, 2026-07-21).
322
+
323
+ Args:
324
+ definition: The watermark endpoint being run.
325
+ mode: The endpoint's watermark mode (the fixed unit width, when
326
+ declared).
327
+ residual: The resolved residual window to plan.
328
+
329
+ Side Effects:
330
+ Releases the window's done units, enqueues the planned units,
331
+ and emits one INFO line.
332
+ """
333
+ provider = definition.provider
334
+ name = definition.name
335
+ released_units = self._spine.state.units.release_done_units(
336
+ provider, name, window=residual
337
+ )
338
+ chunk_days = (
339
+ self._spine.sync.backfill_chunk_days
340
+ if mode.fixed_unit_days is None
341
+ else mode.fixed_unit_days
342
+ )
343
+ chunk = timedelta(days=chunk_days)
344
+ claimable_units = self._spine.state.units.enqueue(
345
+ plan_backfill_units(provider, name, residual, chunk)
346
+ )
347
+ logger.info(
348
+ 'window planned: provider=%s endpoint=%s window_start=%s '
349
+ 'window_end=%s claimable_units=%d released_units=%d',
350
+ provider.value,
351
+ name,
352
+ to_iso8601(residual.start),
353
+ to_iso8601(residual.end),
354
+ claimable_units,
355
+ released_units,
356
+ )
357
+
358
+ def _drive_unit(
359
+ self,
360
+ definition: EndpointDefinition[ResponseModel],
361
+ driver: RequestDriver,
362
+ observer: BatchObserver | None,
363
+ window: DateWindow,
364
+ ) -> Executed:
365
+ """Drive one unit: fetch its window, write, record.
366
+
367
+ The per-unit transaction the claim loop invokes: build the unit's
368
+ batch stream (the fan-out threads the unit's (member x window) pieces
369
+ on the ``FetchPool``, unchanged) and run it through the commit spine.
370
+ The unit's folded observation rides the returned outcome; the
371
+ watermark advance is the unit loop's prefix commit, never this
372
+ drive's.
373
+
374
+ Args:
375
+ definition: The watermark endpoint being run.
376
+ driver: The request driver supplying the unit's batches.
377
+ observer: The optional per-frame hook.
378
+ window: The unit's half-open window.
379
+
380
+ Returns:
381
+ The unit's ``Executed`` outcome, its ``latest_observed`` carrying
382
+ the folded in-window maximum (or ``None`` for an empty unit).
383
+
384
+ Raises:
385
+ FleetpullError: A fetch, validation, write, or completion failure
386
+ -- the unit's run is recorded failed and the error re-raised.
387
+ """
388
+ client = self._spine.clients.client_for(definition.provider)
389
+ now = self._spine.clock.now_utc()
390
+ context = _window_context(definition, window, now)
391
+ batches = observe_batches(
392
+ stream_processed_batches(
393
+ definition, driver, client, resume=window, context=context
394
+ ),
395
+ observer,
396
+ )
397
+ return self._execute_window(definition, batches, context)
398
+
399
+ def _execute_window(
400
+ self,
401
+ definition: EndpointDefinition[ResponseModel],
402
+ batches: Iterator[ProcessedBatch],
403
+ context: WindowContext,
404
+ ) -> Executed:
405
+ """Run one window: open, consume/write/finalize, complete.
406
+
407
+ The per-unit commit spine. It consumes an already-built
408
+ processed-batch stream (the caller constructs it, wrapping in the
409
+ batch observer where one applies), so the spine is blind to fetch
410
+ mechanics. Opens a window run, writes each batch's in-window frame,
411
+ folds the observed maximum, finalizes, and completes the run. The
412
+ fold rides the returned outcome; the watermark advance belongs to
413
+ the unit loop's prefix commit. The per-unit crash order is parquet
414
+ -> ``complete_run`` (the ledger, here) -> ``mark_done`` (observed)
415
+ -> prefix commit (both one level up): a crash after completion but
416
+ before the done-mark leaves the unit claimable, so the next
417
+ invocation re-drives it whole before resolving any residual --
418
+ unit-gating plus the run-start prefix heal replace the retired
419
+ cursor-before-completion ordering (DESIGN section 14).
420
+
421
+ Args:
422
+ definition: The endpoint being run.
423
+ batches: The unit's processed-batch stream, ready to consume.
424
+ context: The window, run instant, and event-time column for the
425
+ per-batch transform.
426
+
427
+ Returns:
428
+ ``Executed`` with the fetched-row count, the write report, and
429
+ the folded in-window maximum.
430
+
431
+ Raises:
432
+ FleetpullError: A fetch, validation, write, or completion failure -- the
433
+ run is recorded failed and the original error re-raised.
434
+ """
435
+ window = context.window
436
+ recorder = self._spine.state.recorder
437
+ run_id = recorder.start_window_run(
438
+ definition.provider, definition.name, window=window
439
+ )
440
+ with recorded_run(recorder, run_id):
441
+ writer = self._spine.make_writer(definition, window)
442
+ records_fetched, latest_observed = drain_batches(batches, writer)
443
+ write = writer.finalize()
444
+ # The cursor deliberately does NOT advance here: the unit's
445
+ # observation rides the outcome to the unit loop, which records
446
+ # it at mark_done and advances the watermark across the
447
+ # contiguous done-prefix only (the prefix-advance rule, DESIGN
448
+ # section 5) -- a per-unit advance would overstate coverage the
449
+ # moment units complete out of order.
450
+ recorder.complete_run(run_id, row_count=records_fetched)
451
+ return Executed(
452
+ records_fetched=records_fetched,
453
+ write=write,
454
+ latest_observed=latest_observed,
455
+ )
@@ -0,0 +1,13 @@
1
+ # src/fleetpull/paths/__init__.py
2
+ """Filesystem path expansion, normalization, and dataset-layout utilities."""
3
+
4
+ from fleetpull.paths.datasets import endpoint_directory
5
+ from fleetpull.paths.partitions import date_partition_segment
6
+ from fleetpull.paths.resolution import PathInput, resolve_path
7
+
8
+ __all__: list[str] = [
9
+ 'PathInput',
10
+ 'date_partition_segment',
11
+ 'endpoint_directory',
12
+ 'resolve_path',
13
+ ]
@@ -0,0 +1,38 @@
1
+ # src/fleetpull/paths/datasets.py
2
+ """Dataset-layout path construction: locate an endpoint's directory under a
3
+ dataset root.
4
+
5
+ The shared, filesystem-neutral half of storage path-building. The parquet
6
+ writers and the metadata projection's caller (the runner, which resolves the
7
+ directory and hands it to ``storage.write_metadata_json``) locate an
8
+ endpoint's directory through this one function, so the construction lives in
9
+ ``paths`` -- pure and shared. Like the rest of ``paths``, it never touches
10
+ the filesystem; directory creation is the writing layer's concern.
11
+ """
12
+
13
+ from pathlib import Path
14
+
15
+ from fleetpull.paths.resolution import PathInput, resolve_path
16
+
17
+ __all__: list[str] = ['endpoint_directory']
18
+
19
+
20
+ def endpoint_directory(dataset_root: PathInput, provider: str, endpoint: str) -> Path:
21
+ """Build the directory holding one endpoint's output files.
22
+
23
+ The dataset is laid out one directory per ``(provider, endpoint)`` under the
24
+ root (DESIGN §3): ``{root}/{provider}/{endpoint}/``. The provider and endpoint
25
+ are passed as their directory-name strings (e.g. ``definition.provider.value``
26
+ and ``definition.name`` at the call site), so ``paths`` need not import the
27
+ vocabulary or endpoints layers.
28
+
29
+ Args:
30
+ dataset_root: The dataset root path, normalized via ``resolve_path``.
31
+ provider: The provider directory name (e.g. ``'motive'``).
32
+ endpoint: The endpoint directory name (e.g. ``'vehicles'``).
33
+
34
+ Returns:
35
+ The absolute, normalized endpoint directory path. Not created -- the
36
+ writing layer creates it.
37
+ """
38
+ return resolve_path(dataset_root) / provider / endpoint
@@ -0,0 +1,41 @@
1
+ # src/fleetpull/paths/partitions.py
2
+ """Hive date-partition directory-name construction: the ``date=YYYY-MM-DD``
3
+ segment a date-partitioned dataset uses.
4
+
5
+ The shared, filesystem-neutral translation from a partition ``date`` to the
6
+ hive directory-name segment that encodes it, built for the write path (the
7
+ storage layer joins it under an endpoint directory). The segment grammar is
8
+ pinned by a direct test of this function's output; a strict inverse parser
9
+ existed here but was deleted with no production caller -- a future
10
+ directory-reading layer re-derives it from the pinned grammar if one ever
11
+ lands.
12
+
13
+ A pure leaf -- stdlib ``date`` only, imports nothing internal, never touches the
14
+ filesystem (directory creation and scanning are the writing/reading layers'
15
+ concerns). It lives in ``paths`` rather than ``storage`` because the hive
16
+ ``date=`` convention is a shared structural fact about the dataset layout -- the
17
+ storage write path builds it and BigQuery external tables read it natively --
18
+ not parquet-specific arithmetic like the ``part.parquet`` filename, which is
19
+ storage's.
20
+ """
21
+
22
+ from datetime import date
23
+
24
+ __all__: list[str] = ['date_partition_segment']
25
+
26
+ _PARTITION_PREFIX: str = 'date='
27
+
28
+
29
+ def date_partition_segment(partition_date: date) -> str:
30
+ """Build the hive partition directory-name segment for a date.
31
+
32
+ Args:
33
+ partition_date: The partition's calendar date.
34
+
35
+ Returns:
36
+ The hive segment ``'date=YYYY-MM-DD'`` (e.g. ``'date=2026-06-01'``).
37
+
38
+ Side Effects:
39
+ None -- pure function.
40
+ """
41
+ return f'{_PARTITION_PREFIX}{partition_date.isoformat()}'
@@ -0,0 +1,98 @@
1
+ # src/fleetpull/paths/resolution.py
2
+ """Path expansion and lexical absolute-path normalization.
3
+
4
+ The resolution leaf of the ``paths`` package — a dependency-free utility used by
5
+ config, state, storage, and anywhere else that needs to turn a user-provided path
6
+ into an absolute, normalized ``Path``. It performs only lexical resolution —
7
+ expanding ``~`` and anchoring relative paths to the current working directory —
8
+ and never touches the filesystem: no existence check, no directory creation, no
9
+ symlink dereference. Domain meaning (is this a file or a directory? must it exist?
10
+ is it on local disk?) belongs to the layer that knows what the path is for.
11
+ """
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ __all__: list[str] = ['PathInput', 'resolve_path']
17
+
18
+ type PathInput = str | Path
19
+
20
+
21
+ def resolve_path(value: PathInput) -> Path:
22
+ """
23
+ Expand and lexically normalize a user-provided filesystem path.
24
+
25
+ Resolution order:
26
+ 1. Reject unsupported types and empty or whitespace-only strings.
27
+ 2. Expand ``~`` and ``~user`` to the home directory via
28
+ :meth:`Path.expanduser`.
29
+ 3. Anchor a still-relative path to the current working directory.
30
+ 4. Lexically normalize — collapse ``.``, ``..``, and redundant
31
+ separators — via :func:`os.path.normpath`, without touching the
32
+ filesystem.
33
+
34
+ Purely lexical: no filesystem access, no symlink dereference, no existence
35
+ check, and no directory creation. An intentional symlink in the path is
36
+ preserved rather than canonicalized, and a path to something that does not
37
+ exist resolves fine — the caller decides what existence and on-disk
38
+ suitability mean.
39
+
40
+ Args:
41
+ value: User-provided path. ``str`` and :class:`Path` are accepted; empty
42
+ or whitespace-only strings are rejected rather than silently resolving
43
+ to the current directory.
44
+
45
+ Returns:
46
+ An absolute, expanded, lexically normalized path.
47
+
48
+ Raises:
49
+ TypeError: ``value`` is not a ``str`` or :class:`Path`.
50
+ ValueError: ``value`` is empty or whitespace-only, or ``~`` cannot be
51
+ expanded (no resolvable home directory).
52
+
53
+ Side Effects:
54
+ Reads the current user's home directory and the current working
55
+ directory. Does not touch the target path on the filesystem.
56
+ """
57
+ raw_path: str = _coerce_nonempty_str(value)
58
+
59
+ try:
60
+ expanded_path: Path = Path(raw_path).expanduser()
61
+ except RuntimeError as exc:
62
+ raise ValueError(f'path {raw_path!r} could not expand user home') from exc
63
+
64
+ anchored_path: Path
65
+ if expanded_path.is_absolute():
66
+ anchored_path = expanded_path
67
+ else:
68
+ anchored_path = Path.cwd() / expanded_path
69
+
70
+ normalized_path: str = os.path.normpath(anchored_path)
71
+ return Path(normalized_path)
72
+
73
+
74
+ def _coerce_nonempty_str(value: PathInput) -> str:
75
+ """
76
+ Convert a supported path value to a non-empty string.
77
+
78
+ Args:
79
+ value: Path ``str`` or :class:`Path` value.
80
+
81
+ Returns:
82
+ Non-empty path string.
83
+
84
+ Raises:
85
+ TypeError: ``value`` is not a ``str`` or :class:`Path`.
86
+ ValueError: ``value`` is an empty or whitespace-only string.
87
+
88
+ Side Effects:
89
+ None.
90
+ """
91
+ if not isinstance(value, str | Path):
92
+ raise TypeError(f'path value must be str or Path, got {type(value).__name__}')
93
+
94
+ raw_path: str = str(value)
95
+ if raw_path.strip() == '':
96
+ raise ValueError('path value must not be empty')
97
+
98
+ return raw_path
@@ -0,0 +1,20 @@
1
+ # src/fleetpull/polars_typing/__init__.py
2
+ """The single sanctioned boundary for Polars type aliases that lack a public
3
+ equivalent.
4
+
5
+ Polars exposes some annotation types only under the private ``polars._typing``
6
+ module (``ParquetCompression`` and others to come). A strict-typing project needs
7
+ the exact types -- a hand-mirrored ``Literal`` can silently drift from what Polars
8
+ actually accepts -- but importing a private module across the codebase scatters
9
+ the risk. Quarantining those imports here makes a Polars relocation a one-file
10
+ fix, the same blast-radius isolation the package applies elsewhere; the locked
11
+ Polars version keeps any such upgrade deliberate. Add aliases here as needed.
12
+
13
+ A one-file subpackage (not a root module) so an internal compat shim stays out of
14
+ the user-facing package root, and so importers reach it through a package face
15
+ rather than an underscore-prefixed module name (CLAUDE.md).
16
+ """
17
+
18
+ from polars._typing import ParquetCompression
19
+
20
+ __all__: list[str] = ['ParquetCompression']
fleetpull/py.typed ADDED
File without changes