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,427 @@
1
+ # src/fleetpull/endpoints/geotab/_requests.py
2
+ """The shared GeoTab JSON-RPC request machinery for the endpoint leaves.
3
+
4
+ One module carries everything a GeoTab leaf composes on the request side
5
+ (renamed from ``_get_requests`` when the ``GetFeed`` builder joined,
6
+ 2026-07-21): the JSON-RPC POST envelope (every request is a POST to
7
+ ``https://{server}/apiv1``), the snapshot seek-walk builder, the
8
+ windowed builder with its per-type sort declaration, the ``GetFeed``
9
+ seed-or-resume builder every feed leaf shares, the ``server_host``
10
+ resolver, and the ``GetCountOfCheck`` truth instrument. Underscore-
11
+ prefixed so the registry walk skips it: this module is machinery, not an
12
+ endpoint leaf.
13
+
14
+ The probe provenance the shapes rest on:
15
+
16
+ - Plain ``Get`` silently hard-caps at 5,000 records with no continuation
17
+ signal; a captured ``GetCountOf`` above the cap proved records beyond
18
+ 5,000 are invisible to bare ``Get``.
19
+ - Id-sortability is PER-TYPE, never assumed: ``Device`` supports it
20
+ (captured 2026-07-09), ``User`` supports it (re-proven 2026-07-16),
21
+ ``ExceptionEvent`` rejects it outright (captured 2026-07-15:
22
+ ``ArgumentException``, "Can not sort by id") -- and any sort composed
23
+ with a search degrades to the deterministic ``-32000
24
+ GenericException`` for that type.
25
+ - The sorted first-request shape is the probed one: ``sort`` inside
26
+ ``params`` with ``sortBy: id``, ``sortDirection: asc``, and an
27
+ EXPLICIT null ``offset`` -- never an absent key. ``lastId`` is never
28
+ written (probe-settled decision; the docs name it an
29
+ ``ArgumentException`` beside id-sort).
30
+ - ``GetFeed`` rides the same JSON-RPC POST but is its OWN method class
31
+ (its own ~60/min rate budget; the 2026-07-21 header-decrement probe).
32
+ Seeding via ``search.fromDate`` on the tokenless first call is
33
+ wire-proven (DESIGN section 4 carries the docs-falsified record);
34
+ resuming sends ``fromVersion`` and never ``search``.
35
+ """
36
+
37
+ from collections.abc import Mapping
38
+ from dataclasses import dataclass
39
+ from typing import Final
40
+
41
+ from fleetpull.config import DEFAULT_GEOTAB_SERVER, GeotabConfig
42
+ from fleetpull.endpoints.shared import (
43
+ ResumeValue,
44
+ require_date_window,
45
+ require_feed_resume,
46
+ )
47
+ from fleetpull.incremental import DateWindow, FeedSeed, FeedToken
48
+ from fleetpull.network.contract import (
49
+ EnvelopeFetcher,
50
+ HttpMethod,
51
+ RequestSpec,
52
+ StrictEnvelopeSlice,
53
+ validated_envelope_slice,
54
+ )
55
+ from fleetpull.timing import to_iso8601
56
+ from fleetpull.vocabulary import JsonValue
57
+
58
+ __all__: list[str] = [
59
+ 'GeotabGetFeedSpecBuilder',
60
+ 'GeotabGetSpecBuilder',
61
+ 'GeotabWindowedGetSpecBuilder',
62
+ 'GetCountOfCheck',
63
+ 'server_host',
64
+ ]
65
+
66
+ # The JSON-RPC ingress path every GeoTab method POSTs to.
67
+ _API_PATH: Final[str] = '/apiv1'
68
+
69
+ # Wire-protocol tokens: module-private Final constants, colocated with
70
+ # the strategies that emit them (the constants-scope precedent;
71
+ # deliberately unshared with the decoder module's own copies).
72
+ _METHOD_KEY: Final[str] = 'method'
73
+ _PARAMS_KEY: Final[str] = 'params'
74
+ _TYPE_NAME_KEY: Final[str] = 'typeName'
75
+ _SEARCH_KEY: Final[str] = 'search'
76
+ _FROM_DATE_KEY: Final[str] = 'fromDate'
77
+ _TO_DATE_KEY: Final[str] = 'toDate'
78
+ _RESULTS_LIMIT_KEY: Final[str] = 'resultsLimit'
79
+ _SORT_KEY: Final[str] = 'sort'
80
+ _SORT_BY_KEY: Final[str] = 'sortBy'
81
+ _SORT_DIRECTION_KEY: Final[str] = 'sortDirection'
82
+ _OFFSET_KEY: Final[str] = 'offset'
83
+ _FROM_VERSION_KEY: Final[str] = 'fromVersion'
84
+ _GET_METHOD: Final[str] = 'Get'
85
+ _GET_COUNT_OF_METHOD: Final[str] = 'GetCountOf'
86
+ _GET_FEED_METHOD: Final[str] = 'GetFeed'
87
+ _ID_SORT: Final[str] = 'id'
88
+ _ASCENDING: Final[str] = 'asc'
89
+
90
+
91
+ def server_host(config: GeotabConfig) -> str:
92
+ """The authentication host the spec URLs are built on.
93
+
94
+ Args:
95
+ config: The validated GeoTab configuration.
96
+
97
+ Returns:
98
+ ``auth.server`` when a credential is configured; the placeholder
99
+ default otherwise (a credential-less config still builds every
100
+ discovered leaf -- the registry walk requires it -- but can never
101
+ fetch, so the placeholder never reaches the wire; the session
102
+ strategy retargets every prepared request to the resolved host).
103
+
104
+ Side Effects:
105
+ None.
106
+ """
107
+ if config.auth is not None:
108
+ return config.auth.server
109
+ # Pre-auth placeholder host for a default-constructed (credential-less)
110
+ # config; the session strategy retargets every prepared request, so no
111
+ # request ever leaves for this host un-retargeted.
112
+ return DEFAULT_GEOTAB_SERVER
113
+
114
+
115
+ def _post_spec(server: str, json_body: dict[str, JsonValue]) -> RequestSpec:
116
+ """The JSON-RPC POST envelope every GeoTab method rides.
117
+
118
+ Args:
119
+ server: The pre-auth authentication host.
120
+ json_body: The credential-less JSON-RPC body to send.
121
+
122
+ Returns:
123
+ A credential-less POST for ``https://{server}/apiv1``.
124
+ """
125
+ return RequestSpec(
126
+ method=HttpMethod.POST,
127
+ url=f'https://{server}{_API_PATH}',
128
+ json_body=json_body,
129
+ )
130
+
131
+
132
+ def _get_json_body(
133
+ type_name: str,
134
+ results_limit: int,
135
+ window: DateWindow | None,
136
+ id_sort: bool,
137
+ ) -> dict[str, JsonValue]:
138
+ """Assemble a ``Get`` body from the leaf's declared shape.
139
+
140
+ ``params`` composes ``typeName`` always; the window as
141
+ ``search.fromDate`` / ``search.toDate`` (UTC ISO-8601 ``Z`` strings)
142
+ when one is given; ``resultsLimit`` always; and the probed ``sort``
143
+ member (``sortBy: id``, ``sortDirection: asc``, EXPLICIT null
144
+ ``offset``) only when the walk is id-sorted.
145
+
146
+ Args:
147
+ type_name: The GeoTab entity name (``'Device'``, ``'Trip'``).
148
+ results_limit: The per-request record limit.
149
+ window: The resume window riding ``search``; ``None`` for a
150
+ snapshot (no search member at all).
151
+ id_sort: Whether the probed ``sort`` member is written.
152
+
153
+ Returns:
154
+ The credential-less JSON-RPC ``Get`` body.
155
+ """
156
+ params: dict[str, JsonValue] = {_TYPE_NAME_KEY: type_name}
157
+ if window is not None:
158
+ params[_SEARCH_KEY] = {
159
+ _FROM_DATE_KEY: to_iso8601(window.start),
160
+ _TO_DATE_KEY: to_iso8601(window.end),
161
+ }
162
+ params[_RESULTS_LIMIT_KEY] = results_limit
163
+ if id_sort:
164
+ params[_SORT_KEY] = {
165
+ _SORT_BY_KEY: _ID_SORT,
166
+ _SORT_DIRECTION_KEY: _ASCENDING,
167
+ _OFFSET_KEY: None,
168
+ }
169
+ return {_METHOD_KEY: _GET_METHOD, _PARAMS_KEY: params}
170
+
171
+
172
+ @dataclass(frozen=True, slots=True)
173
+ class GeotabGetSpecBuilder:
174
+ """Build the snapshot seek walk's first ``Get`` request.
175
+
176
+ Sort is intrinsic here, not declared: a seek walk without id-sort is
177
+ meaningless -- termination and completeness both ride the
178
+ id-ascending advance (the probed shape's provenance is the module
179
+ docstring). Every request after this one is the decoder's.
180
+
181
+ ``build_spec`` rejects any non-``None`` resume: a snapshot always
182
+ resumes from nothing, so a window reaching this builder is a wiring
183
+ bug that would otherwise silently fetch the entire entity set
184
+ unwindowed.
185
+
186
+ Attributes:
187
+ server: The pre-auth authentication host (retargeted by the
188
+ session strategy after Authenticate).
189
+ type_name: The GeoTab entity to walk (``'Device'``, ``'User'``).
190
+ results_limit: The page size; 5000 -- the largest sound page
191
+ under the silent cap.
192
+ """
193
+
194
+ server: str
195
+ type_name: str
196
+ results_limit: int
197
+
198
+ def build_spec(
199
+ self, resume: ResumeValue, member_values: Mapping[str, str]
200
+ ) -> RequestSpec:
201
+ """Build the walk's first request.
202
+
203
+ Args:
204
+ resume: Must be ``None`` -- a snapshot resumes from nothing;
205
+ any other value is a wiring bug.
206
+ member_values: Accepted to satisfy the protocol; unused --
207
+ a single-chain endpoint binds no member.
208
+
209
+ Returns:
210
+ A credential-less JSON-RPC POST; ``params.credentials`` and
211
+ the resolved host are the session strategy's injections.
212
+
213
+ Raises:
214
+ TypeError: ``resume`` is not ``None``.
215
+ """
216
+ if resume is not None:
217
+ raise TypeError(
218
+ f'{type(self).__name__} requires a None resume -- a snapshot '
219
+ f'resumes from nothing; got {type(resume).__name__}.'
220
+ )
221
+ return _post_spec(
222
+ self.server,
223
+ _get_json_body(
224
+ type_name=self.type_name,
225
+ results_limit=self.results_limit,
226
+ window=None,
227
+ id_sort=True,
228
+ ),
229
+ )
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class GeotabWindowedGetSpecBuilder:
234
+ """Build a watermark endpoint's windowed ``Get`` request.
235
+
236
+ The resume window rides ``search.fromDate`` / ``search.toDate``;
237
+ ``id_sort`` declares whether the probed ``sort`` member is written
238
+ beside it. Id-sortability is a per-type PROBED provider capability,
239
+ never assumed (module docstring: Device yes 2026-07-09, User yes
240
+ 2026-07-16, ExceptionEvent no 2026-07-15). ``id_sort=True`` selects
241
+ the seek-walk pairing (the decoder advances ``sort.offset``, and its
242
+ advance spreads the sent params so ``search`` survives every page --
243
+ live-verified 2026-07-13 on Trip); ``id_sort=False`` also selects
244
+ the single-shot/bisection pairing -- there is no page advance, the
245
+ driver re-invokes this builder per sub-window. The ExceptionEvent
246
+ probes found ``version`` and date fields sortable for that type --
247
+ banked as feed-design input, which is why this field may later widen
248
+ to a seek-key enum.
249
+
250
+ Attributes:
251
+ server: The pre-auth authentication host (retargeted by the
252
+ session strategy after Authenticate).
253
+ type_name: The GeoTab entity to fetch (``'Trip'``,
254
+ ``'ExceptionEvent'``).
255
+ results_limit: The per-request record limit; for an unsorted
256
+ leaf it doubles as the bisection overflow threshold.
257
+ id_sort: Whether the probed ``sort`` member is written -- the
258
+ per-type declared capability.
259
+ """
260
+
261
+ server: str
262
+ type_name: str
263
+ results_limit: int
264
+ id_sort: bool
265
+
266
+ def build_spec(
267
+ self, resume: ResumeValue, member_values: Mapping[str, str]
268
+ ) -> RequestSpec:
269
+ """Build one window's request.
270
+
271
+ Args:
272
+ resume: The window to fetch. Must be a ``DateWindow`` -- a
273
+ watermark endpoint always resumes from one; any other
274
+ value is a wiring bug.
275
+ member_values: Accepted to satisfy the protocol; unused --
276
+ a single-chain endpoint binds no member.
277
+
278
+ Returns:
279
+ A credential-less JSON-RPC POST carrying the window as
280
+ ``search.fromDate`` / ``search.toDate`` (UTC ISO-8601 ``Z``
281
+ strings; the half-open end passes verbatim -- the runner's
282
+ per-batch window filter owns the boundary), with the probed
283
+ ``sort`` member iff ``id_sort``.
284
+
285
+ Raises:
286
+ TypeError: ``resume`` is not a ``DateWindow``.
287
+ """
288
+ window = require_date_window(resume, type(self).__name__)
289
+ return _post_spec(
290
+ self.server,
291
+ _get_json_body(
292
+ type_name=self.type_name,
293
+ results_limit=self.results_limit,
294
+ window=window,
295
+ id_sort=self.id_sort,
296
+ ),
297
+ )
298
+
299
+
300
+ @dataclass(frozen=True, slots=True)
301
+ class GeotabGetFeedSpecBuilder:
302
+ """Build a feed endpoint's first ``GetFeed`` request: seed or resume.
303
+
304
+ One builder serves every feed leaf; the leaf declares only its
305
+ ``type_name`` and ``results_limit``. The resume value decides the
306
+ shape (the wire-proven pair, DESIGN section 4):
307
+
308
+ - ``FeedSeed`` -- the tokenless first run. ``search.fromDate`` carries
309
+ the cold-start anchor, positioning the feed at a version covering all
310
+ entities with date >= that instant; NO ``fromVersion`` is written.
311
+ Wire-proven to the second on LogRecord and StatusData (2026-07-21)
312
+ DESPITE the docs claiming those types' search is ignored.
313
+ - ``FeedToken`` -- every run after. ``fromVersion`` carries the stored
314
+ token; NO ``search`` is written (the decoder's advances strip it the
315
+ same way, so the pair of sites agree).
316
+
317
+ Every request after this one is the decoder's
318
+ (``GeotabFeedPageDecoder`` advances by ``toVersion`` and reads
319
+ ``resultsLimit`` from the sent body, so builder-versus-decoder
320
+ divergence is structurally impossible).
321
+
322
+ Attributes:
323
+ server: The pre-auth authentication host (retargeted by the
324
+ session strategy after Authenticate).
325
+ type_name: The GeoTab entity to feed (``'LogRecord'``, ``'Trip'``).
326
+ results_limit: The page size; the short-page terminal rule and the
327
+ 50,000 protocol maximum both key off it (per-type caps are the
328
+ leaf's declaration concern).
329
+ """
330
+
331
+ server: str
332
+ type_name: str
333
+ results_limit: int
334
+
335
+ def build_spec(
336
+ self, resume: ResumeValue, member_values: Mapping[str, str]
337
+ ) -> RequestSpec:
338
+ """Build the feed walk's first request.
339
+
340
+ Args:
341
+ resume: The seed-or-token a feed run always carries -- a
342
+ ``FeedSeed`` (first run) or ``FeedToken`` (every run
343
+ after); any other value is a wiring bug.
344
+ member_values: Accepted to satisfy the protocol; unused --
345
+ a single-chain endpoint binds no member.
346
+
347
+ Returns:
348
+ A credential-less JSON-RPC POST; ``params.credentials`` and
349
+ the resolved host are the session strategy's injections.
350
+
351
+ Raises:
352
+ TypeError: ``resume`` is neither a ``FeedSeed`` nor a
353
+ ``FeedToken``.
354
+ ValueError: A seed ``start`` that is naive or non-UTC
355
+ (surfaced from the timing codec).
356
+ """
357
+ feed_resume = require_feed_resume(resume, type(self).__name__)
358
+ params: dict[str, JsonValue] = {_TYPE_NAME_KEY: self.type_name}
359
+ match feed_resume:
360
+ case FeedSeed(start=start):
361
+ params[_SEARCH_KEY] = {_FROM_DATE_KEY: to_iso8601(start)}
362
+ case FeedToken(from_version=from_version):
363
+ params[_FROM_VERSION_KEY] = from_version
364
+ params[_RESULTS_LIMIT_KEY] = self.results_limit
365
+ return _post_spec(
366
+ self.server, {_METHOD_KEY: _GET_FEED_METHOD, _PARAMS_KEY: params}
367
+ )
368
+
369
+
370
+ class _GetCountOfEnvelope(StrictEnvelopeSlice):
371
+ """Envelope slice: ``GetCountOf`` returns the count under ``result``.
372
+
373
+ A ``StrictEnvelopeSlice``, so a stringly count fails loudly instead
374
+ of coercing and a boolean never passes as an integer.
375
+ """
376
+
377
+ result: int
378
+
379
+
380
+ @dataclass(frozen=True, slots=True)
381
+ class GetCountOfCheck:
382
+ """The GeoTab completeness check: ``GetCountOf`` as truth instrument.
383
+
384
+ Fires one ``GetCountOf`` JSON-RPC request for the harvested entity
385
+ through the same open client the harvest used -- session auth, the
386
+ limiter (one token on the given scope, the token-per-attempt law),
387
+ and the classifier all apply -- and reads the integer count through
388
+ a private envelope slice. Declared on the snapshot definitions so
389
+ the single-fetch driver can prove the capped ``Get`` walk lost
390
+ nothing (probe-settled decision).
391
+
392
+ Attributes:
393
+ server: The pre-auth authentication host (retargeted by the
394
+ session strategy, exactly as the data requests are).
395
+ type_name: The GeoTab entity to count (``'Device'``, ``'User'``).
396
+ """
397
+
398
+ server: str
399
+ type_name: str
400
+
401
+ def expected_count(self, client: EnvelopeFetcher, quota_scope: str) -> int:
402
+ """Return GeoTab's reported count of the harvested entity.
403
+
404
+ Args:
405
+ client: The open transport client the harvest ran on (its
406
+ single-request ``fetch_envelope`` surface).
407
+ quota_scope: The endpoint's rate-limit scope key
408
+ (``GEOTAB_GET`` -- the count spends from the same
409
+ method-class budget as the data pages).
410
+
411
+ Returns:
412
+ The provider-reported entity count.
413
+
414
+ Raises:
415
+ ProviderResponseError: The envelope's ``result`` is not an
416
+ integer (via the slice model), or the request failed
417
+ fatally (via the client).
418
+ """
419
+ spec = _post_spec(
420
+ self.server,
421
+ {
422
+ _METHOD_KEY: _GET_COUNT_OF_METHOD,
423
+ _PARAMS_KEY: {_TYPE_NAME_KEY: self.type_name},
424
+ },
425
+ )
426
+ envelope = client.fetch_envelope(spec, quota_scope)
427
+ return validated_envelope_slice(_GetCountOfEnvelope, envelope).result
@@ -0,0 +1,73 @@
1
+ # src/fleetpull/endpoints/geotab/annotation_logs.py
2
+ """The GeoTab annotation_logs binding: the duty-status-log annotation feed.
3
+
4
+ A ``GetFeed`` drive of the ``AnnotationLog`` entity — free-text
5
+ annotations attached to HOS duty-status logs, carrying a per-record
6
+ ``version``. Annotations are user-editable, so re-emission under newer
7
+ versions is expected, every emitted version is stored, and the consumer
8
+ reconciles by ``(id, max version)`` (DESIGN §4). The annotation's
9
+ ``dutyStatusLog`` ref points back to the ``duty_status_logs`` vertical
10
+ (``models/geotab/annotation_log.py``), completing the wave-two loop.
11
+
12
+ ``resultsLimit`` is the 50,000 protocol maximum: the docs list no lower
13
+ per-type cap for this type (the SCALE census cannot probe a cap the
14
+ population never reaches — the FillUp dual-provenance lesson's other
15
+ arm, DESIGN §8), so the protocol maximum stands.
16
+
17
+ Every request here is a JSON-RPC POST whose ``params.credentials`` and
18
+ resolved host are the session strategy's injections (the devices-leaf
19
+ convention); the host this module writes is a pre-auth placeholder.
20
+ """
21
+
22
+ from typing import Final
23
+
24
+ from fleetpull.config import GeotabConfig
25
+ from fleetpull.endpoints.geotab._requests import GeotabGetFeedSpecBuilder, server_host
26
+ from fleetpull.endpoints.shared import EndpointDefinition, FeedMode, StorageKind
27
+ from fleetpull.models.geotab import AnnotationLog
28
+ from fleetpull.network.decoders import GeotabFeedPageDecoder
29
+ from fleetpull.vocabulary import Provider, QuotaScope
30
+
31
+ __all__: list[str] = ['build_endpoint']
32
+
33
+ # The GetFeed protocol maximum; no lower documented per-type cap.
34
+ _RESULTS_LIMIT: Final[int] = 50000
35
+
36
+ _ANNOTATION_LOG_TYPE_NAME: Final[str] = 'AnnotationLog'
37
+
38
+
39
+ def build_endpoint(config: GeotabConfig) -> EndpointDefinition[AnnotationLog]:
40
+ """Build the GeoTab annotation_logs feed binding.
41
+
42
+ Duty-status-log annotations fetched incrementally as a version-token
43
+ feed: the run resumes from the stored token (seeded via
44
+ ``search.fromDate`` on the tokenless first run only), each page
45
+ appends durably before its ``toVersion`` commits, and the fetched
46
+ records land in ``date=YYYY-MM-DD`` partitions as new numbered part
47
+ files — re-emitted versions accumulate for the consumer's
48
+ ``(id, max version)`` reconcile.
49
+
50
+ Args:
51
+ config: The validated GeoTab configuration; supplies the
52
+ authentication host the pre-auth spec URLs are built on.
53
+
54
+ Returns:
55
+ The frozen annotation_logs ``EndpointDefinition``. Construction
56
+ validates the ``FeedMode`` / ``APPEND_LOG`` /
57
+ ``event_time_column`` triple against the response model.
58
+ """
59
+ return EndpointDefinition(
60
+ provider=Provider.GEOTAB,
61
+ name='annotation_logs',
62
+ spec_builder=GeotabGetFeedSpecBuilder(
63
+ server=server_host(config),
64
+ type_name=_ANNOTATION_LOG_TYPE_NAME,
65
+ results_limit=_RESULTS_LIMIT,
66
+ ),
67
+ page_decoder=GeotabFeedPageDecoder(),
68
+ response_model=AnnotationLog,
69
+ quota_scope=QuotaScope.GEOTAB_FEED,
70
+ storage_kind=StorageKind.APPEND_LOG,
71
+ sync_mode=FeedMode(),
72
+ event_time_column='date_time',
73
+ )
@@ -0,0 +1,70 @@
1
+ # src/fleetpull/endpoints/geotab/audits.py
2
+ """The GeoTab audits binding: the configuration audit-trail feed.
3
+
4
+ A ``GetFeed`` drive of the ``Audit`` entity — audit-trail entries
5
+ carrying a per-record ``version``. Audit entries reconcile by
6
+ ``(id, max version)`` (DESIGN §4). The simplest feed vertical: the
7
+ ``Audit`` model carries no reference fields.
8
+
9
+ ``resultsLimit`` is the 50,000 protocol maximum: the docs list no lower
10
+ per-type cap for this type (the SCALE census cannot probe a cap the
11
+ population never reaches — the FillUp dual-provenance lesson's other
12
+ arm, DESIGN §8), so the protocol maximum stands.
13
+
14
+ Every request here is a JSON-RPC POST whose ``params.credentials`` and
15
+ resolved host are the session strategy's injections (the devices-leaf
16
+ convention); the host this module writes is a pre-auth placeholder.
17
+ """
18
+
19
+ from typing import Final
20
+
21
+ from fleetpull.config import GeotabConfig
22
+ from fleetpull.endpoints.geotab._requests import GeotabGetFeedSpecBuilder, server_host
23
+ from fleetpull.endpoints.shared import EndpointDefinition, FeedMode, StorageKind
24
+ from fleetpull.models.geotab import Audit
25
+ from fleetpull.network.decoders import GeotabFeedPageDecoder
26
+ from fleetpull.vocabulary import Provider, QuotaScope
27
+
28
+ __all__: list[str] = ['build_endpoint']
29
+
30
+ # The GetFeed protocol maximum; no lower documented per-type cap.
31
+ _RESULTS_LIMIT: Final[int] = 50000
32
+
33
+ _AUDIT_TYPE_NAME: Final[str] = 'Audit'
34
+
35
+
36
+ def build_endpoint(config: GeotabConfig) -> EndpointDefinition[Audit]:
37
+ """Build the GeoTab audits feed binding.
38
+
39
+ Audit-trail entries fetched incrementally as a version-token feed:
40
+ the run resumes from the stored token (seeded via ``search.fromDate``
41
+ on the tokenless first run only), each page appends durably before
42
+ its ``toVersion`` commits, and the fetched records land in
43
+ ``date=YYYY-MM-DD`` partitions as new numbered part files —
44
+ re-emitted versions accumulate for the consumer's ``(id, max
45
+ version)`` reconcile.
46
+
47
+ Args:
48
+ config: The validated GeoTab configuration; supplies the
49
+ authentication host the pre-auth spec URLs are built on.
50
+
51
+ Returns:
52
+ The frozen audits ``EndpointDefinition``. Construction validates
53
+ the ``FeedMode`` / ``APPEND_LOG`` / ``event_time_column`` triple
54
+ against the response model.
55
+ """
56
+ return EndpointDefinition(
57
+ provider=Provider.GEOTAB,
58
+ name='audits',
59
+ spec_builder=GeotabGetFeedSpecBuilder(
60
+ server=server_host(config),
61
+ type_name=_AUDIT_TYPE_NAME,
62
+ results_limit=_RESULTS_LIMIT,
63
+ ),
64
+ page_decoder=GeotabFeedPageDecoder(),
65
+ response_model=Audit,
66
+ quota_scope=QuotaScope.GEOTAB_FEED,
67
+ storage_kind=StorageKind.APPEND_LOG,
68
+ sync_mode=FeedMode(),
69
+ event_time_column='date_time',
70
+ )
@@ -0,0 +1,79 @@
1
+ # src/fleetpull/endpoints/geotab/devices.py
2
+ """The GeoTab devices binding: a factory composing the devices snapshot
3
+ EndpointDefinition from resolved GeoTab configuration.
4
+
5
+ A binding cannot be a module-level constant because its spec-builder and
6
+ completeness check need the run's configured authentication host, known
7
+ only after the YAML config loads; so the endpoint is a factory taking a
8
+ validated ``GeotabConfig`` and returning the frozen
9
+ ``EndpointDefinition`` the composition root hands to the client (the
10
+ Motive leaf convention).
11
+
12
+ Every request here is a JSON-RPC POST to ``https://{server}/apiv1``
13
+ whose ``params.credentials`` are injected by the session auth strategy,
14
+ never built here; the strategy also retargets each prepared request to
15
+ the session's resolved host, so the host this module writes is a
16
+ pre-auth placeholder that never reaches the wire on its own (DESIGN
17
+ section 8). The seek walk and the ``GetCountOfCheck`` truth instrument
18
+ live in the shared ``_requests`` module (promoted when the users
19
+ leaf became their second consumer); this leaf binds them to ``Device``.
20
+ """
21
+
22
+ from typing import Final
23
+
24
+ from fleetpull.config import GeotabConfig
25
+ from fleetpull.endpoints.geotab._requests import (
26
+ GeotabGetSpecBuilder,
27
+ GetCountOfCheck,
28
+ server_host,
29
+ )
30
+ from fleetpull.endpoints.shared import (
31
+ EndpointDefinition,
32
+ SnapshotMode,
33
+ StorageKind,
34
+ )
35
+ from fleetpull.models.geotab import Device
36
+ from fleetpull.network.decoders import GeotabGetPageDecoder
37
+ from fleetpull.vocabulary import Provider, QuotaScope
38
+
39
+ __all__: list[str] = ['build_endpoint']
40
+
41
+ # The largest sound page under Get's silent 5,000-record cap.
42
+ _RESULTS_LIMIT: Final[int] = 5000
43
+
44
+ _DEVICE_TYPE_NAME: Final[str] = 'Device'
45
+
46
+
47
+ def build_endpoint(config: GeotabConfig) -> EndpointDefinition[Device]:
48
+ """Build the GeoTab devices snapshot binding.
49
+
50
+ A full-listing snapshot of the account's Device entities (tracked
51
+ vehicles and trailer entries alike): no resume, a single parquet
52
+ file, full replacement each run. Records arrive as a plain list
53
+ under ``result``, walked by id-ascending seek pages under the
54
+ silent 5,000-record ``Get`` cap, and every harvest is verified
55
+ against ``GetCountOf`` before anything flows downstream.
56
+
57
+ Args:
58
+ config: The validated GeoTab configuration; supplies the
59
+ authentication host the pre-auth spec URLs are built on.
60
+
61
+ Returns:
62
+ The frozen devices ``EndpointDefinition``.
63
+ """
64
+ server = server_host(config)
65
+ return EndpointDefinition(
66
+ provider=Provider.GEOTAB,
67
+ name='devices',
68
+ spec_builder=GeotabGetSpecBuilder(
69
+ server=server,
70
+ type_name=_DEVICE_TYPE_NAME,
71
+ results_limit=_RESULTS_LIMIT,
72
+ ),
73
+ page_decoder=GeotabGetPageDecoder(),
74
+ response_model=Device,
75
+ quota_scope=QuotaScope.GEOTAB_GET,
76
+ storage_kind=StorageKind.SINGLE,
77
+ sync_mode=SnapshotMode(),
78
+ completeness_check=GetCountOfCheck(server=server, type_name=_DEVICE_TYPE_NAME),
79
+ )