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,50 @@
1
+ # src/fleetpull/config/rate_limit.py
2
+ """Rate-limit configuration for a single quota scope.
3
+
4
+ One ``RateLimitConfig`` describes the request budget for one quota scope
5
+ (default: one provider). The limiter registry (``network/limits/``) maps
6
+ quota-scope strings to these configs; the limiter reads its refill rate and
7
+ capacity from here. Homed in ``config/`` with the other YAML-facing models;
8
+ each provider config carries its scope's values with a documented default.
9
+ """
10
+
11
+ from pydantic import Field
12
+
13
+ from fleetpull.config.base import ConfigModel
14
+
15
+ __all__: list[str] = ['RateLimitConfig']
16
+
17
+
18
+ class RateLimitConfig(ConfigModel):
19
+ """Token-bucket and concurrency settings for one quota scope.
20
+
21
+ ``burst`` is the bucket CAPACITY: the maximum number of tokens the
22
+ bucket holds, and therefore the number of requests a cold-started
23
+ limiter can fire immediately before settling to the steady rate of
24
+ ``requests_per_period / period_seconds``. This semantics is a settled
25
+ design decision (DESIGN.md §7).
26
+
27
+ Attributes:
28
+ requests_per_period: Requests allowed per period (>= 1).
29
+ period_seconds: Length of the rate period in seconds (> 0).
30
+ burst: Bucket capacity in tokens (>= 1).
31
+ max_concurrency: Maximum requests in flight at once (>= 1) --
32
+ enforced by the limiter's semaphore, and also the size of the
33
+ per-provider fetch executor a fan-out run's workers come from
34
+ (``max_workers``, DESIGN section 7), so the pool can never
35
+ outrun the semaphore.
36
+ """
37
+
38
+ requests_per_period: int = Field(ge=1)
39
+ period_seconds: float = Field(gt=0)
40
+ burst: int = Field(ge=1)
41
+ max_concurrency: int = Field(ge=1)
42
+
43
+ @property
44
+ def refill_rate_per_second(self) -> float:
45
+ """Token refill rate: ``requests_per_period / period_seconds``.
46
+
47
+ Returns:
48
+ Tokens added to the bucket per second.
49
+ """
50
+ return self.requests_per_period / self.period_seconds
@@ -0,0 +1,156 @@
1
+ # src/fleetpull/config/resolution.py
2
+ """Pure cross-section resolution over raw YAML documents.
3
+
4
+ The single-concern functions behind ``FleetpullConfig``'s
5
+ ``mode='before'`` validators (house style: validator bodies delegate to
6
+ functions). Each takes a raw document mapping and returns one -- no
7
+ environment access, no I/O, no logging -- and each acts only where the
8
+ relevant sections are raw mappings with the relevant keys present:
9
+ already-constructed section models and malformed sections pass through
10
+ unchanged, so shape errors surface from validation with their real key
11
+ paths.
12
+
13
+ The three settled rules (DESIGN section 10):
14
+ - Window-knob precedence per provider: a provider's own
15
+ ``lookback_days`` / ``cutoff_days`` key stands; else a declared
16
+ ``sync`` value fans in; else the provider model's default
17
+ validates in naturally.
18
+ - ``state.database_path`` defaults to
19
+ ``<dataset_root>/.fleetpull/state.sqlite3`` when absent.
20
+ - Either logging file key enables file logging: ``file_level``
21
+ without ``file_path`` injects the default path
22
+ (``<dataset_root>/.fleetpull/fleetpull.log``); ``file_path`` alone
23
+ already works via the model's DEBUG default.
24
+ """
25
+
26
+ from collections.abc import Mapping
27
+ from pathlib import Path
28
+
29
+ __all__: list[str] = [
30
+ 'with_log_path_defaulted',
31
+ 'with_provider_knobs_applied',
32
+ 'with_state_path_defaulted',
33
+ ]
34
+
35
+ # The internal directory convention under dataset_root (DESIGN section 5).
36
+ _INTERNAL_SUBDIRECTORY: str = '.fleetpull'
37
+ _STATE_FILENAME: str = 'state.sqlite3'
38
+ _LOG_FILENAME: str = 'fleetpull.log'
39
+
40
+ # The knobs the sync section can declare package-wide.
41
+ _WINDOW_KNOB_KEYS: tuple[str, ...] = ('lookback_days', 'cutoff_days')
42
+
43
+
44
+ # typing-justified: raw YAML documents are arbitrary until validated
45
+ def with_provider_knobs_applied(document: Mapping[str, object]) -> dict[str, object]:
46
+ """Fan declared ``sync`` window knobs into providers lacking their own key.
47
+
48
+ Args:
49
+ document: The raw document mapping.
50
+
51
+ Returns:
52
+ The document with each provider mapping gaining any ``sync``-declared
53
+ knob it does not set itself; everything else unchanged. A provider key
54
+ always wins over the ``sync`` value (proven by the precedence tests).
55
+ """
56
+ sync_section = document.get('sync')
57
+ providers_section = document.get('providers')
58
+ if not isinstance(sync_section, Mapping) or not isinstance(
59
+ providers_section, Mapping
60
+ ):
61
+ return dict(document)
62
+ declared = {
63
+ key: sync_section[key] for key in _WINDOW_KNOB_KEYS if key in sync_section
64
+ }
65
+ if not declared:
66
+ return dict(document)
67
+ # typing-justified: raw provider entries, arbitrary until validated
68
+ fanned_providers: dict[object, object] = {}
69
+ for provider_name, provider_section in providers_section.items():
70
+ # typing-justified: a raw provider section, arbitrary until validated
71
+ fanned_section: object = provider_section
72
+ if isinstance(provider_section, Mapping):
73
+ missing = {
74
+ key: value
75
+ for key, value in declared.items()
76
+ if key not in provider_section
77
+ }
78
+ if missing:
79
+ fanned_section = {**provider_section, **missing}
80
+ fanned_providers[provider_name] = fanned_section
81
+ return {**document, 'providers': fanned_providers}
82
+
83
+
84
+ # typing-justified: raw YAML documents are arbitrary until validated
85
+ def with_state_path_defaulted(document: Mapping[str, object]) -> dict[str, object]:
86
+ """Default ``state.database_path`` under ``dataset_root`` when absent.
87
+
88
+ Args:
89
+ document: The raw document mapping.
90
+
91
+ Returns:
92
+ The document with ``state.database_path`` set to the DESIGN
93
+ section 5 convention when the key is absent and ``dataset_root``
94
+ is readable; otherwise unchanged.
95
+ """
96
+ dataset_root = _raw_dataset_root(document)
97
+ if dataset_root is None:
98
+ return dict(document)
99
+ state_section = document.get('state', {})
100
+ if not isinstance(state_section, Mapping) or 'database_path' in state_section:
101
+ return dict(document)
102
+ defaulted = {
103
+ **state_section,
104
+ 'database_path': dataset_root / _INTERNAL_SUBDIRECTORY / _STATE_FILENAME,
105
+ }
106
+ return {**document, 'state': defaulted}
107
+
108
+
109
+ # typing-justified: raw YAML documents are arbitrary until validated
110
+ def with_log_path_defaulted(document: Mapping[str, object]) -> dict[str, object]:
111
+ """Inject the default log path when ``file_level`` is set without a path.
112
+
113
+ Args:
114
+ document: The raw document mapping.
115
+
116
+ Returns:
117
+ The document with ``logging.file_path`` defaulted when
118
+ ``file_level`` is present without it and ``dataset_root`` is
119
+ readable; otherwise unchanged. Neither file key present means
120
+ file logging stays off; ``file_path`` alone needs no help (the
121
+ model's DEBUG default covers the level).
122
+ """
123
+ logging_section = document.get('logging')
124
+ if not isinstance(logging_section, Mapping):
125
+ return dict(document)
126
+ if 'file_level' not in logging_section or 'file_path' in logging_section:
127
+ return dict(document)
128
+ dataset_root = _raw_dataset_root(document)
129
+ if dataset_root is None:
130
+ return dict(document)
131
+ defaulted = {
132
+ **logging_section,
133
+ 'file_path': dataset_root / _INTERNAL_SUBDIRECTORY / _LOG_FILENAME,
134
+ }
135
+ return {**document, 'logging': defaulted}
136
+
137
+
138
+ # typing-justified: reads a raw YAML value, arbitrary until validated
139
+ def _raw_dataset_root(document: Mapping[str, object]) -> Path | None:
140
+ """The raw ``storage.dataset_root`` as a Path, or None when unreadable.
141
+
142
+ Unreadable (missing section, missing key, non-path type) returns
143
+ ``None`` so the caller passes the document through and validation
144
+ names the real problem at its real key path. The value may still be
145
+ unnormalized here; the derived defaults normalize with everything
146
+ else at field validation (``resolve_path``).
147
+ """
148
+ storage_section = document.get('storage')
149
+ if not isinstance(storage_section, Mapping):
150
+ return None
151
+ dataset_root = storage_section.get('dataset_root')
152
+ if isinstance(dataset_root, str) and dataset_root.strip():
153
+ return Path(dataset_root)
154
+ if isinstance(dataset_root, Path):
155
+ return dataset_root
156
+ return None
@@ -0,0 +1,69 @@
1
+ # src/fleetpull/config/retry.py
2
+ """Retry policy configuration: attempt budgets and backoff shape.
3
+
4
+ Vocabulary (shared with ``network/retry/decision.py``): a *failure
5
+ count* is one-based within the current retryable category — the first
6
+ TRANSIENT failure of an attempt sequence is 1. ``*_max_failures = N``
7
+ means failures 1..N are each answered with a retry and the (N+1)th
8
+ exhausts the budget: at most N + 1 requests. Counters are independent
9
+ per category within an attempt sequence; a RATE_LIMITED failure
10
+ neither resets nor advances the TRANSIENT count.
11
+ """
12
+
13
+ from typing import Self
14
+
15
+ from pydantic import Field, model_validator
16
+
17
+ from fleetpull.config.base import ConfigModel
18
+
19
+ __all__: list[str] = ['RetryConfig']
20
+
21
+
22
+ class RetryConfig(ConfigModel):
23
+ """
24
+ User-facing retry policy, one instance per run.
25
+
26
+ Attributes:
27
+ transient_max_failures: Highest TRANSIENT failure count still
28
+ retried. 0 means TRANSIENT failures are never retried.
29
+ transient_backoff_base_seconds: Full-jitter base; the delay
30
+ envelope for failure ``n`` is
31
+ ``min(cap, base * 2 ** (n - 1))``.
32
+ transient_backoff_cap_seconds: Ceiling on the delay envelope.
33
+ Only reachable when an operator raises the failure budget.
34
+ rate_limited_max_failures: Highest RATE_LIMITED failure count
35
+ still retried. A circuit breaker against 429 storms, not a
36
+ pacer — pacing is the limiter's job, and RATE_LIMITED
37
+ retries never sleep locally.
38
+ fallback_penalty_seconds: Quota-scope penalty the client
39
+ applies when a rate-limited response carries no usable
40
+ Retry-After. Fires only when a provider is already
41
+ misbehaving, so it errs long; the penalty log line carries
42
+ the raw header value to keep the case diagnosable.
43
+ """
44
+
45
+ transient_max_failures: int = Field(default=3, ge=0)
46
+ transient_backoff_base_seconds: float = Field(default=1.0, gt=0)
47
+ transient_backoff_cap_seconds: float = Field(default=30.0, gt=0)
48
+ rate_limited_max_failures: int = Field(default=10, ge=0)
49
+ fallback_penalty_seconds: float = Field(default=60.0, gt=0)
50
+
51
+ @model_validator(mode='after')
52
+ def _cap_not_below_base(self) -> Self:
53
+ """
54
+ Reject a cap below the base: the envelope would be constant at
55
+ the cap from the first failure, which is never what a config
56
+ author meant.
57
+
58
+ Returns:
59
+ The validated model.
60
+
61
+ Raises:
62
+ ValueError: When the cap is below the base.
63
+ """
64
+ if self.transient_backoff_cap_seconds < self.transient_backoff_base_seconds:
65
+ raise ValueError(
66
+ 'transient_backoff_cap_seconds must be >= '
67
+ 'transient_backoff_base_seconds'
68
+ )
69
+ return self
@@ -0,0 +1,157 @@
1
+ # src/fleetpull/config/root.py
2
+ """The whole-document configuration root: ``FleetpullConfig`` and ``from_yaml``.
3
+
4
+ The one model family that IS the schema (DESIGN section 10 -- the YAML
5
+ schema is sync's API): the root composes the section models exactly as
6
+ the YAML reads, and cross-section resolution runs as ``mode='before'``
7
+ validation on the root, where key presence is still visible. Each
8
+ validator body is a thin wrapper over a single-concern pure function in
9
+ ``config/resolution.py``; the loading steps behind ``from_yaml`` live in
10
+ ``config/loading.py``. No masks, no injections, no post-validation
11
+ rewriting -- the sections and the schema agree, so nothing needs
12
+ compensating for.
13
+
14
+ The invariant, precisely: any ``FleetpullConfig`` validated from a raw
15
+ document is fully resolved -- every path field normalized through
16
+ ``paths.resolve_path``, ``state.database_path`` and the log-file default
17
+ composed against ``storage.dataset_root``, and every provider's window
18
+ knobs settled by precedence (provider key > ``sync`` key > provider
19
+ default). Direct construction from already-built section models skips
20
+ raw-document resolution but never the enablement invariant: endpoints
21
+ listed with no credential raise ``ConfigurationError`` at validation
22
+ either way.
23
+ """
24
+
25
+ from collections.abc import Mapping
26
+ from pathlib import Path
27
+ from typing import Self
28
+
29
+ from pydantic import Field, ValidationError, model_validator
30
+
31
+ from fleetpull.config.base import ConfigModel
32
+ from fleetpull.config.http import HttpConfig
33
+ from fleetpull.config.loading import (
34
+ read_yaml_document,
35
+ validation_detail,
36
+ warn_disabled_providers,
37
+ with_environment_credentials,
38
+ )
39
+ from fleetpull.config.logger import LoggerConfig
40
+ from fleetpull.config.providers import ProvidersConfig, require_provider_credentials
41
+ from fleetpull.config.resolution import (
42
+ with_log_path_defaulted,
43
+ with_provider_knobs_applied,
44
+ with_state_path_defaulted,
45
+ )
46
+ from fleetpull.config.retry import RetryConfig
47
+ from fleetpull.config.sections import StateConfig, StorageConfig, SyncConfig
48
+ from fleetpull.exceptions import ConfigurationError
49
+
50
+ __all__: list[str] = ['FleetpullConfig']
51
+
52
+
53
+ class FleetpullConfig(ConfigModel):
54
+ """
55
+ The validated whole-document configuration, one instance per run.
56
+
57
+ Attributes:
58
+ sync: Sync-wide settings: the cold-start anchor and the optional
59
+ package-wide window knobs.
60
+ storage: Where the parquet dataset lives (``dataset_root``'s one
61
+ and only home).
62
+ state: Where operational SQLite state lives; defaulted under
63
+ ``dataset_root`` by raw-document resolution.
64
+ logging: Console and file logging; the file-path default composes
65
+ under ``dataset_root`` when only ``file_level`` is given.
66
+ http: Transport timeouts and TLS posture.
67
+ retry: Attempt budgets and backoff shape.
68
+ providers: The per-provider sections; a provider listing
69
+ endpoints without a credential fails validation.
70
+ """
71
+
72
+ sync: SyncConfig
73
+ storage: StorageConfig
74
+ state: StateConfig = Field(default_factory=StateConfig)
75
+ logging: LoggerConfig = Field(default_factory=LoggerConfig)
76
+ http: HttpConfig = Field(default_factory=HttpConfig)
77
+ retry: RetryConfig = Field(default_factory=RetryConfig)
78
+ providers: ProvidersConfig
79
+
80
+ @model_validator(mode='before')
81
+ @classmethod
82
+ # typing-justified: mode='before' input is the arbitrary raw document
83
+ def _apply_provider_knob_precedence(cls, document: object) -> object:
84
+ """Fan declared ``sync`` knobs into providers lacking their own key."""
85
+ if not isinstance(document, Mapping):
86
+ return document
87
+ return with_provider_knobs_applied(document)
88
+
89
+ @model_validator(mode='before')
90
+ @classmethod
91
+ # typing-justified: mode='before' input is the arbitrary raw document
92
+ def _apply_state_path_default(cls, document: object) -> object:
93
+ """Default ``state.database_path`` under ``dataset_root``."""
94
+ if not isinstance(document, Mapping):
95
+ return document
96
+ return with_state_path_defaulted(document)
97
+
98
+ @model_validator(mode='before')
99
+ @classmethod
100
+ # typing-justified: mode='before' input is the arbitrary raw document
101
+ def _apply_log_path_default(cls, document: object) -> object:
102
+ """Inject the default log path when ``file_level`` is set alone."""
103
+ if not isinstance(document, Mapping):
104
+ return document
105
+ return with_log_path_defaulted(document)
106
+
107
+ @model_validator(mode='after')
108
+ def _require_credentialed_providers(self) -> Self:
109
+ """Enforce enablement's credential half; see the providers family.
110
+
111
+ Raises:
112
+ ConfigurationError: A provider lists endpoints with no
113
+ credential. Raised (not a ``ValueError``) so it reaches
114
+ the caller as itself -- Pydantic wraps only
115
+ ``ValueError``/``AssertionError`` into validation errors.
116
+ """
117
+ require_provider_credentials(self.providers)
118
+ return self
119
+
120
+ @classmethod
121
+ def from_yaml(cls, path: Path | str) -> Self:
122
+ """Load, validate, and resolve one fleetpull YAML configuration file.
123
+
124
+ The loading API: read the file, merge conventional credential
125
+ environment variables (a YAML literal wins; empty counts as
126
+ unset), validate -- which resolves every cross-section default --
127
+ and warn about credentialed providers with no endpoints.
128
+
129
+ Args:
130
+ path: The configuration file to read; a string coerces.
131
+
132
+ Returns:
133
+ The fully resolved configuration (the class docstring's
134
+ invariant).
135
+
136
+ Raises:
137
+ ConfigurationError: The file is missing (naming the path), is
138
+ not valid YAML (naming the line), violates the schema
139
+ (naming each offending key path, never a raw value), or
140
+ lists endpoints for a provider with no resolvable
141
+ credential (naming the YAML field and the environment
142
+ variable).
143
+
144
+ Side Effects:
145
+ Reads the file and the process environment; logs one WARNING
146
+ per credentialed provider whose endpoint list is empty.
147
+ """
148
+ document = read_yaml_document(Path(path))
149
+ merged = with_environment_credentials(document)
150
+ try:
151
+ config = cls.model_validate(merged)
152
+ except ValidationError as validation_error:
153
+ raise ConfigurationError(
154
+ 'invalid configuration', detail=validation_detail(validation_error)
155
+ ) from None
156
+ warn_disabled_providers(config.providers)
157
+ return config
@@ -0,0 +1,146 @@
1
+ # src/fleetpull/config/sections.py
2
+ """The run-scoped standalone sections: ``sync``, ``storage``, ``state``.
3
+
4
+ One model family per file (house rule): these three sections describe
5
+ run-wide facts -- when history starts, where the dataset lands, where
6
+ operational state lives -- and change together as the run vocabulary
7
+ grows. Path fields normalize through ``paths.resolve_path`` at
8
+ validation, so a validated section is fully resolved by construction and
9
+ downstream code never normalizes.
10
+ """
11
+
12
+ from datetime import UTC, date, datetime, time
13
+ from pathlib import Path
14
+
15
+ from pydantic import Field, field_validator
16
+
17
+ from fleetpull.config.base import ConfigModel
18
+ from fleetpull.paths import resolve_path
19
+
20
+ __all__: list[str] = ['StateConfig', 'StorageConfig', 'SyncConfig']
21
+
22
+
23
+ class SyncConfig(ConfigModel):
24
+ """
25
+ User-facing sync-wide settings, one instance per run.
26
+
27
+ ``dataset_root`` deliberately does not live here. It did once; the
28
+ config rebuild moved it to ``StorageConfig`` for real, so the YAML
29
+ section and this model are the same shape and no loader machinery
30
+ bridges them. Consumers that need both the anchor and the dataset
31
+ root (the runner) take the root ``FleetpullConfig`` and read each
32
+ from its own section.
33
+
34
+ Attributes:
35
+ default_start_date: The cold-start backfill anchor -- the UTC calendar date
36
+ a watermark endpoint's history begins from on its very first run, before
37
+ any committed watermark or completed coverage exists (DESIGN section 4/5
38
+ resume precedence arm 3). Required: there is no safe default for "where
39
+ our history begins", so it must be declared. It goes inert the moment
40
+ observed data or completed coverage exists; thereafter the start is
41
+ derived from those, never from this.
42
+ lookback_days: Package-wide late-arrival re-fetch margin in whole days.
43
+ Optional; ``None`` means no package-wide value is declared. Root-level
44
+ resolution fans a declared value into every provider section that does
45
+ not set its own key (provider key > this > provider default); the
46
+ field itself is never read as a runtime knob.
47
+ cutoff_days: Package-wide trailing-edge holdback in whole days, the
48
+ complement of ``lookback_days``; same precedence and ``None``
49
+ semantics.
50
+ backfill_chunk_days: The width, in whole days, of the work units a
51
+ windowed run's plan tiles its window into (DESIGN sections 4/5).
52
+ Every windowed pull is planned as units and each unit commits
53
+ independently, so smaller chunks mean finer crash-resume
54
+ granularity at the cost of more per-unit commits; a window
55
+ smaller than one chunk degenerates to a single unit (the daily
56
+ run). Sync-level only, deliberately without a per-provider
57
+ override: chunk size is fleetpull's transactional knob, not a
58
+ provider-latency fact. Applies to newly planned units only --
59
+ already-persisted unit boundaries are honored on resume. An
60
+ endpoint whose ``WatermarkMode`` declares ``fixed_unit_days``
61
+ (a window-grain rollup surface, where the unit width is part
62
+ of the row's meaning) tiles at exactly that width instead;
63
+ this knob remains the default for every other endpoint.
64
+ backfill_unit_workers: How many of a windowed endpoint's work units
65
+ drive concurrently (DESIGN section 5's prefix-advance rule keeps
66
+ the watermark truthful under any completion order). ``1`` is the
67
+ serial path. Sync-level like ``backfill_chunk_days`` -- unit
68
+ concurrency is fleetpull's execution knob; the per-provider rate
69
+ budget stays the limiter's job at the transport boundary, so
70
+ workers beyond a provider's budget simply pace on tokens.
71
+ """
72
+
73
+ default_start_date: date
74
+ lookback_days: int | None = Field(default=None, ge=0)
75
+ cutoff_days: int | None = Field(default=None, ge=0)
76
+ backfill_chunk_days: int = Field(default=7, ge=1)
77
+ backfill_unit_workers: int = Field(default=4, ge=1)
78
+
79
+ @property
80
+ def default_start_datetime(self) -> datetime:
81
+ """The cold-start anchor as a timezone-aware UTC midnight instant.
82
+
83
+ ``default_start_date`` is the human-authored calendar date history
84
+ begins from; the resume resolver (``resolve_resume_start``) composes it
85
+ with watermark and frontier datetimes, so it is lifted to the start of
86
+ that UTC day here. Deriving it keeps the stored field a ``date`` (a
87
+ time-of-day on a backfill anchor is meaningless) while handing the
88
+ orchestrator the ``datetime`` it needs, the conversion defined in one
89
+ place.
90
+
91
+ Returns:
92
+ ``default_start_date`` at 00:00:00 with ``tzinfo`` ``datetime.UTC``.
93
+ """
94
+ return datetime.combine(self.default_start_date, time.min, tzinfo=UTC)
95
+
96
+
97
+ class StorageConfig(ConfigModel):
98
+ """
99
+ User-facing storage settings, one instance per run.
100
+
101
+ Attributes:
102
+ dataset_root: The root directory the dataset is written under
103
+ (``{root}/{provider}/{endpoint}/``, DESIGN section 3).
104
+ Required: there is no safe default for where output lands.
105
+ Use a real local path -- never a cloud-synced folder
106
+ (OneDrive and kin), whose sync clients fight the writer's
107
+ atomic renames. Normalized through ``resolve_path`` at
108
+ validation.
109
+ drop_exact_duplicates: Whether write-time compaction drops
110
+ exact-duplicate rows (DESIGN section 6 -- the default-on
111
+ dedup with a config flag off; semantic dedup stays out of
112
+ scope). ``False`` preserves duplicates byte-for-byte.
113
+ """
114
+
115
+ dataset_root: Path
116
+ drop_exact_duplicates: bool = True
117
+
118
+ @field_validator('dataset_root')
119
+ @classmethod
120
+ def _resolve(cls, value: Path) -> Path:
121
+ """Normalize the path lexically; see ``paths.resolve_path``."""
122
+ return resolve_path(value)
123
+
124
+
125
+ class StateConfig(ConfigModel):
126
+ """
127
+ User-facing operational-state settings, one instance per run.
128
+
129
+ Attributes:
130
+ database_path: Where the SQLite state database lives -- separable
131
+ from ``dataset_root`` so SQLite stays on a real local disk
132
+ even when the parquet dataset sits on a network filesystem
133
+ (DESIGN section 5). Optional in YAML: root-level resolution
134
+ defaults it to ``<dataset_root>/.fleetpull/state.sqlite3``,
135
+ so any ``FleetpullConfig`` validated from a raw document
136
+ carries a real, normalized path. ``None`` survives only
137
+ direct section construction.
138
+ """
139
+
140
+ database_path: Path | None = None
141
+
142
+ @field_validator('database_path')
143
+ @classmethod
144
+ def _resolve(cls, value: Path | None) -> Path | None:
145
+ """Normalize the path lexically when present; ``None`` passes through."""
146
+ return None if value is None else resolve_path(value)
@@ -0,0 +1,24 @@
1
+ # src/fleetpull/endpoints/__init__.py
2
+ """The endpoints layer: per-endpoint bindings and the discovery catalogs.
3
+
4
+ Provider bindings live in subpackage faces consumers import directly --
5
+ ``fleetpull.endpoints.shared`` for the ``EndpointDefinition`` binding and the
6
+ shared spec-builders, ``fleetpull.endpoints.motive`` (and future provider
7
+ packages) for the binding factories. The catalogs over those bindings --
8
+ ``EndpointRegistry`` / ``build_endpoint_registry`` and the roster sibling
9
+ ``build_roster_registry`` -- are re-exported here as the layer's public
10
+ lookup surface, so a consumer routes through this face rather than reaching
11
+ the ``registry`` submodule directly.
12
+ """
13
+
14
+ from fleetpull.endpoints.registry import (
15
+ EndpointRegistry,
16
+ build_endpoint_registry,
17
+ build_roster_registry,
18
+ )
19
+
20
+ __all__: list[str] = [
21
+ 'EndpointRegistry',
22
+ 'build_endpoint_registry',
23
+ 'build_roster_registry',
24
+ ]
@@ -0,0 +1,12 @@
1
+ # src/fleetpull/endpoints/geotab/__init__.py
2
+ """The GeoTab endpoints package.
3
+
4
+ A provider package under the endpoints layer. It exposes no factory gather:
5
+ endpoint leaves are discovered by ``build_endpoint_registry`` walking this
6
+ package for modules exposing ``build_endpoint``, so a new GeoTab endpoint is
7
+ a new leaf module here with nothing to register. Import a specific factory
8
+ from its leaf module (``fleetpull.endpoints.geotab.devices``) when one is
9
+ needed directly.
10
+ """
11
+
12
+ __all__: list[str] = []