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
fleetpull/api/sync.py ADDED
@@ -0,0 +1,735 @@
1
+ # src/fleetpull/api/sync.py
2
+ """``Sync``: the config-driven public verb (DESIGN section 10).
3
+
4
+ Constructed on a path to a YAML config; ``run()`` returns ``None`` and
5
+ signals failure by raising. Where ``fetch`` is the in-memory convenience
6
+ verb, ``Sync`` is the pipeline: it composes the full machinery -- state
7
+ database, stores, registries, clients, the run executor -- from one
8
+ validated ``FleetpullConfig`` and runs every selected endpoint.
9
+
10
+ Construction is validation only: the config loads (``from_yaml``), every
11
+ selected endpoint name is checked against the public catalog (the
12
+ validation deliberately absent from the config tier, which sits below
13
+ the catalog), and zero enabled providers is a ``ConfigurationError`` --
14
+ a sync that syncs nothing is a configuration failure to surface, not a
15
+ no-op. Nothing global mutates and nothing but the config file is read.
16
+
17
+ ``run()`` applies the logging section first, then composes and executes.
18
+ The concurrency ladder (DESIGN section 7, completed 2026-07-20):
19
+ providers run concurrently, one queue worker each; within a provider,
20
+ endpoints run in two concurrent stages -- the selected feeders
21
+ (endpoints sourcing a roster, derived via ``sourced_by``, never a
22
+ user-facing key; feeders never cross providers) first, a barrier, then
23
+ the consumers -- and within a fan-out endpoint, units and members run
24
+ concurrently on that provider's fetch pool. Queue order -- feeders then
25
+ consumers, config order within each -- is exactly the order the retired
26
+ serial queue executed in, surviving as the reporting contract, not an
27
+ execution order. Endpoints commit independently: one endpoint's
28
+ operational failure (the ``FleetpullError`` family) is recorded while
29
+ its queue continues; any other exception is a bug that stops the
30
+ queue's unstarted work (in-flight siblings finish and commit) and
31
+ re-raises -- the first by queue order within a provider, the first by
32
+ provider order across providers -- once every queue has joined. A run
33
+ with failures ends by raising ``SyncFailuresError`` carrying them in
34
+ queue order within each provider, providers in config order. Only the
35
+ selected set runs: an unselected feeder is never run on a consumer's
36
+ behalf -- roster freshness stays the refresh coordinator's job at
37
+ fan-out time (single-flight per roster key).
38
+ """
39
+
40
+ import logging
41
+ import threading
42
+ from collections.abc import Sequence
43
+ from concurrent.futures import Future, ThreadPoolExecutor
44
+ from dataclasses import dataclass
45
+ from pathlib import Path
46
+
47
+ from pydantic import SecretStr
48
+
49
+ from fleetpull.api.auth_ingress import (
50
+ ProviderProfileContext,
51
+ build_provider_profile,
52
+ )
53
+ from fleetpull.api.catalog import available_endpoints
54
+ from fleetpull.api.identity import EndpointIdentity
55
+ from fleetpull.config import (
56
+ FleetpullConfig,
57
+ GeotabAuthConfig,
58
+ GeotabConfig,
59
+ MotiveConfig,
60
+ ProviderConfig,
61
+ SamsaraConfig,
62
+ default_provider_sections,
63
+ )
64
+ from fleetpull.endpoints import (
65
+ EndpointRegistry,
66
+ build_endpoint_registry,
67
+ build_roster_registry,
68
+ )
69
+ from fleetpull.endpoints.shared import EndpointDefinition
70
+ from fleetpull.exceptions import (
71
+ ConfigurationError,
72
+ EndpointFailure,
73
+ FleetpullError,
74
+ SyncFailuresError,
75
+ )
76
+ from fleetpull.logger import setup_logger
77
+ from fleetpull.model_contract import ResponseModel
78
+ from fleetpull.network.client import (
79
+ ClientRuntime,
80
+ ProviderClientRegistry,
81
+ ProviderProfile,
82
+ )
83
+ from fleetpull.network.limits import RateLimiterRegistry, rate_limits_from_configs
84
+ from fleetpull.orchestrator import (
85
+ EndpointRunner,
86
+ FetchPoolRegistry,
87
+ RosterMachinery,
88
+ RosterRefreshCoordinator,
89
+ RunStateAccess,
90
+ run_endpoint,
91
+ )
92
+ from fleetpull.roster import RosterRegistry
93
+ from fleetpull.state import (
94
+ CursorStore,
95
+ RosterStore,
96
+ RunLedger,
97
+ StateDatabase,
98
+ WorkUnitStore,
99
+ migrate_to_head,
100
+ )
101
+ from fleetpull.timing import SystemClock
102
+ from fleetpull.vocabulary import Provider
103
+
104
+ __all__: list[str] = ['Sync']
105
+
106
+ logger = logging.getLogger(__name__)
107
+
108
+ # The concrete provider-section union: new providers widen it as they port.
109
+ type _ProviderSection = MotiveConfig | GeotabConfig | SamsaraConfig
110
+
111
+ # The catalog as a lookup table: every public identity by registry key.
112
+ _CATALOG: dict[tuple[Provider, str], EndpointIdentity] = {
113
+ (identity.provider, identity.name): identity for identity in available_endpoints()
114
+ }
115
+
116
+
117
+ class Sync:
118
+ """
119
+ The config-driven sync run: one YAML file in, datasets and state out.
120
+
121
+ Construction validates; ``run()`` executes. One instance is one
122
+ validated configuration; ``run()`` may be called repeatedly (each
123
+ call is an independent, freshly composed run against the same
124
+ config).
125
+ """
126
+
127
+ def __init__(self, config_path: Path | str) -> None:
128
+ """Load and validate the configuration; compose nothing yet.
129
+
130
+ Args:
131
+ config_path: The YAML configuration file to load.
132
+
133
+ Raises:
134
+ ConfigurationError: The config file is missing, unparseable,
135
+ or schema-invalid (from ``FleetpullConfig.from_yaml``);
136
+ a selected endpoint name is not in the public catalog
137
+ (naming the provider, the bad name, and the valid
138
+ names); or zero providers are enabled -- a sync that
139
+ syncs nothing is a configuration failure to surface.
140
+
141
+ Side Effects:
142
+ Reads the config file and the credential environment
143
+ variables (via ``from_yaml``, which also logs the
144
+ credential-without-endpoints WARNING). Nothing else.
145
+ """
146
+ self._config = FleetpullConfig.from_yaml(config_path)
147
+ self._selection = _validated_selection(self._config)
148
+
149
+ def run(self) -> None:
150
+ """Run every selected endpoint; raise if any failed.
151
+
152
+ Applies the logging section first (``setup_logger``), then
153
+ composes the run from the validated config: the state database
154
+ at the resolved ``state.database_path`` (migrated to head), the
155
+ stores, the discovered endpoint and roster registries, the
156
+ limiter registry from the precedence-resolved provider configs,
157
+ one transport client per enabled provider through the auth
158
+ ingress, and one fetch pool per enabled provider sized by its
159
+ ``rate_limit.max_concurrency`` (the fan-out workers; shut down
160
+ when the run ends, success or failure). Endpoints run as one
161
+ staged queue per enabled provider, the queues concurrent (one
162
+ worker thread each): stage one runs the selected feeders
163
+ concurrently among themselves (derived from the roster
164
+ bindings; config order within each stage), the stage join is
165
+ the barrier no consumer crosses, and stage two runs the
166
+ consumers concurrently. Endpoints commit independently:
167
+ parquet and state land per endpoint as each finishes, so a
168
+ sibling's failure never rolls anything back, in its own queue
169
+ or another provider's. A non-``FleetpullError`` is a bug that
170
+ stops its queue's unstarted endpoints (in-flight siblings
171
+ finish and commit) while the other queues finish; after every
172
+ queue joins, the first bug -- by queue order within a
173
+ provider, provider order across providers -- re-raises.
174
+
175
+ The run narrates at INFO: a start line naming the enabled
176
+ providers, the validated selection, and the dataset root; a
177
+ finish line with the succeeded/failed endpoint counts and the
178
+ elapsed seconds (a ``monotonic_seconds`` delta on the run's own
179
+ clock -- the whole run's wall clock, the slowest queue rather
180
+ than the queues' sum -- emitted before any failure aggregate
181
+ raises).
182
+
183
+ Returns:
184
+ None. Every selected endpoint ran and committed.
185
+
186
+ Raises:
187
+ SyncFailuresError: One or more endpoints failed with an
188
+ operational (``FleetpullError``-family) error; carries
189
+ the failures in queue order within each provider
190
+ (feeders then consumers, config order within each),
191
+ providers in config order. Successful siblings are
192
+ already committed.
193
+ FleetpullError: An operational failure outside any single
194
+ endpoint's run (e.g. a cold-start roster refresh
195
+ propagated by the entry) -- always one of the public
196
+ subclasses (``ConfigurationError``,
197
+ ``AuthenticationError``, ``RetriesExhaustedError``,
198
+ ``ProviderResponseError``).
199
+
200
+ Side Effects:
201
+ Configures the ``fleetpull`` logger from the config's
202
+ logging section; creates/migrates the SQLite state database;
203
+ fetches over the network (one queue worker thread per
204
+ enabled provider, one short-lived endpoint task pool per
205
+ non-empty stage, plus that provider's fan-out fetch
206
+ workers, all joined before this returns); writes parquet
207
+ under ``storage.dataset_root``; records runs, cursors, and
208
+ roster state.
209
+
210
+ Scope: retrieval, dtype coercion, and light structural
211
+ normalization only -- no cross-endpoint joins, no unified
212
+ schema, no assumed end use (DESIGN section 10).
213
+ """
214
+ setup_logger(self._config.logging)
215
+ clock = SystemClock()
216
+ run_started = clock.monotonic_seconds()
217
+ logger.info(
218
+ 'sync started: providers=[%s] endpoints=%d selection=[%s] dataset_root=%s',
219
+ ', '.join(provider.value for provider, _ in self._enabled_providers()),
220
+ len(self._selection),
221
+ ', '.join(f'{provider.value}.{name}' for provider, name in self._selection),
222
+ self._config.storage.dataset_root,
223
+ )
224
+ provider_configs = self._discovery_provider_configs()
225
+ endpoint_registry = build_endpoint_registry(provider_configs)
226
+ roster_registry = build_roster_registry()
227
+ stages_by_provider = _staged_by_provider(self._selection, roster_registry)
228
+ database = StateDatabase(_required_database_path(self._config))
229
+ database.initialize()
230
+ migrate_to_head(database)
231
+ cursor_store = CursorStore(database, clock)
232
+ run_ledger = RunLedger(database, clock)
233
+ roster_store = RosterStore(database)
234
+ unit_store = WorkUnitStore(database, clock)
235
+ limiter_registry = RateLimiterRegistry(
236
+ rate_limits_from_configs(provider_configs)
237
+ )
238
+ runtime = ClientRuntime(
239
+ http_config=self._config.http,
240
+ retry_config=self._config.retry,
241
+ limiter_registry=limiter_registry,
242
+ )
243
+ profile_context = ProviderProfileContext(
244
+ http_config=self._config.http,
245
+ limiter_registry=limiter_registry,
246
+ clock=clock,
247
+ )
248
+ fetch_workers = {
249
+ provider: config.rate_limit.max_concurrency
250
+ for provider, config in self._enabled_providers()
251
+ }
252
+ with (
253
+ ProviderClientRegistry(
254
+ self._provider_profiles(profile_context), runtime
255
+ ) as clients,
256
+ FetchPoolRegistry(fetch_workers) as fetch_pools,
257
+ ):
258
+ coordinator = RosterRefreshCoordinator(
259
+ endpoint_registry, roster_store, run_ledger, clients, clock
260
+ )
261
+ runner = EndpointRunner(
262
+ clients,
263
+ RunStateAccess(
264
+ recorder=run_ledger, cursors=cursor_store, units=unit_store
265
+ ),
266
+ clock,
267
+ self._config,
268
+ )
269
+ work = _ProviderQueueWork(
270
+ registry=endpoint_registry,
271
+ runner=runner,
272
+ rosters=RosterMachinery(
273
+ registry=roster_registry,
274
+ refresher=coordinator,
275
+ members=roster_store,
276
+ ),
277
+ fetch_pools=fetch_pools,
278
+ )
279
+ # One worker per enabled provider; exiting the block joins them
280
+ # all, so every queue has finished before anything raises. The
281
+ # futures dict is keyed in the fixed provider-config order (the
282
+ # enabled roll-call), which _collected_failures turns into the
283
+ # documented cross-provider order.
284
+ with ThreadPoolExecutor(
285
+ max_workers=len(stages_by_provider),
286
+ thread_name_prefix='fleetpull-sync',
287
+ ) as queue_pool:
288
+ queue_futures = {
289
+ provider: queue_pool.submit(
290
+ _run_provider_queue,
291
+ provider,
292
+ stages_by_provider[provider],
293
+ work,
294
+ )
295
+ for provider, _ in self._enabled_providers()
296
+ }
297
+ failures = _collected_failures(queue_futures)
298
+ logger.info(
299
+ 'sync finished: succeeded=%d failed=%d elapsed_seconds=%.1f',
300
+ len(self._selection) - len(failures),
301
+ len(failures),
302
+ clock.monotonic_seconds() - run_started,
303
+ )
304
+ if failures:
305
+ raise SyncFailuresError(tuple(failures))
306
+
307
+ def _enabled_providers(self) -> list[tuple[Provider, _ProviderSection]]:
308
+ """The enabled providers, leaning on the validated enablement invariant.
309
+
310
+ A validated config guarantees a provider with endpoints has a
311
+ credential (``require_provider_credentials``), so enabled reduces to
312
+ "endpoints non-empty". Typed as the concrete section union in a fixed
313
+ provider order (Motive, GeoTab, Samsara -- ``ProvidersConfig`` field
314
+ order).
315
+ """
316
+ return [
317
+ (provider, section)
318
+ for provider, section in _provider_sections(self._config)
319
+ if section is not None and section.endpoints
320
+ ]
321
+
322
+ def _discovery_provider_configs(self) -> list[ProviderConfig]:
323
+ """One config per provider package: the YAML section, or pure defaults.
324
+
325
+ The discovery walk builds every leaf it finds and requires a config
326
+ per provider package regardless of enablement, so a provider absent
327
+ from the YAML is represented by its default-constructed config
328
+ (``default_provider_sections`` -- the roll-call ``fetch`` consumes
329
+ too). Enabled providers contribute their YAML
330
+ instances, so the limiter budgets derived from this list are the
331
+ user's; a disabled provider's default config merely registers inert
332
+ scopes nothing spends from.
333
+ """
334
+ defaults = default_provider_sections()
335
+ return [
336
+ section if section is not None else defaults[provider]
337
+ for provider, section in _provider_sections(self._config)
338
+ ]
339
+
340
+ def _provider_profiles(
341
+ self, context: ProviderProfileContext
342
+ ) -> dict[Provider, ProviderProfile]:
343
+ """One client profile per enabled provider, through the auth ingress."""
344
+ profiles: dict[Provider, ProviderProfile] = {}
345
+ for provider, provider_config in self._enabled_providers():
346
+ identity = _CATALOG[(provider, provider_config.endpoints[0])]
347
+ credential = _required_credential(provider, provider_config)
348
+ profiles[provider] = build_provider_profile(identity, credential, context)
349
+ return profiles
350
+
351
+
352
+ def _provider_sections(
353
+ config: FleetpullConfig,
354
+ ) -> list[tuple[Provider, _ProviderSection | None]]:
355
+ """Every provider section, present or not, in the fixed provider order.
356
+
357
+ The single provider roll-call in this module: adding a provider means
358
+ extending this list and nothing else here.
359
+ """
360
+ return [
361
+ (Provider.MOTIVE, config.providers.motive),
362
+ (Provider.GEOTAB, config.providers.geotab),
363
+ (Provider.SAMSARA, config.providers.samsara),
364
+ ]
365
+
366
+
367
+ def _validated_selection(config: FleetpullConfig) -> list[tuple[Provider, str]]:
368
+ """The selected endpoints, catalog-validated, in config order.
369
+
370
+ Args:
371
+ config: The loaded configuration.
372
+
373
+ Returns:
374
+ Every enabled provider's selected ``(provider, name)`` keys, in the
375
+ order the config lists them.
376
+
377
+ Raises:
378
+ ConfigurationError: A selected name is not in the public catalog, or
379
+ zero providers are enabled.
380
+ """
381
+ selection: list[tuple[Provider, str]] = []
382
+ for provider, section in _provider_sections(config):
383
+ if section is None or not section.endpoints:
384
+ continue
385
+ for name in section.endpoints:
386
+ if (provider, name) not in _CATALOG:
387
+ valid_names = ', '.join(
388
+ sorted(
389
+ identity_name
390
+ for identity_provider, identity_name in _CATALOG
391
+ if identity_provider is provider
392
+ )
393
+ )
394
+ raise ConfigurationError(
395
+ 'unknown endpoint name',
396
+ provider=provider.value,
397
+ endpoint=name,
398
+ detail=f'valid {provider.value} endpoints: {valid_names}',
399
+ )
400
+ selection.append((provider, name))
401
+ if not selection:
402
+ raise ConfigurationError(
403
+ 'nothing to sync',
404
+ detail=(
405
+ 'no provider is enabled (a provider is enabled when its '
406
+ 'credential resolves and its endpoints list is non-empty)'
407
+ ),
408
+ )
409
+ return selection
410
+
411
+
412
+ @dataclass(frozen=True, slots=True)
413
+ class _ProviderStages:
414
+ """One provider's staged endpoint queue: feeders, then consumers.
415
+
416
+ Queue order -- the order the failure contract reports in -- is the
417
+ concatenation, feeders then consumers, each in config order: exactly
418
+ the feeder-first order the retired serial queue executed in. Stage
419
+ membership is feeder-hood (the endpoint sources a roster), never
420
+ snapshot-hood, because the barrier between the stages exists for one
421
+ dependency only: a consumer's fan-out must not race the reconcile of
422
+ a roster a selected sibling feeder is about to refresh.
423
+
424
+ Attributes:
425
+ feeders: The endpoints sourcing any roster, in config order.
426
+ consumers: Every other selected endpoint, in config order.
427
+ """
428
+
429
+ feeders: tuple[str, ...]
430
+ consumers: tuple[str, ...]
431
+
432
+
433
+ def _staged_by_provider(
434
+ selection: Sequence[tuple[Provider, str]], roster_registry: RosterRegistry
435
+ ) -> dict[Provider, _ProviderStages]:
436
+ """Carve the selection into each provider's two-stage queue, order kept.
437
+
438
+ The pure staging step behind the intra-provider grain (DESIGN section
439
+ 7): per provider, the selected endpoints split into feeders --
440
+ endpoints whose ``sourced_by`` is non-empty, derived from the roster
441
+ bindings and never a user-facing key -- and consumers (everything
442
+ else), each preserving config order. Single-level by construction: a
443
+ feeder is snapshot-mode (the reconcile guards enforce it) and never
444
+ consumes a roster itself, so feeder chains cannot exist today, and
445
+ feeders never cross providers. An unselected feeder is never enlisted
446
+ on a consumer's behalf -- roster freshness stays the refresh
447
+ coordinator's job at fan-out time.
448
+
449
+ Args:
450
+ selection: The catalog-validated ``(provider, name)`` keys, in
451
+ config order.
452
+ roster_registry: The discovered roster catalog.
453
+
454
+ Returns:
455
+ Each selected provider's stages, keyed in first-appearance
456
+ (provider-config) order.
457
+ """
458
+ feeders: dict[Provider, list[str]] = {}
459
+ consumers: dict[Provider, list[str]] = {}
460
+ for provider, name in selection:
461
+ feeders.setdefault(provider, [])
462
+ consumers.setdefault(provider, [])
463
+ stage = feeders if roster_registry.sourced_by(provider, name) else consumers
464
+ stage[provider].append(name)
465
+ return {
466
+ provider: _ProviderStages(
467
+ feeders=tuple(feeders[provider]), consumers=tuple(consumers[provider])
468
+ )
469
+ for provider in feeders
470
+ }
471
+
472
+
473
+ @dataclass(frozen=True, slots=True)
474
+ class _ProviderQueueWork:
475
+ """The shared collaborators every provider queue runs its endpoints through.
476
+
477
+ One instance serves every queue worker and endpoint task; the four ride
478
+ as one parameter because they always travel together into
479
+ ``run_endpoint`` (the bundle rule). Safe to share by construction: the
480
+ registry is an immutable catalog, the runner keeps all per-run state
481
+ local, the roster machinery's state lives in connection-per-operation
482
+ stores (rosters are provider-scoped, so one queue's rosters are never
483
+ another's, and concurrent consumers of one roster serialize through the
484
+ refresh coordinator's per-key single-flight lock), and the fetch pools
485
+ are per-provider.
486
+
487
+ Attributes:
488
+ registry: The discovered endpoint catalog (definition lookup).
489
+ runner: The run executor, shared by every queue.
490
+ rosters: The roster catalog, policy coordinator, and member read.
491
+ fetch_pools: The per-provider fetch pools.
492
+ """
493
+
494
+ registry: EndpointRegistry
495
+ runner: EndpointRunner
496
+ rosters: RosterMachinery
497
+ fetch_pools: FetchPoolRegistry
498
+
499
+
500
+ @dataclass(frozen=True, slots=True)
501
+ class _SkippedEndpoint:
502
+ """The skip sentinel an endpoint task returns instead of running.
503
+
504
+ Returned when the stop event was already set at task start: a sibling's
505
+ bug stopped the queue, so unstarted work is skipped -- never run, never
506
+ failed. Distinct from ``None`` (a clean run) so the task's contract
507
+ states the skip explicitly rather than conflating it with success.
508
+ """
509
+
510
+
511
+ class _StagedQueueRun:
512
+ """The shared state one provider queue's staged endpoint tasks race on.
513
+
514
+ Exactly one thing is cross-thread: the stop event a bug sets before it
515
+ escapes through its future, checked by every task before it runs --
516
+ in-flight siblings finish and commit; only unstarted work is skipped
517
+ (the ``_CrewDrive`` stop semantics). Operational failures never set it:
518
+ the queue continues, exactly as the serial queue did. Everything else
519
+ stays local -- each stage's futures are drained in submission (queue)
520
+ order after the stage's pool joins, so failures collect in queue order
521
+ and the first bug re-raises deterministically by queue order, never by
522
+ completion timing, with no locks and no re-sort. The class exists so
523
+ the task function reads as the endpoint run it is, with the
524
+ synchronization named rather than threaded through arguments.
525
+ """
526
+
527
+ def __init__(self, provider: Provider, work: _ProviderQueueWork) -> None:
528
+ self._provider = provider
529
+ self._work = work
530
+ self._stop_running = threading.Event()
531
+
532
+ def run_stage(self, stage_names: Sequence[str]) -> list[EndpointFailure]:
533
+ """Run one stage's endpoints concurrently; join; report in queue order.
534
+
535
+ An empty stage spawns nothing; a single-endpoint stage degenerates
536
+ to the serial path's behavior on one short-lived task thread. The
537
+ ``with`` block joins every task before the drain, so the barrier
538
+ between the feeder stage and the consumer stage is this join.
539
+
540
+ Args:
541
+ stage_names: The stage's endpoint names, in queue order.
542
+
543
+ Returns:
544
+ The stage's operational failures, in queue order.
545
+
546
+ Raises:
547
+ Exception: The first bug by queue order, re-raised from its
548
+ future after the join -- never a ``FleetpullError`` from
549
+ ``run_endpoint`` (those are collected, not raised).
550
+
551
+ Side Effects:
552
+ Runs every endpoint not skipped by the stop event (network
553
+ fetches, parquet writes, state commits); logs each operational
554
+ failure at ERROR with its traceback.
555
+ """
556
+ if not stage_names:
557
+ return []
558
+ with ThreadPoolExecutor(
559
+ max_workers=len(stage_names),
560
+ thread_name_prefix=f'fleetpull-sync-{self._provider.value}',
561
+ ) as stage_pool:
562
+ endpoint_futures = [
563
+ stage_pool.submit(
564
+ self._run_endpoint_task,
565
+ self._work.registry.get(self._provider, name),
566
+ )
567
+ for name in stage_names
568
+ ]
569
+ return [
570
+ outcome
571
+ for outcome in (future.result() for future in endpoint_futures)
572
+ if isinstance(outcome, EndpointFailure)
573
+ ]
574
+
575
+ def _run_endpoint_task(
576
+ self, definition: EndpointDefinition[ResponseModel]
577
+ ) -> EndpointFailure | _SkippedEndpoint | None:
578
+ """Run one resolved endpoint, unless a sibling bug stopped the queue.
579
+
580
+ Args:
581
+ definition: The endpoint to run, resolved at submission.
582
+
583
+ Returns:
584
+ ``None`` on a clean run, the ``EndpointFailure`` on an
585
+ operational failure, or the skip sentinel when the stop event
586
+ was set before this task ran.
587
+
588
+ Side Effects:
589
+ Renames the current thread to
590
+ ``fleetpull-sync-<provider>-<endpoint>`` for log attribution;
591
+ whatever ``run_endpoint`` performs; sets the stop event before
592
+ a bug escapes through this task's future.
593
+ """
594
+ threading.current_thread().name = (
595
+ f'fleetpull-sync-{self._provider.value}-{definition.name}'
596
+ )
597
+ if self._stop_running.is_set():
598
+ logger.debug(
599
+ 'endpoint skipped: provider=%s endpoint=%s '
600
+ '(a sibling bug stopped the queue)',
601
+ self._provider.value,
602
+ definition.name,
603
+ )
604
+ return _SkippedEndpoint()
605
+ try:
606
+ run_endpoint(
607
+ definition,
608
+ self._work.runner,
609
+ self._work.rosters,
610
+ self._work.fetch_pools,
611
+ )
612
+ except FleetpullError as failure:
613
+ logger.exception(
614
+ 'endpoint failed: provider=%s endpoint=%s',
615
+ self._provider.value,
616
+ definition.name,
617
+ )
618
+ return EndpointFailure(self._provider.value, definition.name, failure)
619
+ except Exception:
620
+ # Stop BEFORE the bug escapes, so a sibling's next stop check
621
+ # sees it; the bug itself re-raises at the queue-order drain.
622
+ self._stop_running.set()
623
+ raise
624
+ return None
625
+
626
+
627
+ def _run_provider_queue(
628
+ provider: Provider, stages: _ProviderStages, work: _ProviderQueueWork
629
+ ) -> list[EndpointFailure]:
630
+ """Run one provider's staged queue, collecting its operational failures.
631
+
632
+ The per-provider queue worker: stage one runs the selected feeders
633
+ concurrently among themselves (they reconcile distinct roster keys, so
634
+ they cannot interfere; the set is usually zero or one), the stage join
635
+ is the barrier no consumer crosses before every feeder has finished,
636
+ and stage two runs the consumers concurrently. A ``FleetpullError``
637
+ records an ``EndpointFailure`` and the queue continues; any other
638
+ exception is a bug that stops the queue's unstarted endpoints and
639
+ propagates through this worker's future -- a stage-one bug skips stage
640
+ two entirely -- to re-raise after every queue has joined. The thread
641
+ is renamed to the provider first, so any stack trace attributes
642
+ cleanly.
643
+
644
+ Args:
645
+ provider: The queue's provider.
646
+ stages: The provider's staged endpoints, in queue order.
647
+ work: The shared collaborators the endpoints run through.
648
+
649
+ Returns:
650
+ The queue's failures, in queue order (feeders then consumers,
651
+ config order within each).
652
+
653
+ Side Effects:
654
+ Renames the current thread to ``fleetpull-sync-<provider>``;
655
+ spawns one short-lived endpoint task pool per non-empty stage;
656
+ runs every endpoint (network fetches, parquet writes, state
657
+ commits); logs each operational failure at ERROR with its
658
+ traceback.
659
+ """
660
+ threading.current_thread().name = f'fleetpull-sync-{provider.value}'
661
+ queue_run = _StagedQueueRun(provider, work)
662
+ failures = queue_run.run_stage(stages.feeders)
663
+ failures.extend(queue_run.run_stage(stages.consumers))
664
+ return failures
665
+
666
+
667
+ def _collected_failures(
668
+ queue_futures: dict[Provider, Future[list[EndpointFailure]]],
669
+ ) -> list[EndpointFailure]:
670
+ """Fold the joined queues into the documented two-level failure order.
671
+
672
+ Iterates the futures in their key order -- provider-config order, the
673
+ caller's contract -- flattening each queue's queue-order failures, so
674
+ the aggregate reads queue order within a provider, provider-config
675
+ order across providers. A queue that died on a bug re-raises it here
676
+ (``Future.result``), and the iteration order makes the first bug by
677
+ provider order win deterministically, never by completion timing. Every
678
+ future is already done (the pool's ``with`` block joined the workers),
679
+ so nothing here blocks.
680
+
681
+ Args:
682
+ queue_futures: One joined future per provider queue, keyed in
683
+ provider-config order.
684
+
685
+ Returns:
686
+ Every queue's failures, in the two-level order.
687
+
688
+ Raises:
689
+ Exception: The first queue's bug in provider order, re-raised
690
+ unchanged -- never a ``FleetpullError`` (those are collected,
691
+ not raised, by the workers).
692
+ """
693
+ failures: list[EndpointFailure] = []
694
+ for queue_future in queue_futures.values():
695
+ failures.extend(queue_future.result())
696
+ return failures
697
+
698
+
699
+ def _required_database_path(config: FleetpullConfig) -> Path:
700
+ """The resolved state database path; a missing one is a wiring bug.
701
+
702
+ ``from_yaml`` always resolves it (the root invariant), and ``Sync``
703
+ only loads via ``from_yaml`` -- this narrows the field's optional
704
+ type and trips loudly if that invariant is ever bypassed.
705
+ """
706
+ database_path = config.state.database_path
707
+ if database_path is None:
708
+ raise ConfigurationError(
709
+ 'state database path unresolved',
710
+ detail='state.database_path is unset; load the config via from_yaml',
711
+ )
712
+ return database_path
713
+
714
+
715
+ def _required_credential(
716
+ provider: Provider, config: _ProviderSection
717
+ ) -> SecretStr | GeotabAuthConfig:
718
+ """Narrow an enabled provider's credential; absence is a wiring bug.
719
+
720
+ The enablement validator guarantees it for any validated config; this
721
+ trips loudly if that invariant is ever bypassed by direct construction.
722
+ The section's ``credential`` property already carries the ingress
723
+ ``AuthInput`` shape for its provider: the ``SecretStr`` key for the
724
+ static-key providers (Motive, Samsara), GeoTab's four-field credential
725
+ whole (its password's ``SecretStr`` passes straight through -- no
726
+ unwrap/rewrap).
727
+ """
728
+ credential = config.credential
729
+ if credential is None:
730
+ raise ConfigurationError(
731
+ 'provider credential missing',
732
+ provider=provider.value,
733
+ detail='an enabled provider must carry a credential (validated at load)',
734
+ )
735
+ return credential