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,223 @@
1
+ # src/fleetpull/network/auth/manager.py
2
+ """GeoTab session lifecycle manager.
3
+
4
+ GeoTab authenticates by session: an ``Authenticate`` call returns a
5
+ session id and a resolved host; the session lives ~14 days but can die
6
+ early (password change; a 100-concurrent-session LRU cap per account).
7
+ This module is pure state and thread choreography — it knows nothing
8
+ about HTTP. The actual ``Authenticate`` call is injected as
9
+ ``authenticate_fn``; in production that implementation is an HTTP
10
+ attempt and passes through the rate limiter like any other
11
+ (token-per-attempt has no exceptions).
12
+
13
+ Sessions are never persisted to disk — a deliberate non-goal. A session
14
+ id is a bearer-equivalent secret, and at one process per scheduled run,
15
+ in-memory sessions stay far below GeoTab's 100-session cap.
16
+ """
17
+
18
+ import logging
19
+ import threading
20
+ from collections.abc import Callable
21
+ from datetime import datetime, timedelta
22
+ from typing import Final
23
+
24
+ from fleetpull.config import GeotabAuthConfig
25
+ from fleetpull.network.auth.models import AuthenticationResult, GeotabSession
26
+ from fleetpull.timing import Clock, SystemClock
27
+
28
+ __all__: list[str] = ['GeotabSessionManager']
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Documented GeoTab policy, not a server-returned contract — there is no
33
+ # expires_in in the Authenticate response, and the docs reserve the
34
+ # right to change the lifetime.
35
+ _SESSION_LIFETIME: Final[timedelta] = timedelta(days=14)
36
+
37
+ # Proactive refresh threshold. Generous (a day, not seconds) because the
38
+ # lifetime is assumed, not negotiated: refreshing early costs one
39
+ # Authenticate call; trusting an assumed lifetime to the minute risks a
40
+ # mid-run failure.
41
+ _REFRESH_MARGIN: Final[timedelta] = timedelta(days=1)
42
+
43
+
44
+ class GeotabSessionManager:
45
+ """
46
+ Single-flight GeoTab session cache: one session per process, shared
47
+ by all threads.
48
+
49
+ Reactive invalidation (a caller reports a server-rejected session
50
+ via :meth:`invalidate`) is the primary mechanism; proactive refresh
51
+ near the assumed lifetime is insurance. A generation counter guards
52
+ against invalidation stampedes: ten workers hitting expiry
53
+ simultaneously produce one ``Authenticate`` call, not ten.
54
+
55
+ The lock is deliberately held across the ``authenticate_fn`` call.
56
+ That is the single-flight design: concurrent callers needing a
57
+ refresh block on the lock and receive the just-refreshed session
58
+ when they acquire it. This is the opposite of the SQLite
59
+ never-hold-a-transaction-across-HTTP rule, and the difference is
60
+ the point — do not "fix" it.
61
+
62
+ This class never logs ``session_id`` and never touches
63
+ ``config.password``; only ``authenticate_fn`` reads the secret.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ config: GeotabAuthConfig,
69
+ authenticate_fn: Callable[[GeotabAuthConfig], AuthenticationResult],
70
+ clock: Clock = SystemClock(), # noqa: B008 — SystemClock is frozen and stateless; one shared default instance is intentional
71
+ ) -> None:
72
+ """
73
+ Initialize the manager with no cached session.
74
+
75
+ Args:
76
+ config: Validated GeoTab authentication configuration.
77
+ authenticate_fn: Performs the actual ``Authenticate`` call
78
+ and resolves the returned host. Injected so the manager
79
+ stays pure state and choreography (the real
80
+ implementation in ``network/auth/authenticate.py`` is the
81
+ one place httpx is imported); in tests it is a stub.
82
+ clock: Time source; injected so tests can be deterministic.
83
+ """
84
+ self._config: GeotabAuthConfig = config
85
+ self._authenticate_fn: Callable[[GeotabAuthConfig], AuthenticationResult] = (
86
+ authenticate_fn
87
+ )
88
+ self._clock: Clock = clock
89
+ self._lock: threading.Lock = threading.Lock()
90
+ self._current_session: GeotabSession | None = None
91
+ self._generation_counter: int = 0
92
+
93
+ def get_session(self) -> GeotabSession:
94
+ """
95
+ Return a usable session, authenticating or refreshing as needed.
96
+
97
+ Returns:
98
+ The cached session, or a freshly authenticated one when no
99
+ session exists yet or the cached session has crossed the
100
+ proactive refresh threshold.
101
+
102
+ Raises:
103
+ AuthenticationError: Bad credentials (from the real
104
+ ``authenticate_fn``).
105
+ ProviderResponseError: A malformed or non-200 Authenticate
106
+ response.
107
+ UnknownQuotaScopeError: The Authenticate quota scope is
108
+ unconfigured.
109
+ httpx.TransportError: A transport failure during
110
+ authentication. Cached state is untouched on any raise.
111
+
112
+ Side Effects:
113
+ May call ``authenticate_fn`` (blocking other threads on the
114
+ internal lock for the duration) and replace the cached
115
+ session.
116
+ """
117
+ with self._lock:
118
+ if self._current_session is None:
119
+ logger.debug('No GeoTab session cached; initial authentication.')
120
+ return self._refresh_holding_lock()
121
+
122
+ now_utc: datetime = self._clock.now_utc()
123
+ refresh_due_at: datetime = self._current_session.acquired_at_utc + (
124
+ _SESSION_LIFETIME - _REFRESH_MARGIN
125
+ )
126
+ # Inclusive >= so the boundary instant refreshes rather than
127
+ # living in an untestable gap.
128
+ if now_utc >= refresh_due_at:
129
+ session_age: timedelta = now_utc - self._current_session.acquired_at_utc
130
+ logger.debug(
131
+ 'GeoTab session past proactive refresh threshold '
132
+ '(age %s); refreshing.',
133
+ session_age,
134
+ )
135
+ return self._refresh_holding_lock()
136
+
137
+ logger.debug(
138
+ 'GeoTab session cache hit (generation %d).',
139
+ self._current_session.generation,
140
+ )
141
+ return self._current_session
142
+
143
+ def invalidate(self, stale_session: GeotabSession) -> GeotabSession:
144
+ """
145
+ Report a server-rejected session and receive a replacement.
146
+
147
+ Called by a caller whose request failed with a session-invalid
148
+ error. If another thread already refreshed since the caller read
149
+ its session (the reported generation is older than the current
150
+ one), the current session is returned without authenticating —
151
+ the single-flight stampede guard.
152
+
153
+ Args:
154
+ stale_session: The session the server rejected.
155
+
156
+ Returns:
157
+ The current session: freshly authenticated, or the newer
158
+ one another thread already obtained.
159
+
160
+ Raises:
161
+ AuthenticationError: Bad credentials (from the real
162
+ ``authenticate_fn``).
163
+ ProviderResponseError: A malformed or non-200 Authenticate
164
+ response.
165
+ UnknownQuotaScopeError: The Authenticate quota scope is
166
+ unconfigured.
167
+ httpx.TransportError: A transport failure during
168
+ authentication. Cached state is untouched on any raise.
169
+
170
+ Side Effects:
171
+ May call ``authenticate_fn`` and replace the cached
172
+ session. Logs a WARNING when the current session was
173
+ genuinely rejected (it died before its assumed lifetime).
174
+ """
175
+ with self._lock:
176
+ current_session: GeotabSession | None = self._current_session
177
+ if (
178
+ current_session is not None
179
+ and stale_session.generation < current_session.generation
180
+ ):
181
+ logger.debug(
182
+ 'Stale invalidation for generation %d; current '
183
+ 'generation %d is already newer.',
184
+ stale_session.generation,
185
+ current_session.generation,
186
+ )
187
+ return current_session
188
+
189
+ # The reported session IS current and the server rejected it:
190
+ # it died before its assumed lifetime — password change or
191
+ # LRU eviction, something that happened outside this process.
192
+ logger.warning(
193
+ 'GeoTab session (generation %d) rejected by the server '
194
+ 'before its assumed lifetime; re-authenticating.',
195
+ stale_session.generation,
196
+ )
197
+ return self._refresh_holding_lock()
198
+
199
+ def _refresh_holding_lock(self) -> GeotabSession:
200
+ """
201
+ Authenticate and replace the cached session. Caller holds the lock.
202
+
203
+ ``issued_at`` is read BEFORE calling ``authenticate_fn`` —
204
+ pessimistic timestamping, so network latency counts against the
205
+ session lifetime rather than silently extending it. If
206
+ ``authenticate_fn`` raises, cached state is untouched and the
207
+ exception propagates.
208
+ """
209
+ issued_at: datetime = self._clock.now_utc()
210
+ authentication_result: AuthenticationResult = self._authenticate_fn(
211
+ self._config
212
+ )
213
+ self._generation_counter += 1
214
+ new_session = GeotabSession(
215
+ session_id=authentication_result.session_id,
216
+ resolved_host=authentication_result.resolved_host,
217
+ database=self._config.database,
218
+ username=self._config.username,
219
+ generation=self._generation_counter,
220
+ acquired_at_utc=issued_at,
221
+ )
222
+ self._current_session = new_session
223
+ return new_session
@@ -0,0 +1,57 @@
1
+ # src/fleetpull/network/auth/models.py
2
+ """Runtime session state for GeoTab authentication.
3
+
4
+ Internal runtime state, not user YAML — hence frozen dataclasses rather
5
+ than Pydantic models. Pure data: assembling request credentials from a
6
+ session is the auth strategy's job, so nothing here knows JSON-RPC
7
+ payload shapes.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+
13
+ __all__: list[str] = ['AuthenticationResult', 'GeotabSession']
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class AuthenticationResult:
18
+ """
19
+ What an ``authenticate_fn`` returns to the session manager.
20
+
21
+ GeoTab's ``Authenticate`` returns either a server path or the
22
+ literal ``'ThisServer'``; RESOLVING that to a concrete host is the
23
+ ``authenticate_fn``'s job (it knows which host it called), so by the
24
+ time a result reaches the manager, ``resolved_host`` is always a
25
+ real host.
26
+
27
+ Attributes:
28
+ session_id: The session credential returned by ``Authenticate``.
29
+ resolved_host: The host all subsequent API calls must target.
30
+ """
31
+
32
+ session_id: str
33
+ resolved_host: str
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class GeotabSession:
38
+ """
39
+ The session the manager hands to callers.
40
+
41
+ Attributes:
42
+ session_id: The session credential for request payloads.
43
+ resolved_host: The host all subsequent API calls must target.
44
+ database: GeoTab database name, carried from config.
45
+ username: GeoTab username, carried from config.
46
+ generation: Monotonically increasing per manager instance; the
47
+ single-flight staleness check compares generations.
48
+ acquired_at_utc: When authentication was initiated, from the
49
+ injected clock's ``now_utc()``.
50
+ """
51
+
52
+ session_id: str
53
+ resolved_host: str
54
+ database: str
55
+ username: str
56
+ generation: int
57
+ acquired_at_utc: datetime
@@ -0,0 +1,169 @@
1
+ # src/fleetpull/network/auth/strategies.py
2
+ """The ``AuthStrategy`` implementations, beside the session manager.
3
+
4
+ ``StaticHeaderAuth`` (Motive/Samsara static keys) and
5
+ ``GeotabSessionAuth`` (GeoTab sessions) implement the ``AuthStrategy``
6
+ protocol declared in ``network/contract/auth.py``. They live here, in
7
+ ``network/auth/``, because ``GeotabSessionAuth`` wraps the
8
+ ``GeotabSessionManager`` it sits beside — keeping the implementations
9
+ out of the contract surface is what lets the surface stay free of any
10
+ ``network/auth`` dependency.
11
+
12
+ Provider names appear only at the composition root that constructs
13
+ strategies; the client and everything downstream is provider-blind.
14
+ """
15
+
16
+ import threading
17
+ from collections.abc import Mapping
18
+ from dataclasses import replace
19
+ from urllib.parse import urlsplit, urlunsplit
20
+
21
+ from pydantic import SecretStr
22
+
23
+ from fleetpull.network.auth.manager import GeotabSessionManager
24
+ from fleetpull.network.auth.models import GeotabSession
25
+ from fleetpull.network.contract import RequestSpec
26
+ from fleetpull.vocabulary import JsonValue
27
+
28
+ __all__: list[str] = ['GeotabSessionAuth', 'StaticHeaderAuth']
29
+
30
+
31
+ class StaticHeaderAuth:
32
+ """
33
+ Static API-key auth for Motive and Samsara.
34
+
35
+ The composition root pre-formats the header value (e.g.
36
+ ``'Bearer <token>'`` for Samsara) before wrapping it in
37
+ ``SecretStr``; the secret is extracted only at the moment of use.
38
+ """
39
+
40
+ def __init__(self, header_name: str, header_value: SecretStr) -> None:
41
+ """
42
+ Args:
43
+ header_name: The header carrying the credential.
44
+ header_value: The pre-formatted credential value.
45
+ """
46
+ self._header_name: str = header_name
47
+ self._header_value: SecretStr = header_value
48
+
49
+ def prepare(self, spec: RequestSpec) -> RequestSpec:
50
+ """Return a new spec with the credential header injected."""
51
+ return spec.with_extra_headers(
52
+ {self._header_name: self._header_value.get_secret_value()}
53
+ )
54
+
55
+ def on_auth_failure(self) -> bool:
56
+ """A rejected static key cannot be fixed by retrying."""
57
+ return False
58
+
59
+
60
+ class _ThreadLocalSession(threading.local):
61
+ """Per-thread slot for the last session injected by prepare()."""
62
+
63
+ last_prepared: GeotabSession | None = None
64
+
65
+
66
+ class GeotabSessionAuth:
67
+ """
68
+ Session auth for GeoTab: marries the session manager to the
69
+ ``AuthStrategy`` protocol.
70
+
71
+ The last-prepared session lives in a ``threading.local`` slot
72
+ because the strategy instance is shared across worker threads.
73
+ prepare → send → on_auth_failure always executes within one thread,
74
+ but a plain instance attribute would let thread A's failure
75
+ invalidate the fresher session thread B just prepared with,
76
+ triggering a spurious refresh. The thread-local pins each failure
77
+ to the session that actually failed.
78
+ """
79
+
80
+ def __init__(self, manager: GeotabSessionManager) -> None:
81
+ """
82
+ Args:
83
+ manager: The single-flight session manager for this account.
84
+ """
85
+ self._manager: GeotabSessionManager = manager
86
+ self._thread_local: _ThreadLocalSession = _ThreadLocalSession()
87
+
88
+ def prepare(self, spec: RequestSpec) -> RequestSpec:
89
+ """
90
+ Inject session credentials into the JSON-RPC body and retarget
91
+ the URL to the session's resolved host.
92
+
93
+ Args:
94
+ spec: A GeoTab JSON-RPC request spec; ``json_body`` is
95
+ required.
96
+
97
+ Returns:
98
+ A new spec whose body carries ``params.credentials`` and
99
+ whose URL host is the session's resolved host (scheme,
100
+ path, and query untouched). The input spec and its body
101
+ are never mutated.
102
+
103
+ Raises:
104
+ ValueError: If ``spec.json_body`` is None (every GeoTab
105
+ request is a JSON-RPC POST with a body), or if the
106
+ body's ``params`` exists but is not a mapping — both
107
+ are programming errors in the endpoint layer.
108
+ """
109
+ # Validate before the session fetch: a body-less spec is a
110
+ # programming error and must not trigger a real Authenticate
111
+ # call (which would burn sessions against GeoTab's LRU cap).
112
+ if spec.json_body is None:
113
+ raise ValueError(
114
+ 'GeoTab requests require a JSON-RPC body; '
115
+ 'a body-less spec reaching GeoTab auth is a programming error'
116
+ )
117
+ session: GeotabSession = self._manager.get_session()
118
+
119
+ # The strategy is the sole authority on credentials: an existing
120
+ # 'credentials' key left by a caller is overwritten, never kept.
121
+ credentials: dict[str, JsonValue] = {
122
+ 'database': session.database,
123
+ 'sessionId': session.session_id,
124
+ 'userName': session.username,
125
+ }
126
+ if 'params' not in spec.json_body:
127
+ # Some JSON-RPC methods are parameterless apart from credentials.
128
+ new_params: dict[str, JsonValue] = {'credentials': credentials}
129
+ else:
130
+ existing_params: JsonValue = spec.json_body['params']
131
+ if not isinstance(existing_params, Mapping):
132
+ raise ValueError(
133
+ f"json_body['params'] must be a mapping, "
134
+ f'got {type(existing_params).__name__}'
135
+ )
136
+ new_params = {**existing_params, 'credentials': credentials}
137
+ new_body: dict[str, JsonValue] = {**spec.json_body, 'params': new_params}
138
+
139
+ # Authenticate may have redirected the session to a different
140
+ # server; every subsequent call must target it.
141
+ url_parts = urlsplit(spec.url)
142
+ retargeted_url: str = urlunsplit(
143
+ url_parts._replace(netloc=session.resolved_host)
144
+ )
145
+
146
+ self._thread_local.last_prepared = session
147
+ return replace(spec, url=retargeted_url, json_body=new_body)
148
+
149
+ def on_auth_failure(self) -> bool:
150
+ """
151
+ Invalidate the session this thread last prepared with; one
152
+ retry is worthwhile because fresh credentials are now cached.
153
+
154
+ Returns:
155
+ True, always — the session manager guarantees a usable
156
+ replacement (or raises trying).
157
+
158
+ Raises:
159
+ RuntimeError: If no session was ever prepared on this
160
+ thread — on_auth_failure without a prior prepare is a
161
+ client-logic bug.
162
+ """
163
+ failed_session: GeotabSession | None = self._thread_local.last_prepared
164
+ if failed_session is None:
165
+ raise RuntimeError(
166
+ 'on_auth_failure called before any prepare on this thread'
167
+ )
168
+ self._manager.invalidate(failed_session)
169
+ return True
@@ -0,0 +1,11 @@
1
+ """Per-provider response classifiers."""
2
+
3
+ from fleetpull.network.classifiers.geotab import GeotabResponseClassifier
4
+ from fleetpull.network.classifiers.motive import MotiveResponseClassifier
5
+ from fleetpull.network.classifiers.samsara import SamsaraResponseClassifier
6
+
7
+ __all__: list[str] = [
8
+ 'GeotabResponseClassifier',
9
+ 'MotiveResponseClassifier',
10
+ 'SamsaraResponseClassifier',
11
+ ]
@@ -0,0 +1,145 @@
1
+ # src/fleetpull/network/classifiers/geotab.py
2
+ """GeoTab response classifier (sources: scrubbed provider-behavior
3
+ verification, June 2026).
4
+
5
+ GeoTab is JSON-RPC: every application-level failure arrives inside
6
+ HTTP 200, so this classifier is envelope-driven — a status-code-only
7
+ client cannot see failure. The discriminator is ``error.data.type``
8
+ (observed present in every captured failure); classification never
9
+ reads human-readable message text. ``detail`` carries
10
+ ``error['message']`` for humans, decisions never read it. Branch logic
11
+ deliberately resembles sibling classifiers without sharing code:
12
+ provider classifiers evolve independently (blast-radius over DRY).
13
+ """
14
+
15
+ import json
16
+ from collections.abc import Mapping
17
+
18
+ from fleetpull.network.contract import (
19
+ SERVER_ERROR_FLOOR,
20
+ ClassifiedResponse,
21
+ ResponseClassifier,
22
+ body_snippet,
23
+ retry_after_seconds_from_headers,
24
+ )
25
+ from fleetpull.vocabulary import JsonValue, ResponseCategory
26
+
27
+ __all__: list[str] = ['GeotabResponseClassifier']
28
+
29
+
30
+ def _detail_with_message(prefix: str, error_message: str | None) -> str:
31
+ """Append the envelope's human-readable message when present."""
32
+ if error_message is None:
33
+ return prefix
34
+ return f'{prefix} ({error_message})'
35
+
36
+
37
+ def _classify_error_envelope(
38
+ error_envelope: JsonValue, headers: Mapping[str, str]
39
+ ) -> ClassifiedResponse:
40
+ """Classify a JSON-RPC ``error`` member by ``data.type``."""
41
+ error_message: str | None = None
42
+ error_type: str | None = None
43
+ if isinstance(error_envelope, dict):
44
+ message_value: JsonValue = error_envelope.get('message')
45
+ if isinstance(message_value, str):
46
+ error_message = message_value
47
+ data_value: JsonValue = error_envelope.get('data')
48
+ if isinstance(data_value, dict):
49
+ type_value: JsonValue = data_value.get('type')
50
+ if isinstance(type_value, str):
51
+ error_type = type_value
52
+
53
+ if error_type is None:
54
+ return ClassifiedResponse(
55
+ category=ResponseCategory.FATAL,
56
+ detail=_detail_with_message(
57
+ 'malformed GeoTab error envelope: missing data.type',
58
+ error_message,
59
+ ),
60
+ )
61
+
62
+ match error_type:
63
+ case 'OverLimitException':
64
+ return ClassifiedResponse(
65
+ category=ResponseCategory.RATE_LIMITED,
66
+ retry_after_seconds=retry_after_seconds_from_headers(headers),
67
+ detail=error_message,
68
+ )
69
+ case 'InvalidUserException':
70
+ # Captured June 2026: bad credentials and dead sessions BOTH
71
+ # produce InvalidUserException, distinguished only by message
72
+ # text, which we never match on. Disambiguation is contextual
73
+ # and already structural: on a data call the auth strategy
74
+ # invalidates and retries once; on the Authenticate call
75
+ # itself the failure propagates out of the session manager
76
+ # as fatal.
77
+ return ClassifiedResponse(
78
+ category=ResponseCategory.AUTH_FAILURE,
79
+ detail=error_message,
80
+ )
81
+ case 'DbUnavailableException':
82
+ # Documented GeoTab transient.
83
+ return ClassifiedResponse(
84
+ category=ResponseCategory.TRANSIENT,
85
+ detail=error_message,
86
+ )
87
+ case _:
88
+ # Fail loud on exception types we have never met rather
89
+ # than guessing they are retryable.
90
+ return ClassifiedResponse(
91
+ category=ResponseCategory.FATAL,
92
+ detail=_detail_with_message(
93
+ f'unrecognized GeoTab exception type: {error_type}',
94
+ error_message,
95
+ ),
96
+ )
97
+
98
+
99
+ class GeotabResponseClassifier(ResponseClassifier):
100
+ """Classifies GeoTab JSON-RPC responses by envelope inspection."""
101
+
102
+ def classify_response(
103
+ self,
104
+ status_code: int,
105
+ headers: Mapping[str, str],
106
+ body_text: str,
107
+ ) -> ClassifiedResponse:
108
+ """Classify one GeoTab response: status first, then envelope."""
109
+ if status_code >= SERVER_ERROR_FLOOR:
110
+ # Infrastructure ahead of the API; no body parsing needed.
111
+ return ClassifiedResponse(
112
+ category=ResponseCategory.TRANSIENT,
113
+ detail=f'HTTP {status_code}: {body_snippet(body_text)}',
114
+ )
115
+
116
+ try:
117
+ parsed_body: JsonValue = json.loads(body_text)
118
+ except json.JSONDecodeError:
119
+ # Exactly what the load balancer's HTML error pages look
120
+ # like from here.
121
+ return ClassifiedResponse(
122
+ category=ResponseCategory.FATAL,
123
+ detail=(
124
+ f'HTTP {status_code}: unparseable (non-JSON) body: '
125
+ f'{body_snippet(body_text)}'
126
+ ),
127
+ )
128
+
129
+ if isinstance(parsed_body, dict):
130
+ if 'error' in parsed_body:
131
+ return _classify_error_envelope(parsed_body['error'], headers)
132
+ if 'result' in parsed_body:
133
+ # The parse already paid for classification is handed
134
+ # forward; the client must not parse the body again.
135
+ return ClassifiedResponse(
136
+ category=ResponseCategory.SUCCESS, parsed_body=parsed_body
137
+ )
138
+
139
+ return ClassifiedResponse(
140
+ category=ResponseCategory.FATAL,
141
+ detail=(
142
+ 'malformed GeoTab JSON-RPC envelope: neither result nor '
143
+ f'error present: {body_snippet(body_text)}'
144
+ ),
145
+ )
@@ -0,0 +1,79 @@
1
+ # src/fleetpull/network/classifiers/motive.py
2
+ """Motive response classifier (sources: scrubbed provider-behavior
3
+ verification, June 2026; rate limiting probed and never observed —
4
+ generic HTTP posture).
5
+
6
+ Classification reads status codes and structured fields, never
7
+ human-readable message text; ``detail`` carries messages for humans,
8
+ decisions never read them. Branch logic deliberately resembles sibling
9
+ classifiers without sharing code: provider classifiers evolve
10
+ independently (blast-radius over DRY).
11
+ """
12
+
13
+ import json
14
+ from collections.abc import Mapping
15
+ from http import HTTPStatus
16
+
17
+ from fleetpull.network.contract import (
18
+ SERVER_ERROR_FLOOR,
19
+ SUCCESS_STATUS_RANGE,
20
+ ClassifiedResponse,
21
+ ResponseClassifier,
22
+ body_snippet,
23
+ retry_after_seconds_from_headers,
24
+ )
25
+ from fleetpull.vocabulary import JsonValue, ResponseCategory
26
+
27
+ __all__: list[str] = ['MotiveResponseClassifier']
28
+
29
+
30
+ def _auth_failure_detail(body_text: str) -> str:
31
+ """Extract the body's ``error_message`` when JSON, else a snippet."""
32
+ try:
33
+ parsed_body: JsonValue = json.loads(body_text)
34
+ except json.JSONDecodeError:
35
+ return body_snippet(body_text)
36
+ if isinstance(parsed_body, dict):
37
+ error_message: JsonValue = parsed_body.get('error_message')
38
+ if isinstance(error_message, str):
39
+ return error_message
40
+ return body_snippet(body_text)
41
+
42
+
43
+ class MotiveResponseClassifier(ResponseClassifier):
44
+ """Classifies Motive REST responses (observed shape:
45
+ ``{"error_message": "invalid API key"}`` on 401)."""
46
+
47
+ def classify_response(
48
+ self,
49
+ status_code: int,
50
+ headers: Mapping[str, str],
51
+ body_text: str,
52
+ ) -> ClassifiedResponse:
53
+ """Classify one Motive response by status code."""
54
+ match status_code:
55
+ case code if code in SUCCESS_STATUS_RANGE:
56
+ return ClassifiedResponse(category=ResponseCategory.SUCCESS)
57
+ case HTTPStatus.TOO_MANY_REQUESTS:
58
+ # Motive rate limiting was probed and never observed
59
+ # (June 2026); this branch is built to the generic HTTP
60
+ # contract.
61
+ return ClassifiedResponse(
62
+ category=ResponseCategory.RATE_LIMITED,
63
+ retry_after_seconds=retry_after_seconds_from_headers(headers),
64
+ )
65
+ case HTTPStatus.UNAUTHORIZED | HTTPStatus.FORBIDDEN:
66
+ return ClassifiedResponse(
67
+ category=ResponseCategory.AUTH_FAILURE,
68
+ detail=_auth_failure_detail(body_text),
69
+ )
70
+ case code if code >= SERVER_ERROR_FLOOR:
71
+ return ClassifiedResponse(
72
+ category=ResponseCategory.TRANSIENT,
73
+ detail=f'HTTP {status_code}: {body_snippet(body_text)}',
74
+ )
75
+ case _:
76
+ return ClassifiedResponse(
77
+ category=ResponseCategory.FATAL,
78
+ detail=f'HTTP {status_code}: {body_snippet(body_text)}',
79
+ )