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,309 @@
1
+ # src/fleetpull/network/decoders/geotab.py
2
+ """GeoTab page decoders: GetFeed (toVersion cursor) and seek-paging Get.
3
+
4
+ Feed pagination over JSON-RPC (scrubbed provider-behavior verification,
5
+ June 2026): the request body's ``params`` carry ``typeName``,
6
+ ``resultsLimit``, and either ``search`` (the historical bootstrap) or
7
+ ``fromVersion`` (the resume cursor); the ``result`` is
8
+ ``{"data": [...], "toVersion": str}``. ``toVersion`` is the durable
9
+ cursor and surfaces from every page including the terminal one.
10
+
11
+ Seek paging over plain ``Get`` (captured 2026-07-09): ``Get`` silently
12
+ hard-caps at 5,000 records with no continuation signal (``GetCountOf``
13
+ 5,666 vs the capped 5,000), so a capped entity pages by ``sort`` on
14
+ ``id`` ascending -- each advance sets ``sort.offset`` to the last
15
+ returned record's ``id``; termination is the empty result list (a short
16
+ page is NOT terminal, deliberately unlike the feed rule). The ``result``
17
+ is a plain record list.
18
+
19
+ Decoder logic deliberately resembles its siblings without sharing code
20
+ across providers.
21
+ """
22
+
23
+ from collections.abc import Mapping
24
+ from dataclasses import dataclass
25
+ from typing import Final
26
+
27
+ from pydantic import Field
28
+
29
+ from fleetpull.exceptions import ProviderResponseError
30
+ from fleetpull.network.contract import (
31
+ DecodedPage,
32
+ PageAdvance,
33
+ RequestSpec,
34
+ StrictEnvelopeSlice,
35
+ validated_envelope_slice,
36
+ )
37
+ from fleetpull.vocabulary import JsonObject, JsonValue
38
+
39
+ __all__: list[str] = ['GeotabFeedPageDecoder', 'GeotabGetPageDecoder']
40
+
41
+ # Wire-protocol tokens: Final constants, not an enum. Deliberately unshared
42
+ # with other providers' decoder modules.
43
+ _PARAMS_KEY: Final[str] = 'params'
44
+ _RESULTS_LIMIT_KEY: Final[str] = 'resultsLimit'
45
+ _SEARCH_KEY: Final[str] = 'search'
46
+ _FROM_VERSION_KEY: Final[str] = 'fromVersion'
47
+ _SORT_KEY: Final[str] = 'sort'
48
+ _OFFSET_KEY: Final[str] = 'offset'
49
+ _ID_KEY: Final[str] = 'id'
50
+
51
+
52
+ class _GeotabFeedResult(StrictEnvelopeSlice):
53
+ """GetFeed's result: the records and the durable cursor.
54
+
55
+ ``data`` is typed as a list of JSON objects so the decoder's
56
+ ``list[JsonObject]`` records return is honored at validation time.
57
+ """
58
+
59
+ data: list[JsonObject]
60
+ to_version: str = Field(alias='toVersion')
61
+
62
+
63
+ class _GeotabFeedEnvelope(StrictEnvelopeSlice):
64
+ """Envelope slice: locates the feed result."""
65
+
66
+ result: _GeotabFeedResult
67
+
68
+
69
+ def _params_from_body(
70
+ sent: RequestSpec, method_label: str
71
+ ) -> tuple[Mapping[str, JsonValue], Mapping[str, JsonValue], int]:
72
+ """Locate the sent spec's JSON-RPC body, ``params``, and ``resultsLimit``.
73
+
74
+ The sent body is fleetpull's own construction, not provider data, so
75
+ malformation here is a caller bug -- stdlib ``ValueError``, no slice
76
+ model -- and the body-is-present guard lives here too, stated once.
77
+ Shared by both GeoTab decoders (same module, same provider).
78
+
79
+ Args:
80
+ sent: The spec under inspection.
81
+ method_label: The method-class word for the error messages
82
+ (``'feed'`` / ``'Get'``).
83
+
84
+ Returns:
85
+ The JSON-RPC body, its ``params`` mapping, and the integer
86
+ ``resultsLimit``.
87
+
88
+ Raises:
89
+ ValueError: When the spec carries no JSON-RPC body, or ``params``
90
+ or ``resultsLimit`` is missing or mistyped.
91
+ """
92
+ if sent.json_body is None:
93
+ raise ValueError(f'GeoTab {method_label} requests require a JSON-RPC body')
94
+ sent_body: Mapping[str, JsonValue] = sent.json_body
95
+ params_value: JsonValue = sent_body.get(_PARAMS_KEY)
96
+ if not isinstance(params_value, Mapping):
97
+ raise ValueError(
98
+ f'GeoTab {method_label} request body must carry a {_PARAMS_KEY!r} mapping'
99
+ )
100
+ results_limit: JsonValue = params_value.get(_RESULTS_LIMIT_KEY)
101
+ # bool is a subclass of int; a True resultsLimit is a bug, not 1.
102
+ if not isinstance(results_limit, int) or isinstance(results_limit, bool):
103
+ raise ValueError(
104
+ f'GeoTab {method_label} request params must carry an integer '
105
+ f'{_RESULTS_LIMIT_KEY!r}'
106
+ )
107
+ return sent_body, params_value, results_limit
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class GeotabFeedPageDecoder:
112
+ """Decode GeoTab GetFeed pages and advance by ``toVersion``.
113
+
114
+ No configuration fields: ``resultsLimit`` is read from the sent
115
+ body, so decoder-versus-endpoint divergence is structurally
116
+ impossible.
117
+ """
118
+
119
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
120
+ """Return the spec unchanged.
121
+
122
+ The base spec already carries the bootstrap ``search.fromDate``
123
+ or resume ``fromVersion`` -- state-layer input, not the
124
+ decoder's concern.
125
+ """
126
+ return spec
127
+
128
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
129
+ """Extract the feed records and compute the version verdict.
130
+
131
+ Args:
132
+ sent: The spec that produced this page; its body supplies
133
+ ``resultsLimit`` and seeds the next page's body.
134
+ envelope: The parsed response body.
135
+
136
+ Returns:
137
+ The feed records and the verdict. ``durable_progress``
138
+ carries ``toVersion`` on EVERY page, terminal included.
139
+
140
+ Raises:
141
+ ProviderResponseError: When the feed result is structurally
142
+ violating.
143
+ ValueError: When ``sent`` is malformed for this decoder -- a
144
+ caller bug, deliberately stdlib.
145
+ """
146
+ sent_body, sent_params, results_limit = _params_from_body(sent, 'feed')
147
+ feed = validated_envelope_slice(_GeotabFeedEnvelope, envelope).result
148
+ if len(feed.data) < results_limit:
149
+ # The terminal page still carries the resume point.
150
+ return DecodedPage(
151
+ records=feed.data,
152
+ advance=PageAdvance(next_spec=None, durable_progress=feed.to_version),
153
+ )
154
+ # Advance by cursor: fromVersion replaces search. A full page
155
+ # whose toVersion equals the version it was asked from cannot
156
+ # advance -- continuing would refetch the identical page forever
157
+ # (unbounded duplicate growth at the append cell), so the
158
+ # forward-progress violation is surfaced loudly instead. Never
159
+ # observed live (probed full pages always advanced); the guard
160
+ # exists for wedged providers and replaying proxies.
161
+ if feed.to_version == sent_params.get(_FROM_VERSION_KEY):
162
+ raise ProviderResponseError(
163
+ detail=(
164
+ f'GetFeed returned a full page without advancing '
165
+ f'toVersion ({feed.to_version!r}) -- a stalled feed '
166
+ f'would loop forever'
167
+ )
168
+ )
169
+ next_params: dict[str, JsonValue] = {
170
+ param_name: param_value
171
+ for param_name, param_value in sent_params.items()
172
+ if param_name != _SEARCH_KEY
173
+ }
174
+ next_params[_FROM_VERSION_KEY] = feed.to_version
175
+ next_body: dict[str, JsonValue] = {**sent_body, _PARAMS_KEY: next_params}
176
+ return DecodedPage(
177
+ records=feed.data,
178
+ advance=PageAdvance(
179
+ next_spec=sent.with_json_body(next_body),
180
+ durable_progress=feed.to_version,
181
+ ),
182
+ )
183
+
184
+
185
+ class _GeotabGetEnvelope(StrictEnvelopeSlice):
186
+ """Envelope slice: ``Get`` returns records as a plain top-level list."""
187
+
188
+ result: list[JsonObject]
189
+
190
+
191
+ def _sort_from_params(sent_params: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
192
+ """Locate the Get request's ``sort`` mapping; its absence is a caller bug.
193
+
194
+ Args:
195
+ sent_params: The sent body's ``params`` mapping.
196
+
197
+ Returns:
198
+ The ``sort`` mapping the advance rewrites.
199
+
200
+ Raises:
201
+ ValueError: When ``sort`` is missing or not a mapping.
202
+ """
203
+ sort_value: JsonValue = sent_params.get(_SORT_KEY)
204
+ if not isinstance(sort_value, Mapping):
205
+ raise ValueError(
206
+ f'GeoTab Get request params must carry a {_SORT_KEY!r} mapping'
207
+ )
208
+ return sort_value
209
+
210
+
211
+ def _last_record_id(records: list[JsonObject]) -> str:
212
+ """The last returned record's ``id`` -- the next page's seek offset.
213
+
214
+ Args:
215
+ records: The page's records, non-empty.
216
+
217
+ Returns:
218
+ The trailing record's string ``id``.
219
+
220
+ Raises:
221
+ ProviderResponseError: When the trailing record lacks a string
222
+ ``id`` -- provider data violating the seek contract, not a
223
+ caller bug.
224
+ """
225
+ last_id: JsonValue = records[-1].get(_ID_KEY)
226
+ if not isinstance(last_id, str):
227
+ raise ProviderResponseError(
228
+ detail=(
229
+ f'GeoTab Get seek paging requires a string {_ID_KEY!r} on '
230
+ f'every record; the last record of the page carries '
231
+ f'{type(last_id).__name__}'
232
+ )
233
+ )
234
+ return last_id
235
+
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class GeotabGetPageDecoder:
239
+ """Decode plain ``Get`` pages and advance by id-sorted seek offset.
240
+
241
+ Plain ``Get`` silently hard-caps at 5,000 records with no
242
+ continuation signal (captured 2026-07-09), so the walk seeks: the
243
+ spec builder's first request sorts by ``id`` ascending with an
244
+ explicit null ``offset``, and each advance sets ``sort.offset`` to
245
+ the last returned record's ``id``. Termination is the EMPTY result
246
+ list only -- a short page still advances (unlike the feed rule),
247
+ because the cap makes page fullness meaningless as a completion
248
+ signal. ``lastId`` is never written, by construction: the probe
249
+ bodies carried ``"lastId": null`` (tolerated), but the settled
250
+ decision (DESIGN section 8, decision 1) is to never send the key --
251
+ the docs name it an ``ArgumentException`` beside id-sort.
252
+
253
+ No configuration fields: ``resultsLimit`` lives only in the sent
254
+ body, so decoder-versus-endpoint divergence is structurally
255
+ impossible (the feed precedent).
256
+ """
257
+
258
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
259
+ """Return the spec unchanged.
260
+
261
+ The base spec already carries ``resultsLimit`` and the initial
262
+ ``sort`` (``sortBy: id``, explicit null ``offset``) -- the spec
263
+ builder's concern, not the decoder's.
264
+ """
265
+ return spec
266
+
267
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
268
+ """Extract the page's records and compute the seek verdict.
269
+
270
+ Args:
271
+ sent: The spec that produced this page; its body's ``sort``
272
+ seeds the next page's offset.
273
+ envelope: The parsed response body.
274
+
275
+ Returns:
276
+ The page's records and the verdict: an empty page
277
+ terminates; any records advance with ``sort.offset`` set to
278
+ the last record's ``id``. ``durable_progress`` is always
279
+ ``None`` -- the seek offset is fetch-private, never a resume
280
+ cursor.
281
+
282
+ Raises:
283
+ ProviderResponseError: When the envelope is structurally
284
+ violating, or a record lacks the string ``id`` the seek
285
+ contract stands on.
286
+ ValueError: When ``sent`` is malformed for this decoder -- a
287
+ caller bug, deliberately stdlib.
288
+ """
289
+ sent_body, sent_params, _results_limit = _params_from_body(sent, 'Get')
290
+ sent_sort = _sort_from_params(sent_params)
291
+ records = validated_envelope_slice(_GeotabGetEnvelope, envelope).result
292
+ if not records:
293
+ return DecodedPage(
294
+ records=[],
295
+ advance=PageAdvance(next_spec=None, durable_progress=None),
296
+ )
297
+ next_sort: dict[str, JsonValue] = {
298
+ **sent_sort,
299
+ _OFFSET_KEY: _last_record_id(records),
300
+ }
301
+ next_params: dict[str, JsonValue] = {**sent_params, _SORT_KEY: next_sort}
302
+ next_body: dict[str, JsonValue] = {**sent_body, _PARAMS_KEY: next_params}
303
+ return DecodedPage(
304
+ records=records,
305
+ advance=PageAdvance(
306
+ next_spec=sent.with_json_body(next_body),
307
+ durable_progress=None,
308
+ ),
309
+ )
@@ -0,0 +1,204 @@
1
+ # src/fleetpull/network/decoders/motive.py
2
+ """Motive page decoders: wrapped-list records, paginated and single-page
3
+ (sources: scrubbed provider-behavior verification, June 2026).
4
+
5
+ Records arrive as a list of single-key wrappers under a per-endpoint
6
+ top-level key -- ``{"vehicles": [{"vehicle": {...}}, ...]}`` -- and the
7
+ ``pagination`` block carries ``page_no``/``per_page``/``total``. Decoder
8
+ logic deliberately resembles its siblings without sharing code:
9
+ provider decoders evolve independently (blast-radius over DRY). WITHIN
10
+ this module the wrapped-list extraction and the offset-page verdict are
11
+ each written once (``_unwrap_wrapped_list`` / ``_offset_page_advance``)
12
+ and shared by every decoder that speaks them.
13
+
14
+ The window-stamping report composition for the utilization rollup
15
+ surfaces (``MotiveWindowReportPageDecoder``) lives in
16
+ ``motive_reports.py``, delegating to this module's wrapped-list decoder.
17
+ """
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Final
21
+
22
+ from fleetpull.network.contract import (
23
+ DecodedPage,
24
+ PageAdvance,
25
+ RequestSpec,
26
+ StrictEnvelopeSlice,
27
+ require_record_list,
28
+ unwrap_record_objects,
29
+ validated_envelope_slice,
30
+ )
31
+ from fleetpull.vocabulary import JsonObject, JsonValue
32
+
33
+ __all__: list[str] = [
34
+ 'MotiveWrappedListPageDecoder',
35
+ 'MotiveWrappedSinglePageDecoder',
36
+ ]
37
+
38
+ # Wire-protocol tokens: Final constants, not an enum -- nothing
39
+ # dispatches over these. Per-provider and deliberately unshared.
40
+ _PAGE_NO_PARAM: Final[str] = 'page_no'
41
+ _PER_PAGE_PARAM: Final[str] = 'per_page'
42
+
43
+
44
+ class _MotivePageEcho(StrictEnvelopeSlice):
45
+ """The pagination block Motive returns on every page."""
46
+
47
+ page_no: int
48
+ per_page: int
49
+ total: int
50
+
51
+
52
+ class _MotiveEnvelope(StrictEnvelopeSlice):
53
+ """Envelope slice: locates the echo; the record key is ignored."""
54
+
55
+ pagination: _MotivePageEcho
56
+
57
+
58
+ def _unwrap_wrapped_list(
59
+ envelope: JsonValue, list_key: str, item_key: str
60
+ ) -> list[JsonObject]:
61
+ """Extract and unwrap one page's wrapped-list records.
62
+
63
+ The one Motive record-bearing shape, written once for the decoders
64
+ in this module (a same-file extraction, not a cross-provider
65
+ abstraction): the wrapper list under ``list_key``, each wrapper's
66
+ record under ``item_key``.
67
+
68
+ Args:
69
+ envelope: The parsed response body.
70
+ list_key: The top-level key holding the wrapper list.
71
+ item_key: The key inside each wrapper holding the record.
72
+
73
+ Returns:
74
+ The unwrapped record objects.
75
+
76
+ Raises:
77
+ ProviderResponseError: The record-bearing shape is structurally
78
+ violating (missing list key, non-list value, missing item
79
+ key, or a non-object record).
80
+ """
81
+ wrappers = require_record_list(envelope, list_key)
82
+ return unwrap_record_objects(wrappers, item_key)
83
+
84
+
85
+ def _offset_page_advance(sent: RequestSpec, envelope: JsonValue) -> PageAdvance:
86
+ """Compute one page's offset verdict from its ``pagination`` echo.
87
+
88
+ The one page-numbered cursor contract every paginated Motive walk
89
+ shares, written once for the decoders in this module: terminal when
90
+ ``page_no * per_page >= total``; otherwise ``page_no + 1`` at the
91
+ echoed size merges onto the SENT spec, so every first-request
92
+ parameter (a window, a fixed selector) persists across the whole
93
+ walk. ``durable_progress`` is always ``None`` -- Motive offsets are
94
+ fetch-private.
95
+
96
+ Args:
97
+ sent: The spec that produced this page.
98
+ envelope: The parsed response body.
99
+
100
+ Returns:
101
+ The page's pagination verdict.
102
+
103
+ Raises:
104
+ ProviderResponseError: The ``pagination`` block is structurally
105
+ violating.
106
+ """
107
+ echo = validated_envelope_slice(_MotiveEnvelope, envelope).pagination
108
+ if echo.page_no * echo.per_page >= echo.total:
109
+ return PageAdvance(next_spec=None, durable_progress=None)
110
+ next_spec = sent.with_merged_params(
111
+ {
112
+ _PAGE_NO_PARAM: str(echo.page_no + 1),
113
+ _PER_PAGE_PARAM: str(echo.per_page),
114
+ }
115
+ )
116
+ return PageAdvance(next_spec=next_spec, durable_progress=None)
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class MotiveWrappedListPageDecoder:
121
+ """Decode Motive's wrapped-list pages and page-numbered cursor.
122
+
123
+ Attributes:
124
+ list_key: The top-level key holding the wrapper list.
125
+ item_key: The key inside each wrapper holding the record.
126
+ per_page: The page size sent on the first request.
127
+ """
128
+
129
+ list_key: str
130
+ item_key: str
131
+ per_page: int
132
+
133
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
134
+ """Send page one with ``page_no=1`` and the configured size."""
135
+ return spec.with_merged_params(
136
+ {_PAGE_NO_PARAM: '1', _PER_PAGE_PARAM: str(self.per_page)}
137
+ )
138
+
139
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
140
+ """Extract the wrapped records and compute the page verdict.
141
+
142
+ Args:
143
+ sent: The spec that produced this page.
144
+ envelope: The parsed response body.
145
+
146
+ Returns:
147
+ The unwrapped records and the pagination verdict.
148
+
149
+ Raises:
150
+ ProviderResponseError: When the record-bearing shape or the
151
+ pagination block is structurally violating.
152
+ """
153
+ records = _unwrap_wrapped_list(envelope, self.list_key, self.item_key)
154
+ return DecodedPage(
155
+ records=records, advance=_offset_page_advance(sent, envelope)
156
+ )
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class MotiveWrappedSinglePageDecoder:
161
+ """Decode a single unpaginated page of Motive's wrapped-list records.
162
+
163
+ The non-paginated sibling of ``MotiveWrappedListPageDecoder``: the same
164
+ wrapped-list shape (a list of single-key wrappers under ``list_key``, each
165
+ holding the record under ``item_key``), but the endpoint returns one page and
166
+ no ``pagination`` block, so the first request is the base spec unchanged and
167
+ every page is terminal. ``SinglePageDecoder`` does not fit -- it reads a
168
+ top-level record list and never strips the per-item wrapper.
169
+
170
+ Attributes:
171
+ list_key: The top-level key holding the wrapper list.
172
+ item_key: The key inside each wrapper holding the record.
173
+ """
174
+
175
+ list_key: str
176
+ item_key: str
177
+
178
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
179
+ """Return the base spec unchanged -- the endpoint is unpaginated."""
180
+ return spec
181
+
182
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
183
+ """Unwrap the wrapped records; the page is always terminal.
184
+
185
+ Args:
186
+ sent: The spec that produced this page (unused; no continuation).
187
+ envelope: The parsed response body.
188
+
189
+ Returns:
190
+ The unwrapped records and a terminal verdict.
191
+
192
+ Raises:
193
+ ProviderResponseError: When the record-bearing shape is structurally
194
+ violating (missing list key, non-list value, missing item key, or
195
+ a non-object record).
196
+
197
+ Side Effects:
198
+ None.
199
+ """
200
+ records = _unwrap_wrapped_list(envelope, self.list_key, self.item_key)
201
+ return DecodedPage(
202
+ records=records,
203
+ advance=PageAdvance(next_spec=None, durable_progress=None),
204
+ )
@@ -0,0 +1,114 @@
1
+ # src/fleetpull/network/decoders/motive_reports.py
2
+ """The Motive window-report decoder family: the utilization rollup surfaces.
3
+
4
+ ``MotiveWindowReportPageDecoder`` decodes the utilization rollup surfaces
5
+ (``/v2/vehicle_utilization``, ``/v2/driver_utilization``; probe-settled
6
+ 2026-07-21, DESIGN section 8), whose rows carry NO event-time key of any kind
7
+ -- each row is the provider's rollup over exactly the requested window, so the
8
+ decoder stamps every unwrapped record with the window the SENT spec asked for.
9
+ Envelope extraction and pagination are the sibling wrapped-list decoder's
10
+ (``motive.py``), composed by delegation; the window stamp is the shared
11
+ provider-uniform vocabulary (``_window_stamp.py``).
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Final
16
+
17
+ from fleetpull.network.contract import DecodedPage, RequestSpec
18
+ from fleetpull.network.decoders._window_stamp import window_stamp_from_sent_spec
19
+ from fleetpull.network.decoders.motive import MotiveWrappedListPageDecoder
20
+ from fleetpull.vocabulary import JsonValue
21
+
22
+ __all__: list[str] = ['MotiveWindowReportPageDecoder']
23
+
24
+ # The utilization report surfaces' window wire params (2026-07-21
25
+ # capture): the day-granular date-label pair every windowed Motive
26
+ # surface takes, inclusive on both ends and interpreted on COMPANY-LOCAL
27
+ # day boundaries on these surfaces. The shared window-stamp helper
28
+ # (`_window_stamp.py`) reads them back off the SENT spec to stamp each
29
+ # rollup row with the provider-uniform synthesized keys.
30
+ _WINDOW_START_PARAM: Final[str] = 'start_date'
31
+ _WINDOW_END_PARAM: Final[str] = 'end_date'
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class MotiveWindowReportPageDecoder:
36
+ """Decode window-grain rollup pages into window-stamped records.
37
+
38
+ The decoder for ``GET /v2/vehicle_utilization`` and
39
+ ``GET /v2/driver_utilization`` (probe-settled 2026-07-21, DESIGN
40
+ section 8): the standard Motive wrapped-list envelope and
41
+ page-numbered cursor -- composed by DELEGATION to an inner
42
+ ``MotiveWrappedListPageDecoder`` (the Samsara series-decoder
43
+ pattern), which handles ``first_request`` and the whole
44
+ extraction-and-verdict pass verbatim -- plus
45
+ the window-grain difference the Samsara fuel-energy pair
46
+ established, applied to the inner page's records:
47
+
48
+ **The rollup grain is the request window.** Rows carry NO date or
49
+ time identity of any kind; each row is the provider's aggregate over
50
+ exactly the requested inclusive ``start_date``/``end_date`` label
51
+ pair (a 1-day and a 6-day request each returned one rollup row per
52
+ entity). So the decoder stamps every unwrapped record with the
53
+ synthesized ``windowStartDate``/``windowEndDate`` keys, copied
54
+ verbatim from the SENT spec's own ``start_date``/``end_date`` params
55
+ -- the shared window-stamp vocabulary (``_window_stamp.py``),
56
+ sourced from the sent spec rather than the record. The stamp wins
57
+ any (census-impossible) key collision: it is the row's REQUIRED time
58
+ identity, and a colliding future wire key must never silently
59
+ supplant what was actually asked of the provider. A sent spec
60
+ lacking either param raises loudly -- a wiring bug surfaced, never
61
+ silently unstamped rows.
62
+
63
+ Attributes:
64
+ list_key: The top-level key holding the wrapper list
65
+ (``'vehicle_utilizations'`` / ``'driver_idle_rollups'``).
66
+ item_key: The key inside each wrapper holding the record
67
+ (``'vehicle_utilization'`` / ``'driver_idle_rollup'``).
68
+ per_page: The page size sent on the first request.
69
+ """
70
+
71
+ list_key: str
72
+ item_key: str
73
+ per_page: int
74
+
75
+ def _list_decoder(self) -> MotiveWrappedListPageDecoder:
76
+ """The inner wrapped-list decoder extraction and pagination delegate to."""
77
+ return MotiveWrappedListPageDecoder(
78
+ list_key=self.list_key, item_key=self.item_key, per_page=self.per_page
79
+ )
80
+
81
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
82
+ """Delegate page one verbatim to the inner wrapped-list decoder.
83
+
84
+ The offset advance merges onto the sent spec, so the builder's
85
+ ``start_date``/``end_date`` window persists across every page --
86
+ and with it, the stamp.
87
+ """
88
+ return self._list_decoder().first_request(spec)
89
+
90
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
91
+ """Stamp the inner page's unwrapped records with the sent window.
92
+
93
+ Args:
94
+ sent: The spec that produced this page.
95
+ envelope: The parsed response body.
96
+
97
+ Returns:
98
+ One record per rollup row, each carrying the synthesized
99
+ ``windowStartDate``/``windowEndDate`` keys (class
100
+ docstring); the pagination advance passes through the inner
101
+ decoder untouched.
102
+
103
+ Raises:
104
+ ProviderResponseError: The sent spec lacks a window param
105
+ (a wiring bug -- never silently unstamped rows), the
106
+ record-bearing shape is structurally violating, or the
107
+ pagination block is.
108
+ """
109
+ window_stamp = window_stamp_from_sent_spec(
110
+ sent, start_param=_WINDOW_START_PARAM, end_param=_WINDOW_END_PARAM
111
+ )
112
+ inner_page = self._list_decoder().decode_page(sent, envelope)
113
+ stamped = [{**record, **window_stamp} for record in inner_page.records]
114
+ return DecodedPage(records=stamped, advance=inner_page.advance)