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,122 @@
1
+ # src/fleetpull/endpoints/motive/vehicle_utilizations.py
2
+ """The Motive vehicle_utilizations binding: the window-grain rollup
3
+ surface -- Motive's arm of the fixed-unit-width watermark family
4
+ (probe-settled 2026-07-21, DESIGN section 8). The legacy hub's
5
+ ``vehicle_utilization``, shipped under the wire's own plural envelope
6
+ vocabulary (model ``VehicleUtilization``).
7
+
8
+ ``GET /v2/vehicle_utilization`` carries the standard Motive
9
+ wrapped-list envelope (wrapper ``vehicle_utilizations`` /
10
+ ``vehicle_utilization``) and offset pagination at the configured page
11
+ size (``per_page`` 50 and 100 both honored live). The population is the
12
+ WHOLE vehicle fleet regardless of window (1,466 on the 1-day and the
13
+ 6-day probe alike -- inactive vehicles ride with zeroed metrics and a
14
+ ``message`` status string), fleet-wide with per-record vehicle
15
+ attribution, so there is NO fan-out -- the default ``SingleFetch``
16
+ shape, declared by declaring nothing, and no roster is sourced or
17
+ consumed.
18
+
19
+ **THE ROLLUP GRAIN IS THE REQUEST WINDOW** (a 1-day and a 6-day request
20
+ each returned one rollup row per vehicle), so the binding declares
21
+ ``fixed_unit_days=1`` on its ``WatermarkMode`` -- the unit width is
22
+ part of the ROW'S MEANING and never floats with
23
+ ``sync.backfill_chunk_days`` (the Samsara fuel-energy machinery, its
24
+ second consumer; additivity was not probed here, and the family
25
+ precedent's do-not-sum posture rides the model docstring). Rows carry
26
+ NO date or time identity; the ``MotiveWindowReportPageDecoder`` stamps
27
+ each with the sent window and ``event_time_column='window_start'``
28
+ routes each day's rollup to its own partition.
29
+
30
+ **The window mapping and the company-local caveat.** The shared
31
+ ``MotiveFleetDateRangeSpecBuilder`` maps the half-open unit
32
+ ``DateWindow`` ``[start, end)`` to the INCLUSIVE date-label pair the
33
+ wire takes -- ``start_date`` is ``start``'s date and ``end_date`` the
34
+ last covered date (``end`` minus a day) -- proven inclusive on both
35
+ ends live (``start_date=end_date`` returned exactly one day's rollup).
36
+ With the fixed 1-day unit both labels are the unit's day. The labels
37
+ are interpreted in COMPANY-LOCAL days (the account's ``/v1/companies``
38
+ zone at a UTC-5 offset -- DESIGN section 8), so each unit's rows are
39
+ the provider's company-local-day rollups, mirrored verbatim: no pad, no
40
+ trim, the caveat documented on the model, never converted.
41
+ """
42
+
43
+ from datetime import timedelta
44
+ from typing import Final
45
+
46
+ from fleetpull.config import MotiveConfig
47
+ from fleetpull.endpoints.motive._spec_builders import MotiveFleetDateRangeSpecBuilder
48
+ from fleetpull.endpoints.shared import (
49
+ EndpointDefinition,
50
+ StorageKind,
51
+ WatermarkMode,
52
+ )
53
+ from fleetpull.models.motive import VehicleUtilization
54
+ from fleetpull.network.decoders import MotiveWindowReportPageDecoder
55
+ from fleetpull.vocabulary import Provider, QuotaScope
56
+
57
+ __all__: list[str] = ['build_endpoint']
58
+
59
+ _VEHICLE_UTILIZATIONS_PATH: Final[str] = '/v2/vehicle_utilization'
60
+
61
+ # The wire's own envelope vocabulary -- plural list key, singular item
62
+ # key, exactly the sibling wrapped-list convention (captured 2026-07-21).
63
+ _VEHICLE_UTILIZATIONS_LIST_KEY: Final[str] = 'vehicle_utilizations'
64
+ _VEHICLE_UTILIZATIONS_ITEM_KEY: Final[str] = 'vehicle_utilization'
65
+
66
+ # The fixed work-unit width, in whole days. The rollup grain is the
67
+ # request window (a 1-day and a 6-day request each returned one rollup
68
+ # row per vehicle, captured 2026-07-21), so the unit width is part of
69
+ # the row's meaning and is pinned here rather than left to
70
+ # sync.backfill_chunk_days (the fuel-energy machinery, DESIGN section 5).
71
+ _FIXED_UNIT_DAYS: Final[int] = 1
72
+
73
+
74
+ def build_endpoint(config: MotiveConfig) -> EndpointDefinition[VehicleUtilization]:
75
+ """Build the Motive vehicle_utilizations watermark binding.
76
+
77
+ Whole-fleet per-vehicle utilization rollups fetched incrementally at
78
+ the fixed 1-day unit width: the run resumes from a ``DateWindow``
79
+ (watermark with the provider's late-arrival lookback from config),
80
+ the planner tiles it into exactly-one-day units (the declared
81
+ ``fixed_unit_days`` -- module docstring for the window-grain proof),
82
+ each unit's rows are stamped with its window by the decoder and
83
+ written to the ``date=YYYY-MM-DD`` partition on ``window_start``,
84
+ and each refetched partition is replaced. Records arrive wrapped
85
+ (``{"vehicle_utilizations": [{"vehicle_utilization": {...}}]}``)
86
+ under page-numbered pagination at the page size the config requests,
87
+ the ``start_date``/``end_date`` labels persisting across the walk.
88
+
89
+ Args:
90
+ config: The validated Motive configuration; supplies the base
91
+ URL the spec-builder joins to the utilization path, the page
92
+ size the decoder requests, and the lookback and cutoff the
93
+ watermark mode carries.
94
+
95
+ Returns:
96
+ The frozen vehicle_utilizations ``EndpointDefinition``.
97
+ Construction validates the ``WatermarkMode`` /
98
+ ``DATE_PARTITIONED`` / ``event_time_column`` triple against the
99
+ response model.
100
+ """
101
+ return EndpointDefinition(
102
+ provider=Provider.MOTIVE,
103
+ name='vehicle_utilizations',
104
+ spec_builder=MotiveFleetDateRangeSpecBuilder(
105
+ base_url=config.base_url,
106
+ path=_VEHICLE_UTILIZATIONS_PATH,
107
+ ),
108
+ page_decoder=MotiveWindowReportPageDecoder(
109
+ list_key=_VEHICLE_UTILIZATIONS_LIST_KEY,
110
+ item_key=_VEHICLE_UTILIZATIONS_ITEM_KEY,
111
+ per_page=config.records_per_page,
112
+ ),
113
+ response_model=VehicleUtilization,
114
+ quota_scope=QuotaScope.MOTIVE,
115
+ storage_kind=StorageKind.DATE_PARTITIONED,
116
+ sync_mode=WatermarkMode(
117
+ lookback=timedelta(days=config.lookback_days),
118
+ cutoff=timedelta(days=config.cutoff_days),
119
+ fixed_unit_days=_FIXED_UNIT_DAYS,
120
+ ),
121
+ event_time_column='window_start',
122
+ )
@@ -0,0 +1,106 @@
1
+ # src/fleetpull/endpoints/motive/vehicles.py
2
+ """The Motive vehicles binding: a factory composing the vehicles snapshot
3
+ EndpointDefinition from resolved Motive configuration, plus the
4
+ ``vehicle_ids`` roster the listing feeds.
5
+
6
+ A binding cannot be a module-level constant because its spec-builder
7
+ needs the run's configured base URL and page size, known only after the
8
+ YAML config loads; capturing config at import time would freeze a
9
+ default, and module-level mutable state is forbidden. So the endpoint is
10
+ a factory taking a validated ``MotiveConfig`` and returning the frozen
11
+ ``EndpointDefinition`` the composition root hands to the client.
12
+
13
+ ``VEHICLE_IDS_ROSTER`` is declared here, beside the feeder it describes:
14
+ the roster names this module's endpoint and its frame column, which is
15
+ provider-specific knowledge that belongs in the provider leaf. Unlike the
16
+ endpoint factory it needs no config, so it is a frozen constant -- and a
17
+ public one deliberately: ``build_roster_registry`` discovers public
18
+ module-level ``RosterDefinition`` constants in the same walk that finds
19
+ ``build_endpoint``, so declaring the constant IS the registration
20
+ (no hand-maintained list exists to drift).
21
+ The include-inactive guarantee binds at the feeder population, not at
22
+ eviction policy: ``/v1/vehicles`` lists inactive and retired vehicles, so
23
+ a fan-out over this roster covers vehicles that were active during a
24
+ historical window even if they are inactive today.
25
+ """
26
+
27
+ from datetime import timedelta
28
+ from typing import Final
29
+
30
+ from fleetpull.config import MotiveConfig
31
+ from fleetpull.endpoints.shared import (
32
+ EndpointDefinition,
33
+ SnapshotMode,
34
+ StaticGetSpecBuilder,
35
+ StorageKind,
36
+ )
37
+ from fleetpull.models.motive import Vehicle
38
+ from fleetpull.network.decoders import MotiveWrappedListPageDecoder
39
+ from fleetpull.roster import RosterDefinition, RosterKey
40
+ from fleetpull.vocabulary import Provider, QuotaScope
41
+
42
+ __all__: list[str] = ['VEHICLE_IDS_ROSTER', 'build_endpoint']
43
+
44
+ _VEHICLES_PATH: Final[str] = '/v1/vehicles'
45
+ _VEHICLES_LIST_KEY: Final[str] = 'vehicles'
46
+ _VEHICLES_ITEM_KEY: Final[str] = 'vehicle'
47
+
48
+ # The fleet's membership changes on the order of days, so a daily re-list
49
+ # keeps the roster current without spending a full vehicles listing on
50
+ # every sync.
51
+ _VEHICLE_IDS_MAX_AGE: Final[timedelta] = timedelta(days=1)
52
+
53
+ # Eviction hysteresis (DESIGN §3): vehicle ids are permanent, absent-means-
54
+ # empty keys, so eviction is an efficiency lever (stop fanning over
55
+ # long-retired vehicles), not a correctness one. Three consecutive absent
56
+ # listings tolerate a transient provider omission before dropping a member.
57
+ _VEHICLE_IDS_EVICTION_THRESHOLD: Final[int] = 3
58
+
59
+ # The Motive vehicle_ids roster: fed by this module's vehicles listing, read
60
+ # by the vehicle_locations fan-out (which carries only the RosterKey).
61
+ VEHICLE_IDS_ROSTER: RosterDefinition = RosterDefinition(
62
+ key=RosterKey(Provider.MOTIVE, 'vehicle_ids'),
63
+ source_endpoint='vehicles',
64
+ source_column='vehicle_id',
65
+ max_age=_VEHICLE_IDS_MAX_AGE,
66
+ eviction_threshold=_VEHICLE_IDS_EVICTION_THRESHOLD,
67
+ )
68
+
69
+
70
+ def build_endpoint(config: MotiveConfig) -> EndpointDefinition[Vehicle]:
71
+ """Build the Motive vehicles snapshot binding.
72
+
73
+ A full-dataset snapshot of the fleet's vehicles: no resume, a single
74
+ parquet file, full replacement each run. Records arrive wrapped
75
+ (``{"vehicles": [{"vehicle": {...}}]}``) under page-numbered
76
+ pagination, at the page size the config requests.
77
+
78
+ Only the pagination parameters are sent. The endpoint's optional
79
+ filters -- ``driver_ids[]``, ``fuel_type``, ``updated_after``, and the
80
+ ``X-Time-Zone`` / ``X-Metric-Units`` / ``X-User-Id`` header params --
81
+ are deliberately not wired yet.
82
+
83
+ Args:
84
+ config: The validated Motive configuration; supplies the base URL
85
+ the spec-builder joins to the vehicles path and the page size
86
+ the decoder requests.
87
+
88
+ Returns:
89
+ The frozen vehicles ``EndpointDefinition``.
90
+ """
91
+ return EndpointDefinition(
92
+ provider=Provider.MOTIVE,
93
+ name='vehicles',
94
+ spec_builder=StaticGetSpecBuilder(
95
+ base_url=config.base_url, path=_VEHICLES_PATH
96
+ ),
97
+ page_decoder=MotiveWrappedListPageDecoder(
98
+ list_key=_VEHICLES_LIST_KEY,
99
+ item_key=_VEHICLES_ITEM_KEY,
100
+ per_page=config.records_per_page,
101
+ ),
102
+ response_model=Vehicle,
103
+ quota_scope=QuotaScope.MOTIVE,
104
+ storage_kind=StorageKind.SINGLE,
105
+ sync_mode=SnapshotMode(),
106
+ )
@@ -0,0 +1,280 @@
1
+ # src/fleetpull/endpoints/registry.py
2
+ """The endpoint catalog: ``EndpointRegistry`` and the discovery walk.
3
+
4
+ ``EndpointRegistry`` is a dumb, immutable map from ``(provider, name)`` to the
5
+ endpoint's ``EndpointDefinition``. It answers ``get(provider, name)``; a duplicate
6
+ key at construction is a wiring bug and raises ``ConfigurationError``. It knows
7
+ nothing about providers, discovery, or config -- it is handed a bag of definitions
8
+ and indexes them.
9
+
10
+ ``build_endpoint_registry`` is the one place endpoints are enumerated. It discovers
11
+ every endpoint leaf by walking the ``endpoints.<provider>`` packages (skipping
12
+ ``shared``) for modules exposing a ``build_endpoint`` factory, injects each factory's
13
+ provider config by matching the factory's annotated config type against the supplied
14
+ configs, calls it, and indexes the results. Adding an endpoint is adding one leaf
15
+ module: no manifest, no registration, no provider list here.
16
+
17
+ ``build_roster_registry`` is its sibling over the same walk: rosters are declared
18
+ as public module-level ``RosterDefinition`` constants beside their feeders, in
19
+ exactly the leaf modules the endpoint walk visits, so they are discovered the
20
+ same way -- no hand-maintained registration list, no per-provider export
21
+ to drift. Adding a roster is declaring one constant in its feeder's
22
+ module.
23
+
24
+ Discovery reaches the leaf modules dynamically rather than through each provider
25
+ package's face. That is a deliberate, named exception to the clause-3 face-routing
26
+ rule the import-discipline test enforces: the walk depends only on the
27
+ ``build_endpoint`` contract, not on any specific module, so it is generic enumeration
28
+ rather than static coupling. The replacement guardrail is the structural contract
29
+ test (``tests/endpoints/test_endpoint_contract.py``): every endpoint leaf must expose
30
+ a callable ``build_endpoint`` taking one ``ProviderConfig`` subclass, so a typo or a
31
+ missing factory fails loudly there rather than silently vanishing from the catalog.
32
+ """
33
+
34
+ import importlib
35
+ import pkgutil
36
+ from collections.abc import Callable, Iterable, Iterator
37
+ from types import ModuleType
38
+ from typing import cast, get_type_hints
39
+
40
+ import fleetpull.endpoints
41
+ from fleetpull.config import ProviderConfig
42
+ from fleetpull.endpoints.shared import EndpointDefinition
43
+ from fleetpull.exceptions import ConfigurationError
44
+ from fleetpull.model_contract import ResponseModel
45
+ from fleetpull.roster import RosterDefinition, RosterRegistry
46
+ from fleetpull.vocabulary import Provider
47
+
48
+ __all__: list[str] = [
49
+ 'EndpointRegistry',
50
+ 'build_endpoint_registry',
51
+ 'build_roster_registry',
52
+ ]
53
+
54
+ _FACTORY_NAME: str = 'build_endpoint'
55
+ _SHARED_PACKAGE: str = 'shared'
56
+
57
+ _EndpointFactory = Callable[[ProviderConfig], EndpointDefinition[ResponseModel]]
58
+
59
+
60
+ class EndpointRegistry:
61
+ """An immutable catalog mapping ``(provider, name)`` to its definition.
62
+
63
+ Built once from a bag of definitions, it answers ``get(provider, name)``. The
64
+ map is private and frozen at construction; a duplicate ``(provider, name)`` is a
65
+ wiring bug and raises. It holds no provider knowledge and does no discovery.
66
+
67
+ Args:
68
+ definitions: The endpoint definitions to catalog; their ``(provider, name)``
69
+ keys must be distinct.
70
+
71
+ Raises:
72
+ ConfigurationError: Two definitions share a ``(provider, name)`` key.
73
+ """
74
+
75
+ def __init__(
76
+ self, definitions: Iterable[EndpointDefinition[ResponseModel]]
77
+ ) -> None:
78
+ by_key: dict[tuple[Provider, str], EndpointDefinition[ResponseModel]] = {}
79
+ for definition in definitions:
80
+ key = (definition.provider, definition.name)
81
+ if key in by_key:
82
+ raise ConfigurationError(
83
+ 'duplicate endpoint definition',
84
+ provider=definition.provider.value,
85
+ endpoint=definition.name,
86
+ detail=f'endpoint {definition.name!r} is defined twice',
87
+ )
88
+ by_key[key] = definition
89
+ self._by_key = by_key
90
+
91
+ def get(self, provider: Provider, name: str) -> EndpointDefinition[ResponseModel]:
92
+ """Return the definition for a ``(provider, name)`` key.
93
+
94
+ Args:
95
+ provider: The endpoint's provider.
96
+ name: The endpoint's name (e.g. ``'vehicles'``).
97
+
98
+ Returns:
99
+ The endpoint's definition.
100
+
101
+ Raises:
102
+ ConfigurationError: No definition is registered for the key -- a consumer
103
+ references an endpoint the catalog does not declare.
104
+ """
105
+ try:
106
+ return self._by_key[(provider, name)]
107
+ except KeyError:
108
+ raise ConfigurationError(
109
+ 'unknown endpoint',
110
+ provider=provider.value,
111
+ endpoint=name,
112
+ detail=f'no endpoint definition registered for {name!r}',
113
+ ) from None
114
+
115
+
116
+ def build_endpoint_registry(configs: Iterable[ProviderConfig]) -> EndpointRegistry:
117
+ """Discover every endpoint leaf, build its definition, and catalog them.
118
+
119
+ Walks the ``endpoints.<provider>`` packages for ``build_endpoint`` factories,
120
+ injects each factory's annotated provider config from ``configs`` (matched by
121
+ exact type), calls it, and returns the populated registry.
122
+
123
+ Args:
124
+ configs: The resolved provider config instances, one per provider whose
125
+ endpoints should be built. Matched to each factory by the factory's
126
+ annotated parameter type.
127
+
128
+ Returns:
129
+ The populated ``EndpointRegistry``.
130
+
131
+ Raises:
132
+ ConfigurationError: A leaf exposes no callable ``build_endpoint``, a factory's
133
+ parameter is not exactly one ``ProviderConfig`` subclass, or no supplied
134
+ config matches a factory's annotated type.
135
+ """
136
+ config_by_type: dict[type[ProviderConfig], ProviderConfig] = {
137
+ type(config): config for config in configs
138
+ }
139
+ definitions: list[EndpointDefinition[ResponseModel]] = []
140
+ for module_name in _iter_endpoint_leaf_modules():
141
+ module = importlib.import_module(module_name)
142
+ factory = _required_factory(module, module_name)
143
+ config = _config_for_factory(factory, module_name, config_by_type)
144
+ definitions.append(factory(config))
145
+ return EndpointRegistry(definitions)
146
+
147
+
148
+ def build_roster_registry() -> RosterRegistry:
149
+ """Discover every declared roster and catalog them.
150
+
151
+ The sibling of ``build_endpoint_registry``, sharing the same leaf walk: a
152
+ roster is a public module-level ``RosterDefinition`` constant declared
153
+ beside its feeder, so it is discovered rather than hand-listed. Takes no
154
+ configs -- roster declarations are constants, not factories.
155
+
156
+ Returns:
157
+ The populated ``RosterRegistry``.
158
+
159
+ Raises:
160
+ ConfigurationError: Two collected definitions share a ``RosterKey``
161
+ (e.g. a constant re-exported into a second leaf module), from
162
+ ``RosterRegistry`` construction.
163
+ """
164
+ definitions: list[RosterDefinition] = []
165
+ for module_name in _iter_endpoint_leaf_modules():
166
+ module = importlib.import_module(module_name)
167
+ definitions.extend(_module_roster_definitions(module))
168
+ return RosterRegistry(definitions)
169
+
170
+
171
+ def _module_roster_definitions(module: ModuleType) -> list[RosterDefinition]:
172
+ """The public module-level roster declarations of one endpoint leaf.
173
+
174
+ Only non-underscore names register: an underscore-prefixed definition is
175
+ file-private by the naming rule and stays out of the catalog.
176
+
177
+ Args:
178
+ module: The imported leaf module.
179
+
180
+ Returns:
181
+ The module's declared ``RosterDefinition`` constants, in definition
182
+ order (module namespace order).
183
+ """
184
+ return [
185
+ value
186
+ for name, value in vars(module).items()
187
+ if not name.startswith('_') and isinstance(value, RosterDefinition)
188
+ ]
189
+
190
+
191
+ def _iter_endpoint_leaf_modules() -> Iterator[str]:
192
+ """Yield the dotted name of every endpoint leaf module.
193
+
194
+ Walks the ``endpoints`` package for provider subpackages (skipping ``shared`` and
195
+ any non-package), then each provider package for leaf modules (skipping
196
+ ``__init__``, which ``iter_modules`` omits, and any ``_``-prefixed private
197
+ module). Every yielded module is an endpoint leaf obligated to expose
198
+ ``build_endpoint``.
199
+ """
200
+ root_path = fleetpull.endpoints.__path__
201
+ root_name = fleetpull.endpoints.__name__
202
+ for provider in pkgutil.iter_modules(root_path):
203
+ if not provider.ispkg or provider.name == _SHARED_PACKAGE:
204
+ continue
205
+ package_name = f'{root_name}.{provider.name}'
206
+ package = importlib.import_module(package_name)
207
+ for leaf in pkgutil.iter_modules(package.__path__):
208
+ if leaf.ispkg or leaf.name.startswith('_'):
209
+ continue
210
+ yield f'{package_name}.{leaf.name}'
211
+
212
+
213
+ def _required_factory(module: ModuleType, module_name: str) -> _EndpointFactory:
214
+ """Return the module's ``build_endpoint``, or raise if it is missing.
215
+
216
+ Args:
217
+ module: The imported leaf module.
218
+ module_name: Its dotted name, for the error.
219
+
220
+ Returns:
221
+ The module's ``build_endpoint`` callable.
222
+
223
+ Raises:
224
+ ConfigurationError: The module exposes no callable ``build_endpoint``.
225
+ """
226
+ factory = getattr(module, _FACTORY_NAME, None)
227
+ if not callable(factory):
228
+ raise ConfigurationError(
229
+ 'endpoint leaf missing build_endpoint',
230
+ detail=f'{module_name!r} must expose a callable {_FACTORY_NAME!r}',
231
+ )
232
+ return cast(_EndpointFactory, factory)
233
+
234
+
235
+ def _config_for_factory(
236
+ factory: _EndpointFactory,
237
+ module_name: str,
238
+ config_by_type: dict[type[ProviderConfig], ProviderConfig],
239
+ ) -> ProviderConfig:
240
+ """Resolve the config instance a factory's annotation asks for.
241
+
242
+ Inspects the factory's single parameter, requires its type to be a
243
+ ``ProviderConfig`` subclass, and returns the matching supplied instance.
244
+
245
+ Args:
246
+ factory: The leaf's ``build_endpoint`` callable.
247
+ module_name: Its dotted name, for errors.
248
+ config_by_type: The supplied configs, keyed by exact type.
249
+
250
+ Returns:
251
+ The config instance matching the factory's annotated parameter type.
252
+
253
+ Raises:
254
+ ConfigurationError: The factory does not take exactly one parameter, its
255
+ parameter is not a ``ProviderConfig`` subclass, or no supplied config
256
+ matches its annotated type.
257
+ """
258
+ hints = get_type_hints(factory)
259
+ hints.pop('return', None)
260
+ if len(hints) != 1:
261
+ raise ConfigurationError(
262
+ 'endpoint factory arity',
263
+ detail=f'{module_name!r} build_endpoint must take exactly one '
264
+ f'annotated parameter',
265
+ )
266
+ (annotation,) = hints.values()
267
+ if not (isinstance(annotation, type) and issubclass(annotation, ProviderConfig)):
268
+ raise ConfigurationError(
269
+ 'endpoint factory parameter type',
270
+ detail=f'{module_name!r} build_endpoint must annotate a '
271
+ f'ProviderConfig subclass',
272
+ )
273
+ try:
274
+ return config_by_type[annotation]
275
+ except KeyError:
276
+ raise ConfigurationError(
277
+ 'no config supplied for endpoint',
278
+ detail=f'{module_name!r} requires {annotation.__name__} but no '
279
+ f'instance was supplied',
280
+ ) from None
@@ -0,0 +1,12 @@
1
+ # src/fleetpull/endpoints/samsara/__init__.py
2
+ """The Samsara endpoints package.
3
+
4
+ A provider package under the endpoints layer. It exposes no factory gather:
5
+ endpoint leaves are discovered by ``build_endpoint_registry`` walking this
6
+ package for modules exposing ``build_endpoint``, so a new Samsara endpoint is
7
+ a new leaf module here with nothing to register. Import a specific factory
8
+ from its leaf module (``fleetpull.endpoints.samsara.vehicles``) when one is
9
+ needed directly.
10
+ """
11
+
12
+ __all__: list[str] = []