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,192 @@
1
+ # src/fleetpull/timing/clock.py
2
+ """Time abstraction for fleetpull.
3
+
4
+ Provides an injectable clock interface to avoid scattered
5
+ ``datetime.now()`` calls and enable deterministic time in tests.
6
+ Anything in fleetpull that needs the current time — rate-limiter token
7
+ refill, watermark computation, fetch-window resolution, run-ledger
8
+ timestamps — takes a clock rather than calling the standard library
9
+ directly. ``Clock`` is the Protocol defining the time-provider
10
+ interface; ``SystemClock`` is the production implementation backed by
11
+ system time; ``FrozenClock`` is the test implementation fixed at a
12
+ specific moment.
13
+ """
14
+
15
+ import time
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, date, datetime, timedelta
18
+ from typing import Protocol, runtime_checkable
19
+
20
+ from fleetpull.timing.canon import require_utc
21
+
22
+ __all__: list[str] = [
23
+ 'Clock',
24
+ 'FrozenClock',
25
+ 'SystemClock',
26
+ ]
27
+
28
+
29
+ @runtime_checkable
30
+ class Clock(Protocol):
31
+ """
32
+ Interface for time providers in fleetpull.
33
+
34
+ Centralizes time access (no scattered ``datetime.now()`` calls),
35
+ enables deterministic time in tests, and enforces timezone-aware
36
+ UTC timestamps internally. All implementations must return
37
+ timezone-aware UTC datetimes.
38
+ """
39
+
40
+ def now_utc(self) -> datetime:
41
+ """
42
+ Return the current time as a timezone-aware UTC datetime.
43
+
44
+ Returns:
45
+ Timezone-aware datetime in UTC.
46
+ """
47
+ ...
48
+
49
+ def today_utc(self) -> date:
50
+ """
51
+ Return today's date in UTC.
52
+
53
+ Returns:
54
+ Current UTC date.
55
+ """
56
+ ...
57
+
58
+ def monotonic_seconds(self) -> float:
59
+ """
60
+ Return a monotonic timestamp for duration measurement.
61
+
62
+ Monotonic clocks are unaffected by NTP adjustments or DST changes,
63
+ making them suitable for measuring elapsed time.
64
+
65
+ Returns:
66
+ Monotonic time in seconds.
67
+ """
68
+ ...
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class SystemClock:
73
+ """
74
+ Production clock backed by system wall-clock and monotonic timer.
75
+
76
+ Returns timezone-aware UTC datetimes. This is the default clock
77
+ for production use.
78
+ """
79
+
80
+ def now_utc(self) -> datetime:
81
+ """Return current UTC time."""
82
+ return datetime.now(tz=UTC)
83
+
84
+ def today_utc(self) -> date:
85
+ """Return current UTC date."""
86
+ return self.now_utc().date()
87
+
88
+ def monotonic_seconds(self) -> float:
89
+ """
90
+ Return monotonic time from ``time.perf_counter()``.
91
+
92
+ The invariant is monotonic, never wall clock.
93
+ ``time.perf_counter()`` is the deliberate choice over
94
+ ``time.monotonic()``: on Windows under CPython 3.12,
95
+ ``monotonic()`` is GetTickCount64 with roughly 15.6 ms
96
+ resolution, while ``perf_counter()`` is
97
+ QueryPerformanceCounter — and the test convention of real
98
+ threads with tiny waits needs the finer clock on exactly that
99
+ platform.
100
+ """
101
+ return time.perf_counter()
102
+
103
+
104
+ class FrozenClock:
105
+ """
106
+ Deterministic clock for tests and reproducible runs.
107
+
108
+ Starts at a fixed UTC datetime and only advances when explicitly
109
+ mutated via ``advance()`` or ``set_time()``. Not thread-safe: keep
110
+ usage test-scoped or wrap externally.
111
+
112
+ Example:
113
+ >>> clock = FrozenClock(start_time_utc=datetime(2026, 1, 23, 12, tzinfo=UTC))
114
+ >>> clock.now_utc()
115
+ datetime.datetime(2026, 1, 23, 12, 0, tzinfo=datetime.timezone.utc)
116
+ >>> clock.advance(timedelta(hours=1))
117
+ >>> clock.now_utc().hour
118
+ 13
119
+ """
120
+
121
+ __slots__ = ('_current_monotonic_seconds', '_current_time_utc')
122
+
123
+ def __init__(
124
+ self,
125
+ *,
126
+ start_time_utc: datetime,
127
+ start_monotonic_seconds: float = 0.0,
128
+ ) -> None:
129
+ """
130
+ Initialize a frozen clock.
131
+
132
+ Args:
133
+ start_time_utc: Initial time (must be timezone-aware UTC).
134
+ start_monotonic_seconds: Initial monotonic value (non-negative).
135
+
136
+ Raises:
137
+ ValueError: If start_time_utc is naive or not UTC (the
138
+ ``require_utc`` canonical-UTC guard).
139
+ ValueError: If start_monotonic_seconds is negative.
140
+ """
141
+ if start_monotonic_seconds < 0.0:
142
+ raise ValueError('start_monotonic_seconds must be non-negative')
143
+
144
+ self._current_time_utc: datetime = require_utc(start_time_utc)
145
+ self._current_monotonic_seconds: float = start_monotonic_seconds
146
+
147
+ def now_utc(self) -> datetime:
148
+ """Return the frozen UTC time."""
149
+ return self._current_time_utc
150
+
151
+ def today_utc(self) -> date:
152
+ """Return the frozen UTC date."""
153
+ return self._current_time_utc.date()
154
+
155
+ def monotonic_seconds(self) -> float:
156
+ """Return the frozen monotonic value."""
157
+ return self._current_monotonic_seconds
158
+
159
+ def advance(self, delta: timedelta) -> None:
160
+ """
161
+ Advance the clock by a duration.
162
+
163
+ Advances wall-clock and monotonic time together, keeping them
164
+ correlated the way real time behaves.
165
+
166
+ Args:
167
+ delta: Time to advance (must be non-negative).
168
+
169
+ Raises:
170
+ ValueError: If delta is negative.
171
+ """
172
+ if delta.total_seconds() < 0:
173
+ raise ValueError('delta must be non-negative')
174
+
175
+ self._current_time_utc += delta
176
+ self._current_monotonic_seconds += delta.total_seconds()
177
+
178
+ def set_time(self, new_time_utc: datetime) -> None:
179
+ """
180
+ Set the clock to a specific UTC time.
181
+
182
+ Does not adjust the monotonic counter — use ``advance()`` for
183
+ correlated wall/monotonic changes.
184
+
185
+ Args:
186
+ new_time_utc: New time (must be timezone-aware UTC).
187
+
188
+ Raises:
189
+ ValueError: If new_time_utc is naive or not UTC (the
190
+ ``require_utc`` canonical-UTC guard).
191
+ """
192
+ self._current_time_utc = require_utc(new_time_utc)
@@ -0,0 +1,122 @@
1
+ # src/fleetpull/timing/codec.py
2
+ """Pure conversions between UTC datetimes and their wire/storage string forms.
3
+
4
+ A leaf: stdlib plus the sibling ``canon`` module, nothing internal beyond
5
+ this package. Converts our own outbound and persisted values — request
6
+ parameters and DateWatermark state — between ``datetime`` and string. It
7
+ never parses a provider response body; response timestamps are cast
8
+ vectorized in the records layer.
9
+
10
+ UTC discipline, enforced both directions (the canonical-UTC doctrine,
11
+ ``canon.py``):
12
+ - Encoders reject a naive or non-UTC datetime via ``require_utc`` — a
13
+ non-UTC value reaching here missed an ingress and is a bug, failed
14
+ loud.
15
+ - ``from_iso8601`` normalizes any offset to UTC and rejects a naive
16
+ result — an ISO string with no offset is ambiguous, never assumed UTC.
17
+ It is the string-ingress twin of ``canon.ensure_utc``.
18
+
19
+ Because every internal timestamp is UTC end to end, DST and ambiguous-local
20
+ bugs are structurally impossible.
21
+
22
+ Bad input raises stdlib ``ValueError`` — a bad value is a caller bug or
23
+ malformed input, never a ``FleetpullError``. The consuming boundary
24
+ translates when it wants a typed failure (the config loader maps a user's
25
+ bad date to ``ConfigurationError``). Keeping the raise stdlib is what lets
26
+ ``timing`` import nothing internal.
27
+
28
+ The epoch encoders (``to_unix_seconds`` / ``to_unix_millis``) are
29
+ deliberately absent still: the first epoch-consuming endpoint (Samsara
30
+ trips, epoch milliseconds, captured 2026-07-20) keeps its conversion at
31
+ the leaf as a single-consumer fact (exact integer arithmetic in
32
+ ``endpoints/samsara/trips.py``); the codec takes the function when a
33
+ second epoch consumer lands (the rule of two).
34
+ """
35
+
36
+ from datetime import UTC, datetime
37
+
38
+ from fleetpull.timing.canon import require_utc
39
+
40
+ __all__: list[str] = [
41
+ 'from_iso8601',
42
+ 'to_iso8601',
43
+ 'to_utc_date_string',
44
+ ]
45
+
46
+
47
+ def to_iso8601(moment: datetime) -> str:
48
+ """
49
+ Render a UTC datetime as a seconds-precision ISO-8601 'Z' string.
50
+
51
+ The form GeoTab's ``fromDate`` and DateWatermark persistence use, e.g.
52
+ ``'2026-06-01T00:00:00Z'``. Sub-second precision is dropped: window
53
+ parameters are second-granular and persisted watermarks read cleaner
54
+ without it.
55
+
56
+ Args:
57
+ moment: A timezone-aware UTC datetime.
58
+
59
+ Returns:
60
+ The ISO-8601 string with a trailing ``Z``.
61
+
62
+ Raises:
63
+ ValueError: If ``moment`` is naive or not UTC.
64
+ """
65
+ validated_moment: datetime = require_utc(moment)
66
+ # isoformat emits '+00:00' for a UTC datetime; swap for the 'Z' form
67
+ # providers expect. The offset is the only '+00:00' a validated-UTC ISO
68
+ # string can contain, so the suffix strip is unambiguous.
69
+ iso_with_offset: str = validated_moment.isoformat(timespec='seconds')
70
+ return iso_with_offset.removesuffix('+00:00') + 'Z'
71
+
72
+
73
+ def to_utc_date_string(moment: datetime) -> str:
74
+ """
75
+ Render the UTC calendar date of a datetime as ``'YYYY-MM-DD'``.
76
+
77
+ Used for hive partition keys (``date=YYYY-MM-DD``) and date-only request
78
+ parameters. The date is the UTC date, since ``moment`` is UTC.
79
+
80
+ Args:
81
+ moment: A timezone-aware UTC datetime.
82
+
83
+ Returns:
84
+ The UTC date in ISO ``'YYYY-MM-DD'`` form.
85
+
86
+ Raises:
87
+ ValueError: If ``moment`` is naive or not UTC.
88
+ """
89
+ validated_moment: datetime = require_utc(moment)
90
+ return validated_moment.date().isoformat()
91
+
92
+
93
+ def from_iso8601(text: str) -> datetime:
94
+ """
95
+ Parse an ISO-8601 datetime string into a timezone-aware UTC datetime.
96
+
97
+ Accepts any offset form ``datetime.fromisoformat`` handles on 3.12 (``Z``,
98
+ ``+HH:MM``, fractional seconds) and normalizes it to UTC. A string with no
99
+ offset — including a date-only string — is ambiguous and rejected; this
100
+ module never assumes UTC for an unzoned value.
101
+
102
+ Consumers: reading a persisted DateWatermark back, and parsing a
103
+ user-supplied date from YAML config. The config path is untrusted, so a
104
+ malformed value raises here and the config loader translates it.
105
+
106
+ Args:
107
+ text: An ISO-8601 datetime string carrying a UTC offset.
108
+
109
+ Returns:
110
+ The instant as a timezone-aware UTC datetime (tzinfo is
111
+ ``datetime.UTC``).
112
+
113
+ Raises:
114
+ ValueError: If ``text`` is not parseable ISO-8601, or carries no
115
+ offset (naive).
116
+ """
117
+ parsed_moment: datetime = datetime.fromisoformat(text)
118
+ if parsed_moment.tzinfo is None:
119
+ raise ValueError(
120
+ f'ISO-8601 datetime must carry a UTC offset; got naive {text!r}'
121
+ )
122
+ return parsed_moment.astimezone(UTC)
@@ -0,0 +1,65 @@
1
+ # src/fleetpull/timing/sleeper.py
2
+ """Injectable sleep seam for backoff waiting.
3
+
4
+ A one-method seam kept separate from ``Clock``: ``Clock`` reads time
5
+ (``now_utc`` / ``today_utc`` / ``monotonic_seconds``) and never consumes it.
6
+ Code that must pause — the transport client's TRANSIENT backoff — takes a
7
+ ``Sleeper`` so tests substitute a recording double and never wait real time,
8
+ exactly as ``Clock`` is injected to make time deterministic.
9
+ """
10
+
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ __all__: list[str] = [
16
+ 'Sleeper',
17
+ 'SystemSleeper',
18
+ ]
19
+
20
+
21
+ @runtime_checkable
22
+ class Sleeper(Protocol):
23
+ """
24
+ Interface for sleep providers.
25
+
26
+ One capability — pause the calling thread for a duration — so the seam is
27
+ a single method (the ``Clock`` / ``RandomFractionGenerator`` precedent).
28
+ Tests inject a recording double; production injects ``SystemSleeper``.
29
+ """
30
+
31
+ def sleep(self, seconds: float) -> None:
32
+ """
33
+ Block the calling thread for ``seconds``.
34
+
35
+ Args:
36
+ seconds: Non-negative duration to sleep. Behavior on a negative
37
+ value is the implementation's (``SystemSleeper`` defers to
38
+ ``time.sleep``, which raises ``ValueError``).
39
+ """
40
+ ...
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class SystemSleeper:
45
+ """
46
+ Production sleeper backed by ``time.sleep``.
47
+
48
+ Frozen and stateless, so one shared instance is safe to inject anywhere
49
+ (the ``SystemClock`` precedent).
50
+ """
51
+
52
+ def sleep(self, seconds: float) -> None:
53
+ """
54
+ Sleep via ``time.sleep``.
55
+
56
+ Args:
57
+ seconds: Non-negative duration to sleep.
58
+
59
+ Side Effects:
60
+ Blocks the calling thread for ``seconds``.
61
+
62
+ Raises:
63
+ ValueError: If ``seconds`` is negative (raised by ``time.sleep``).
64
+ """
65
+ time.sleep(seconds)
@@ -0,0 +1,16 @@
1
+ # src/fleetpull/vocabulary/__init__.py
2
+ """Shared, dependency-free package vocabulary."""
3
+
4
+ from fleetpull.vocabulary.json_types import JsonObject, JsonScalar, JsonValue
5
+ from fleetpull.vocabulary.provider import Provider
6
+ from fleetpull.vocabulary.quota_scope import QuotaScope
7
+ from fleetpull.vocabulary.response_category import ResponseCategory
8
+
9
+ __all__: list[str] = [
10
+ 'JsonObject',
11
+ 'JsonScalar',
12
+ 'JsonValue',
13
+ 'Provider',
14
+ 'QuotaScope',
15
+ 'ResponseCategory',
16
+ ]
@@ -0,0 +1,20 @@
1
+ # src/fleetpull/vocabulary/json_types.py
2
+ """JSON type vocabulary: the recursive shape of a JSON document.
3
+
4
+ The package-wide aliases for JSON data -- spoken by the network contract
5
+ (request bodies, response envelopes, page decoding), the records layer (raw
6
+ record dicts entering validation), and the orchestrator (batch plumbing).
7
+ Homed in ``vocabulary`` because the concept is generic JSON, not a network
8
+ contract: three layers speak it, so it lives in the dependency-free leaf
9
+ they all sit above, not in the transport package one of them happens to be.
10
+ """
11
+
12
+ __all__: list[str] = ['JsonObject', 'JsonScalar', 'JsonValue']
13
+
14
+ # The actual type of a JSON document, recursively. Used for JSON-RPC
15
+ # bodies and response envelopes; available to every layer.
16
+ type JsonScalar = str | int | float | bool | None
17
+ type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
18
+ # A single JSON object -- the shape one extracted record takes; the endpoints
19
+ # layer types the record extractor's output as ``list[JsonObject]``.
20
+ type JsonObject = dict[str, JsonValue]
@@ -0,0 +1,29 @@
1
+ # src/fleetpull/vocabulary/provider.py
2
+ """
3
+ The closed set of telematics providers fleetpull extracts from.
4
+
5
+ Shared, dependency-free package vocabulary, sibling to ``ResponseCategory``:
6
+ an endpoint declares the provider it belongs to, and the composition root
7
+ keys per-provider auth and response classification on it. Homed in the leaf
8
+ that imports nothing internal so every layer can name a provider without
9
+ forming a cycle.
10
+ """
11
+
12
+ from enum import StrEnum
13
+
14
+ __all__: list[str] = ['Provider']
15
+
16
+
17
+ class Provider(StrEnum):
18
+ """
19
+ Closed set of telematics providers.
20
+
21
+ String values are the lowercase provider keys used in logging and
22
+ quota-scope naming. A provider earns a member only when fleetpull
23
+ implements its auth and response classification — which all three
24
+ already have.
25
+ """
26
+
27
+ GEOTAB = 'geotab'
28
+ MOTIVE = 'motive'
29
+ SAMSARA = 'samsara'
@@ -0,0 +1,47 @@
1
+ # src/fleetpull/vocabulary/quota_scope.py
2
+ """
3
+ The closed set of quota scopes an endpoint definition declares.
4
+
5
+ Shared, dependency-free package vocabulary, sibling to ``Provider`` and
6
+ ``ResponseCategory`` in the leaf. A ``QuotaScope`` is which token bucket an
7
+ endpoint spends from — the type of ``endpoint.quota_scope`` — not which
8
+ provider it belongs to. The two are separate vocabularies on purpose — folding
9
+ both into one type would be exactly the conflation this avoids — and they
10
+ diverged when GeoTab metered its endpoints apart from the rest (the
11
+ method-class scopes below; the §13 Samsara ``vehicle_locations`` case would
12
+ add another ``QuotaScope`` member while the ``Provider`` stays ``SAMSARA``).
13
+
14
+ GeoTab meters per method class, not per provider (§8, captured 2026-07-09),
15
+ so its scopes are method-class members: ``GEOTAB_GET`` is the Get-class data
16
+ scope endpoint definitions declare, ``GEOTAB_FEED`` is the GetFeed-class
17
+ scope the feed endpoints declare (its own ~60/min budget, proven by the
18
+ 2026-07-21 header-decrement probe — distinct from the ~650/min Get class),
19
+ and ``GEOTAB_AUTHENTICATE`` is the dedicated Authenticate scope —
20
+ auth-internal, never an endpoint declaration; the composition root passes it
21
+ to the authenticator factory by name.
22
+ """
23
+
24
+ from enum import StrEnum
25
+
26
+ __all__: list[str] = ['QuotaScope']
27
+
28
+
29
+ class QuotaScope(StrEnum):
30
+ """
31
+ Closed set of quota scopes (which token bucket an endpoint spends from).
32
+
33
+ String values are the lowercase scope keys the ``RateLimiterRegistry`` is
34
+ already keyed on (matching the provider-derived scope strings in use today),
35
+ so a ``QuotaScope`` member passes to ``QuotaScopeLimiter(quota_scope: str)``
36
+ transparently — it is a ``str``.
37
+
38
+ Membership vs. limits: scope *membership* is this closed architectural set
39
+ (code); scope *limits* are config (§7). Adding a scope is therefore a code
40
+ change (a new member) plus a config change (its limits) — not config-only.
41
+ """
42
+
43
+ GEOTAB_AUTHENTICATE = 'geotab_authenticate'
44
+ GEOTAB_FEED = 'geotab_feed'
45
+ GEOTAB_GET = 'geotab_get'
46
+ MOTIVE = 'motive'
47
+ SAMSARA = 'samsara'
@@ -0,0 +1,32 @@
1
+ # src/fleetpull/vocabulary/response_category.py
2
+ """
3
+ The closed response-classification vocabulary.
4
+
5
+ Shared, dependency-free package vocabulary: the classification layer
6
+ produces it, the client dispatches on it, the retry layer takes it as
7
+ input, and ``RetriesExhaustedError`` carries it on a public field.
8
+ Homed in a leaf that imports nothing internal so every layer — root
9
+ exceptions included — can depend on it without forming a cycle.
10
+ """
11
+
12
+ from enum import StrEnum
13
+
14
+ __all__: list[str] = ['ResponseCategory']
15
+
16
+
17
+ class ResponseCategory(StrEnum):
18
+ """
19
+ Closed vocabulary of "what the client does next."
20
+
21
+ Each member earns its slot by demanding a distinct client action.
22
+ Closure invariant (DESIGN.md §8): a new category is admissible only
23
+ if it arrives with a new client action.
24
+ """
25
+
26
+ SUCCESS = 'success' # parse and yield records
27
+ TRANSIENT = 'transient' # retry with backoff
28
+ RATE_LIMITED = 'rate_limited' # penalize the shared quota scope, then retry
29
+ AUTH_FAILURE = (
30
+ 'auth_failure' # ask the auth strategy whether one retry is worthwhile
31
+ )
32
+ FATAL = 'fatal' # raise