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,139 @@
1
+ # src/fleetpull/config/logger.py
2
+ """Logging configuration model.
3
+
4
+ LoggerConfig is one section of fleetpull's user-provided YAML
5
+ configuration. Like every model in fleetpull.config, it validates user
6
+ input at the boundary; consuming code (fleetpull.logger.setup) receives
7
+ only validated, normalized values.
8
+
9
+ Level fields accept standard level names case-insensitively ('debug',
10
+ 'INFO') or the standard integer levels (10, 20, 30, 40, 50) and are
11
+ normalized to integers at validation time. Booleans, nonstandard
12
+ integers, deprecated aliases (WARN, FATAL), and NOTSET are rejected.
13
+ ``file_path`` normalizes through ``paths.resolve_path`` at validation
14
+ time, like every path field in the config layer.
15
+ """
16
+
17
+ import logging
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from pydantic import field_validator
22
+
23
+ from fleetpull.config.base import ConfigModel
24
+ from fleetpull.paths import resolve_path
25
+
26
+ __all__: list[str] = ['LoggerConfig']
27
+
28
+ # Canonical mapping from level name to Python logging integer. The name
29
+ # set is fixed to the five standard levels on purpose: the deprecated
30
+ # aliases (WARN, FATAL) and NOTSET (level 0, "inherit from parent") are
31
+ # rejected so a YAML file reads the same as the logging documentation.
32
+ # The integer values are pulled from the stdlib rather than hardcoded.
33
+ _LEVEL_NAME_TO_INT: dict[str, int] = {
34
+ level_name: getattr(logging, level_name)
35
+ for level_name in ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
36
+ }
37
+
38
+ # Used to validate integer input and to render error messages that show
39
+ # both accepted forms.
40
+ _ALLOWED_LEVEL_INTS: frozenset[int] = frozenset(_LEVEL_NAME_TO_INT.values())
41
+
42
+
43
+ # typing-justified: mode='before' validator input is contractually arbitrary
44
+ def _coerce_log_level(raw_value: Any) -> int:
45
+ # ``Any``: receives the raw pre-validation value from a Pydantic
46
+ # ``mode='before'`` validator, which is contractually arbitrary —
47
+ # any YAML scalar the user might write. The isinstance chain below
48
+ # enumerates every accepted shape and rejects the rest.
49
+ """
50
+ Convert a user-supplied log level (name or int) to a logging integer.
51
+
52
+ Accepts:
53
+ - A ``str`` naming one of DEBUG / INFO / WARNING / ERROR /
54
+ CRITICAL (case-insensitive, surrounding whitespace stripped).
55
+ - An ``int`` equal to one of the standard levels (10, 20, 30,
56
+ 40, 50).
57
+
58
+ Args:
59
+ raw_value: The value supplied by the user.
60
+
61
+ Returns:
62
+ The Python logging integer corresponding to ``raw_value``.
63
+
64
+ Raises:
65
+ ValueError: If ``raw_value`` is a bool, a nonstandard integer,
66
+ an unrecognized name, or any other type. ``ValueError``
67
+ (rather than ``TypeError``) is used throughout so Pydantic
68
+ wraps the failure into a ``ValidationError`` attributed to
69
+ the offending field.
70
+ """
71
+ if isinstance(raw_value, bool):
72
+ # bool is a subclass of int; reject explicitly so True does not
73
+ # quietly become log level 1.
74
+ raise ValueError('log level must be a level name or integer, got bool')
75
+
76
+ if isinstance(raw_value, int):
77
+ if raw_value not in _ALLOWED_LEVEL_INTS:
78
+ allowed_pairs: str = ', '.join(
79
+ f'{name}={value}' for name, value in _LEVEL_NAME_TO_INT.items()
80
+ )
81
+ raise ValueError(
82
+ f'integer {raw_value} is not a standard log level; '
83
+ f'allowed: {allowed_pairs}'
84
+ )
85
+ return raw_value
86
+
87
+ if isinstance(raw_value, str):
88
+ normalized_name: str = raw_value.strip().upper()
89
+ if normalized_name not in _LEVEL_NAME_TO_INT:
90
+ allowed_names: str = ', '.join(_LEVEL_NAME_TO_INT)
91
+ raise ValueError(
92
+ f'{raw_value!r} is not a recognized log level; '
93
+ f'allowed: {allowed_names} (case-insensitive)'
94
+ )
95
+ return _LEVEL_NAME_TO_INT[normalized_name]
96
+
97
+ raise ValueError(
98
+ f'log level must be a level name or integer, got {type(raw_value).__name__}'
99
+ )
100
+
101
+
102
+ class LoggerConfig(ConfigModel):
103
+ """
104
+ User-facing logging configuration.
105
+
106
+ Attributes:
107
+ console_level: Minimum level for stderr console output. Accepts
108
+ a standard level name or integer; normalized to int.
109
+ Defaults to INFO.
110
+ file_path: Path to a log file, normalized through
111
+ ``paths.resolve_path`` at validation. When None (the
112
+ default), file logging is disabled and ``file_level`` is
113
+ inert.
114
+ file_level: Minimum level for file output. Required with a
115
+ default of DEBUG so there is never a None to narrow at the
116
+ use site; it simply has no effect while ``file_path`` is
117
+ None.
118
+ """
119
+
120
+ console_level: int = logging.INFO
121
+ file_path: Path | None = None
122
+ file_level: int = logging.DEBUG
123
+
124
+ @field_validator('console_level', 'file_level', mode='before')
125
+ @classmethod
126
+ # typing-justified: mode='before' validator input is contractually arbitrary
127
+ def _coerce_level(cls, raw_value: Any) -> int:
128
+ # ``Any``: mode='before' validators receive arbitrary
129
+ # pre-validation input; _coerce_log_level enumerates the
130
+ # accepted shapes. No field label is threaded through —
131
+ # Pydantic's ValidationError attributes the failure to the
132
+ # correct field via its loc.
133
+ return _coerce_log_level(raw_value)
134
+
135
+ @field_validator('file_path')
136
+ @classmethod
137
+ def _resolve(cls, value: Path | None) -> Path | None:
138
+ """Normalize the path lexically when present; ``None`` passes through."""
139
+ return None if value is None else resolve_path(value)
@@ -0,0 +1,520 @@
1
+ # src/fleetpull/config/providers.py
2
+ """The provider config family: the shared base, the sections, the container.
3
+
4
+ One model family per file (house rule): ``ProviderConfig`` (the
5
+ per-provider contract), the concrete provider sections (``MotiveConfig``,
6
+ ``GeotabConfig``, ``SamsaraConfig``), and ``ProvidersConfig`` (the
7
+ ``providers:`` YAML container) evolve together.
8
+
9
+ The family also owns two provider facts consumed above the models:
10
+ ``PROVIDER_CREDENTIAL_ENV_VARS`` (the conventional per-provider
11
+ credential environment variables the loading step merges from) and
12
+ ``require_provider_credentials`` (the enablement invariant the root
13
+ config enforces at validation: endpoints listed with no credential is a
14
+ ``ConfigurationError``).
15
+ """
16
+
17
+ from collections.abc import Mapping
18
+ from typing import ClassVar
19
+
20
+ from pydantic import Field, SecretStr, field_validator
21
+
22
+ from fleetpull.config.base import ConfigModel
23
+ from fleetpull.config.geotab import GeotabAuthConfig
24
+ from fleetpull.config.rate_limit import RateLimitConfig
25
+ from fleetpull.exceptions import ConfigurationError
26
+ from fleetpull.vocabulary import Provider, QuotaScope
27
+
28
+ __all__: list[str] = [
29
+ 'PROVIDER_CREDENTIAL_ENV_VARS',
30
+ 'GeotabConfig',
31
+ 'MotiveConfig',
32
+ 'ProviderConfig',
33
+ 'ProvidersConfig',
34
+ 'SamsaraConfig',
35
+ 'default_provider_sections',
36
+ 'require_provider_credentials',
37
+ ]
38
+
39
+ # The conventional credential environment variable per provider -- the
40
+ # fallback the loading step merges when the YAML key is absent (a YAML
41
+ # literal wins). The mapping is asymmetric by credential shape: Motive's
42
+ # variable supplies the WHOLE credential (`api_key`); GeoTab's supplies
43
+ # the PASSWORD FIELD only (username, database, and server always come
44
+ # from the YAML `auth` section -- they are not secrets). New providers
45
+ # add their entry as they port.
46
+ PROVIDER_CREDENTIAL_ENV_VARS: Mapping[str, str] = {
47
+ 'motive': 'MOTIVE_API_KEY',
48
+ 'geotab': 'GEOTAB_PASSWORD',
49
+ 'samsara': 'SAMSARA_API_KEY',
50
+ }
51
+
52
+
53
+ def _validated_base_url(value: str) -> str:
54
+ """Reject a schemeless URL and drop any trailing slash.
55
+
56
+ The shared per-field check behind the ``base_url`` validators of the
57
+ static-key providers (Motive, Samsara) -- generic URL hygiene, not
58
+ provider semantics, so sharing it couples nothing.
59
+
60
+ Args:
61
+ value: The configured base URL.
62
+
63
+ Returns:
64
+ The base URL with no trailing slash.
65
+
66
+ Raises:
67
+ ValueError: When the URL carries no http(s) scheme.
68
+ """
69
+ if not value.startswith(('http://', 'https://')):
70
+ raise ValueError('base_url must start with http:// or https://')
71
+ return value.rstrip('/')
72
+
73
+
74
+ # The watermark-window defaults every provider section shares: a week
75
+ # of late-arrival margin, no trailing-edge holdback. Declared once here
76
+ # because the knobs are part of the per-provider contract on
77
+ # ``ProviderConfig``; per-provider YAML keys and a declared ``sync``
78
+ # value still override (provider key > sync key > default; the
79
+ # precedence lives in ``config/resolution.py``).
80
+ _DEFAULT_LOOKBACK_DAYS: int = 7
81
+ _DEFAULT_CUTOFF_DAYS: int = 0
82
+
83
+ _MOTIVE_DEFAULT_BASE_URL: str = 'https://api.gomotive.com'
84
+ _MOTIVE_MAX_RECORDS_PER_PAGE: int = 100
85
+
86
+ # Conservative default budget for the Motive scope. Motive's real published
87
+ # per-key limits remain unverified (DESIGN §13 open question; the documented
88
+ # /vehicle_locations limit was not observed to enforce, §8), so this default
89
+ # is the diagnostic's proven-safe posture: the live full-fleet fan-out ran
90
+ # under these values without a single 429. Tighten or raise once the real
91
+ # limits are pinned by probing.
92
+ _MOTIVE_DEFAULT_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
93
+ requests_per_period=60, period_seconds=60.0, burst=10, max_concurrency=2
94
+ )
95
+
96
+
97
+ class ProviderConfig(ConfigModel):
98
+ """Base for per-provider configuration sections.
99
+
100
+ Subclassed once per provider (``MotiveConfig``, ...). Carries the
101
+ per-provider contract: each subclass binds its ``quota_scope`` and
102
+ ``credential_hint``, exposes its credential shape through the
103
+ ``credential`` property, and
104
+ defaults its ``rate_limit``; the shared watermark window knobs
105
+ (``lookback_days``, ``cutoff_days``) are declared here once for
106
+ every provider; the model policy itself comes from ``ConfigModel``.
107
+
108
+ Attributes:
109
+ quota_scope: The quota scope this provider's budget governs. A
110
+ ``ClassVar`` bound by each subclass -- provider identity is a
111
+ code fact, not user configuration; a subclass that forgets to
112
+ bind it fails loudly on first attribute access.
113
+ credential_hint: Where this provider's credential comes from -- the
114
+ YAML field and the conventional environment variable -- phrased
115
+ to complete the enablement error's ``no credential resolves;
116
+ set <hint>`` sentence. A ``ClassVar`` bound by each subclass,
117
+ like ``quota_scope``.
118
+ rate_limit: The token-bucket budget for this provider's scope.
119
+ Each subclass supplies its own documented default.
120
+ endpoints: The endpoint names this provider syncs, as listed in
121
+ the YAML section. Strings here -- validation against the
122
+ endpoint catalog happens at ``Sync`` construction, above this
123
+ tier, never in ``config`` -- but duplicates are rejected at
124
+ validation (a duplicated name would run twice, concurrently).
125
+ Default empty; a provider with no endpoints is disabled
126
+ regardless of its credential.
127
+ lookback_days: Late-arrival re-fetch margin in whole days for
128
+ watermark endpoints -- how far before the stored watermark
129
+ each resume re-fetches, so a record that landed after its
130
+ event-time day is recovered and its partitions replaced.
131
+ Optional per-provider YAML key; when absent, root-level
132
+ resolution fans in a declared ``sync.lookback_days``, else
133
+ this documented default stands (provider key > sync key >
134
+ default). Non-negative; zero means no margin beyond the
135
+ watermark's own date.
136
+ cutoff_days: Trailing-edge holdback in whole days for watermark
137
+ endpoints -- how far the resume window's end is held back
138
+ from the clock, so a still-arriving day is never frozen as a
139
+ complete partition. The complement of ``lookback_days``:
140
+ both express the same provider data-latency concern from
141
+ opposite ends, and both carry the same per-provider-key >
142
+ ``sync``-key > default precedence. Optional; defaults to 0.
143
+ """
144
+
145
+ quota_scope: ClassVar[QuotaScope]
146
+ credential_hint: ClassVar[str]
147
+
148
+ rate_limit: RateLimitConfig
149
+ endpoints: tuple[str, ...] = ()
150
+ lookback_days: int = Field(default=_DEFAULT_LOOKBACK_DAYS, ge=0)
151
+ cutoff_days: int = Field(default=_DEFAULT_CUTOFF_DAYS, ge=0)
152
+
153
+ @property
154
+ def credential(self) -> SecretStr | GeotabAuthConfig | None:
155
+ """The provider's configured credential, or ``None`` when absent.
156
+
157
+ Each section binds its own credential shape (a bare ``SecretStr``
158
+ key for the static-key providers, GeoTab's four-field ``auth``
159
+ section whole) behind this one property, so the enablement guard,
160
+ the disabled-provider warning, and ``Sync``'s credential read all
161
+ stay provider-agnostic loops.
162
+
163
+ Raises:
164
+ NotImplementedError: The subclass failed to bind its credential
165
+ shape -- a definition bug surfaced on first read.
166
+ """
167
+ raise NotImplementedError(
168
+ f'{type(self).__name__} must define its credential property'
169
+ )
170
+
171
+ def scope_rate_limits(self) -> dict[str, RateLimitConfig]:
172
+ """Every quota scope this provider's config budgets, with its limit.
173
+
174
+ The base emission is the one bound scope: ``{quota_scope: rate_limit}``.
175
+ A provider metering more than one method class overrides this to add
176
+ its extra scopes (GeoTab's feed and authenticate classes), so the
177
+ limiter registry derives every scope from the configs themselves
178
+ without provider special-casing.
179
+
180
+ Returns:
181
+ The quota-scope-keyed budgets this config governs.
182
+ """
183
+ return {self.quota_scope.value: self.rate_limit}
184
+
185
+ @field_validator('endpoints')
186
+ @classmethod
187
+ def _reject_duplicate_endpoints(cls, value: tuple[str, ...]) -> tuple[str, ...]:
188
+ """Reject a duplicated endpoint name loudly, naming every duplicate.
189
+
190
+ A duplicated name would run twice -- concurrently, since endpoints
191
+ within a provider run staged-concurrent -- so it is a configuration
192
+ failure to surface, never a silent dedup.
193
+ """
194
+ duplicated = sorted({name for name in value if value.count(name) > 1})
195
+ if duplicated:
196
+ raise ValueError(
197
+ f'endpoint names must be unique; duplicated: {", ".join(duplicated)}'
198
+ )
199
+ return value
200
+
201
+
202
+ class MotiveConfig(ProviderConfig):
203
+ """
204
+ User-facing Motive provider settings, one instance per run.
205
+
206
+ Attributes:
207
+ api_key: The Motive API credential for the config-driven sync
208
+ path (``fetch`` takes its credential as an argument instead).
209
+ Optional in YAML -- ``FleetpullConfig.from_yaml`` merges the
210
+ ``MOTIVE_API_KEY`` environment variable when the key is
211
+ absent. ``SecretStr`` from parse time on: masked in every
212
+ repr and never logged.
213
+ base_url: Root of the Motive API. Optional; defaults to Motive's
214
+ documented production host. Must carry an http(s) scheme and
215
+ is normalized to drop any trailing slash, so a spec-builder
216
+ joins a leading-slash request path to it directly.
217
+ records_per_page: Page size requested from paginated Motive
218
+ endpoints. Optional; defaults to Motive's documented maximum.
219
+ Bounded to ``1..100`` (the documented ceiling) so a typo
220
+ cannot silently request an out-of-range page.
221
+ rate_limit: The Motive scope's token-bucket budget. Optional;
222
+ defaults to the conservative values the live diagnostic proved
223
+ safe (Motive's real published limits are unverified -- DESIGN
224
+ §13); see ``_MOTIVE_DEFAULT_RATE_LIMIT`` for the rationale.
225
+ """
226
+
227
+ quota_scope: ClassVar[QuotaScope] = QuotaScope.MOTIVE
228
+ credential_hint: ClassVar[str] = (
229
+ f"'providers.motive.api_key' in the YAML or the "
230
+ f'{PROVIDER_CREDENTIAL_ENV_VARS["motive"]} environment variable'
231
+ )
232
+
233
+ api_key: SecretStr | None = None
234
+ base_url: str = Field(default=_MOTIVE_DEFAULT_BASE_URL)
235
+ rate_limit: RateLimitConfig = Field(default=_MOTIVE_DEFAULT_RATE_LIMIT)
236
+ records_per_page: int = Field(
237
+ default=_MOTIVE_MAX_RECORDS_PER_PAGE, ge=1, le=_MOTIVE_MAX_RECORDS_PER_PAGE
238
+ )
239
+
240
+ @property
241
+ def credential(self) -> SecretStr | None:
242
+ """The Motive credential: the bare API key, or ``None``."""
243
+ return self.api_key
244
+
245
+ @field_validator('base_url')
246
+ @classmethod
247
+ def _require_scheme_and_strip_slash(cls, value: str) -> str:
248
+ """Apply the shared base-URL hygiene check (see the module helper)."""
249
+ return _validated_base_url(value)
250
+
251
+
252
+ # The Get method-class budget, from the captured rate headers
253
+ # (2026-07-09): `X-Rate-Limit-Remaining: 649` after one call implies
254
+ # ~650/min -- a SINGLE datum, so this default errs conservative on burst
255
+ # and is revisited if live runs contradict it. The docs caveat that the
256
+ # headers may precede enforcement; fleetpull self-limits at the
257
+ # advertised budget regardless (DESIGN §8 probe-settled decision 3).
258
+ # max_concurrency mirrors the Motive posture and is inert until a GeoTab
259
+ # fan-out endpoint exists (no GeoTab endpoint declares a fan-out today).
260
+ _GEOTAB_DEFAULT_GET_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
261
+ requests_per_period=650, period_seconds=60.0, burst=100, max_concurrency=2
262
+ )
263
+
264
+ # The GetFeed method-class budget: ~60/min, from the 2026-07-21
265
+ # header-decrement probe (x-rate-limit-limit '1m' with remaining counting
266
+ # down call by call on GetFeed while the Get class sat at ~650/min --
267
+ # GetFeed is its OWN method class, not a Get-class spender). Burst stays
268
+ # conservative on the small budget, and max_concurrency 2 lets two feed
269
+ # ENDPOINTS interleave pages within a sync (each feed walk is itself
270
+ # strictly serial, one chain page after page) without letting a wider
271
+ # fan-out ever form on this class.
272
+ _GEOTAB_DEFAULT_FEED_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
273
+ requests_per_period=60, period_seconds=60.0, burst=10, max_concurrency=2
274
+ )
275
+
276
+ # The Authenticate method-class budget: 10/min, from the June 2026
277
+ # `OverLimitException` capture ("API calls quota exceeded. Maximum
278
+ # admitted 10 per 1m.", paired `retry-after: 56`) and the provider docs
279
+ # row (Status: Active). Authenticate fires rarely behind the session
280
+ # manager's single-flight lock, so burst stays small and max_concurrency
281
+ # is 1 -- inert by construction (nothing fans out on Authenticate; the
282
+ # call only ever takes limiter slots one at a time).
283
+ _GEOTAB_DEFAULT_AUTHENTICATE_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
284
+ requests_per_period=10, period_seconds=60.0, burst=2, max_concurrency=1
285
+ )
286
+
287
+
288
+ class GeotabConfig(ProviderConfig):
289
+ """
290
+ User-facing GeoTab provider settings, one instance per run.
291
+
292
+ The inherited watermark window knobs apply since the ``trips``
293
+ vertical (amended 2026-07-13; the earlier omission encoded a
294
+ superseded feeds-only view of GeoTab incrementality -- windowed
295
+ ``Get`` is GeoTab's history path today, and feeds remain the future
296
+ incremental mechanism; DESIGN §4's amendment). For ``trips``, the
297
+ ``lookback_days`` margin is also what absorbs GeoTab's Trip
298
+ recalculation. Deliberately no ``base_url``: the API host is
299
+ ``auth.server``, and the session strategy retargets every call to
300
+ the host ``Authenticate`` resolves (DESIGN §8).
301
+
302
+ Attributes:
303
+ auth: The four-field GeoTab credential (username, password,
304
+ database, server), nested -- it never flattens into the
305
+ provider section. Optional in YAML for a disabled provider;
306
+ enablement requires it (``require_provider_credentials``).
307
+ ``from_yaml`` merges the ``GEOTAB_PASSWORD`` environment
308
+ variable into an ``auth`` section missing its password.
309
+ endpoints: The endpoint names this provider syncs (catalog
310
+ validation happens at ``Sync`` construction, above this tier).
311
+ rate_limit: The Get method-class budget (the scope ``devices``
312
+ and ``trips`` declare); default from the captured 2026-07-09
313
+ headers -- see ``_GEOTAB_DEFAULT_GET_RATE_LIMIT`` for the
314
+ single-datum caveat.
315
+ feed_rate_limit: The GetFeed method-class budget (the scope the
316
+ feed endpoints declare); default ~60/min from the 2026-07-21
317
+ header-decrement probe -- see
318
+ ``_GEOTAB_DEFAULT_FEED_RATE_LIMIT``.
319
+ authenticate_rate_limit: The Authenticate method-class budget;
320
+ default 10/min from the June 2026 capture -- see
321
+ ``_GEOTAB_DEFAULT_AUTHENTICATE_RATE_LIMIT``.
322
+ """
323
+
324
+ quota_scope: ClassVar[QuotaScope] = QuotaScope.GEOTAB_GET
325
+ credential_hint: ClassVar[str] = (
326
+ f"'providers.geotab.auth' (username, database, and optional "
327
+ f'server in the YAML; the password from the YAML or the '
328
+ f'{PROVIDER_CREDENTIAL_ENV_VARS["geotab"]} environment variable)'
329
+ )
330
+
331
+ auth: GeotabAuthConfig | None = None
332
+ rate_limit: RateLimitConfig = Field(default=_GEOTAB_DEFAULT_GET_RATE_LIMIT)
333
+ feed_rate_limit: RateLimitConfig = Field(default=_GEOTAB_DEFAULT_FEED_RATE_LIMIT)
334
+ authenticate_rate_limit: RateLimitConfig = Field(
335
+ default=_GEOTAB_DEFAULT_AUTHENTICATE_RATE_LIMIT
336
+ )
337
+
338
+ @property
339
+ def credential(self) -> GeotabAuthConfig | None:
340
+ """The GeoTab credential: the four-field ``auth`` section, or ``None``.
341
+
342
+ The resolvable-password half of GeoTab enablement is structural:
343
+ ``GeotabAuthConfig`` requires its password, so a present ``auth``
344
+ that reached validation carries one (YAML literal or the merged
345
+ ``GEOTAB_PASSWORD`` environment variable). A ``None`` here is the
346
+ wholly absent credential section.
347
+ """
348
+ return self.auth
349
+
350
+ def scope_rate_limits(self) -> dict[str, RateLimitConfig]:
351
+ """The three GeoTab method-class budgets, each under its own scope.
352
+
353
+ GeoTab meters per method class (DESIGN §8): the inherited emission
354
+ covers the Get-class scope the ``quota_scope`` ClassVar binds, and
355
+ this override adds the GetFeed class (the feed endpoints' scope) and
356
+ the dedicated Authenticate class from the same config, so every
357
+ GeoTab method-class scope is registered wherever a ``GeotabConfig``
358
+ is.
359
+
360
+ Returns:
361
+ The Get, GetFeed, and Authenticate scope budgets.
362
+ """
363
+ return {
364
+ **super().scope_rate_limits(),
365
+ QuotaScope.GEOTAB_FEED.value: self.feed_rate_limit,
366
+ QuotaScope.GEOTAB_AUTHENTICATE.value: self.authenticate_rate_limit,
367
+ }
368
+
369
+
370
+ _SAMSARA_DEFAULT_BASE_URL: str = 'https://api.samsara.com'
371
+
372
+ # Conservative default budget for the provider-wide Samsara scope.
373
+ # Samsara's documented limits (developers.samsara.com/docs/rate-limits,
374
+ # fetched 2026-07-17; documented, not captured) are 150 requests/second
375
+ # per token and 200/second per organization, BUT individual endpoints
376
+ # carry tiered limits down to 100 requests per MINUTE. Until the
377
+ # per-endpoint scope split lands (DESIGN §7 anticipates it; each
378
+ # endpoint's tier is pinned as it ports), the provider-wide scope
379
+ # self-limits at that tightest documented tier so no endpoint can be
380
+ # over-driven by a provider-level default. Raise in config for known
381
+ # faster tiers; revisit as endpoints declare their own scopes.
382
+ _SAMSARA_DEFAULT_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
383
+ requests_per_period=100, period_seconds=60.0, burst=10, max_concurrency=2
384
+ )
385
+
386
+
387
+ class SamsaraConfig(ProviderConfig):
388
+ """
389
+ User-facing Samsara provider settings, one instance per run.
390
+
391
+ Attributes:
392
+ api_key: The Samsara API token for the config-driven sync path
393
+ (``fetch`` takes its credential as an argument instead).
394
+ Optional in YAML -- ``FleetpullConfig.from_yaml`` merges the
395
+ ``SAMSARA_API_KEY`` environment variable when the key is
396
+ absent. ``SecretStr`` from parse time on: masked in every
397
+ repr and never logged. Travels as a bearer token; the
398
+ ``Bearer`` prefix is the auth ingress's concern, never
399
+ configured here.
400
+ base_url: Root of the Samsara API. Optional; defaults to
401
+ Samsara's documented production host. Must carry an http(s)
402
+ scheme and is normalized to drop any trailing slash, so a
403
+ spec-builder joins a leading-slash request path to it
404
+ directly.
405
+ rate_limit: The provider-wide Samsara scope's token-bucket
406
+ budget. Optional; defaults to the tightest documented
407
+ per-endpoint tier -- see ``_SAMSARA_DEFAULT_RATE_LIMIT`` for
408
+ the rationale and the per-endpoint-scope revisit note.
409
+ """
410
+
411
+ quota_scope: ClassVar[QuotaScope] = QuotaScope.SAMSARA
412
+ credential_hint: ClassVar[str] = (
413
+ f"'providers.samsara.api_key' in the YAML or the "
414
+ f'{PROVIDER_CREDENTIAL_ENV_VARS["samsara"]} environment variable'
415
+ )
416
+
417
+ api_key: SecretStr | None = None
418
+ base_url: str = Field(default=_SAMSARA_DEFAULT_BASE_URL)
419
+ rate_limit: RateLimitConfig = Field(default=_SAMSARA_DEFAULT_RATE_LIMIT)
420
+
421
+ @property
422
+ def credential(self) -> SecretStr | None:
423
+ """The Samsara credential: the bare API token, or ``None``."""
424
+ return self.api_key
425
+
426
+ @field_validator('base_url')
427
+ @classmethod
428
+ def _require_scheme_and_strip_slash(cls, value: str) -> str:
429
+ """Apply the shared base-URL hygiene check (see the module helper)."""
430
+ return _validated_base_url(value)
431
+
432
+
433
+ class ProvidersConfig(ConfigModel):
434
+ """
435
+ The per-provider configuration entries, one instance per run.
436
+
437
+ An absent entry means the provider is simply not configured -- no
438
+ warning, no error; the enablement rules apply only to entries that
439
+ are present.
440
+
441
+ Attributes:
442
+ motive: The Motive provider section, or ``None`` when the YAML
443
+ does not configure Motive.
444
+ geotab: The GeoTab provider section, or ``None`` when the YAML
445
+ does not configure GeoTab.
446
+ samsara: The Samsara provider section, or ``None`` when the YAML
447
+ does not configure Samsara.
448
+ """
449
+
450
+ motive: MotiveConfig | None = None
451
+ geotab: GeotabConfig | None = None
452
+ samsara: SamsaraConfig | None = None
453
+
454
+ def named_sections(self) -> list[tuple[str, ProviderConfig | None]]:
455
+ """Every provider section, present or not, named and in field order.
456
+
457
+ The provider roll-call the enablement guard and the
458
+ disabled-provider warning loop over, so neither restates the
459
+ provider list; adding a provider extends this and nothing there.
460
+
461
+ Returns:
462
+ ``(provider name, section-or-None)`` pairs in the container's
463
+ field order.
464
+ """
465
+ return [
466
+ ('motive', self.motive),
467
+ ('geotab', self.geotab),
468
+ ('samsara', self.samsara),
469
+ ]
470
+
471
+
472
+ def require_provider_credentials(providers: ProvidersConfig) -> None:
473
+ """Enforce the credential half of enablement over every present provider.
474
+
475
+ A provider that lists endpoints must have a credential; the root
476
+ config calls this during validation, so the rule holds for YAML
477
+ loading and direct construction alike. (The other half -- a
478
+ credential with no endpoints -- is merely a disabled provider and
479
+ warns at load time, outside validation.)
480
+
481
+ Args:
482
+ providers: The validated providers container.
483
+
484
+ Raises:
485
+ ConfigurationError: A provider lists endpoints but carries no
486
+ credential, naming the YAML field and the conventional
487
+ environment variable (the section's ``credential_hint``) --
488
+ never any credential value.
489
+ """
490
+ for name, section in providers.named_sections():
491
+ if section is None or not section.endpoints or section.credential is not None:
492
+ continue
493
+ raise ConfigurationError(
494
+ 'provider credential missing',
495
+ provider=name,
496
+ detail=(
497
+ f'endpoints are configured but no credential resolves; set '
498
+ f'{section.credential_hint}'
499
+ ),
500
+ )
501
+
502
+
503
+ def default_provider_sections() -> dict[Provider, ProviderConfig]:
504
+ """One default-constructed provider section per provider package.
505
+
506
+ The discovery walk (``build_endpoint_registry``) builds every leaf it
507
+ finds and requires a config per provider package regardless of
508
+ enablement, so both composition roots (``fetch`` and ``Sync``) need
509
+ every provider represented even when the requested endpoints belong to
510
+ another; this is the one statement of that default roll-call. Extends
511
+ as provider endpoint packages land.
512
+
513
+ Returns:
514
+ The default-constructed provider configs by provider.
515
+ """
516
+ return {
517
+ Provider.MOTIVE: MotiveConfig(),
518
+ Provider.GEOTAB: GeotabConfig(),
519
+ Provider.SAMSARA: SamsaraConfig(),
520
+ }