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,343 @@
1
+ # src/fleetpull/network/decoders/samsara.py
2
+ """Samsara page decoders: the cursor walk and its series-unnesting
3
+ composition for the vehicle-stats surfaces
4
+ (sources: scrubbed provider-behavior verification, June 2026; cursor
5
+ contract from provider documentation, proven live 2026-07-17 -- the
6
+ advance continued across a real page boundary with no overlap or loss,
7
+ and the terminal page carried ``hasNextPage: false`` beside an
8
+ EMPTY-STRING ``endCursor``, the shape the continuation guard below is
9
+ calibrated against).
10
+
11
+ Records arrive as a top-level list under a per-endpoint key; the
12
+ ``pagination`` block carries ``endCursor``/``hasNextPage``. The first
13
+ page sends ``limit`` and no ``after``; subsequent pages send
14
+ ``after=<endCursor>`` (merged onto the sent spec, so ``limit``
15
+ persists). Decoder logic deliberately resembles its siblings without
16
+ sharing code; the cursor verdict is written once here
17
+ (``cursor_page_advance``, exported so ``samsara_reports.py`` shares it)
18
+ and consumed by every decoder that walks the cursor.
19
+ The one deliberate cross-module share is the window stamp
20
+ (``_window_stamp.py``): the synthesized keys are our own
21
+ provider-uniform vocabulary, not envelope logic.
22
+
23
+ ``SamsaraVehicleSeriesPageDecoder`` composes the cursor decoder by
24
+ delegation for ``/fleet/vehicles/stats/history``, whose cursor walks
25
+ the VEHICLE axis while each vehicle record nests a per-type reading
26
+ series (probe-settled 2026-07-20, DESIGN section 8).
27
+
28
+ The window-stamping report family for the fuel-energy report surfaces
29
+ (``SamsaraWindowReportPageDecoder``) lives in ``samsara_reports.py``,
30
+ sharing this module's cursor verdict and first-page shape.
31
+ """
32
+
33
+ from dataclasses import dataclass
34
+ from typing import Final
35
+
36
+ from pydantic import Field
37
+
38
+ from fleetpull.exceptions import ProviderResponseError
39
+ from fleetpull.network.contract import (
40
+ DecodedPage,
41
+ PageAdvance,
42
+ RequestSpec,
43
+ StrictEnvelopeSlice,
44
+ require_record_list,
45
+ validated_envelope_slice,
46
+ )
47
+ from fleetpull.vocabulary import JsonObject, JsonValue
48
+
49
+ __all__: list[str] = [
50
+ 'SamsaraCursorPageDecoder',
51
+ 'SamsaraVehicleSeriesPageDecoder',
52
+ 'cursor_page_advance',
53
+ 'first_page_spec',
54
+ ]
55
+
56
+ # Wire-protocol tokens: Final constants, not an enum. Deliberately unshared.
57
+ _AFTER_PARAM: Final[str] = 'after'
58
+ _LIMIT_PARAM: Final[str] = 'limit'
59
+
60
+
61
+ class _SamsaraPageEcho(StrictEnvelopeSlice):
62
+ """The pagination block Samsara returns on every page."""
63
+
64
+ has_next_page: bool = Field(alias='hasNextPage')
65
+ end_cursor: str | None = Field(default=None, alias='endCursor')
66
+
67
+
68
+ class _SamsaraEnvelope(StrictEnvelopeSlice):
69
+ """Envelope slice: locates the echo; the record key is ignored."""
70
+
71
+ pagination: _SamsaraPageEcho
72
+
73
+
74
+ def cursor_page_advance(sent: RequestSpec, envelope: JsonValue) -> PageAdvance:
75
+ """Compute one page's cursor verdict from its ``pagination`` echo.
76
+
77
+ The one cursor contract every Samsara walk shares, written once for
78
+ the decoders in this module and the report family beside it
79
+ (``samsara_reports.py`` -- a same-provider extraction, not a
80
+ cross-provider abstraction): terminal on ``hasNextPage: false``;
81
+ otherwise ``after=<endCursor>`` merges onto the SENT spec, so every
82
+ first-request parameter (``limit``, a window, a fixed selector)
83
+ persists across the whole walk. ``durable_progress`` is always
84
+ ``None`` -- Samsara cursors are fetch-private.
85
+
86
+ Args:
87
+ sent: The spec that produced this page.
88
+ envelope: The parsed response body.
89
+
90
+ Returns:
91
+ The page's pagination verdict.
92
+
93
+ Raises:
94
+ ProviderResponseError: The ``pagination`` block is structurally
95
+ violating, including continuation promised without a cursor.
96
+ """
97
+ echo = validated_envelope_slice(_SamsaraEnvelope, envelope).pagination
98
+ if not echo.has_next_page:
99
+ return PageAdvance(next_spec=None, durable_progress=None)
100
+ if echo.end_cursor is None or echo.end_cursor == '':
101
+ # Continuation promised without a cursor: silently finishing
102
+ # here would truncate data -- the one failure mode a fetch
103
+ # library must never have.
104
+ raise ProviderResponseError(
105
+ detail='hasNextPage is true but endCursor is missing or empty'
106
+ )
107
+ next_spec = sent.with_merged_params({_AFTER_PARAM: echo.end_cursor})
108
+ return PageAdvance(next_spec=next_spec, durable_progress=None)
109
+
110
+
111
+ def first_page_spec(spec: RequestSpec, results_limit: int) -> RequestSpec:
112
+ """Page one's spec: ``limit`` merged, and deliberately NO ``after``.
113
+
114
+ The first-request shape every Samsara cursor walk sends, written once
115
+ for this module's decoders and the report family beside it: the shared
116
+ cursor verdict's ``after`` merge layers onto this spec, so the limit
117
+ persists across every subsequent page.
118
+
119
+ Args:
120
+ spec: The endpoint's base first request.
121
+ results_limit: The per-page record count to request via ``limit``.
122
+
123
+ Returns:
124
+ The first request with ``limit`` merged.
125
+ """
126
+ return spec.with_merged_params({_LIMIT_PARAM: str(results_limit)})
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class SamsaraCursorPageDecoder:
131
+ """Decode Samsara's top-level-list pages and cursor.
132
+
133
+ Attributes:
134
+ records_key: The top-level key holding the record list.
135
+ results_limit: The per-page record count requested via the
136
+ ``limit`` query parameter (pagination parameters are the
137
+ decoder's, per the ``StaticGetSpecBuilder`` seam).
138
+ """
139
+
140
+ records_key: str
141
+ results_limit: int
142
+
143
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
144
+ """Send page one via the shared first-page shape (``first_page_spec``)."""
145
+ return first_page_spec(spec, self.results_limit)
146
+
147
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
148
+ """Extract the records and compute the cursor verdict.
149
+
150
+ Args:
151
+ sent: The spec that produced this page.
152
+ envelope: The parsed response body.
153
+
154
+ Returns:
155
+ The records and the pagination verdict; ``durable_progress``
156
+ is always None -- Samsara cursors are fetch-private.
157
+
158
+ Raises:
159
+ ProviderResponseError: When the record-bearing shape or the
160
+ cursor block is structurally violating, including
161
+ continuation promised without a cursor.
162
+ """
163
+ records = require_record_list(envelope, self.records_key)
164
+ return DecodedPage(records=records, advance=cursor_page_advance(sent, envelope))
165
+
166
+
167
+ # The vehicle-stats wire keys the unnesting reads (2026-07-20 capture):
168
+ # per-vehicle identity is `id`/`name` plus the `externalIds` object's
169
+ # literal DOTTED keys `samsara.serial`/`samsara.vin`.
170
+ _VEHICLE_ID_SOURCE_KEY: Final[str] = 'id'
171
+ _VEHICLE_NAME_SOURCE_KEY: Final[str] = 'name'
172
+ _EXTERNAL_IDS_KEY: Final[str] = 'externalIds'
173
+ _SERIAL_SOURCE_KEY: Final[str] = 'samsara.serial'
174
+ _VIN_SOURCE_KEY: Final[str] = 'samsara.vin'
175
+
176
+ # The synthesized identity keys merged onto each emitted reading. Chosen
177
+ # to be collision-free against every series key observed in the
178
+ # 2026-07-20 census (`time`, `value`, `latitude`, `longitude`,
179
+ # `headingDegrees`, `speedMilesPerHour`, `isEcuSpeed`, `reverseGeo`,
180
+ # `address`), so the reading-keys-win merge order below never actually
181
+ # discards an identity key.
182
+ _VEHICLE_ID_KEY: Final[str] = 'vehicleId'
183
+ _VEHICLE_NAME_KEY: Final[str] = 'vehicleName'
184
+ _VEHICLE_SERIAL_KEY: Final[str] = 'vehicleSerial'
185
+ _VEHICLE_VIN_KEY: Final[str] = 'vehicleVin'
186
+
187
+
188
+ def _synthesized_identity(vehicle: JsonObject) -> JsonObject:
189
+ """The identity keys one vehicle record contributes to its readings.
190
+
191
+ Every key is synthesized ONLY when its source is present -- the
192
+ omit-absent-keys posture: a vehicle without ``externalIds`` (or
193
+ without a dotted key inside it) contributes readings without the
194
+ corresponding synthesized key, never a null.
195
+
196
+ Args:
197
+ vehicle: One vehicle record from the page's record list.
198
+
199
+ Returns:
200
+ The synthesized identity keys, wire values verbatim.
201
+
202
+ Raises:
203
+ ProviderResponseError: ``externalIds`` is present but not a JSON
204
+ object -- silently dropping serial/vin there would hide the
205
+ malformation.
206
+ """
207
+ identity: JsonObject = {}
208
+ if _VEHICLE_ID_SOURCE_KEY in vehicle:
209
+ identity[_VEHICLE_ID_KEY] = vehicle[_VEHICLE_ID_SOURCE_KEY]
210
+ if _VEHICLE_NAME_SOURCE_KEY in vehicle:
211
+ identity[_VEHICLE_NAME_KEY] = vehicle[_VEHICLE_NAME_SOURCE_KEY]
212
+ if _EXTERNAL_IDS_KEY not in vehicle:
213
+ return identity
214
+ external_ids = vehicle[_EXTERNAL_IDS_KEY]
215
+ if not isinstance(external_ids, dict):
216
+ raise ProviderResponseError(
217
+ detail=f'vehicle {_EXTERNAL_IDS_KEY!r} is not a JSON object'
218
+ )
219
+ if _SERIAL_SOURCE_KEY in external_ids:
220
+ identity[_VEHICLE_SERIAL_KEY] = external_ids[_SERIAL_SOURCE_KEY]
221
+ if _VIN_SOURCE_KEY in external_ids:
222
+ identity[_VEHICLE_VIN_KEY] = external_ids[_VIN_SOURCE_KEY]
223
+ return identity
224
+
225
+
226
+ @dataclass(frozen=True, slots=True)
227
+ class SamsaraVehicleSeriesPageDecoder:
228
+ """Decode vehicle-stats pages into flat per-reading records.
229
+
230
+ The series-unnesting composition over ``SamsaraCursorPageDecoder``
231
+ for ``GET /fleet/vehicles/stats/history`` (probe-settled
232
+ 2026-07-20): the cursor walks the VEHICLE axis within the fixed
233
+ window (three consecutive live pages showed zero vehicle-id
234
+ overlap), and each vehicle record nests one reading series under
235
+ the requested stat type's key. Composition is by DELEGATION: an
236
+ inner cursor decoder handles ``first_request`` and the whole
237
+ pagination verdict verbatim -- this class only unnests the inner
238
+ page's vehicle records.
239
+
240
+ The unnesting contract: for each vehicle record, for each element
241
+ of ``vehicle[series_key]``, emit one flat record carrying the
242
+ reading's keys verbatim plus the SYNTHESIZED identity keys
243
+ ``vehicleId``/``vehicleName``/``vehicleSerial``/``vehicleVin``
244
+ (sourced from ``id``/``name`` and the ``externalIds`` object's
245
+ literal dotted ``samsara.serial``/``samsara.vin`` keys). Identity
246
+ keys are synthesized only when their source is present; reading
247
+ keys win any collision -- impossible by census, since the
248
+ synthesized names were chosen collision-free against every observed
249
+ series key. A vehicle whose series array is missing or empty
250
+ contributes zero records.
251
+
252
+ This decoder is Samsara-stats-specific by evidence, not a generic
253
+ flattener: the identity-key sourcing, the dotted ``externalIds``
254
+ keys, and the one-series-per-record shape are this surface's
255
+ captured facts, and generalizing beyond them would encode structure
256
+ no probe has shown.
257
+
258
+ Attributes:
259
+ records_key: The top-level key holding the vehicle-record list
260
+ (forwarded to the inner cursor decoder).
261
+ results_limit: The per-page vehicle count requested via
262
+ ``limit`` (forwarded to the inner cursor decoder).
263
+ series_key: The per-vehicle key holding this endpoint's reading
264
+ series -- the requested stat type's wire name.
265
+ """
266
+
267
+ records_key: str
268
+ results_limit: int
269
+ series_key: str
270
+
271
+ def _cursor_decoder(self) -> SamsaraCursorPageDecoder:
272
+ """The inner cursor decoder pagination delegates to."""
273
+ return SamsaraCursorPageDecoder(
274
+ records_key=self.records_key, results_limit=self.results_limit
275
+ )
276
+
277
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
278
+ """Delegate page one verbatim to the inner cursor decoder."""
279
+ return self._cursor_decoder().first_request(spec)
280
+
281
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
282
+ """Unnest the inner page's vehicles into flat reading records.
283
+
284
+ Args:
285
+ sent: The spec that produced this page.
286
+ envelope: The parsed response body.
287
+
288
+ Returns:
289
+ One record per reading (the unnesting contract, class
290
+ docstring); the pagination advance passes through the inner
291
+ decoder untouched.
292
+
293
+ Raises:
294
+ ProviderResponseError: When the inner decode raises (the
295
+ cursor contract's guards, including continuation
296
+ promised without a cursor), or when a present series
297
+ value or element is structurally violating.
298
+ """
299
+ inner_page = self._cursor_decoder().decode_page(sent, envelope)
300
+ readings = [
301
+ reading
302
+ for vehicle in inner_page.records
303
+ for reading in self._unnest_vehicle(vehicle)
304
+ ]
305
+ return DecodedPage(records=readings, advance=inner_page.advance)
306
+
307
+ def _unnest_vehicle(self, vehicle: JsonObject) -> list[JsonObject]:
308
+ """One vehicle record's flat reading records (possibly none).
309
+
310
+ Args:
311
+ vehicle: One vehicle record from the inner page.
312
+
313
+ Returns:
314
+ One flat record per series element; empty when the series
315
+ is missing or empty (only carriers are returned per
316
+ requested type in capture, but absence stays a zero-record
317
+ vehicle, never an error).
318
+
319
+ Raises:
320
+ ProviderResponseError: A present series value is not a
321
+ list, or a series element is not a JSON object.
322
+ """
323
+ if self.series_key not in vehicle:
324
+ return []
325
+ series = vehicle[self.series_key]
326
+ if not isinstance(series, list):
327
+ raise ProviderResponseError(
328
+ detail=f'vehicle series {self.series_key!r} is not a list'
329
+ )
330
+ identity = _synthesized_identity(vehicle)
331
+ readings: list[JsonObject] = []
332
+ for element in series:
333
+ if not isinstance(element, dict):
334
+ raise ProviderResponseError(
335
+ detail=(
336
+ f'vehicle series {self.series_key!r} element is not a '
337
+ 'JSON object'
338
+ )
339
+ )
340
+ # Reading keys win the merge (collision-free by census; the
341
+ # class docstring records the naming choice).
342
+ readings.append({**identity, **element})
343
+ return readings
@@ -0,0 +1,119 @@
1
+ # src/fleetpull/network/decoders/samsara_reports.py
2
+ """The Samsara window-report decoder family: the fuel-energy report surfaces.
3
+
4
+ ``SamsaraWindowReportPageDecoder`` decodes the fuel-energy report surfaces
5
+ (``/fleet/reports/{vehicles,drivers}/fuel-energy``; probe-settled 2026-07-21,
6
+ DESIGN section 8), whose record list nests one level deeper (``data`` is an
7
+ OBJECT holding the list under ``vehicleReports``/``driverReports``) and whose
8
+ rows carry NO event-time key of any kind -- each row is the provider's rollup
9
+ over exactly the requested window, so the decoder stamps every report with the
10
+ window the SENT spec asked for. Pagination and the first-page shape are the
11
+ sibling cursor module's (``samsara.py``: ``cursor_page_advance`` /
12
+ ``first_page_spec``); the window stamp is the shared provider-uniform
13
+ vocabulary (``_window_stamp.py``).
14
+ """
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Final
18
+
19
+ from fleetpull.network.contract import (
20
+ DecodedPage,
21
+ RequestSpec,
22
+ require_child_object,
23
+ require_record_list,
24
+ )
25
+ from fleetpull.network.decoders._window_stamp import window_stamp_from_sent_spec
26
+ from fleetpull.network.decoders.samsara import cursor_page_advance, first_page_spec
27
+ from fleetpull.vocabulary import JsonValue
28
+
29
+ __all__: list[str] = ['SamsaraWindowReportPageDecoder']
30
+
31
+ # The fuel-energy report surfaces' window wire params (2026-07-21
32
+ # capture): these surfaces take startDate/endDate NAMES -- unlike every
33
+ # other probed Samsara vertical's startTime/endTime -- while accepting
34
+ # full RFC3339 datetimes despite the names. The shared window-stamp
35
+ # helper (`_window_stamp.py`) reads them back off the SENT spec to stamp
36
+ # each report row with the provider-uniform synthesized keys.
37
+ _WINDOW_START_PARAM: Final[str] = 'startDate'
38
+ _WINDOW_END_PARAM: Final[str] = 'endDate'
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class SamsaraWindowReportPageDecoder:
43
+ """Decode fuel-energy report pages into window-stamped records.
44
+
45
+ The decoder for ``GET /fleet/reports/{vehicles,drivers}/fuel-energy``
46
+ (probe-settled 2026-07-21, DESIGN section 8), whose envelope differs
47
+ from the flat cursor surfaces twice over:
48
+
49
+ - **The record list is NESTED.** ``data`` is an OBJECT whose only
50
+ key is the per-surface report key (``vehicleReports`` /
51
+ ``driverReports``), each a list of report objects -- extracted
52
+ with the same structural-violation loudness ``require_record_list``
53
+ gives flat lists.
54
+ - **The rollup grain is the request window.** Report rows carry NO
55
+ event-time key of any kind; each row is the provider's aggregate
56
+ over exactly the requested window (widening the window GREW
57
+ per-entity metrics, and day rollups are NOT additive into wider
58
+ windows -- 89/267 mismatched). So the decoder stamps every report
59
+ with the synthesized keys ``windowStartDate``/``windowEndDate``,
60
+ copied verbatim from the SENT spec's own ``startDate``/``endDate``
61
+ params -- the stats triple's synthesized-identity-keys precedent,
62
+ sourced from the sent spec rather than the record. The stamp wins
63
+ any (census-impossible) key collision: it is the row's REQUIRED
64
+ time identity, and a colliding future wire key must never silently
65
+ supplant what was actually asked of the provider -- the inverse of
66
+ the series decoder's reading-keys-win order, where the synthesized
67
+ keys are auxiliary attribution.
68
+
69
+ Pagination is the standard cursor contract, shared via the sibling
70
+ module's ``cursor_page_advance`` (real at scale: a 2-day
71
+ vehicle window walked 3 pages/267 reports); ``first_request``
72
+ injects ``limit`` exactly as the cursor decoder does.
73
+
74
+ Attributes:
75
+ records_key: The top-level key holding the report container
76
+ object (``'data'``).
77
+ report_key: The container key holding this surface's report
78
+ list (``'vehicleReports'`` / ``'driverReports'``).
79
+ results_limit: The per-page record count requested via the
80
+ ``limit`` query parameter (pagination parameters are the
81
+ decoder's, per the ``StaticGetSpecBuilder`` seam).
82
+ """
83
+
84
+ records_key: str
85
+ report_key: str
86
+ results_limit: int
87
+
88
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
89
+ """Send page one via the shared first-page shape (``first_page_spec``)."""
90
+ return first_page_spec(spec, self.results_limit)
91
+
92
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
93
+ """Extract the nested reports, stamp each with the sent window.
94
+
95
+ Args:
96
+ sent: The spec that produced this page.
97
+ envelope: The parsed response body.
98
+
99
+ Returns:
100
+ One record per report, each carrying the synthesized
101
+ ``windowStartDate``/``windowEndDate`` keys (class
102
+ docstring); the pagination verdict is the shared cursor
103
+ contract's.
104
+
105
+ Raises:
106
+ ProviderResponseError: The sent spec lacks a window param
107
+ (a wiring bug -- never silently unstamped rows), the
108
+ nested record-bearing shape is structurally violating,
109
+ or the cursor block is (including continuation promised
110
+ without a cursor).
111
+ """
112
+ window_stamp = window_stamp_from_sent_spec(
113
+ sent, start_param=_WINDOW_START_PARAM, end_param=_WINDOW_END_PARAM
114
+ )
115
+ reports = require_record_list(
116
+ require_child_object(envelope, self.records_key), self.report_key
117
+ )
118
+ stamped = [{**report, **window_stamp} for report in reports]
119
+ return DecodedPage(records=stamped, advance=cursor_page_advance(sent, envelope))
@@ -0,0 +1,55 @@
1
+ # src/fleetpull/network/decoders/single_page.py
2
+ """Single-page decoder: top-level-list records, no pagination.
3
+
4
+ For unpaginated endpoints -- replaces any is-paginated flag. The first
5
+ request is the base spec unchanged and every page is terminal. Decoder
6
+ logic deliberately resembles its siblings without sharing code.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from fleetpull.network.contract import (
12
+ DecodedPage,
13
+ PageAdvance,
14
+ RequestSpec,
15
+ require_record_list,
16
+ )
17
+ from fleetpull.vocabulary import JsonValue
18
+
19
+ __all__: list[str] = ['SinglePageDecoder']
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class SinglePageDecoder:
24
+ """Decode a single unpaginated page of top-level-list records.
25
+
26
+ Attributes:
27
+ records_key: The top-level key holding the record list.
28
+ """
29
+
30
+ records_key: str
31
+
32
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
33
+ """Return the base spec unchanged."""
34
+ return spec
35
+
36
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
37
+ """Extract the records; the page is always terminal.
38
+
39
+ Args:
40
+ sent: The spec that produced this page (unused; there is no
41
+ continuation).
42
+ envelope: The parsed response body.
43
+
44
+ Returns:
45
+ The records and a terminal verdict.
46
+
47
+ Raises:
48
+ ProviderResponseError: When the record-bearing shape is
49
+ structurally violating.
50
+ """
51
+ records = require_record_list(envelope, self.records_key)
52
+ return DecodedPage(
53
+ records=records,
54
+ advance=PageAdvance(next_spec=None, durable_progress=None),
55
+ )
@@ -0,0 +1,13 @@
1
+ """Rate limiting: token-bucket limiter and registry, one per quota scope."""
2
+
3
+ from fleetpull.network.limits.limiter import QuotaScopeLimiter
4
+ from fleetpull.network.limits.registry import (
5
+ RateLimiterRegistry,
6
+ rate_limits_from_configs,
7
+ )
8
+
9
+ __all__: list[str] = [
10
+ 'QuotaScopeLimiter',
11
+ 'RateLimiterRegistry',
12
+ 'rate_limits_from_configs',
13
+ ]
@@ -0,0 +1,58 @@
1
+ # src/fleetpull/network/limits/bucket_math.py
2
+ """Pure token-bucket arithmetic.
3
+
4
+ Stateless functions with no clock, no threading, and no state — just math.
5
+ They exist so the bucket arithmetic is exhaustively testable single-threaded
6
+ with plain assertions, leaving ``limiter.py`` to hold only state and thread
7
+ choreography.
8
+ """
9
+
10
+ __all__: list[str] = ['refill_tokens', 'seconds_until_available']
11
+
12
+
13
+ def refill_tokens(
14
+ current_tokens: float,
15
+ elapsed_seconds: float,
16
+ refill_rate_per_second: float,
17
+ capacity: float,
18
+ ) -> float:
19
+ """Compute the token count after lazily refilling for elapsed time.
20
+
21
+ Args:
22
+ current_tokens: Tokens in the bucket before the refill.
23
+ elapsed_seconds: Seconds since the last refill (must be >= 0).
24
+ refill_rate_per_second: Tokens added per second.
25
+ capacity: Maximum tokens the bucket holds.
26
+
27
+ Returns:
28
+ ``min(capacity, current_tokens + elapsed_seconds * refill_rate_per_second)``.
29
+
30
+ Raises:
31
+ ValueError: If ``elapsed_seconds`` is negative. A monotonic clock can
32
+ never produce one; if it appears, something upstream is broken
33
+ and must fail loudly.
34
+ """
35
+ if elapsed_seconds < 0:
36
+ raise ValueError(f'elapsed_seconds must be non-negative, got {elapsed_seconds}')
37
+ return min(capacity, current_tokens + elapsed_seconds * refill_rate_per_second)
38
+
39
+
40
+ def seconds_until_available(
41
+ current_tokens: float,
42
+ refill_rate_per_second: float,
43
+ tokens_needed: float = 1.0,
44
+ ) -> float:
45
+ """Compute the exact wait until the bucket holds ``tokens_needed`` tokens.
46
+
47
+ Args:
48
+ current_tokens: Tokens currently in the bucket.
49
+ refill_rate_per_second: Tokens added per second.
50
+ tokens_needed: Tokens required before proceeding.
51
+
52
+ Returns:
53
+ 0.0 if ``current_tokens >= tokens_needed``; otherwise the seconds
54
+ needed for the deficit to refill at ``refill_rate_per_second``.
55
+ """
56
+ if current_tokens >= tokens_needed:
57
+ return 0.0
58
+ return (tokens_needed - current_tokens) / refill_rate_per_second