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,529 @@
1
+ # src/fleetpull/endpoints/shared/base.py
2
+ """The endpoints-layer binding: the ``EndpointDefinition`` and the types it composes.
3
+
4
+ An ``EndpointDefinition`` is the single source of truth per endpoint — a frozen,
5
+ keyword-only dataclass that composes one implementation per behavioral axis (the
6
+ ``SpecBuilder`` and the ``PageDecoder``) plus the per-endpoint facts the generic
7
+ machinery reads (DESIGN §11). It is a thin declarative binding, not a fat base
8
+ class: the network layer already owns auth, pagination, classification, and
9
+ parsing as separate strategies, so the only work that remains on the endpoint is
10
+ its spec-builder.
11
+
12
+ This module ships the binding, the two Protocols it defines (``SpecBuilder``
13
+ and ``CompletenessCheck``; the ``PageDecoder`` it composes is imported from the
14
+ contract), and the ``ResumeValue`` alias; the declaration families the binding
15
+ composes live in their own modules -- the ``RequestShape`` union in
16
+ ``request_shape.py``, ``StorageKind`` and the ``SyncMode`` union
17
+ (``SnapshotMode`` / ``WatermarkMode`` / ``FeedMode``) in ``sync_mode.py``. The
18
+ ``event_time_column`` the watermark and the partitioned layouts read
19
+ (§3/§5) now ships here on the binding, validated at construction; the records
20
+ ``schema_overrides`` hatch (§9) is the one contract piece still deferred.
21
+ """
22
+
23
+ from collections.abc import Mapping
24
+ from dataclasses import dataclass, field
25
+ from datetime import date, datetime
26
+ from typing import Any, Protocol, get_args
27
+
28
+ from fleetpull.endpoints.shared.request_shape import (
29
+ BatchedRosterFanOut,
30
+ BisectedWindowFetch,
31
+ ParamSweep,
32
+ RequestShape,
33
+ RosterFanOut,
34
+ SingleFetch,
35
+ )
36
+ from fleetpull.endpoints.shared.sync_mode import (
37
+ FeedMode,
38
+ SnapshotMode,
39
+ StorageKind,
40
+ SyncMode,
41
+ WatermarkMode,
42
+ )
43
+ from fleetpull.incremental import DateWindow, FeedSeed, FeedToken
44
+ from fleetpull.model_contract import ResponseModel
45
+ from fleetpull.network.contract import EnvelopeFetcher, PageDecoder, RequestSpec
46
+ from fleetpull.vocabulary import Provider, QuotaScope
47
+
48
+ __all__: list[str] = [
49
+ 'CompletenessCheck',
50
+ 'EndpointDefinition',
51
+ 'ResumeValue',
52
+ 'SpecBuilder',
53
+ ]
54
+
55
+
56
+ # The resume value a spec-builder consumes: a DateWindow (watermark, from the
57
+ # resume resolver), a FeedToken (feed, the stored token), a FeedSeed (feed, the
58
+ # cold-start anchor on the tokenless first run), or None — meaning no resume at
59
+ # all, i.e. a snapshot every run. Named here with SpecBuilder because it is the
60
+ # spec-builder's input contract; the resolver returns stay narrower per arm
61
+ # (DateWindow | None for the watermark resolver, FeedResume for the feed one).
62
+ type ResumeValue = DateWindow | FeedToken | FeedSeed | None
63
+
64
+
65
+ class SpecBuilder(Protocol):
66
+ """
67
+ Builds the first request for an endpoint — the one genuine per-endpoint behavior.
68
+
69
+ Builds only the first request (URL, base params, and the resume injection);
70
+ the page decoder produces every request after it. This is where the canonical
71
+ half-open ``DateWindow`` (§4) is translated to the provider's own request
72
+ convention. A plain Protocol (not ``@runtime_checkable``): it is only composed
73
+ into and called through the stateless ``EndpointDefinition``, never verified
74
+ dynamically.
75
+ """
76
+
77
+ def build_spec(
78
+ self, resume: ResumeValue, member_values: Mapping[str, str]
79
+ ) -> RequestSpec:
80
+ """
81
+ Build the endpoint's first request.
82
+
83
+ Args:
84
+ resume: The resume value to inject — a ``DateWindow`` (watermark),
85
+ a ``FeedToken`` or ``FeedSeed`` (feed), or ``None`` (snapshot).
86
+ member_values: The per-chain member binding the request shape
87
+ supplies — an empty mapping for a single-chain run, one
88
+ ``{member_key: member}`` entry per fan-out or sweep chain. The
89
+ spec builder owns the interpretation: a URL-path placeholder
90
+ value for a roster fan-out (e.g. a per-vehicle locations
91
+ endpoint), a query-parameter value for a param sweep.
92
+
93
+ Returns:
94
+ The first ``RequestSpec``; the page decoder builds every request after it.
95
+ """
96
+ ...
97
+
98
+
99
+ class CompletenessCheck(Protocol):
100
+ """
101
+ A provider-reported expected count fired beside a snapshot harvest.
102
+
103
+ The truth instrument behind the single-fetch driver's verified
104
+ harvest (an endpoint that silently caps its listing needs an
105
+ independent count to prove the walk lost nothing -- GeoTab's
106
+ ``GetCountOf`` beside the capped ``Get`` is the first case). An
107
+ implementation fires its count request through the SAME open client
108
+ the harvest used, so auth, the limiter (token-per-attempt on the
109
+ given scope), and the classifier all apply -- typed against the
110
+ contract's one-method ``EnvelopeFetcher``, which ``TransportClient``
111
+ satisfies structurally, so this binding layer never imports the
112
+ client. A plain Protocol (not
113
+ ``@runtime_checkable``): it is only declared on the stateless
114
+ ``EndpointDefinition`` and called by the driver, never verified
115
+ dynamically.
116
+ """
117
+
118
+ def expected_count(self, client: EnvelopeFetcher, quota_scope: str) -> int:
119
+ """
120
+ Return the provider-reported count of the harvested entity.
121
+
122
+ Args:
123
+ client: The open transport client the harvest ran on (its
124
+ single-request ``fetch_envelope`` surface).
125
+ quota_scope: The endpoint's rate-limit scope key -- the
126
+ count request spends from the same budget as the data
127
+ pages.
128
+
129
+ Returns:
130
+ The provider's expected entity count.
131
+ """
132
+ ...
133
+
134
+
135
+ @dataclass(frozen=True, slots=True, kw_only=True)
136
+ class EndpointDefinition[ModelT: ResponseModel]:
137
+ """
138
+ The single source of truth per endpoint: a frozen binding, one strategy per axis.
139
+
140
+ A frozen, keyword-only dataclass generic over its per-record response model,
141
+ composing one implementation per behavioral axis (``spec_builder``,
142
+ ``page_decoder`` — swappable implementations) plus the per-endpoint facts. It
143
+ does no work itself except through its ``spec_builder``.
144
+
145
+ Never subclassed per provider. Variation lives in the composed strategies, not
146
+ in subclasses — subclassing would re-braid the per-provider variation the
147
+ network layer already pulled apart into separate strategies, recreating the
148
+ predecessor's tangle. ``if endpoint.name == 'vehicle_locations':`` in the client
149
+ is the failure mode; the fix is always a swapped-in strategy, never a branch or
150
+ a subclass.
151
+
152
+ Generic over ``ModelT`` (bound ``ResponseModel``). ``response_model`` is
153
+ ``type[ModelT]``, so an ``EndpointDefinition[Vehicle]`` carries the concrete
154
+ model type forward to a typed ``iter_records`` (§10). ``ModelT`` appears only in
155
+ that output position, so a heterogeneous collection of definitions uses the base
156
+ bound, ``EndpointDefinition[ResponseModel]``. ``response_model`` is the
157
+ per-record model (e.g. ``Vehicle``), not a full-response wrapper — the
158
+ ``page_decoder`` owns the envelope, so full-response wrapper models are never
159
+ modeled, only the per-record ones.
160
+
161
+ Keyword-only because positional fields here are an error waiting to happen. The
162
+ ``event_time_column`` the watermark and date-partitioning read (§3/§5) now
163
+ attaches here, validated against the response model at construction; the
164
+ records ``schema_overrides`` / ``coercion_overrides`` hatch (§9) is the one
165
+ excluded concern still deferred.
166
+
167
+ Attributes:
168
+ provider: The provider this endpoint belongs to.
169
+ name: The endpoint's name (e.g. ``'vehicles'``).
170
+ spec_builder: Builds the first request (the one per-endpoint behavior).
171
+ page_decoder: Interprets each response envelope — the page's records and
172
+ its pagination verdict, from one validated view.
173
+ response_model: The per-record response model type.
174
+ quota_scope: Which token bucket this endpoint spends from.
175
+ storage_kind: The §3 storage layout (single file vs date-partitioned) —
176
+ layout only; merge semantics follow ``sync_mode``.
177
+ sync_mode: The sync-mode declaration (``SnapshotMode`` / ``WatermarkMode``
178
+ / ``FeedMode``) — drives resume and write semantics.
179
+ event_time_column: The response model's UTC datetime field the watermark
180
+ and the partitioned layouts read (§3/§5) — e.g. ``'located_at'``.
181
+ ``None`` for endpoints with no event-time dimension (every
182
+ snapshot). Required for ``WatermarkMode`` / ``DATE_PARTITIONED`` /
183
+ ``APPEND_LOG`` (the feed cell routes each record into its event
184
+ date's partition by it), forbidden for snapshots; validated against
185
+ ``response_model`` at construction.
186
+ request_shape: The request-cardinality declaration — exactly one
187
+ ``RequestShape`` member, so mutual exclusion between patterns is
188
+ structural. Defaults to ``SingleFetch()`` (one chain), keeping
189
+ single-chain leaves undeclared; ``RosterFanOut`` fans one chain per
190
+ roster member, ``BatchedRosterFanOut`` one per comma-joined
191
+ roster batch, ``ParamSweep`` one per declared query-param value,
192
+ ``BisectedWindowFetch`` fetches each unit window whole and halves
193
+ on overflow. Resolved to a request driver by the orchestrator's
194
+ shape resolution; semantic sync-mode pairings are validated here at
195
+ construction.
196
+ completeness_check: The provider-reported-count truth check the
197
+ single-fetch driver fires after the harvest streams (``None``
198
+ for endpoints with no silent cap to guard against).
199
+ Snapshot-mode, ``SingleFetch``-shaped endpoints only -- an
200
+ expected-count comparison is meaningful only against one complete
201
+ listing, which a windowed (partial) or per-member (fan-out /
202
+ sweep) run never is; any other declaration is a wiring error
203
+ rejected at construction.
204
+ """
205
+
206
+ provider: Provider
207
+ name: str
208
+ spec_builder: SpecBuilder
209
+ page_decoder: PageDecoder
210
+ response_model: type[ModelT]
211
+ quota_scope: QuotaScope
212
+ storage_kind: StorageKind
213
+ sync_mode: SyncMode
214
+ event_time_column: str | None = None
215
+ request_shape: RequestShape = field(default_factory=SingleFetch)
216
+ completeness_check: CompletenessCheck | None = None
217
+
218
+ @property
219
+ def required_event_time_column(self) -> str:
220
+ """The declared event-time column, narrowed for cells that require one.
221
+
222
+ Construction validation already guarantees a ``WatermarkMode``,
223
+ ``DATE_PARTITIONED``, or ``APPEND_LOG`` binding declares the column
224
+ (``_validate_event_time_column``), so a ``None`` here is impossible on
225
+ any constructed binding -- this property states that narrowing once
226
+ for every consumer instead of a per-call-site ``is None`` raise.
227
+
228
+ Returns:
229
+ The declared ``event_time_column``.
230
+
231
+ Raises:
232
+ RuntimeError: ``event_time_column`` is ``None`` -- construction
233
+ validation was bypassed, a wiring bug surfaced loudly.
234
+ """
235
+ if self.event_time_column is None:
236
+ raise RuntimeError(
237
+ f'{self.provider.value}.{self.name}: event_time_column is None '
238
+ f'on a cell that requires one -- construction validation '
239
+ f'should have rejected this binding'
240
+ )
241
+ return self.event_time_column
242
+
243
+ def __post_init__(self) -> None:
244
+ """Validate the binding's storage / sync / event-time / shape coherence.
245
+
246
+ Raises:
247
+ ValueError: The storage-kind / sync-mode pairing is invalid; the
248
+ event-time column is required-but-missing or forbidden-but-present
249
+ or names no field on the response model; or the declared request
250
+ shape (or the completeness check against it) fails a semantic
251
+ sync-mode pairing.
252
+ TypeError: ``event_time_column`` names a non-date-like field.
253
+
254
+ Side Effects:
255
+ None -- reads fields and may raise.
256
+ """
257
+ self._validate_storage_sync_pairing()
258
+ self._validate_event_time_column()
259
+ self._validate_request_shape()
260
+ self._validate_completeness_check()
261
+
262
+ def _validate_request_shape(self) -> None:
263
+ """Reject a request shape (or its check pairing) outside its semantics.
264
+
265
+ Mutual exclusion between cardinality patterns is structural (one
266
+ ``request_shape`` field); what remains here are the semantic
267
+ pairings. The roster-backed shapes (``RosterFanOut``,
268
+ ``BatchedRosterFanOut``) require their roster to share the
269
+ endpoint's provider -- ``Sync`` (§7) runs one queue per provider,
270
+ and cross-queue independence rests on rosters never crossing
271
+ providers (within a queue, the feeder barrier and the refresh
272
+ coordinator's per-key single-flight serialize roster writes).
273
+ ``BisectedWindowFetch`` recursively narrows a resume
274
+ window, so it requires a windowed (``WatermarkMode``),
275
+ date-partitioned endpoint. A ``completeness_check`` requires
276
+ ``SnapshotMode`` AND ``SingleFetch`` -- an expected-count comparison
277
+ is meaningful only against one complete listing, and a windowed
278
+ harvest is deliberately partial while a fan-out or sweep run is
279
+ per-member. ``ParamSweep`` composes with ``SnapshotMode`` (the sweep
280
+ union is the full current listing); windowed sweep composition is
281
+ untested against any provider, so non-snapshot sweeps are rejected
282
+ loudly for now.
283
+
284
+ Raises:
285
+ ValueError: A pairing above is violated.
286
+
287
+ Side Effects:
288
+ None.
289
+ """
290
+ match self.request_shape:
291
+ case RosterFanOut() | BatchedRosterFanOut() if (
292
+ self.request_shape.roster.provider is not self.provider
293
+ ):
294
+ # Sync runs one queue per provider; the cross-queue
295
+ # independence argument (§7) rests on rosters never
296
+ # crossing providers -- a cross-provider roster would let
297
+ # two queues reconcile one roster's rows concurrently,
298
+ # outside the reach of either queue's feeder barrier.
299
+ # Both roster-backed shapes carry the invariant.
300
+ raise ValueError(
301
+ f'{self.provider.value}.{self.name}: '
302
+ f'{type(self.request_shape).__name__} roster '
303
+ f'{self.request_shape.roster.provider.value}/'
304
+ f'{self.request_shape.roster.name} crosses the provider '
305
+ f'boundary -- a roster and its consumer share one provider.'
306
+ )
307
+ case BisectedWindowFetch():
308
+ if not isinstance(self.sync_mode, WatermarkMode) or (
309
+ self.storage_kind is not StorageKind.DATE_PARTITIONED
310
+ ):
311
+ raise ValueError(
312
+ f'{self.provider.value}.{self.name}: BisectedWindowFetch '
313
+ f'requires WatermarkMode and DATE_PARTITIONED storage, '
314
+ f'got {type(self.sync_mode).__name__} / '
315
+ f'{self.storage_kind}.'
316
+ )
317
+ case ParamSweep() if not isinstance(self.sync_mode, SnapshotMode):
318
+ # Reopen note: a windowed sweep (one chain per value per
319
+ # window) is coherent on paper but untested against any
320
+ # provider's window-matching semantics -- widen this pairing
321
+ # only with a probed consumer, not speculatively.
322
+ raise ValueError(
323
+ f'{self.provider.value}.{self.name}: ParamSweep requires '
324
+ f'SnapshotMode, got {type(self.sync_mode).__name__} -- '
325
+ f'windowed sweep composition is unprobed.'
326
+ )
327
+ case _:
328
+ pass
329
+
330
+ def _validate_storage_sync_pairing(self) -> None:
331
+ """Reject an invalid storage-kind / sync-mode pairing.
332
+
333
+ A snapshot has no event-time dimension to partition on, so
334
+ ``DATE_PARTITIONED`` is structurally unexecutable for it -- there is no
335
+ column for ``split_by_date`` to split on (DESIGN §3). The feed pairing
336
+ is exclusive in BOTH directions: ``FeedMode`` requires ``APPEND_LOG``
337
+ (append-only is the feed stream's one write semantic — any other
338
+ layout would delete or replace what the stored-as-emitted contract
339
+ must keep), and ``APPEND_LOG`` requires ``FeedMode`` (a windowed or
340
+ snapshot semantic run against an accumulate-only layout would either
341
+ never clear its window or grow a snapshot without bound).
342
+
343
+ Raises:
344
+ ValueError: The endpoint is a snapshot with a non-``SINGLE``
345
+ layout, a feed with a non-``APPEND_LOG`` layout, or an
346
+ ``APPEND_LOG`` layout on a non-feed mode.
347
+
348
+ Side Effects:
349
+ None.
350
+ """
351
+ if (
352
+ isinstance(self.sync_mode, SnapshotMode)
353
+ and self.storage_kind is not StorageKind.SINGLE
354
+ ):
355
+ raise ValueError(
356
+ f'{self.provider.value}.{self.name}: SnapshotMode requires '
357
+ f'storage_kind SINGLE, got {self.storage_kind}.'
358
+ )
359
+ if (
360
+ isinstance(self.sync_mode, FeedMode)
361
+ and self.storage_kind is not StorageKind.APPEND_LOG
362
+ ):
363
+ raise ValueError(
364
+ f'{self.provider.value}.{self.name}: FeedMode requires '
365
+ f'storage_kind APPEND_LOG, got {self.storage_kind}.'
366
+ )
367
+ if self.storage_kind is StorageKind.APPEND_LOG and not isinstance(
368
+ self.sync_mode, FeedMode
369
+ ):
370
+ raise ValueError(
371
+ f'{self.provider.value}.{self.name}: APPEND_LOG requires '
372
+ f'FeedMode, got {type(self.sync_mode).__name__}.'
373
+ )
374
+
375
+ def _validate_event_time_column(self) -> None:
376
+ """Validate the event-time column against sync mode, layout, and model.
377
+
378
+ Snapshots forbid it (no event-time dimension); ``WatermarkMode``,
379
+ ``DATE_PARTITIONED``, and ``APPEND_LOG`` require it (the watermark
380
+ reads it, and both partitioned layouts route rows into ``date=``
381
+ partitions by it). When present it must name a date-like field on the
382
+ response model, caught here at construction rather than mid-persist,
383
+ where ``split_by_date`` or ``latest_event_time`` would otherwise fail
384
+ on a non-temporal column after the fetch is already spent (DESIGN
385
+ §3/§5).
386
+
387
+ Raises:
388
+ ValueError: The column is required-but-missing, forbidden-but-present,
389
+ or names no field on the response model.
390
+ TypeError: The named field is not date-like.
391
+
392
+ Side Effects:
393
+ None.
394
+ """
395
+ is_snapshot = isinstance(self.sync_mode, SnapshotMode)
396
+ requires_event_time = isinstance(self.sync_mode, WatermarkMode) or (
397
+ self.storage_kind in (StorageKind.DATE_PARTITIONED, StorageKind.APPEND_LOG)
398
+ )
399
+ if is_snapshot and self.event_time_column is not None:
400
+ raise ValueError(
401
+ f'{self.provider.value}.{self.name}: snapshot endpoints have no '
402
+ f'event-time dimension, so event_time_column must be None.'
403
+ )
404
+ if requires_event_time and self.event_time_column is None:
405
+ raise ValueError(
406
+ f'{self.provider.value}.{self.name}: WatermarkMode, '
407
+ f'DATE_PARTITIONED, or APPEND_LOG requires an '
408
+ f'event_time_column.'
409
+ )
410
+ if self.event_time_column is not None:
411
+ self._require_date_like_field(self.event_time_column)
412
+
413
+ def _validate_completeness_check(self) -> None:
414
+ """Reject a completeness check declared outside snapshot single-fetch.
415
+
416
+ An expected-count comparison is meaningful only against a complete
417
+ listing fetched as one chain: a snapshot's single chain fetches the
418
+ entity's full current state, so the provider's count and the harvest
419
+ describe the same population. A windowed harvest is deliberately
420
+ partial (one window of an unbounded history) and a fan-out or sweep
421
+ run is per-member -- on either, the comparison would be counting
422
+ different things. Both declarations are wiring bugs, rejected here at
423
+ the declaration seam so the driver layer never needs to reason about
424
+ it.
425
+
426
+ Raises:
427
+ ValueError: The check is declared on a non-snapshot or
428
+ non-``SingleFetch`` endpoint.
429
+
430
+ Side Effects:
431
+ None.
432
+ """
433
+ if self.completeness_check is None:
434
+ return
435
+ if not isinstance(self.sync_mode, SnapshotMode):
436
+ raise ValueError(
437
+ f'{self.provider.value}.{self.name}: a completeness_check '
438
+ f'requires SnapshotMode -- an expected-count comparison is '
439
+ f'meaningful only against a complete listing, and a windowed '
440
+ f'harvest is deliberately partial.'
441
+ )
442
+ if not isinstance(self.request_shape, SingleFetch):
443
+ raise ValueError(
444
+ f'{self.provider.value}.{self.name}: a completeness_check '
445
+ f'requires the SingleFetch request shape, got '
446
+ f'{type(self.request_shape).__name__} -- a fan-out or sweep '
447
+ f'run is per-member, not the one complete listing an expected '
448
+ f'count describes.'
449
+ )
450
+
451
+ def _require_date_like_field(self, column: str) -> None:
452
+ """Require ``column`` to name a date-like field on the response model.
453
+
454
+ Validated as a top-level Pydantic field name: the records flatten
455
+ preserves top-level field names as column names, so a top-level
456
+ event-time field's column name equals its field name. A nested event-time
457
+ field is not yet supported and would not resolve here (deferred until an
458
+ endpoint needs one).
459
+
460
+ Args:
461
+ column: The event-time column name to check against the model.
462
+
463
+ Raises:
464
+ ValueError: ``column`` names no field on ``response_model``.
465
+ TypeError: The field is annotated as neither ``date`` nor ``datetime``
466
+ (nullable forms included).
467
+
468
+ Side Effects:
469
+ None.
470
+ """
471
+ model_fields = self.response_model.model_fields
472
+ if column not in model_fields:
473
+ valid_names = ', '.join(sorted(model_fields))
474
+ raise ValueError(
475
+ f'{self.provider.value}.{self.name}: event_time_column '
476
+ f'{column!r} is not a field on {self.response_model.__name__}. '
477
+ f'Fields: {valid_names}.'
478
+ )
479
+ annotation = model_fields[column].annotation
480
+ if not _is_date_like_annotation(annotation):
481
+ raise TypeError(
482
+ f'{self.provider.value}.{self.name}: event_time_column '
483
+ f'{column!r} must be date-like, got annotation {annotation!r}.'
484
+ )
485
+
486
+
487
+ # A field annotation is an arbitrary type form (a class, a PEP 604 union, ...):
488
+ # typing-justified: Any is the honest input type for annotation inspection
489
+ def _is_date_like_annotation(annotation: Any) -> bool:
490
+ """Whether an annotation resolves to ``date`` or ``datetime``.
491
+
492
+ Nullable forms count: a provider's ``datetime | None`` timestamp is still a
493
+ valid event-time field. Other unions and non-temporal types do not.
494
+
495
+ Args:
496
+ annotation: The field annotation to inspect.
497
+
498
+ Returns:
499
+ ``True`` if the annotation is ``date``, ``datetime``, or a two-arm
500
+ optional of either.
501
+
502
+ Side Effects:
503
+ None.
504
+ """
505
+ return _unwrap_optional(annotation) in {date, datetime}
506
+
507
+
508
+ # typing-justified: annotation forms (unions, aliases) in, annotation forms out
509
+ def _unwrap_optional(annotation: Any) -> Any:
510
+ """Strip ``None`` from a two-arm optional annotation, else return it as is.
511
+
512
+ Args:
513
+ annotation: The annotation to inspect.
514
+
515
+ Returns:
516
+ The sole non-``None`` arm of a two-arm ``X | None`` union; otherwise
517
+ ``annotation`` unchanged. A union of more than two arms passes through, so
518
+ a genuinely ambiguous union is not treated as date-like.
519
+
520
+ Side Effects:
521
+ None.
522
+ """
523
+ union_args = get_args(annotation)
524
+ if not union_args:
525
+ return annotation
526
+ non_none_args = [arg for arg in union_args if arg is not type(None)]
527
+ if len(non_none_args) == 1 and len(non_none_args) != len(union_args):
528
+ return non_none_args[0]
529
+ return annotation