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,113 @@
1
+ # src/fleetpull/endpoints/motive/driver_idle_rollups.py
2
+ """The Motive driver_idle_rollups binding: the driver arm of the
3
+ window-grain utilization rollup pair (probe-settled 2026-07-21, DESIGN
4
+ section 8). The legacy hub's ``driver_utilization``, shipped under the
5
+ WIRE'S OWN envelope vocabulary -- the wrapper is
6
+ ``driver_idle_rollups``/``driver_idle_rollup``, a different vocabulary
7
+ from its ``/v2/driver_utilization`` path, and the endpoint mirrors the
8
+ wire (model ``DriverIdleRollup``; the legacy-name mapping is recorded
9
+ in ``ENDPOINTS.md``).
10
+
11
+ The vehicle arm's binding with the path, wrapper keys, and population
12
+ swapped: the standard Motive wrapped-list envelope and offset
13
+ pagination at the configured page size, but rows are the DRIVERS WITH
14
+ ACTIVITY in the window (13 on a quiet single day, 653 across six days
15
+ -- per-driver-per-window grain, unlike the vehicle arm's
16
+ whole-fleet-every-window population), each attributed to the shared
17
+ 8-key ``UserSummary`` ``driver`` ref -- or to NULL on the unattributed
18
+ rollup bucket row. Fleet-wide with per-record attribution, so no
19
+ fan-out and no roster -- the default ``SingleFetch`` shape, declared by
20
+ declaring nothing.
21
+
22
+ The pair's window-grain facts are shared (vehicle_utilizations carries
23
+ the full statement): the rollup grain is the request window, so
24
+ ``fixed_unit_days=1`` (the fuel-energy machinery's second consumer; the
25
+ family precedent's do-not-sum posture rides the model docstring), the
26
+ decoder-stamped ``window_start`` routes each day's rollup to its own
27
+ partition, and the shared ``MotiveFleetDateRangeSpecBuilder`` maps the
28
+ unit ``DateWindow`` to the INCLUSIVE ``start_date``/``end_date`` label
29
+ pair -- interpreted in COMPANY-LOCAL days (the account's
30
+ ``/v1/companies`` zone at a UTC-5 offset), mirrored verbatim, never
31
+ converted.
32
+ """
33
+
34
+ from datetime import timedelta
35
+ from typing import Final
36
+
37
+ from fleetpull.config import MotiveConfig
38
+ from fleetpull.endpoints.motive._spec_builders import MotiveFleetDateRangeSpecBuilder
39
+ from fleetpull.endpoints.shared import (
40
+ EndpointDefinition,
41
+ StorageKind,
42
+ WatermarkMode,
43
+ )
44
+ from fleetpull.models.motive import DriverIdleRollup
45
+ from fleetpull.network.decoders import MotiveWindowReportPageDecoder
46
+ from fleetpull.vocabulary import Provider, QuotaScope
47
+
48
+ __all__: list[str] = ['build_endpoint']
49
+
50
+ _DRIVER_IDLE_ROLLUPS_PATH: Final[str] = '/v2/driver_utilization'
51
+
52
+ # The wire's OWN envelope vocabulary -- NOT the path's: the wrapper keys
53
+ # are driver_idle_rollups/driver_idle_rollup (captured 2026-07-21), and
54
+ # the endpoint name follows the wire.
55
+ _DRIVER_IDLE_ROLLUPS_LIST_KEY: Final[str] = 'driver_idle_rollups'
56
+ _DRIVER_IDLE_ROLLUPS_ITEM_KEY: Final[str] = 'driver_idle_rollup'
57
+
58
+ # The fixed work-unit width, in whole days -- the pair's window-grain
59
+ # proof (module docstring; captured 2026-07-21): the unit width is part
60
+ # of the row's meaning, pinned here rather than left to
61
+ # sync.backfill_chunk_days (the fuel-energy machinery, DESIGN section 5).
62
+ _FIXED_UNIT_DAYS: Final[int] = 1
63
+
64
+
65
+ def build_endpoint(config: MotiveConfig) -> EndpointDefinition[DriverIdleRollup]:
66
+ """Build the Motive driver_idle_rollups watermark binding.
67
+
68
+ Per-driver idle rollups fetched incrementally at the fixed 1-day
69
+ unit width: the run resumes from a ``DateWindow`` (watermark with
70
+ the provider's late-arrival lookback from config), the planner tiles
71
+ it into exactly-one-day units (the declared ``fixed_unit_days`` --
72
+ module docstring for the window-grain statement), each unit's rows
73
+ are stamped with its window by the decoder and written to the
74
+ ``date=YYYY-MM-DD`` partition on ``window_start``, and each
75
+ refetched partition is replaced. Records arrive wrapped
76
+ (``{"driver_idle_rollups": [{"driver_idle_rollup": {...}}]}``)
77
+ under page-numbered pagination at the page size the config requests,
78
+ the ``start_date``/``end_date`` labels persisting across the walk.
79
+
80
+ Args:
81
+ config: The validated Motive configuration; supplies the base
82
+ URL the spec-builder joins to the utilization path, the page
83
+ size the decoder requests, and the lookback and cutoff the
84
+ watermark mode carries.
85
+
86
+ Returns:
87
+ The frozen driver_idle_rollups ``EndpointDefinition``.
88
+ Construction validates the ``WatermarkMode`` /
89
+ ``DATE_PARTITIONED`` / ``event_time_column`` triple against the
90
+ response model.
91
+ """
92
+ return EndpointDefinition(
93
+ provider=Provider.MOTIVE,
94
+ name='driver_idle_rollups',
95
+ spec_builder=MotiveFleetDateRangeSpecBuilder(
96
+ base_url=config.base_url,
97
+ path=_DRIVER_IDLE_ROLLUPS_PATH,
98
+ ),
99
+ page_decoder=MotiveWindowReportPageDecoder(
100
+ list_key=_DRIVER_IDLE_ROLLUPS_LIST_KEY,
101
+ item_key=_DRIVER_IDLE_ROLLUPS_ITEM_KEY,
102
+ per_page=config.records_per_page,
103
+ ),
104
+ response_model=DriverIdleRollup,
105
+ quota_scope=QuotaScope.MOTIVE,
106
+ storage_kind=StorageKind.DATE_PARTITIONED,
107
+ sync_mode=WatermarkMode(
108
+ lookback=timedelta(days=config.lookback_days),
109
+ cutoff=timedelta(days=config.cutoff_days),
110
+ fixed_unit_days=_FIXED_UNIT_DAYS,
111
+ ),
112
+ event_time_column='window_start',
113
+ )
@@ -0,0 +1,85 @@
1
+ # src/fleetpull/endpoints/motive/driving_periods.py
2
+ """The Motive driving_periods watermark binding.
3
+
4
+ A fleet-wide driving-event endpoint: one date-range request covers every
5
+ vehicle, offset-paginated through the shared wrapped-list decoder. Window
6
+ matching is START-anchored on UTC days (DESIGN §8, captured 2026-07-15):
7
+ retrieval anchor and partition-routing anchor coincide natively, so the
8
+ builder maps the resume window to the wire dates with no pad.
9
+
10
+ The provider enforces a loud 30-day range cap — HTTP 400, ``"Date range
11
+ cannot be greater than 30 days"`` — counting the date delta. Steady-state
12
+ windows sit far inside it; a backfill configured with chunks wider than
13
+ 30 days fails loudly on the first request rather than losing data
14
+ silently, and the fix is a smaller chunk width.
15
+ """
16
+
17
+ from datetime import timedelta
18
+ from typing import Final
19
+
20
+ from fleetpull.config import MotiveConfig
21
+ from fleetpull.endpoints.motive._spec_builders import MotiveFleetDateRangeSpecBuilder
22
+ from fleetpull.endpoints.shared import (
23
+ EndpointDefinition,
24
+ StorageKind,
25
+ WatermarkMode,
26
+ )
27
+ from fleetpull.models.motive import DrivingPeriod
28
+ from fleetpull.network.decoders import MotiveWrappedListPageDecoder
29
+ from fleetpull.vocabulary import Provider, QuotaScope
30
+
31
+ __all__: list[str] = ['build_endpoint']
32
+
33
+ _DRIVING_PERIODS_PATH: Final[str] = '/v1/driving_periods'
34
+ _DRIVING_PERIODS_LIST_KEY: Final[str] = 'driving_periods'
35
+ _DRIVING_PERIODS_ITEM_KEY: Final[str] = 'driving_period'
36
+
37
+ # The page size sent on every request. Captured honored at 100
38
+ # (2026-07-15); larger values are unprobed. A strong candidate for a
39
+ # user config knob — promotion is one new MotiveConfig field passed
40
+ # where this constant is passed.
41
+ _PER_PAGE: Final[int] = 100
42
+
43
+
44
+ def build_endpoint(config: MotiveConfig) -> EndpointDefinition[DrivingPeriod]:
45
+ """Build the Motive driving_periods watermark binding.
46
+
47
+ Fleet-wide driving spans fetched incrementally: the run resumes from
48
+ a ``DateWindow`` (watermark with the provider's late-arrival lookback
49
+ from config — which also refetches yesterday's in-progress records
50
+ once they complete), the fetched whole days are written to
51
+ ``date=YYYY-MM-DD`` partitions on ``start_time``, and each refetched
52
+ partition is replaced. Records arrive wrapped and offset-paginated
53
+ (``{"driving_periods": [{"driving_period": {...}}], "pagination":
54
+ {...}}``).
55
+
56
+ Args:
57
+ config: The validated Motive configuration; supplies the base URL
58
+ and the lookback and cutoff the watermark mode carries.
59
+
60
+ Returns:
61
+ The frozen driving_periods ``EndpointDefinition``. Construction
62
+ validates the ``WatermarkMode`` / ``DATE_PARTITIONED`` /
63
+ ``event_time_column`` triple against the response model.
64
+ """
65
+ return EndpointDefinition(
66
+ provider=Provider.MOTIVE,
67
+ name='driving_periods',
68
+ spec_builder=MotiveFleetDateRangeSpecBuilder(
69
+ base_url=config.base_url,
70
+ path=_DRIVING_PERIODS_PATH,
71
+ ),
72
+ page_decoder=MotiveWrappedListPageDecoder(
73
+ list_key=_DRIVING_PERIODS_LIST_KEY,
74
+ item_key=_DRIVING_PERIODS_ITEM_KEY,
75
+ per_page=_PER_PAGE,
76
+ ),
77
+ response_model=DrivingPeriod,
78
+ quota_scope=QuotaScope.MOTIVE,
79
+ storage_kind=StorageKind.DATE_PARTITIONED,
80
+ sync_mode=WatermarkMode(
81
+ lookback=timedelta(days=config.lookback_days),
82
+ cutoff=timedelta(days=config.cutoff_days),
83
+ ),
84
+ event_time_column='start_time',
85
+ )
@@ -0,0 +1,61 @@
1
+ # src/fleetpull/endpoints/motive/groups.py
2
+ """The Motive groups binding: a factory composing the groups snapshot
3
+ EndpointDefinition from resolved Motive configuration.
4
+
5
+ The vehicles template verbatim, bound to ``/v1/groups`` (probed
6
+ 2026-07-21): a full-population wrapped-list snapshot on the shared
7
+ static-GET builder and the existing Motive offset-pagination decoder, at
8
+ the configured page size (``per_page`` 50 and 100 both honored live). No
9
+ roster is sourced or consumed.
10
+ """
11
+
12
+ from typing import Final
13
+
14
+ from fleetpull.config import MotiveConfig
15
+ from fleetpull.endpoints.shared import (
16
+ EndpointDefinition,
17
+ SnapshotMode,
18
+ StaticGetSpecBuilder,
19
+ StorageKind,
20
+ )
21
+ from fleetpull.models.motive import Group
22
+ from fleetpull.network.decoders import MotiveWrappedListPageDecoder
23
+ from fleetpull.vocabulary import Provider, QuotaScope
24
+
25
+ __all__: list[str] = ['build_endpoint']
26
+
27
+ _GROUPS_PATH: Final[str] = '/v1/groups'
28
+ _GROUPS_LIST_KEY: Final[str] = 'groups'
29
+ _GROUPS_ITEM_KEY: Final[str] = 'group'
30
+
31
+
32
+ def build_endpoint(config: MotiveConfig) -> EndpointDefinition[Group]:
33
+ """Build the Motive groups snapshot binding.
34
+
35
+ A full-dataset snapshot of the company's group tree: no resume, a
36
+ single parquet file, full replacement each run. Records arrive
37
+ wrapped (``{"groups": [{"group": {...}}]}``) under page-numbered
38
+ pagination, at the page size the config requests.
39
+
40
+ Args:
41
+ config: The validated Motive configuration; supplies the base URL
42
+ the spec-builder joins to the groups path and the page size
43
+ the decoder requests.
44
+
45
+ Returns:
46
+ The frozen groups ``EndpointDefinition``.
47
+ """
48
+ return EndpointDefinition(
49
+ provider=Provider.MOTIVE,
50
+ name='groups',
51
+ spec_builder=StaticGetSpecBuilder(base_url=config.base_url, path=_GROUPS_PATH),
52
+ page_decoder=MotiveWrappedListPageDecoder(
53
+ list_key=_GROUPS_LIST_KEY,
54
+ item_key=_GROUPS_ITEM_KEY,
55
+ per_page=config.records_per_page,
56
+ ),
57
+ response_model=Group,
58
+ quota_scope=QuotaScope.MOTIVE,
59
+ storage_kind=StorageKind.SINGLE,
60
+ sync_mode=SnapshotMode(),
61
+ )
@@ -0,0 +1,94 @@
1
+ # src/fleetpull/endpoints/motive/idle_events.py
2
+ """The Motive idle_events watermark binding.
3
+
4
+ A fleet-wide idle-event endpoint: one date-range request covers every
5
+ vehicle, offset-paginated through the shared wrapped-list decoder. Unlike
6
+ its driving_periods sibling, window matching here is OVERLAP-anchored on
7
+ **company-local** day boundaries, not UTC (DESIGN §8, captured
8
+ 2026-07-15, prediction-confirmed): the wire dates are interpreted in the
9
+ account's configured timezone. The binding therefore pads the wire window
10
+ one day on each side — covering any account timezone — and the true UTC
11
+ window does the trimming: the post-fetch window filter keeps only records
12
+ whose ``start_time`` falls in the resume window, and the writer's
13
+ partition tripwire enforces it. Every record lands in exactly one chunk;
14
+ the pad widens only what is fetched, never what is written.
15
+
16
+ The 30-day range cap is NOT enforced here (a 35-day window was honored
17
+ end-to-end in capture) — but chunking stays bounded with the sibling's
18
+ cap anyway, keeping wide-window latency (observed 12-18 s) inside the
19
+ configured HTTP read timeout.
20
+ """
21
+
22
+ from datetime import timedelta
23
+ from typing import Final
24
+
25
+ from fleetpull.config import MotiveConfig
26
+ from fleetpull.endpoints.motive._spec_builders import MotiveFleetDateRangeSpecBuilder
27
+ from fleetpull.endpoints.shared import (
28
+ EndpointDefinition,
29
+ StorageKind,
30
+ WatermarkMode,
31
+ )
32
+ from fleetpull.models.motive import IdleEvent
33
+ from fleetpull.network.decoders import MotiveWrappedListPageDecoder
34
+ from fleetpull.vocabulary import Provider, QuotaScope
35
+
36
+ __all__: list[str] = ['build_endpoint']
37
+
38
+ _IDLE_EVENTS_PATH: Final[str] = '/v1/idle_events'
39
+ _IDLE_EVENTS_LIST_KEY: Final[str] = 'idle_events'
40
+ _IDLE_EVENTS_ITEM_KEY: Final[str] = 'idle_event'
41
+
42
+ # The page size sent on every request. Captured honored at 100
43
+ # (2026-07-15); larger values are unprobed. A strong candidate for a
44
+ # user config knob — promotion is one new MotiveConfig field passed
45
+ # where this constant is passed.
46
+ _PER_PAGE: Final[int] = 100
47
+
48
+ # One whole day each side: the timezone-agnostic cover for the
49
+ # company-local overlap matching described in the module docstring.
50
+ _WINDOW_PAD_DAYS: Final[int] = 1
51
+
52
+
53
+ def build_endpoint(config: MotiveConfig) -> EndpointDefinition[IdleEvent]:
54
+ """Build the Motive idle_events watermark binding.
55
+
56
+ Fleet-wide idle intervals fetched incrementally: the run resumes from
57
+ a ``DateWindow`` (watermark with the provider's late-arrival lookback
58
+ from config), the wire window is padded per the module docstring, the
59
+ kept records are written to ``date=YYYY-MM-DD`` partitions on
60
+ ``start_time``, and each refetched partition is replaced. Records
61
+ arrive wrapped and offset-paginated (``{"idle_events":
62
+ [{"idle_event": {...}}], "pagination": {...}}``).
63
+
64
+ Args:
65
+ config: The validated Motive configuration; supplies the base URL
66
+ and the lookback and cutoff the watermark mode carries.
67
+
68
+ Returns:
69
+ The frozen idle_events ``EndpointDefinition``. Construction
70
+ validates the ``WatermarkMode`` / ``DATE_PARTITIONED`` /
71
+ ``event_time_column`` triple against the response model.
72
+ """
73
+ return EndpointDefinition(
74
+ provider=Provider.MOTIVE,
75
+ name='idle_events',
76
+ spec_builder=MotiveFleetDateRangeSpecBuilder(
77
+ base_url=config.base_url,
78
+ path=_IDLE_EVENTS_PATH,
79
+ window_pad_days=_WINDOW_PAD_DAYS,
80
+ ),
81
+ page_decoder=MotiveWrappedListPageDecoder(
82
+ list_key=_IDLE_EVENTS_LIST_KEY,
83
+ item_key=_IDLE_EVENTS_ITEM_KEY,
84
+ per_page=_PER_PAGE,
85
+ ),
86
+ response_model=IdleEvent,
87
+ quota_scope=QuotaScope.MOTIVE,
88
+ storage_kind=StorageKind.DATE_PARTITIONED,
89
+ sync_mode=WatermarkMode(
90
+ lookback=timedelta(days=config.lookback_days),
91
+ cutoff=timedelta(days=config.cutoff_days),
92
+ ),
93
+ event_time_column='start_time',
94
+ )
@@ -0,0 +1,64 @@
1
+ # src/fleetpull/endpoints/motive/users.py
2
+ """The Motive users binding: a factory composing the users snapshot
3
+ EndpointDefinition from resolved Motive configuration.
4
+
5
+ The vehicles template verbatim, bound to ``/v1/users`` (probed
6
+ 2026-07-21): a full-population wrapped-list snapshot on the shared
7
+ static-GET builder and the existing Motive offset-pagination decoder, at
8
+ the configured page size (``per_page`` 50 and 100 both honored live).
9
+ Only the pagination parameters are sent — the unfiltered listing is the
10
+ complete population, one dataset despite the role-partitioned record
11
+ shape: the ``role`` column carries the split (DESIGN section 8). No
12
+ roster is sourced or consumed.
13
+ """
14
+
15
+ from typing import Final
16
+
17
+ from fleetpull.config import MotiveConfig
18
+ from fleetpull.endpoints.shared import (
19
+ EndpointDefinition,
20
+ SnapshotMode,
21
+ StaticGetSpecBuilder,
22
+ StorageKind,
23
+ )
24
+ from fleetpull.models.motive import User
25
+ from fleetpull.network.decoders import MotiveWrappedListPageDecoder
26
+ from fleetpull.vocabulary import Provider, QuotaScope
27
+
28
+ __all__: list[str] = ['build_endpoint']
29
+
30
+ _USERS_PATH: Final[str] = '/v1/users'
31
+ _USERS_LIST_KEY: Final[str] = 'users'
32
+ _USERS_ITEM_KEY: Final[str] = 'user'
33
+
34
+
35
+ def build_endpoint(config: MotiveConfig) -> EndpointDefinition[User]:
36
+ """Build the Motive users snapshot binding.
37
+
38
+ A full-dataset snapshot of every account — drivers, admins, and
39
+ fleet users: no resume, a single parquet file, full replacement each
40
+ run. Records arrive wrapped (``{"users": [{"user": {...}}]}``) under
41
+ page-numbered pagination, at the page size the config requests.
42
+
43
+ Args:
44
+ config: The validated Motive configuration; supplies the base URL
45
+ the spec-builder joins to the users path and the page size
46
+ the decoder requests.
47
+
48
+ Returns:
49
+ The frozen users ``EndpointDefinition``.
50
+ """
51
+ return EndpointDefinition(
52
+ provider=Provider.MOTIVE,
53
+ name='users',
54
+ spec_builder=StaticGetSpecBuilder(base_url=config.base_url, path=_USERS_PATH),
55
+ page_decoder=MotiveWrappedListPageDecoder(
56
+ list_key=_USERS_LIST_KEY,
57
+ item_key=_USERS_ITEM_KEY,
58
+ per_page=config.records_per_page,
59
+ ),
60
+ response_model=User,
61
+ quota_scope=QuotaScope.MOTIVE,
62
+ storage_kind=StorageKind.SINGLE,
63
+ sync_mode=SnapshotMode(),
64
+ )
@@ -0,0 +1,170 @@
1
+ # src/fleetpull/endpoints/motive/vehicle_locations.py
2
+ """The Motive vehicle_locations watermark spec-builder and roster fan-out shape.
3
+
4
+ A per-vehicle breadcrumb endpoint. The binding declares its request shape
5
+ (``request_shape=RosterFanOut(...)``) naming the ``vehicle_ids`` roster by its
6
+ opaque ``RosterKey`` -- this module knows nothing about the roster's feeder.
7
+ The orchestration entry (``orchestrator/entry.py``) resolves that key through
8
+ the ``RosterRegistry`` to its ``RosterDefinition``, refreshes the stored
9
+ membership via the ``RosterRefreshCoordinator`` when stale, reads the members
10
+ from the ``RosterStore``, and feeds the shape-resolution seam, which builds
11
+ the ``FanOutRequestDriver`` -- that driver then calls ``build_spec`` once per
12
+ member, passing that vehicle's id in ``member_values`` and the run's resume
13
+ ``DateWindow`` as ``resume``. The builder renders the per-vehicle path and
14
+ maps the window to Motive's ``start_date`` / ``end_date`` query parameters.
15
+
16
+ Motive's ``start_date`` / ``end_date`` are inclusive on both ends and day-granular,
17
+ anchored on ``located_at`` -- the endpoint returns every breadcrumb whose date falls
18
+ in ``[start_date, end_date]`` (confirmed against the predecessor's production
19
+ fetcher, whose comment records that Motive "returns full days"). The date-partitioned
20
+ watermark path wants exactly those whole days -- each ``date=`` partition is replaced
21
+ wholesale -- so unlike the predecessor (which post-filtered to an exact datetime
22
+ range for its single-file output), this builder does not trim: it fetches the whole
23
+ days and lets the writer replace whole partitions.
24
+
25
+ The mapping aligns with the storage layer's covered dates (``window_dates``):
26
+ ``start_date`` is the UTC date of ``window.start`` and ``end_date`` is the window's
27
+ ``last_covered_date`` (the one-microsecond derivation is stated once, on
28
+ ``DateWindow``). So what
29
+ this builder fetches equals what the writer replaces and prunes: the same date set,
30
+ no edge double-count, idempotent on refetch.
31
+
32
+ This is the dedicated builder for vehicle_locations specifically -- a date-range
33
+ fetch plus a per-vehicle path fan-out. The fleet-wide Motive date-range endpoints
34
+ (driving_periods, idle_events) share ``MotiveFleetDateRangeSpecBuilder``
35
+ (``endpoints/motive/_spec_builders.py``); this one stays separate because it renders
36
+ the per-vehicle path.
37
+ """
38
+
39
+ from collections.abc import Mapping
40
+ from dataclasses import dataclass
41
+ from datetime import timedelta
42
+ from typing import Final
43
+
44
+ from fleetpull.config import MotiveConfig
45
+ from fleetpull.endpoints.shared import (
46
+ EndpointDefinition,
47
+ ResumeValue,
48
+ RosterFanOut,
49
+ StorageKind,
50
+ WatermarkMode,
51
+ render_url_path_template,
52
+ require_date_window,
53
+ )
54
+ from fleetpull.models.motive import VehicleLocation
55
+ from fleetpull.network.contract import HttpMethod, RequestSpec
56
+ from fleetpull.network.decoders import MotiveWrappedSinglePageDecoder
57
+ from fleetpull.roster import RosterKey
58
+ from fleetpull.timing import to_utc_date_string
59
+ from fleetpull.vocabulary import Provider, QuotaScope
60
+
61
+ __all__: list[str] = [
62
+ 'MotiveVehicleLocationsSpecBuilder',
63
+ 'build_endpoint',
64
+ ]
65
+
66
+ _VEHICLE_LOCATIONS_PATH: Final[str] = '/v3/vehicle_locations/{vehicle_id}'
67
+ _VEHICLE_LOCATIONS_LIST_KEY: Final[str] = 'vehicle_locations'
68
+ _VEHICLE_LOCATIONS_ITEM_KEY: Final[str] = 'vehicle_location'
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class MotiveVehicleLocationsSpecBuilder:
73
+ """Build the per-vehicle, date-windowed first request for vehicle_locations.
74
+
75
+ The ``SpecBuilder`` for the Motive vehicle_locations watermark endpoint:
76
+ renders the per-vehicle path and injects the resume window as Motive's
77
+ ``start_date`` / ``end_date`` parameters. The endpoint is not paginated, so this
78
+ first request is the only request; the page decoder returns no successor.
79
+
80
+ Attributes:
81
+ base_url: Root of the Motive API, trailing-slash-normalized by the provider
82
+ config so the leading-slash path joins directly.
83
+ path_template: The per-vehicle request path with the ``{vehicle_id}``
84
+ placeholder (``'/v3/vehicle_locations/{vehicle_id}'``).
85
+ """
86
+
87
+ base_url: str
88
+ path_template: str
89
+
90
+ def build_spec(
91
+ self, resume: ResumeValue, member_values: Mapping[str, str]
92
+ ) -> RequestSpec:
93
+ """Build the per-vehicle, date-windowed GET.
94
+
95
+ Args:
96
+ resume: The run's resume window. Must be a ``DateWindow`` -- a watermark
97
+ endpoint always resumes from one; any other value is a wiring bug.
98
+ member_values: The fan-out member binding; must carry ``vehicle_id``
99
+ (the strict template renderer rejects a missing or extra key).
100
+
101
+ Returns:
102
+ A credential-less ``GET`` for the per-vehicle URL, carrying
103
+ ``start_date`` / ``end_date`` for the window's covered dates. Auth
104
+ headers are layered on by the client's ``ProviderProfile``.
105
+
106
+ Raises:
107
+ TypeError: ``resume`` is not a ``DateWindow``.
108
+ UrlPathTemplateError: ``member_values`` does not match the template's
109
+ placeholders, or a value is empty.
110
+
111
+ Side Effects:
112
+ None.
113
+ """
114
+ resume_window = require_date_window(resume, type(self).__name__)
115
+ rendered_path = render_url_path_template(self.path_template, member_values)
116
+ url = f'{self.base_url}{rendered_path}'
117
+ start_date = to_utc_date_string(resume_window.start)
118
+ end_date = resume_window.last_covered_date.isoformat()
119
+ params = {'start_date': start_date, 'end_date': end_date}
120
+ return RequestSpec(method=HttpMethod.GET, url=url, params=params)
121
+
122
+
123
+ def build_endpoint(config: MotiveConfig) -> EndpointDefinition[VehicleLocation]:
124
+ """Build the Motive vehicle_locations watermark binding.
125
+
126
+ A per-vehicle breadcrumb history fetched incrementally: the run resumes from a
127
+ ``DateWindow`` (watermark with the provider's late-arrival lookback from config),
128
+ the fetched whole days are written to ``date=YYYY-MM-DD`` partitions, and each
129
+ refetched partition is replaced. Records arrive wrapped and unpaginated
130
+ (``{"vehicle_locations": [{"vehicle_location": {...}}]}``). The
131
+ ``request_shape`` declaration (``RosterFanOut``) names the ``vehicle_ids``
132
+ roster; the orchestration entry resolves it to members and fans one request
133
+ chain per vehicle, passing each ``vehicle_id`` to the spec-builder's
134
+ ``member_values`` — this binding only declares the strategies and the
135
+ roster key, never the roster's feeder.
136
+
137
+ Args:
138
+ config: The validated Motive configuration; supplies the base URL the
139
+ spec-builder joins to the per-vehicle path and the lookback and
140
+ cutoff the watermark mode carry.
141
+
142
+ Returns:
143
+ The frozen vehicle_locations ``EndpointDefinition``. Construction validates
144
+ the ``WatermarkMode`` / ``DATE_PARTITIONED`` / ``event_time_column`` triple
145
+ against the response model.
146
+ """
147
+ return EndpointDefinition(
148
+ provider=Provider.MOTIVE,
149
+ name='vehicle_locations',
150
+ spec_builder=MotiveVehicleLocationsSpecBuilder(
151
+ base_url=config.base_url,
152
+ path_template=_VEHICLE_LOCATIONS_PATH,
153
+ ),
154
+ page_decoder=MotiveWrappedSinglePageDecoder(
155
+ list_key=_VEHICLE_LOCATIONS_LIST_KEY,
156
+ item_key=_VEHICLE_LOCATIONS_ITEM_KEY,
157
+ ),
158
+ response_model=VehicleLocation,
159
+ quota_scope=QuotaScope.MOTIVE,
160
+ storage_kind=StorageKind.DATE_PARTITIONED,
161
+ sync_mode=WatermarkMode(
162
+ lookback=timedelta(days=config.lookback_days),
163
+ cutoff=timedelta(days=config.cutoff_days),
164
+ ),
165
+ event_time_column='located_at',
166
+ request_shape=RosterFanOut(
167
+ roster=RosterKey(Provider.MOTIVE, 'vehicle_ids'),
168
+ member_key='vehicle_id',
169
+ ),
170
+ )