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/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """fleetpull: Pull fleet telematics data from provider APIs into typed Polars output.
2
+
3
+ Retrieval, dtype coercion, and light structural normalization only. Output
4
+ stays as close to the raw API responses as is reasonable. No cross-endpoint
5
+ merging, no unified schema, no assumed end use.
6
+
7
+ The public data API: ``fetch(Endpoints.Motive.vehicles, auth=...)`` returns
8
+ an eager typed DataFrame, and ``Sync(config_path).run()`` executes a
9
+ config-driven parquet/state sync. Consumers catch ``FleetpullError`` or its
10
+ five public subclasses, all importable here; every other exception type is
11
+ internal.
12
+ """
13
+
14
+ from fleetpull.api import Endpoints, Sync, fetch
15
+ from fleetpull.exceptions import (
16
+ AuthenticationError,
17
+ ConfigurationError,
18
+ FleetpullError,
19
+ ProviderResponseError,
20
+ RetriesExhaustedError,
21
+ SyncFailuresError,
22
+ )
23
+
24
+ __version__: str = '0.1.0'
25
+
26
+ __all__: list[str] = [
27
+ 'AuthenticationError',
28
+ 'ConfigurationError',
29
+ 'Endpoints',
30
+ 'FleetpullError',
31
+ 'ProviderResponseError',
32
+ 'RetriesExhaustedError',
33
+ 'Sync',
34
+ 'SyncFailuresError',
35
+ 'fetch',
36
+ ]
@@ -0,0 +1,32 @@
1
+ # src/fleetpull/api/__init__.py
2
+ """The public data API: the ``Endpoints`` catalog, its identities, ``fetch``, and ``Sync``.
3
+
4
+ The top tier of the package vertical: it composes everything below
5
+ (config, endpoints, network, records, orchestrator) behind DESIGN §10's
6
+ two-verb surface. ``Sync``, the config-driven verb, composes the
7
+ orchestrator entry -- which is why this tier sits above ``orchestrator``,
8
+ not beside it.
9
+ """
10
+
11
+ from fleetpull.api.auth_ingress import AuthInput
12
+ from fleetpull.api.catalog import Endpoints, available_endpoints
13
+ from fleetpull.api.fetch import fetch
14
+ from fleetpull.api.identity import (
15
+ EndpointIdentity,
16
+ FeedEndpoint,
17
+ SnapshotEndpoint,
18
+ WindowedEndpoint,
19
+ )
20
+ from fleetpull.api.sync import Sync
21
+
22
+ __all__: list[str] = [
23
+ 'AuthInput',
24
+ 'EndpointIdentity',
25
+ 'Endpoints',
26
+ 'FeedEndpoint',
27
+ 'SnapshotEndpoint',
28
+ 'Sync',
29
+ 'WindowedEndpoint',
30
+ 'available_endpoints',
31
+ 'fetch',
32
+ ]
@@ -0,0 +1,216 @@
1
+ # src/fleetpull/api/auth_ingress.py
2
+ """The auth ingress: the one public ``auth=`` shape, coerced immediately.
3
+
4
+ The public verbs take a single lax ``auth=`` value -- a bare string for
5
+ single-credential providers (Motive, Samsara), named fields (a plain
6
+ mapping or ``GeotabAuthConfig``) for GeoTab's four-field credential.
7
+ Tuples are rejected in both directions: the 1-tuple requires the
8
+ trailing-comma trap and the 4-tuple invites transposed fields discovered
9
+ only at auth-failure time (DESIGN §10). Ingress coerces every accepted
10
+ shape into the internal ``SecretStr``-carrying auth at this boundary, so
11
+ no bare secret survives past it -- not into a repr, an exception
12
+ message, or a log line.
13
+
14
+ Dispatch keys off the endpoint identity's provider, never off the auth
15
+ value's shape: a shape that happens to fit another provider is still a
16
+ ``ConfigurationError`` here. The provider → (auth strategy, classifier)
17
+ knowledge lives in this module because it can live nowhere lower: the
18
+ ingress sits in the top tier and may import both ``config`` and
19
+ ``network``, while ``config`` cannot import ``network``, so the mapping
20
+ cannot ride on provider configs.
21
+
22
+ Profile construction takes a ``ProviderProfileContext`` alongside the
23
+ identity and the credential: the composition-root collaborators a
24
+ provider's auth machinery draws on. Motive's static header needs none of
25
+ them and ignores the context; GeoTab's session stack consumes all three
26
+ (the authenticator's HTTP posture and limiter slot, the session
27
+ manager's clock). The context grows only when a provider's auth
28
+ machinery demands a new collaborator -- it is not a general-purpose bag.
29
+ """
30
+
31
+ from collections.abc import Mapping
32
+ from dataclasses import dataclass
33
+
34
+ from pydantic import SecretStr, ValidationError
35
+
36
+ from fleetpull.api.identity import EndpointIdentity
37
+ from fleetpull.config import GeotabAuthConfig, HttpConfig
38
+ from fleetpull.exceptions import ConfigurationError
39
+ from fleetpull.network.auth import (
40
+ GeotabSessionAuth,
41
+ GeotabSessionManager,
42
+ StaticHeaderAuth,
43
+ build_geotab_authenticator,
44
+ )
45
+ from fleetpull.network.classifiers import (
46
+ GeotabResponseClassifier,
47
+ MotiveResponseClassifier,
48
+ SamsaraResponseClassifier,
49
+ )
50
+ from fleetpull.network.client import ProviderProfile
51
+ from fleetpull.network.limits import RateLimiterRegistry
52
+ from fleetpull.timing import Clock
53
+ from fleetpull.vocabulary import Provider, QuotaScope
54
+
55
+ __all__: list[str] = ['AuthInput', 'ProviderProfileContext', 'build_provider_profile']
56
+
57
+ # The §10 auth union. SecretStr is accepted alongside the bare string so
58
+ # the config path (Sync) hands its already-wrapped credential straight
59
+ # through -- the raw value never needs unwrapping just to be re-wrapped
60
+ # at the boundary. GeoTab's arms carry the four-field credential whole.
61
+ type AuthInput = str | SecretStr | Mapping[str, str] | GeotabAuthConfig
62
+
63
+ # Provider knowledge no consumer should type: the header a Motive
64
+ # static key travels in.
65
+ _MOTIVE_AUTH_HEADER: str = 'X-API-Key'
66
+
67
+ # Samsara's bearer credential: the header and the value prefix. The
68
+ # ingress pre-formats 'Bearer <token>' before wrapping in SecretStr --
69
+ # StaticHeaderAuth documents pre-formatting as the composition root's
70
+ # job, and this is that site.
71
+ _SAMSARA_AUTH_HEADER: str = 'Authorization'
72
+ _SAMSARA_BEARER_PREFIX: str = 'Bearer '
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class ProviderProfileContext:
77
+ """Composition-root collaborators provider profile construction draws on.
78
+
79
+ Grows only when a provider's auth machinery demands a new
80
+ collaborator; this is not a general-purpose bag.
81
+
82
+ Attributes:
83
+ http_config: Timeouts and TLS posture for auth-side HTTP calls.
84
+ limiter_registry: The shared registry; auth calls take limiter
85
+ slots like any other request (token-per-attempt).
86
+ clock: The run's shared clock (session-age timestamping). Required
87
+ -- no default -- because clock identity matters (the one-clock
88
+ rule): ``Sync`` passes its shared clock, ``fetch`` constructs
89
+ one at the profile-build site.
90
+ """
91
+
92
+ http_config: HttpConfig
93
+ limiter_registry: RateLimiterRegistry
94
+ clock: Clock
95
+
96
+
97
+ def build_provider_profile(
98
+ endpoint: EndpointIdentity, auth: AuthInput, context: ProviderProfileContext
99
+ ) -> ProviderProfile:
100
+ """Coerce the public ``auth=`` value into the provider's client profile.
101
+
102
+ Args:
103
+ endpoint: The identity whose provider selects the credential
104
+ shape and classifier; the auth value's own shape never
105
+ drives dispatch.
106
+ auth: The public credential value. Motive and Samsara each
107
+ require a bare API-key/token string; GeoTab requires a
108
+ ``GeotabAuthConfig`` or a mapping with its named fields.
109
+ context: The composition-root collaborators (HTTP posture,
110
+ limiter registry, clock) a provider's auth machinery draws
111
+ on; the static-key providers ignore it, GeoTab consumes it.
112
+
113
+ Returns:
114
+ The ``ProviderProfile`` (``SecretStr``-carrying auth strategy
115
+ plus response classifier) the transport client is built on.
116
+
117
+ Raises:
118
+ ConfigurationError: ``auth``'s shape mismatches the endpoint's
119
+ provider (naming the expected shape and the provider, never
120
+ the value).
121
+ """
122
+ match endpoint.provider:
123
+ case Provider.MOTIVE:
124
+ if not isinstance(auth, str | SecretStr):
125
+ raise ConfigurationError(
126
+ 'auth shape mismatch',
127
+ provider=endpoint.provider.value,
128
+ endpoint=endpoint.name,
129
+ detail=(
130
+ f'Motive auth is a bare API-key string (or SecretStr); '
131
+ f'got {type(auth).__name__}'
132
+ ),
133
+ )
134
+ secret = auth if isinstance(auth, SecretStr) else SecretStr(auth)
135
+ return ProviderProfile(
136
+ auth=StaticHeaderAuth(_MOTIVE_AUTH_HEADER, secret),
137
+ classifier=MotiveResponseClassifier(),
138
+ )
139
+ case Provider.GEOTAB:
140
+ credential = _coerced_geotab_credential(endpoint, auth)
141
+ authenticate_fn = build_geotab_authenticator(
142
+ context.http_config,
143
+ context.limiter_registry,
144
+ QuotaScope.GEOTAB_AUTHENTICATE.value,
145
+ )
146
+ manager = GeotabSessionManager(credential, authenticate_fn, context.clock)
147
+ return ProviderProfile(
148
+ auth=GeotabSessionAuth(manager),
149
+ classifier=GeotabResponseClassifier(),
150
+ )
151
+ case Provider.SAMSARA:
152
+ if not isinstance(auth, str | SecretStr):
153
+ raise ConfigurationError(
154
+ 'auth shape mismatch',
155
+ provider=endpoint.provider.value,
156
+ endpoint=endpoint.name,
157
+ detail=(
158
+ f'Samsara auth is a bare API-token string (or SecretStr); '
159
+ f'got {type(auth).__name__}'
160
+ ),
161
+ )
162
+ raw_token = auth.get_secret_value() if isinstance(auth, SecretStr) else auth
163
+ bearer = SecretStr(f'{_SAMSARA_BEARER_PREFIX}{raw_token}')
164
+ return ProviderProfile(
165
+ auth=StaticHeaderAuth(_SAMSARA_AUTH_HEADER, bearer),
166
+ classifier=SamsaraResponseClassifier(),
167
+ )
168
+
169
+
170
+ def _coerced_geotab_credential(
171
+ endpoint: EndpointIdentity, auth: AuthInput
172
+ ) -> GeotabAuthConfig:
173
+ """Coerce the GeoTab ``auth=`` shapes into the internal credential.
174
+
175
+ A ``GeotabAuthConfig`` passes through as-is; a plain mapping is
176
+ validated into one (Pydantic wraps the password into ``SecretStr``).
177
+ Every rejection names the provider and the expected shapes and the
178
+ received type -- never the value.
179
+
180
+ Args:
181
+ endpoint: The GeoTab identity being fetched (error context).
182
+ auth: The public credential value.
183
+
184
+ Returns:
185
+ The ``SecretStr``-carrying credential.
186
+
187
+ Raises:
188
+ ConfigurationError: The shape is neither accepted form, or the
189
+ mapping's fields fail credential validation.
190
+ """
191
+ if isinstance(auth, GeotabAuthConfig):
192
+ return auth
193
+ shape_detail = (
194
+ 'GeoTab auth is a GeotabAuthConfig or a mapping with '
195
+ 'username/password/database and optional server; '
196
+ f'got {type(auth).__name__}'
197
+ )
198
+ if not isinstance(auth, Mapping):
199
+ raise ConfigurationError(
200
+ 'auth shape mismatch',
201
+ provider=endpoint.provider.value,
202
+ endpoint=endpoint.name,
203
+ detail=shape_detail,
204
+ )
205
+ try:
206
+ return GeotabAuthConfig.model_validate(dict(auth))
207
+ except ValidationError as error:
208
+ field_names = ', '.join(
209
+ '.'.join(str(item) for item in entry['loc']) for entry in error.errors()
210
+ )
211
+ raise ConfigurationError(
212
+ 'auth shape mismatch',
213
+ provider=endpoint.provider.value,
214
+ endpoint=endpoint.name,
215
+ detail=f'{shape_detail} (invalid fields: {field_names})',
216
+ ) from None
@@ -0,0 +1,139 @@
1
+ # src/fleetpull/api/catalog.py
2
+ """The public ``Endpoints`` catalog: every exposed endpoint identity.
3
+
4
+ A static, committed module -- never codegen. Provider namespaces are
5
+ CapWords class-like containers (``Endpoints.Motive``, PEP 8's convention
6
+ for public class-like names); endpoint attributes are lowercase
7
+ identities typed snapshot, windowed, or feed, so the type checker
8
+ enforces ``fetch``'s snapshot-only exposure at the call site. The load-bearing
9
+ identity everywhere strings live stays the lowercase ``Provider`` value,
10
+ so the CapWords surface introduces no string drift (DESIGN §10).
11
+
12
+ The drift protection is the two-way parity discipline test
13
+ (``tests/api/test_catalog.py``) against ``build_endpoint_registry``:
14
+ every identity here must resolve in the discovery registry with a
15
+ matching mode, and every discovered endpoint must appear here with the
16
+ mode-matching identity type.
17
+ """
18
+
19
+ from fleetpull.api.identity import (
20
+ EndpointIdentity,
21
+ FeedEndpoint,
22
+ SnapshotEndpoint,
23
+ WindowedEndpoint,
24
+ )
25
+ from fleetpull.vocabulary import Provider
26
+
27
+ __all__: list[str] = ['Endpoints', 'available_endpoints']
28
+
29
+
30
+ class Endpoints:
31
+ """Provider-namespaced catalog of every public endpoint identity.
32
+
33
+ The namespaces hold inert identities only -- the verbs stay flat and
34
+ provider-agnostic, so the catalog is organized by provider while the
35
+ behavior is not (DESIGN §10, the orchestrator-boundary principle).
36
+ """
37
+
38
+ class Motive:
39
+ """Motive endpoint identities."""
40
+
41
+ vehicles: SnapshotEndpoint = SnapshotEndpoint(Provider.MOTIVE, 'vehicles')
42
+ vehicle_locations: WindowedEndpoint = WindowedEndpoint(
43
+ Provider.MOTIVE, 'vehicle_locations'
44
+ )
45
+ driving_periods: WindowedEndpoint = WindowedEndpoint(
46
+ Provider.MOTIVE, 'driving_periods'
47
+ )
48
+ idle_events: WindowedEndpoint = WindowedEndpoint(Provider.MOTIVE, 'idle_events')
49
+ groups: SnapshotEndpoint = SnapshotEndpoint(Provider.MOTIVE, 'groups')
50
+ users: SnapshotEndpoint = SnapshotEndpoint(Provider.MOTIVE, 'users')
51
+ vehicle_utilizations: WindowedEndpoint = WindowedEndpoint(
52
+ Provider.MOTIVE, 'vehicle_utilizations'
53
+ )
54
+ driver_idle_rollups: WindowedEndpoint = WindowedEndpoint(
55
+ Provider.MOTIVE, 'driver_idle_rollups'
56
+ )
57
+
58
+ class Samsara:
59
+ """Samsara endpoint identities."""
60
+
61
+ vehicles: SnapshotEndpoint = SnapshotEndpoint(Provider.SAMSARA, 'vehicles')
62
+ drivers: SnapshotEndpoint = SnapshotEndpoint(Provider.SAMSARA, 'drivers')
63
+ trips: WindowedEndpoint = WindowedEndpoint(Provider.SAMSARA, 'trips')
64
+ idling_events: WindowedEndpoint = WindowedEndpoint(
65
+ Provider.SAMSARA, 'idling_events'
66
+ )
67
+ addresses: SnapshotEndpoint = SnapshotEndpoint(Provider.SAMSARA, 'addresses')
68
+ engine_states: WindowedEndpoint = WindowedEndpoint(
69
+ Provider.SAMSARA, 'engine_states'
70
+ )
71
+ gps_readings: WindowedEndpoint = WindowedEndpoint(
72
+ Provider.SAMSARA, 'gps_readings'
73
+ )
74
+ odometer_readings: WindowedEndpoint = WindowedEndpoint(
75
+ Provider.SAMSARA, 'odometer_readings'
76
+ )
77
+ asset_locations: WindowedEndpoint = WindowedEndpoint(
78
+ Provider.SAMSARA, 'asset_locations'
79
+ )
80
+ driver_vehicle_assignments: WindowedEndpoint = WindowedEndpoint(
81
+ Provider.SAMSARA, 'driver_vehicle_assignments'
82
+ )
83
+ vehicle_fuel_energy_reports: WindowedEndpoint = WindowedEndpoint(
84
+ Provider.SAMSARA, 'vehicle_fuel_energy_reports'
85
+ )
86
+ driver_fuel_energy_reports: WindowedEndpoint = WindowedEndpoint(
87
+ Provider.SAMSARA, 'driver_fuel_energy_reports'
88
+ )
89
+
90
+ class Geotab:
91
+ """GeoTab endpoint identities."""
92
+
93
+ devices: SnapshotEndpoint = SnapshotEndpoint(Provider.GEOTAB, 'devices')
94
+ users: SnapshotEndpoint = SnapshotEndpoint(Provider.GEOTAB, 'users')
95
+ trips: WindowedEndpoint = WindowedEndpoint(Provider.GEOTAB, 'trips')
96
+ exception_events: WindowedEndpoint = WindowedEndpoint(
97
+ Provider.GEOTAB, 'exception_events'
98
+ )
99
+ log_records: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'log_records')
100
+ status_data: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'status_data')
101
+ fill_ups: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'fill_ups')
102
+ fuel_and_energy_used: FeedEndpoint = FeedEndpoint(
103
+ Provider.GEOTAB, 'fuel_and_energy_used'
104
+ )
105
+ fuel_tax_details: FeedEndpoint = FeedEndpoint(
106
+ Provider.GEOTAB, 'fuel_tax_details'
107
+ )
108
+ fault_data: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'fault_data')
109
+ duty_status_logs: FeedEndpoint = FeedEndpoint(
110
+ Provider.GEOTAB, 'duty_status_logs'
111
+ )
112
+ driver_changes: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'driver_changes')
113
+ dvir_logs: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'dvir_logs')
114
+ annotation_logs: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'annotation_logs')
115
+ shipment_logs: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'shipment_logs')
116
+ audits: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'audits')
117
+ text_messages: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'text_messages')
118
+ media_files: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'media_files')
119
+
120
+
121
+ def available_endpoints() -> tuple[EndpointIdentity, ...]:
122
+ """Enumerate the whole catalog -- its manifest, in declaration order.
123
+
124
+ Derived from the provider namespaces themselves: each namespace's
125
+ class ``vars()`` preserves declaration order, so the manifest is the
126
+ catalog read back, never a hand-kept repeat that could drift from it
127
+ (the parity test still proves both directions against discovery).
128
+
129
+ Returns:
130
+ Every identity the ``Endpoints`` catalog exposes -- Motive, then
131
+ Samsara, then GeoTab, each in declaration order.
132
+ """
133
+ provider_namespaces = (Endpoints.Motive, Endpoints.Samsara, Endpoints.Geotab)
134
+ return tuple(
135
+ attribute
136
+ for namespace in provider_namespaces
137
+ for attribute in vars(namespace).values()
138
+ if isinstance(attribute, (SnapshotEndpoint, WindowedEndpoint, FeedEndpoint))
139
+ )
fleetpull/api/fetch.py ADDED
@@ -0,0 +1,229 @@
1
+ # src/fleetpull/api/fetch.py
2
+ """``fetch``: the snapshot-only, in-memory programmatic convenience verb.
3
+
4
+ The convenience charter (DESIGN §10): an endpoint identity, one
5
+ ``auth=`` value, and almost nothing else, returning an eager typed
6
+ DataFrame with no state machinery -- no SQLite, no disk, no cursor, no
7
+ run ledger, no roster. ``fetch`` deliberately limits options; a caller
8
+ who wants windows, incremental resume, partitioned storage, or fan-out
9
+ is a sync user, not a fetch user with missing parameters.
10
+
11
+ Snapshot-only because the in-memory contract is only honest for
12
+ snapshots: a snapshot result is bounded by entity count, while a
13
+ windowed result grows with window width and fleet activity, unbounded by
14
+ anything the caller controls in memory. The exposure gate is the
15
+ ``SnapshotEndpoint`` parameter type; a runtime guard backs it for the
16
+ audiences mypy never covers (notebooks foremost).
17
+
18
+ No member filtering, by design: ``fetch`` returns the endpoint's full
19
+ current listing. Filtering (by vehicle, by group, by status) presumes a
20
+ use case, and the scope refusal (§10) forbids exactly that presumption
21
+ -- consumers filter the returned frame themselves.
22
+
23
+ The composition is the state-free fetch trace (established by the
24
+ 2026-07-06 pre-API audit):
25
+ provider configs from defaults → the discovery registry → the definition
26
+ by identity key → a ``ClientRuntime`` on internal defaults → the
27
+ auth-ingress profile (its ``ProviderProfileContext`` reusing the
28
+ runtime's HTTP posture and limiter registry, plus a clock held solely
29
+ for GeoTab session aging) → the shape-resolved request driver (the
30
+ shared ``resolve_request_driver`` seam with no roster source: the
31
+ stateless shapes a snapshot can declare -- single fetch, param sweep
32
+ -- resolve (a bisected window is watermark-only by construction and
33
+ never reaches this verb), a roster-backed shape (``RosterFanOut`` /
34
+ ``BatchedRosterFanOut``) is refused loudly, and the
35
+ driver honors a declared completeness check, so a guarded snapshot is
36
+ verified on this verb exactly as under sync) → ``TransportClient`` →
37
+ ``validate_records`` → ``models_to_dataframe``.
38
+ """
39
+
40
+ import logging
41
+
42
+ import polars as pl
43
+
44
+ from fleetpull.api.auth_ingress import (
45
+ AuthInput,
46
+ ProviderProfileContext,
47
+ build_provider_profile,
48
+ )
49
+ from fleetpull.api.identity import FeedEndpoint, SnapshotEndpoint, WindowedEndpoint
50
+ from fleetpull.config import (
51
+ HttpConfig,
52
+ RetryConfig,
53
+ default_provider_sections,
54
+ )
55
+ from fleetpull.endpoints import build_endpoint_registry
56
+ from fleetpull.exceptions import ConfigurationError
57
+ from fleetpull.network.client import ClientRuntime, TransportClient
58
+ from fleetpull.network.limits import RateLimiterRegistry, rate_limits_from_configs
59
+ from fleetpull.orchestrator import FetchPoolRegistry, resolve_request_driver
60
+ from fleetpull.records import models_to_dataframe, validate_records
61
+ from fleetpull.timing import SystemClock
62
+ from fleetpull.vocabulary import JsonObject
63
+
64
+ __all__: list[str] = ['fetch']
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+
69
+ # typing-justified: ingress guard; input unknowable by design; object forces narrowing
70
+ def _require_snapshot_identity(endpoint: object) -> None:
71
+ """Reject a non-snapshot identity before any client construction.
72
+
73
+ The static gate (the ``SnapshotEndpoint`` parameter type) protects
74
+ type-checked callers; this guard is the same gate for the
75
+ convenience verb's unchecked audience. Typed on ``object`` so the
76
+ narrowing is real work to the type checker rather than a
77
+ statically-unreachable branch.
78
+
79
+ Args:
80
+ endpoint: Whatever the caller passed as the endpoint identity.
81
+
82
+ Returns:
83
+ None when ``endpoint`` is a ``SnapshotEndpoint``.
84
+
85
+ Raises:
86
+ ConfigurationError: ``endpoint`` is windowed- or feed-typed (naming
87
+ the endpoint and its mode) or is no catalog identity at all.
88
+ """
89
+ if isinstance(endpoint, SnapshotEndpoint):
90
+ return
91
+ if isinstance(endpoint, WindowedEndpoint):
92
+ raise ConfigurationError(
93
+ 'fetch is snapshot-only',
94
+ provider=endpoint.provider.value,
95
+ endpoint=endpoint.name,
96
+ detail=(
97
+ 'this endpoint is windowed-mode; windowed retrieval is the '
98
+ 'config-driven sync path, not a fetch option'
99
+ ),
100
+ )
101
+ if isinstance(endpoint, FeedEndpoint):
102
+ raise ConfigurationError(
103
+ 'fetch is snapshot-only',
104
+ provider=endpoint.provider.value,
105
+ endpoint=endpoint.name,
106
+ detail=(
107
+ 'this endpoint is feed-mode; a feed is an unbounded version '
108
+ 'stream with durable cursor state, retrieved only through '
109
+ 'the config-driven sync path'
110
+ ),
111
+ )
112
+ raise ConfigurationError(
113
+ 'fetch requires a snapshot identity from the Endpoints catalog',
114
+ detail=f'got {type(endpoint).__name__}',
115
+ )
116
+
117
+
118
+ def fetch(
119
+ endpoint: SnapshotEndpoint,
120
+ auth: AuthInput,
121
+ *,
122
+ use_truststore: bool = False,
123
+ ) -> pl.DataFrame:
124
+ """Fetch one snapshot endpoint's full current listing into a DataFrame.
125
+
126
+ End-to-end in memory: no SQLite, no disk, no cursor, no run ledger,
127
+ no roster. Anything beyond this surface -- windows, incremental
128
+ resume, partitioned storage, roster fan-out, member filtering -- is
129
+ the config-driven sync path's territory, not a missing parameter
130
+ here. The request driver is shape-resolved through the shared seam,
131
+ so every stateless request shape (single fetch, param sweep) is
132
+ served; a roster fan-out needs durable roster state and is refused.
133
+
134
+ Args:
135
+ endpoint: A snapshot-typed identity from the ``Endpoints``
136
+ catalog (``Endpoints.Motive.vehicles``). Windowed identities
137
+ fail the type checker and, for unchecked callers, the
138
+ runtime guard.
139
+ auth: The provider credential. Motive and Samsara take a bare
140
+ API-key string; GeoTab takes named fields (a plain mapping
141
+ or a ``GeotabAuthConfig``). Coerced immediately into
142
+ ``SecretStr``-carrying internals; the raw value never
143
+ appears in errors or logs.
144
+ use_truststore: Build TLS contexts from the operating system's
145
+ trust store -- required behind TLS-intercepting corporate
146
+ proxies. Default False, coerced into the identically named
147
+ ``HttpConfig.use_truststore``. Timeouts and any further
148
+ transport posture are config-phase territory.
149
+
150
+ Returns:
151
+ An eager Polars DataFrame, dtype-coerced per the endpoint's
152
+ response model. Column order is deliberately unspecified. An
153
+ empty listing is a zero-row frame carrying the full typed schema
154
+ -- never ``None``, never a schemaless frame.
155
+
156
+ Raises:
157
+ FleetpullError: Any operational failure -- always one of the
158
+ four public subclasses below; every other exception type is
159
+ internal and renameable.
160
+ ConfigurationError: The identity is not snapshot-typed, the auth
161
+ shape mismatches the endpoint's provider, the identity
162
+ resolves to no registered endpoint, or the endpoint declares
163
+ a roster-backed shape (``RosterFanOut`` /
164
+ ``BatchedRosterFanOut``: roster state is sync territory;
165
+ fetch is stateless by contract).
166
+ AuthenticationError: The provider rejected the credential
167
+ unfixably.
168
+ RetriesExhaustedError: A retryable failure category exhausted
169
+ its attempt budget.
170
+ ProviderResponseError: A non-retryable or contract-violating
171
+ provider response, including a 200 whose body is not JSON
172
+ and records that fail model validation.
173
+
174
+ Scope: retrieval, dtype coercion, and light structural normalization
175
+ only -- no cross-endpoint joins, no unified schema, no assumed end
176
+ use (DESIGN §10).
177
+ """
178
+ _require_snapshot_identity(endpoint)
179
+ provider_configs = default_provider_sections()
180
+ registry = build_endpoint_registry(list(provider_configs.values()))
181
+ definition = registry.get(endpoint.provider, endpoint.name)
182
+ runtime = ClientRuntime(
183
+ http_config=HttpConfig(use_truststore=use_truststore),
184
+ retry_config=RetryConfig(),
185
+ limiter_registry=RateLimiterRegistry(
186
+ rate_limits_from_configs(list(provider_configs.values()))
187
+ ),
188
+ )
189
+ # fetch holds a clock solely for GeoTab session aging; the state-free
190
+ # trace otherwise needs none.
191
+ profile = build_provider_profile(
192
+ endpoint,
193
+ auth,
194
+ ProviderProfileContext(
195
+ http_config=runtime.http_config,
196
+ limiter_registry=runtime.limiter_registry,
197
+ clock=SystemClock(),
198
+ ),
199
+ )
200
+ fetch_workers = {
201
+ endpoint.provider: (
202
+ provider_configs[endpoint.provider].rate_limit.max_concurrency
203
+ )
204
+ }
205
+ with FetchPoolRegistry(fetch_workers) as fetch_pools:
206
+ # The shared shape seam with no roster source: every stateless
207
+ # shape resolves; a roster-backed shape is refused loudly before any
208
+ # transport pool opens. The resolved driver owns the chain (spec
209
+ # build, page loop) and, when the definition declares a
210
+ # completeness check, the post-stream count verification -- fetch
211
+ # must not offer a weaker read of a guarded endpoint than sync
212
+ # does.
213
+ driver = resolve_request_driver(
214
+ definition, fetch_pools=fetch_pools, roster_members=None
215
+ )
216
+ with TransportClient(profile, runtime) as client:
217
+ raw_records: list[JsonObject] = [
218
+ record
219
+ for page in driver.record_batches(definition, client, None)
220
+ for record in page.records
221
+ ]
222
+ logger.info(
223
+ 'Fetched %d %s.%s records across the snapshot listing.',
224
+ len(raw_records),
225
+ endpoint.provider.value,
226
+ endpoint.name,
227
+ )
228
+ validated_models = validate_records(raw_records, definition.response_model)
229
+ return models_to_dataframe(validated_models, definition.response_model)