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,202 @@
1
+ # src/fleetpull/endpoints/samsara/_spec_builders.py
2
+ """The shared Samsara multi-leaf spec-builders.
3
+
4
+ Two builder families, each serving one wire family through multiple
5
+ leaves (the Motive ``_spec_builders`` promotion precedent):
6
+
7
+ - ``SamsaraVehicleStatsSpecBuilder`` for the three
8
+ ``/fleet/vehicles/stats/history`` leaves (engine_states,
9
+ gps_readings, odometer_readings) -- one wire surface serving three
10
+ endpoints, each requesting exactly its own stat type, so the one
11
+ varying fact (``types``) is a builder field and the window rendering
12
+ is written once (three users at birth).
13
+ - ``SamsaraFuelEnergyReportSpecBuilder`` for the two
14
+ ``/fleet/reports/{vehicles,drivers}/fuel-energy`` leaves -- one wire
15
+ family whose only varying fact is the path, and whose window rides
16
+ the surface family's own ``startDate``/``endDate`` param NAMES (two
17
+ users at birth).
18
+
19
+ The idling_events leaf keeps its own builder: it carries no ``types``
20
+ param and no naming quirk, and folding it in would trade a real
21
+ one-family bundle for a provider-wide window-builder abstraction
22
+ nobody asked for.
23
+ """
24
+
25
+ from collections.abc import Mapping
26
+ from dataclasses import dataclass
27
+ from typing import Final
28
+
29
+ from fleetpull.endpoints.shared import ResumeValue, require_date_window
30
+ from fleetpull.network.contract import HttpMethod, RequestSpec
31
+ from fleetpull.timing import to_iso8601
32
+
33
+ __all__: list[str] = [
34
+ 'RECORDS_KEY',
35
+ 'RESULTS_LIMIT',
36
+ 'STATS_HISTORY_PATH',
37
+ 'SamsaraFuelEnergyReportSpecBuilder',
38
+ 'SamsaraVehicleStatsSpecBuilder',
39
+ ]
40
+
41
+ # The one probed wire surface's coinciding facts, stated once for the
42
+ # three leaves (only the stat type varies per leaf). A re-probe that
43
+ # moves any of these lands here and reaches every leaf.
44
+ STATS_HISTORY_PATH: Final[str] = '/fleet/vehicles/stats/history'
45
+ RECORDS_KEY: Final[str] = 'data'
46
+
47
+ # The per-page vehicle count. 512 is THIS surface's probed maximum
48
+ # (limit=512 -> HTTP 200, limit=513 -> HTTP 400, captured 2026-07-20):
49
+ # the vehicles/drivers tier, NOT idling's 200. Never assume a sibling's
50
+ # limit.
51
+ RESULTS_LIMIT: Final[int] = 512
52
+
53
+ # The stat-type selector, API-enforced on input: an unknown value is a
54
+ # loud HTTP 400 naming the bogus type ('Invalid stat type(s): ...'),
55
+ # never a silent empty page (captured 2026-07-20).
56
+ _TYPES_PARAM: Final[str] = 'types'
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class SamsaraVehicleStatsSpecBuilder:
61
+ """Build the fleet-wide, date-windowed first request for one stat type.
62
+
63
+ The ``SpecBuilder`` for a vehicle-stats single chain: a fixed
64
+ ``GET base_url + path`` carrying the resume window as RFC3339
65
+ ``startTime``/``endTime`` (the timing codec's ``to_iso8601``) plus
66
+ the FIXED ``types=<stat_type>`` selector baked into every request.
67
+ The decoder owns pagination: its ``first_request`` merges ``limit``
68
+ onto this spec and its ``after`` advance merges onto the sent spec,
69
+ so the window and the type selector persist across the whole
70
+ vehicle-axis cursor walk (the idling_events mechanism).
71
+
72
+ The canonical half-open ``[start, end)`` window maps to the wire as
73
+ ``startTime = start`` and ``endTime = end``. Retrieval is
74
+ READING-TIME anchored on exactly that half-open window
75
+ (probe-proven: a 12:00-13:00Z window returned min 12:00:03.062Z,
76
+ max 12:59:56.881Z), so a window's readings are exactly those
77
+ timestamped inside it; the runner's post-fetch window filter is
78
+ pure hygiene, never load-bearing.
79
+
80
+ Attributes:
81
+ base_url: Root of the Samsara API, trailing-slash-normalized by
82
+ the provider config so the leading-slash path joins
83
+ directly.
84
+ path: The endpoint's leading-slash request path
85
+ (``'/fleet/vehicles/stats/history'``).
86
+ stat_type: The one stat type this endpoint requests -- the
87
+ verbatim ``types`` wire value, which is also the
88
+ per-vehicle series key the decoder unnests.
89
+ """
90
+
91
+ base_url: str
92
+ path: str
93
+ stat_type: str
94
+
95
+ def build_spec(
96
+ self, resume: ResumeValue, member_values: Mapping[str, str]
97
+ ) -> RequestSpec:
98
+ """Build the fleet-wide, date-windowed, single-type GET.
99
+
100
+ Args:
101
+ resume: The run's resume window. Must be a ``DateWindow`` --
102
+ a watermark endpoint always resumes from one; any other
103
+ value is a wiring bug.
104
+ member_values: Accepted to satisfy the protocol; unused -- a
105
+ fleet-wide single chain binds no member.
106
+
107
+ Returns:
108
+ A credential-less ``GET`` for ``base_url + path`` carrying
109
+ ``types=<stat_type>`` and the window's bounds as RFC3339
110
+ ``startTime``/``endTime``. Auth headers are layered on by
111
+ the client's ``ProviderProfile``; pagination parameters are
112
+ injected by the page decoder's ``first_request``.
113
+
114
+ Raises:
115
+ TypeError: ``resume`` is not a ``DateWindow``.
116
+ ValueError: A window bound is not canonical UTC.
117
+
118
+ Side Effects:
119
+ None.
120
+ """
121
+ resume_window = require_date_window(resume, type(self).__name__)
122
+ params = {
123
+ _TYPES_PARAM: self.stat_type,
124
+ 'startTime': to_iso8601(resume_window.start),
125
+ 'endTime': to_iso8601(resume_window.end),
126
+ }
127
+ return RequestSpec(
128
+ method=HttpMethod.GET,
129
+ url=f'{self.base_url}{self.path}',
130
+ params=params,
131
+ )
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class SamsaraFuelEnergyReportSpecBuilder:
136
+ """Build the fleet-wide, date-windowed first request for one report arm.
137
+
138
+ The ``SpecBuilder`` for a fuel-energy report single chain: a fixed
139
+ ``GET base_url + path`` carrying the resume window as RFC3339
140
+ ``startDate``/``endDate`` (the timing codec's ``to_iso8601``). NOTE
141
+ the param NAMES: this surface family takes ``startDate``/``endDate``
142
+ -- unlike every other probed Samsara vertical's
143
+ ``startTime``/``endTime`` -- while accepting full RFC3339 datetimes
144
+ despite the names (probed with ``T00:00:00Z`` values, and a 1-hour
145
+ window returned 200; captured 2026-07-21). The rendering itself is
146
+ exactly the sibling windowed builders' ``[start, end)`` mapping.
147
+ The decoder owns pagination AND the window stamp: its
148
+ ``first_request`` merges ``limit`` onto this spec, its ``after``
149
+ advance merges onto the sent spec (so the window persists across
150
+ the whole walk), and it copies these two params back off the sent
151
+ spec onto every report row.
152
+
153
+ Attributes:
154
+ base_url: Root of the Samsara API, trailing-slash-normalized by
155
+ the provider config so the leading-slash path joins
156
+ directly.
157
+ path: The arm's leading-slash request path
158
+ (``'/fleet/reports/vehicles/fuel-energy'`` /
159
+ ``'/fleet/reports/drivers/fuel-energy'``) -- the one fact
160
+ varying between the two leaves.
161
+ """
162
+
163
+ base_url: str
164
+ path: str
165
+
166
+ def build_spec(
167
+ self, resume: ResumeValue, member_values: Mapping[str, str]
168
+ ) -> RequestSpec:
169
+ """Build the fleet-wide, date-windowed GET for one report arm.
170
+
171
+ Args:
172
+ resume: The run's resume window. Must be a ``DateWindow`` --
173
+ a watermark endpoint always resumes from one; any other
174
+ value is a wiring bug.
175
+ member_values: Accepted to satisfy the protocol; unused -- a
176
+ fleet-wide single chain binds no member.
177
+
178
+ Returns:
179
+ A credential-less ``GET`` for ``base_url + path`` carrying
180
+ the window's bounds as RFC3339 ``startDate``/``endDate``
181
+ (the surface family's own param names). Auth headers are
182
+ layered on by the client's ``ProviderProfile``; pagination
183
+ parameters are injected by the page decoder's
184
+ ``first_request``.
185
+
186
+ Raises:
187
+ TypeError: ``resume`` is not a ``DateWindow``.
188
+ ValueError: A window bound is not canonical UTC.
189
+
190
+ Side Effects:
191
+ None.
192
+ """
193
+ resume_window = require_date_window(resume, type(self).__name__)
194
+ params = {
195
+ 'startDate': to_iso8601(resume_window.start),
196
+ 'endDate': to_iso8601(resume_window.end),
197
+ }
198
+ return RequestSpec(
199
+ method=HttpMethod.GET,
200
+ url=f'{self.base_url}{self.path}',
201
+ params=params,
202
+ )
@@ -0,0 +1,77 @@
1
+ # src/fleetpull/endpoints/samsara/addresses.py
2
+ """The Samsara addresses binding: a factory composing the addresses
3
+ snapshot EndpointDefinition from resolved Samsara configuration.
4
+
5
+ A binding cannot be a module-level constant because its spec-builder
6
+ needs the run's configured base URL, known only after the YAML config
7
+ loads; so the endpoint is a factory taking a validated ``SamsaraConfig``
8
+ and returning the frozen ``EndpointDefinition`` the composition root
9
+ hands to the client (the Motive leaf convention).
10
+
11
+ The vehicles template verbatim (2026-07-20 probe session): a plain
12
+ snapshot on the standard Samsara cursor contract -- ``data`` beside
13
+ ``pagination {endCursor, hasNextPage}`` -- with no roster sourced, no
14
+ roster consumed, and no window. The full walk was the whole population
15
+ in one page (25 records), and the walk is complete by construction:
16
+ continuation is explicit on every page and a promised continuation
17
+ without a cursor fails loudly in the decoder, so no completeness check
18
+ is declared.
19
+ """
20
+
21
+ from typing import Final
22
+
23
+ from fleetpull.config import SamsaraConfig
24
+ from fleetpull.endpoints.shared import (
25
+ EndpointDefinition,
26
+ SnapshotMode,
27
+ StaticGetSpecBuilder,
28
+ StorageKind,
29
+ )
30
+ from fleetpull.models.samsara import Address
31
+ from fleetpull.network.decoders import SamsaraCursorPageDecoder
32
+ from fleetpull.vocabulary import Provider, QuotaScope
33
+
34
+ __all__: list[str] = ['build_endpoint']
35
+
36
+ _ADDRESSES_PATH: Final[str] = '/addresses'
37
+ _RECORDS_KEY: Final[str] = 'data'
38
+
39
+ # The per-page record count. The limit tier was probed directly on THIS
40
+ # endpoint (2026-07-20): limit=512 returned HTTP 200 and limit=513 a
41
+ # loud HTTP 400, so /addresses sits in the vehicles/drivers 512 tier --
42
+ # NOT the 200 tier of /idling/events. Samsara limit maxima are
43
+ # per-endpoint (the idling_events capture's rule): never assume a
44
+ # sibling's tier. A strong candidate for a user config knob.
45
+ _RESULTS_LIMIT: Final[int] = 512
46
+
47
+
48
+ def build_endpoint(config: SamsaraConfig) -> EndpointDefinition[Address]:
49
+ """Build the Samsara addresses snapshot binding.
50
+
51
+ A full-listing snapshot of the fleet's defined addresses (named
52
+ locations with geofences): no resume, a single parquet file, full
53
+ replacement each run. Records arrive as a top-level list under
54
+ ``data``, walked by explicit cursor pages (``limit`` on page one,
55
+ ``after`` merged thereafter), terminal on ``hasNextPage: false``.
56
+
57
+ Args:
58
+ config: The validated Samsara configuration; supplies the base
59
+ URL the spec-builder joins to the addresses path.
60
+
61
+ Returns:
62
+ The frozen addresses ``EndpointDefinition``.
63
+ """
64
+ return EndpointDefinition(
65
+ provider=Provider.SAMSARA,
66
+ name='addresses',
67
+ spec_builder=StaticGetSpecBuilder(
68
+ base_url=config.base_url, path=_ADDRESSES_PATH
69
+ ),
70
+ page_decoder=SamsaraCursorPageDecoder(
71
+ records_key=_RECORDS_KEY, results_limit=_RESULTS_LIMIT
72
+ ),
73
+ response_model=Address,
74
+ quota_scope=QuotaScope.SAMSARA,
75
+ storage_kind=StorageKind.SINGLE,
76
+ sync_mode=SnapshotMode(),
77
+ )
@@ -0,0 +1,217 @@
1
+ # src/fleetpull/endpoints/samsara/asset_locations.py
2
+ """The Samsara asset_locations binding: the windowed batched roster
3
+ fan-out -- the first ``BatchedRosterFanOut`` consumer.
4
+
5
+ ``GET /assets/location-and-speed/stream`` is a modern-envelope surface
6
+ (``data`` + ``pagination {endCursor, hasNextPage}`` -- the standard
7
+ cursor contract; the ``endCursor`` is a fat composite token, opaque,
8
+ passed back verbatim as ``after`` like every other cursor). The legacy
9
+ hub called this surface ``location_stream``; the shipped endpoint is
10
+ ``asset_locations`` per the name=plural-of-entity invariant.
11
+
12
+ The ``ids`` parameter is REQUIRED: omitting it is a loud HTTP 400
13
+ (``"Need to include asset IDs to filter by."``), and the batch cap is
14
+ API-ENFORCED AT 50 -- 50 comma-joined ids returned 200 while 100,
15
+ 200, and 609 each returned HTTP 400 naming the bound (``"Need to
16
+ filter by 50 or less asset IDs or syncTokens."``; captured
17
+ 2026-07-20). So the binding declares
18
+ ``request_shape=BatchedRosterFanOut`` over the Samsara ``vehicle_ids``
19
+ roster (declared beside its feeder in ``endpoints/samsara/vehicles.py``;
20
+ this module knows only the ``RosterKey``): one cursor-walk chain per
21
+ sorted 50-member batch, the batch comma-joined into the ``ids`` query
22
+ parameter. The batch is transport packing only -- every record carries
23
+ its own ``asset.id`` attribution, so no member attribution rides on the
24
+ request mapping, and the fan-out driver's progress narration counts
25
+ BATCHES for this shape (a deliberate, recorded consequence -- DESIGN
26
+ section 8).
27
+
28
+ The windowed leaf builder carries the resume window as RFC3339
29
+ ``startTime``/``endTime`` (the idling_events builder precedent) and
30
+ merges the batch binding verbatim as a query parameter (the trips
31
+ member-merge precedent); the decoder's ``first_request`` merges
32
+ ``limit`` and its ``after`` advance merges onto the SENT spec, so the
33
+ window and the batch ride every page of each chain's walk.
34
+
35
+ The per-endpoint ``limit`` maximum is 512, probed directly on THIS
36
+ surface: limit=512 returned HTTP 200 and limit=513 a loud HTTP 400 --
37
+ the vehicles/drivers tier, NOT idling's 200 (the per-endpoint
38
+ limit-tier rule, honored by probing rather than assuming).
39
+
40
+ Retrieval is READING-TIME anchored on the half-open ``[startTime,
41
+ endTime)`` window, probe-proven: a 12:00-13:00Z window returned
42
+ readings spanning exactly 12:00:03Z..12:59:56Z. Consequence:
43
+ ``event_time_column='happened_at_time'``, the retrieval anchor and the
44
+ routing anchor coincide natively, no wire pad exists, and the runner's
45
+ post-fetch window filter is pure hygiene.
46
+ """
47
+
48
+ from collections.abc import Mapping
49
+ from dataclasses import dataclass
50
+ from datetime import timedelta
51
+ from typing import Final
52
+
53
+ from fleetpull.config import SamsaraConfig
54
+ from fleetpull.endpoints.shared import (
55
+ BatchedRosterFanOut,
56
+ EndpointDefinition,
57
+ ResumeValue,
58
+ StorageKind,
59
+ WatermarkMode,
60
+ require_date_window,
61
+ )
62
+ from fleetpull.models.samsara import AssetLocation
63
+ from fleetpull.network.contract import HttpMethod, RequestSpec
64
+ from fleetpull.network.decoders import SamsaraCursorPageDecoder
65
+ from fleetpull.roster import RosterKey
66
+ from fleetpull.timing import to_iso8601
67
+ from fleetpull.vocabulary import Provider, QuotaScope
68
+
69
+ __all__: list[str] = [
70
+ 'SamsaraAssetLocationsSpecBuilder',
71
+ 'build_endpoint',
72
+ ]
73
+
74
+ _ASSET_LOCATIONS_PATH: Final[str] = '/assets/location-and-speed/stream'
75
+ _RECORDS_KEY: Final[str] = 'data'
76
+
77
+ # The per-page record count. 512 is THIS endpoint's probed maximum
78
+ # (limit=512 -> HTTP 200, limit=513 -> HTTP 400, captured 2026-07-20):
79
+ # the vehicles/drivers tier, NOT idling's 200. Never assume a sibling's
80
+ # limit.
81
+ _RESULTS_LIMIT: Final[int] = 512
82
+
83
+ # The fan-out member key IS the wire query parameter, verbatim: the spec
84
+ # builder merges member_values into params unchanged, so declaring the
85
+ # exact wire token here leaves no translation seam to drift (the trips
86
+ # stance). Each member value is one sorted, comma-joined 50-id batch.
87
+ _IDS_PARAM: Final[str] = 'ids'
88
+
89
+ # The batch cap is API-ENFORCED AT 50, probed directly: 50 comma-joined
90
+ # ids -> HTTP 200; 100/200/609 -> HTTP 400 '{"message": "Need to filter
91
+ # by 50 or less asset IDs or syncTokens."}' (captured 2026-07-20).
92
+ _BATCH_SIZE: Final[int] = 50
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class SamsaraAssetLocationsSpecBuilder:
97
+ """Build one batch chain's date-windowed first request.
98
+
99
+ The ``SpecBuilder`` for the asset_locations ``BatchedRosterFanOut``:
100
+ a fixed ``GET base_url + path`` carrying the chain's batch binding
101
+ verbatim as query parameters (``{'ids': '<id>,<id>,...'}`` -- the
102
+ trips member-merge precedent) plus the resume window as RFC3339
103
+ ``startTime``/``endTime`` (the timing codec's ``to_iso8601``). The
104
+ decoder owns pagination: its ``first_request`` merges ``limit`` onto
105
+ this spec and its ``after`` advance merges onto the sent spec, so
106
+ the window and the batch persist across the whole cursor walk.
107
+
108
+ The canonical half-open ``[start, end)`` window maps to the wire as
109
+ ``startTime = start`` and ``endTime = end``. Retrieval is
110
+ reading-time anchored on exactly that half-open window (module
111
+ docstring), so a window's readings are exactly those timestamped
112
+ inside it; the runner's post-fetch window filter is pure hygiene,
113
+ never load-bearing.
114
+
115
+ Attributes:
116
+ base_url: Root of the Samsara API, trailing-slash-normalized by
117
+ the provider config so the leading-slash path joins
118
+ directly.
119
+ path: The endpoint's leading-slash request path
120
+ (``'/assets/location-and-speed/stream'``).
121
+ """
122
+
123
+ base_url: str
124
+ path: str
125
+
126
+ def build_spec(
127
+ self, resume: ResumeValue, member_values: Mapping[str, str]
128
+ ) -> RequestSpec:
129
+ """Build one batch chain's windowed GET.
130
+
131
+ Args:
132
+ resume: The run's resume window. Must be a ``DateWindow`` --
133
+ a watermark endpoint always resumes from one; any other
134
+ value is a wiring bug.
135
+ member_values: The batch binding, merged verbatim as query
136
+ parameters -- ``{'ids': <comma-joined batch>}`` for a
137
+ batched roster chain (``ids`` is REQUIRED by the wire;
138
+ an empty binding would earn the provider's own loud
139
+ 400).
140
+
141
+ Returns:
142
+ A credential-less ``GET`` for ``base_url + path`` carrying
143
+ the batch binding plus the window's bounds as RFC3339
144
+ ``startTime``/``endTime``. Auth headers are layered on by
145
+ the client's ``ProviderProfile``; pagination parameters are
146
+ injected by the page decoder's ``first_request``.
147
+
148
+ Raises:
149
+ TypeError: ``resume`` is not a ``DateWindow``.
150
+ ValueError: A window bound is not canonical UTC.
151
+
152
+ Side Effects:
153
+ None.
154
+ """
155
+ resume_window = require_date_window(resume, type(self).__name__)
156
+ params = dict(member_values)
157
+ params['startTime'] = to_iso8601(resume_window.start)
158
+ params['endTime'] = to_iso8601(resume_window.end)
159
+ return RequestSpec(
160
+ method=HttpMethod.GET,
161
+ url=f'{self.base_url}{self.path}',
162
+ params=params,
163
+ )
164
+
165
+
166
+ def build_endpoint(config: SamsaraConfig) -> EndpointDefinition[AssetLocation]:
167
+ """Build the Samsara asset_locations watermark binding.
168
+
169
+ Per-asset location readings fetched incrementally: the run resumes
170
+ from a ``DateWindow`` (watermark with the provider's late-arrival
171
+ lookback from config), the fetched readings are written to
172
+ ``date=YYYY-MM-DD`` partitions on ``happened_at_time``, and each
173
+ refetched partition is replaced. Records arrive as a top-level list
174
+ under ``data``, already at the reading grain, walked by explicit
175
+ cursor pages per batch chain (``limit`` on page one, ``after``
176
+ merged thereafter, the window and batch parameters persisting
177
+ throughout), terminal on ``hasNextPage: false``. The
178
+ ``request_shape`` declaration (``BatchedRosterFanOut``) names the
179
+ Samsara ``vehicle_ids`` roster; the orchestration entry resolves it
180
+ to members and the shape seam fans one chain per sorted 50-member
181
+ comma-joined batch, passing each batch string to the spec-builder's
182
+ ``member_values`` -- this binding only declares the strategies, the
183
+ roster key, and the probed cap, never the roster's feeder.
184
+
185
+ Args:
186
+ config: The validated Samsara configuration; supplies the base
187
+ URL the spec-builder joins to the stream path and the
188
+ lookback and cutoff the watermark mode carries.
189
+
190
+ Returns:
191
+ The frozen asset_locations ``EndpointDefinition``. Construction
192
+ validates the ``WatermarkMode`` / ``DATE_PARTITIONED`` /
193
+ ``event_time_column`` triple against the response model.
194
+ """
195
+ return EndpointDefinition(
196
+ provider=Provider.SAMSARA,
197
+ name='asset_locations',
198
+ spec_builder=SamsaraAssetLocationsSpecBuilder(
199
+ base_url=config.base_url, path=_ASSET_LOCATIONS_PATH
200
+ ),
201
+ page_decoder=SamsaraCursorPageDecoder(
202
+ records_key=_RECORDS_KEY, results_limit=_RESULTS_LIMIT
203
+ ),
204
+ response_model=AssetLocation,
205
+ quota_scope=QuotaScope.SAMSARA,
206
+ storage_kind=StorageKind.DATE_PARTITIONED,
207
+ sync_mode=WatermarkMode(
208
+ lookback=timedelta(days=config.lookback_days),
209
+ cutoff=timedelta(days=config.cutoff_days),
210
+ ),
211
+ event_time_column='happened_at_time',
212
+ request_shape=BatchedRosterFanOut(
213
+ roster=RosterKey(Provider.SAMSARA, 'vehicle_ids'),
214
+ member_key=_IDS_PARAM,
215
+ batch_size=_BATCH_SIZE,
216
+ ),
217
+ )
@@ -0,0 +1,124 @@
1
+ # src/fleetpull/endpoints/samsara/driver_fuel_energy_reports.py
2
+ """The Samsara driver_fuel_energy_reports binding: the driver arm of
3
+ the window-grain fuel-energy report pair (probe-settled 2026-07-21,
4
+ DESIGN section 8). The legacy hub's ``driver_fuel_energy``, renamed per
5
+ the name=snake-plural-of-model invariant (model
6
+ ``DriverFuelEnergyReport``).
7
+
8
+ ``GET /fleet/reports/drivers/fuel-energy`` is the vehicle arm's wire
9
+ family with the entity swapped: the standard cursor contract over the
10
+ NESTED record list (``data`` an OBJECT whose only key is
11
+ ``driverReports``), reports fleet-wide with per-record driver
12
+ attribution (``driver {id, name}``; NO ``externalIds`` was ever
13
+ observed on this arm), so no fan-out and no roster -- the default
14
+ ``SingleFetch`` shape, declared by declaring nothing.
15
+
16
+ The window-grain facts are the pair's, proven on the family
17
+ (vehicle_fuel_energy_reports carries the full evidence): the rollup
18
+ grain is the request window and day rollups are NON-ADDITIVE into
19
+ wider windows (89/267 mismatched, captured 2026-07-21), so the binding
20
+ declares ``fixed_unit_days=1`` -- the unit width is part of the ROW'S
21
+ MEANING and never floats with ``sync.backfill_chunk_days``. Rows carry
22
+ NO event-time key; the decoder stamps each with the sent window and
23
+ ``event_time_column='window_start'`` routes each day's rollup to its
24
+ own partition. Pagination is real on this arm too: a 1-day driver
25
+ window showed ``hasNextPage: true`` at 100 reports, the same ~100
26
+ server-owned page size the placebo ``limit`` cannot move --
27
+ ``results_limit=100`` is documentation-by-declaration.
28
+
29
+ The window rides the family's own ``startDate``/``endDate`` param
30
+ NAMES (RFC3339 datetimes accepted despite the names; the shared
31
+ ``SamsaraFuelEnergyReportSpecBuilder`` carries the provenance).
32
+ """
33
+
34
+ from datetime import timedelta
35
+ from typing import Final
36
+
37
+ from fleetpull.config import SamsaraConfig
38
+ from fleetpull.endpoints.samsara._spec_builders import (
39
+ SamsaraFuelEnergyReportSpecBuilder,
40
+ )
41
+ from fleetpull.endpoints.shared import (
42
+ EndpointDefinition,
43
+ StorageKind,
44
+ WatermarkMode,
45
+ )
46
+ from fleetpull.models.samsara import DriverFuelEnergyReport
47
+ from fleetpull.network.decoders import SamsaraWindowReportPageDecoder
48
+ from fleetpull.vocabulary import Provider, QuotaScope
49
+
50
+ __all__: list[str] = ['build_endpoint']
51
+
52
+ _DRIVER_FUEL_ENERGY_PATH: Final[str] = '/fleet/reports/drivers/fuel-energy'
53
+ _RECORDS_KEY: Final[str] = 'data'
54
+
55
+ # The nested report key: `data` is an OBJECT whose only key is this
56
+ # arm's report list (captured 2026-07-21).
57
+ _REPORT_KEY: Final[str] = 'driverReports'
58
+
59
+ # The per-page record count. ~100 is the server's OWN observed page
60
+ # size (a 1-day driver window showed hasNextPage: true at 100 reports)
61
+ # and the `limit` param is PROVEN IGNORED on this surface family
62
+ # (limit=512/513/10 all paged identically; 513 NOT rejected -- no
63
+ # enforced tier, captured 2026-07-21). Declaring 100 documents the
64
+ # server's paging; it is not a working knob.
65
+ _RESULTS_LIMIT: Final[int] = 100
66
+
67
+ # The fixed work-unit width, in whole days -- the pair's window-grain
68
+ # and non-additivity proofs (module docstring; captured 2026-07-21):
69
+ # the unit width is part of the row's meaning, pinned here rather than
70
+ # left to sync.backfill_chunk_days.
71
+ _FIXED_UNIT_DAYS: Final[int] = 1
72
+
73
+
74
+ def build_endpoint(
75
+ config: SamsaraConfig,
76
+ ) -> EndpointDefinition[DriverFuelEnergyReport]:
77
+ """Build the Samsara driver_fuel_energy_reports watermark binding.
78
+
79
+ Fleet-wide per-driver fuel-energy rollups fetched incrementally at
80
+ the fixed 1-day unit width: the run resumes from a ``DateWindow``
81
+ (watermark with the provider's late-arrival lookback from config),
82
+ the planner tiles it into exactly-one-day units (the declared
83
+ ``fixed_unit_days`` -- module docstring for the non-additivity
84
+ proof), each unit's reports are stamped with its window by the
85
+ decoder and written to the ``date=YYYY-MM-DD`` partition on
86
+ ``window_start``, and each refetched partition is replaced. Reports
87
+ arrive nested under ``data.driverReports``, walked by explicit
88
+ cursor pages (``limit`` on page one, ``after`` merged thereafter,
89
+ the ``startDate``/``endDate`` window persisting throughout),
90
+ terminal on ``hasNextPage: false``. No request shape is declared --
91
+ the endpoint is a fleet-wide ``SingleFetch``, the default.
92
+
93
+ Args:
94
+ config: The validated Samsara configuration; supplies the base
95
+ URL the spec-builder joins to the report path and the
96
+ lookback and cutoff the watermark mode carries.
97
+
98
+ Returns:
99
+ The frozen driver_fuel_energy_reports ``EndpointDefinition``.
100
+ Construction validates the ``WatermarkMode`` /
101
+ ``DATE_PARTITIONED`` / ``event_time_column`` triple against the
102
+ response model.
103
+ """
104
+ return EndpointDefinition(
105
+ provider=Provider.SAMSARA,
106
+ name='driver_fuel_energy_reports',
107
+ spec_builder=SamsaraFuelEnergyReportSpecBuilder(
108
+ base_url=config.base_url, path=_DRIVER_FUEL_ENERGY_PATH
109
+ ),
110
+ page_decoder=SamsaraWindowReportPageDecoder(
111
+ records_key=_RECORDS_KEY,
112
+ report_key=_REPORT_KEY,
113
+ results_limit=_RESULTS_LIMIT,
114
+ ),
115
+ response_model=DriverFuelEnergyReport,
116
+ quota_scope=QuotaScope.SAMSARA,
117
+ storage_kind=StorageKind.DATE_PARTITIONED,
118
+ sync_mode=WatermarkMode(
119
+ lookback=timedelta(days=config.lookback_days),
120
+ cutoff=timedelta(days=config.cutoff_days),
121
+ fixed_unit_days=_FIXED_UNIT_DAYS,
122
+ ),
123
+ event_time_column='window_start',
124
+ )