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,33 @@
1
+ # src/fleetpull/network/contract/envelope_fetcher.py
2
+ """The single-request fetch surface: one spec in, one parsed envelope out.
3
+
4
+ The contract-layer face of ``TransportClient.fetch_envelope`` — the
5
+ non-paging single request that still rides the whole per-attempt pipeline
6
+ (auth prepare, one limiter token per attempt, retry, classification).
7
+ Declared here so declaration-layer consumers (the ``CompletenessCheck``
8
+ protocol's implementations foremost) can type against the surface without
9
+ importing the client package; ``TransportClient`` satisfies it structurally.
10
+ """
11
+
12
+ from typing import Protocol
13
+
14
+ from fleetpull.network.contract.request import RequestSpec
15
+ from fleetpull.vocabulary import JsonValue
16
+
17
+ __all__: list[str] = ['EnvelopeFetcher']
18
+
19
+
20
+ class EnvelopeFetcher(Protocol):
21
+ """The one-method single-request surface (``TransportClient``'s shape)."""
22
+
23
+ def fetch_envelope(self, spec: RequestSpec, quota_scope: str) -> JsonValue:
24
+ """Execute one request outside any page loop; return its envelope.
25
+
26
+ Args:
27
+ spec: The credential-less request to execute.
28
+ quota_scope: The rate-limit scope key the attempt spends from.
29
+
30
+ Returns:
31
+ The parsed success envelope; interpretation is the caller's.
32
+ """
33
+ ...
@@ -0,0 +1,189 @@
1
+ # src/fleetpull/network/contract/envelopes.py
2
+ """Validate-or-raise for provider response envelopes.
3
+
4
+ Relocated here from ``page_decoder.py`` at its second consumer (the
5
+ GeoTab authenticator): the composition — ``model_validate`` and, on
6
+ failure, raise ``ProviderResponseError`` carrying Pydantic's complaint
7
+ — is contract-layer semantics, not page-decoding semantics, so it is
8
+ named and homed neutrally where both consumers can reach it without a
9
+ name lie.
10
+ """
11
+
12
+ from typing import cast
13
+
14
+ from pydantic import BaseModel, ConfigDict, ValidationError
15
+
16
+ from fleetpull.exceptions import ProviderResponseError
17
+ from fleetpull.vocabulary import JsonObject, JsonValue
18
+
19
+ __all__: list[str] = [
20
+ 'StrictEnvelopeSlice',
21
+ 'require_child_object',
22
+ 'require_record_list',
23
+ 'unwrap_record_objects',
24
+ 'validated_envelope_slice',
25
+ ]
26
+
27
+
28
+ class StrictEnvelopeSlice(BaseModel):
29
+ """The base every private envelope-slice model subclasses.
30
+
31
+ The one home of the slice-model policy, so its rationale is stated once:
32
+ ``strict=True`` refuses type drift on the values the consumer acts on
33
+ (providers elsewhere stringify numerics, and a boolean must never pass as
34
+ an integer); ``extra='ignore'`` tolerates the envelope fields a slice
35
+ does not name (a slice reads its few load-bearing keys, never the whole
36
+ envelope); ``frozen=True`` keeps a validated slice immutable, like every
37
+ internal carrier.
38
+ """
39
+
40
+ model_config = ConfigDict(frozen=True, extra='ignore', strict=True)
41
+
42
+
43
+ def validated_envelope_slice[ModelT: BaseModel](
44
+ model_type: type[ModelT], envelope: JsonValue
45
+ ) -> ModelT:
46
+ """
47
+ Validate a response envelope against a private slice model,
48
+ translating failure into the contract's single-action raise.
49
+
50
+ Shared rather than reimplemented per consumer because the
51
+ composition — validate, and on failure raise
52
+ ``ProviderResponseError`` carrying Pydantic's complaint — is layer
53
+ semantics, not provider behavior.
54
+
55
+ Args:
56
+ model_type: The consumer's private envelope-slice model.
57
+ envelope: The parsed response body (any shape; a non-dict
58
+ fails validation like any other malformation).
59
+
60
+ Returns:
61
+ The validated slice.
62
+
63
+ Raises:
64
+ ProviderResponseError: When the envelope does not satisfy the
65
+ slice model.
66
+ """
67
+ try:
68
+ return model_type.model_validate(envelope)
69
+ except ValidationError as error:
70
+ raise ProviderResponseError(
71
+ detail=f'malformed response envelope: {error}'
72
+ ) from error
73
+
74
+
75
+ def _require_json_object(value: JsonValue) -> JsonObject:
76
+ """Return ``value`` narrowed to a JSON object, or raise.
77
+
78
+ Args:
79
+ value: The parsed value under inspection.
80
+
81
+ Returns:
82
+ The value as a JSON object.
83
+
84
+ Raises:
85
+ ProviderResponseError: When the value is not a JSON object.
86
+ """
87
+ if not isinstance(value, dict):
88
+ raise ProviderResponseError(detail='response envelope is not a JSON object')
89
+ return value
90
+
91
+
92
+ def require_child_object(envelope: JsonValue, key: str) -> JsonObject:
93
+ """Return the JSON object at a top-level envelope key.
94
+
95
+ The nested-container step for envelopes whose record list sits one
96
+ level down: validates, in order, that the envelope is a JSON object,
97
+ ``key`` is present, and its value is itself a JSON object.
98
+
99
+ Args:
100
+ envelope: The parsed response body.
101
+ key: The top-level key whose value is the container object.
102
+
103
+ Returns:
104
+ The JSON object at ``key``.
105
+
106
+ Raises:
107
+ ProviderResponseError: When the envelope is not an object, ``key``
108
+ is absent, or its value is not a JSON object — each with a
109
+ message naming the failure.
110
+ """
111
+ response = _require_json_object(envelope)
112
+ if key not in response:
113
+ raise ProviderResponseError(
114
+ detail=f'response envelope is missing the record key {key!r}'
115
+ )
116
+ child = response[key]
117
+ if not isinstance(child, dict):
118
+ raise ProviderResponseError(detail=f'record key {key!r} is not a JSON object')
119
+ return child
120
+
121
+
122
+ def require_record_list(envelope: JsonValue, key: str) -> list[JsonObject]:
123
+ """Return the record list at a top-level envelope key.
124
+
125
+ Validates, in order: the envelope is a JSON object; ``key`` is
126
+ present; its value is a list; every element is a JSON object.
127
+
128
+ Args:
129
+ envelope: The parsed response body.
130
+ key: The top-level key whose value is the record list.
131
+
132
+ Returns:
133
+ The record list at ``key``, each element a JSON object.
134
+
135
+ Raises:
136
+ ProviderResponseError: When the envelope is not an object,
137
+ ``key`` is absent, its value is not a list, or an element is
138
+ not a JSON object — each with a message naming the failure.
139
+ """
140
+ response = _require_json_object(envelope)
141
+ if key not in response:
142
+ raise ProviderResponseError(
143
+ detail=f'response envelope is missing the record key {key!r}'
144
+ )
145
+ records = response[key]
146
+ if not isinstance(records, list):
147
+ raise ProviderResponseError(detail=f'record key {key!r} is not a list')
148
+ for record in records:
149
+ if not isinstance(record, dict):
150
+ raise ProviderResponseError(
151
+ detail=f'record under {key!r} is not a JSON object'
152
+ )
153
+ return cast(list[JsonObject], records)
154
+
155
+
156
+ def unwrap_record_objects(
157
+ wrappers: list[JsonObject], item_key: str
158
+ ) -> list[JsonObject]:
159
+ """Lift the inner object out of each single-key record wrapper.
160
+
161
+ Motive returns each record wrapped — ``{"vehicle": {...}}`` inside
162
+ the ``vehicles`` list; this lifts the inner object out of every
163
+ wrapper. Each wrapper must carry ``item_key`` and its value must be
164
+ a JSON object.
165
+
166
+ Args:
167
+ wrappers: The wrapper objects (already validated as objects).
168
+ item_key: The key inside each wrapper holding the record.
169
+
170
+ Returns:
171
+ The unwrapped record objects.
172
+
173
+ Raises:
174
+ ProviderResponseError: When a wrapper lacks ``item_key`` or its
175
+ value is not a JSON object.
176
+ """
177
+ records: list[JsonObject] = []
178
+ for wrapper in wrappers:
179
+ if item_key not in wrapper:
180
+ raise ProviderResponseError(
181
+ detail=f'record wrapper is missing the item key {item_key!r}'
182
+ )
183
+ inner = wrapper[item_key]
184
+ if not isinstance(inner, dict):
185
+ raise ProviderResponseError(
186
+ detail=f'record under {item_key!r} is not a JSON object'
187
+ )
188
+ records.append(inner)
189
+ return records
@@ -0,0 +1,43 @@
1
+ # src/fleetpull/network/contract/outcome.py
2
+ """The carrier for a classifier's verdict on one response.
3
+
4
+ Produced exclusively by ``ResponseClassifier`` implementations;
5
+ consumed by the client, which dispatches on ``category`` (the
6
+ ``ResponseCategory`` vocabulary lives in ``fleetpull.vocabulary``).
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from fleetpull.vocabulary import JsonValue, ResponseCategory
12
+
13
+ __all__: list[str] = ['ClassifiedResponse']
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ClassifiedResponse:
18
+ """
19
+ A classifier's verdict on one response or transport failure.
20
+
21
+ Fields are inert outside their category (the established
22
+ required-with-default-inert pattern).
23
+
24
+ Attributes:
25
+ category: What the client does next.
26
+ retry_after_seconds: Meaningful only for RATE_LIMITED; None
27
+ means the provider sent no usable hint and the client
28
+ applies its fallback penalty.
29
+ detail: Human-readable context for failure paths. Decisions
30
+ never read it.
31
+ parsed_body: The parsed equivalent of the response's body text
32
+ — the whole document, not extracted records. Populated only
33
+ when classification itself required parsing the body; None
34
+ on SUCCESS means the classifier did not parse and the
35
+ client must. Inert outside SUCCESS. Excluded from ``repr``
36
+ because it can hold a multi-megabyte structure that no log
37
+ line may embed.
38
+ """
39
+
40
+ category: ResponseCategory
41
+ retry_after_seconds: float | None = None
42
+ detail: str | None = None
43
+ parsed_body: JsonValue | None = field(default=None, repr=False)
@@ -0,0 +1,101 @@
1
+ # src/fleetpull/network/contract/page_decoder.py
2
+ """The page-decoder contract: interpret a response envelope once,
3
+ yielding its records and its pagination verdict together.
4
+
5
+ Supersedes the split ``PaginationStrategy`` + ``RecordExtractor``: a
6
+ provider's envelope is parsed in exactly one place, so the records and
7
+ the continuation cursor are read from a single validated view rather
8
+ than re-interpreted downstream. Implementations live in the sibling
9
+ ``network/decoders/`` package and reach this surface through the
10
+ contract face; they deliberately share no concrete behavior
11
+ (blast-radius over DRY).
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Protocol
16
+
17
+ from fleetpull.network.contract.request import RequestSpec
18
+ from fleetpull.vocabulary import JsonObject, JsonValue
19
+
20
+ __all__: list[str] = ['DecodedPage', 'PageAdvance', 'PageDecoder']
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class PageAdvance:
25
+ """
26
+ A decoder's verdict on one completed page.
27
+
28
+ Attributes:
29
+ next_spec: The request for the next page, or None when
30
+ pagination is complete.
31
+ durable_progress: Cursor progress that must outlive the fetch
32
+ (GeoTab's ``toVersion``; the state layer's FeedToken commit
33
+ value). None for providers whose cursors are fetch-private
34
+ (the established inert pattern). Carried on EVERY page
35
+ including the terminal one — the terminal page's value is
36
+ the resume point.
37
+ """
38
+
39
+ next_spec: RequestSpec | None
40
+ durable_progress: str | None
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class DecodedPage:
45
+ """One decoded page: its records and its pagination verdict.
46
+
47
+ Composes ``PageAdvance`` rather than re-declaring ``next_spec`` /
48
+ ``durable_progress`` — the meaning of "next transient request plus
49
+ durable cursor progress" stays defined in exactly one place.
50
+
51
+ Attributes:
52
+ records: The page's records, each a JSON object.
53
+ advance: The pagination verdict (continue/complete plus durable
54
+ progress) for the page just decoded.
55
+ """
56
+
57
+ records: list[JsonObject]
58
+ advance: PageAdvance
59
+
60
+
61
+ class PageDecoder(Protocol):
62
+ """Per-provider envelope interpreter: records and pagination in one pass.
63
+
64
+ A plain Protocol (no ``@runtime_checkable``) — conformance is a
65
+ static obligation checked by mypy at the binding site, not an
66
+ isinstance gate.
67
+ """
68
+
69
+ def first_request(self, spec: RequestSpec) -> RequestSpec:
70
+ """Decorate the base spec for page one.
71
+
72
+ Absorbs the former ``PaginationStrategy.first_request`` so that
73
+ pagination configuration (page size, the first-page cursor
74
+ rule) stays inside the decoder and the spec builder stays
75
+ pagination-blind.
76
+
77
+ Args:
78
+ spec: The endpoint's base request spec.
79
+
80
+ Returns:
81
+ The spec to send for the first page.
82
+ """
83
+ ...
84
+
85
+ def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
86
+ """Interpret one response envelope into records and a verdict.
87
+
88
+ Args:
89
+ sent: The spec that produced this page; supplies whatever
90
+ the continuation request is derived from.
91
+ envelope: The parsed response body.
92
+
93
+ Returns:
94
+ The page's records and its pagination verdict.
95
+
96
+ Raises:
97
+ ProviderResponseError: When the envelope's record-bearing or
98
+ pagination shape is structurally violating (the §8
99
+ stance).
100
+ """
101
+ ...
@@ -0,0 +1,97 @@
1
+ # src/fleetpull/network/contract/request.py
2
+ """Request description: the spec endpoint definitions build, auth
3
+ strategies transform, and the client executes.
4
+
5
+ A ``RequestSpec`` is a pure description of one HTTP request — no
6
+ transport, no credentials until an ``AuthStrategy`` injects them.
7
+ """
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field, replace
11
+ from enum import StrEnum
12
+ from typing import Self
13
+
14
+ from fleetpull.vocabulary import JsonValue
15
+
16
+ __all__: list[str] = [
17
+ 'HttpMethod',
18
+ 'RequestSpec',
19
+ ]
20
+
21
+
22
+ class HttpMethod(StrEnum):
23
+ """HTTP methods fleetpull uses.
24
+
25
+ GET and POST only — fleetpull never writes to providers. Add
26
+ members only when a real endpoint needs them.
27
+ """
28
+
29
+ GET = 'GET'
30
+ POST = 'POST'
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class RequestSpec:
35
+ """
36
+ Pure description of one HTTP request.
37
+
38
+ Built by endpoint definitions, transformed by auth strategies,
39
+ executed by the client. Frozen prevents rebinding, not mutation of
40
+ the contained mappings — they are treated as immutable by
41
+ convention.
42
+
43
+ Attributes:
44
+ method: The HTTP method.
45
+ url: Fully qualified request URL.
46
+ headers: Request headers (empty by default).
47
+ params: Query parameters, or None.
48
+ json_body: JSON request body (the GeoTab JSON-RPC envelope
49
+ lives here), or None.
50
+ """
51
+
52
+ method: HttpMethod
53
+ url: str
54
+ headers: Mapping[str, str] = field(default_factory=dict)
55
+ params: Mapping[str, str] | None = None
56
+ json_body: Mapping[str, JsonValue] | None = None
57
+
58
+ def with_extra_headers(self, extra: Mapping[str, str]) -> Self:
59
+ """
60
+ Return a NEW spec whose headers merge existing and ``extra``.
61
+
62
+ ``extra`` wins on key collisions. The original spec is
63
+ unchanged.
64
+
65
+ Args:
66
+ extra: Headers to add or override.
67
+
68
+ Returns:
69
+ A new ``RequestSpec`` with the merged headers; every other
70
+ field is preserved.
71
+ """
72
+ return replace(self, headers={**self.headers, **extra})
73
+
74
+ def with_merged_params(self, overrides: Mapping[str, str]) -> Self:
75
+ """
76
+ Copy of this spec with ``overrides`` merged into ``params``
77
+ (add-or-replace per key; existing keys not named are kept).
78
+
79
+ Args:
80
+ overrides: Query parameters to add or replace.
81
+
82
+ Returns:
83
+ The new spec; ``self`` is unchanged.
84
+ """
85
+ return replace(self, params={**(self.params or {}), **overrides})
86
+
87
+ def with_json_body(self, json_body: Mapping[str, JsonValue]) -> Self:
88
+ """
89
+ Copy of this spec with ``json_body`` replaced wholesale.
90
+
91
+ Args:
92
+ json_body: The replacement body.
93
+
94
+ Returns:
95
+ The new spec; ``self`` is unchanged.
96
+ """
97
+ return replace(self, json_body=json_body)
@@ -0,0 +1,40 @@
1
+ # src/fleetpull/network/decoders/__init__.py
2
+ """Per-provider page decoders: envelope interpreters that yield records
3
+ and a pagination verdict in one pass.
4
+
5
+ Implementations of the ``PageDecoder`` protocol (in
6
+ ``network/contract/page_decoder.py``); peers of the contract surface,
7
+ imported by bindings through this face. Each decoder is independent --
8
+ provider envelopes evolve separately (blast-radius over DRY). The one
9
+ shared package-internal piece is the window stamp (``_window_stamp.py``):
10
+ the synthesized window-identity keys are our own provider-uniform
11
+ vocabulary, not envelope logic.
12
+ """
13
+
14
+ from fleetpull.network.decoders.geotab import (
15
+ GeotabFeedPageDecoder,
16
+ GeotabGetPageDecoder,
17
+ )
18
+ from fleetpull.network.decoders.motive import (
19
+ MotiveWrappedListPageDecoder,
20
+ MotiveWrappedSinglePageDecoder,
21
+ )
22
+ from fleetpull.network.decoders.motive_reports import MotiveWindowReportPageDecoder
23
+ from fleetpull.network.decoders.samsara import (
24
+ SamsaraCursorPageDecoder,
25
+ SamsaraVehicleSeriesPageDecoder,
26
+ )
27
+ from fleetpull.network.decoders.samsara_reports import SamsaraWindowReportPageDecoder
28
+ from fleetpull.network.decoders.single_page import SinglePageDecoder
29
+
30
+ __all__: list[str] = [
31
+ 'GeotabFeedPageDecoder',
32
+ 'GeotabGetPageDecoder',
33
+ 'MotiveWindowReportPageDecoder',
34
+ 'MotiveWrappedListPageDecoder',
35
+ 'MotiveWrappedSinglePageDecoder',
36
+ 'SamsaraCursorPageDecoder',
37
+ 'SamsaraVehicleSeriesPageDecoder',
38
+ 'SamsaraWindowReportPageDecoder',
39
+ 'SinglePageDecoder',
40
+ ]
@@ -0,0 +1,78 @@
1
+ # src/fleetpull/network/decoders/_window_stamp.py
2
+ """The shared window stamp for window-grain report decoders.
3
+
4
+ Window-grain rollup surfaces return rows carrying NO event time of any
5
+ kind -- each row is the provider's aggregate over exactly the requested
6
+ window, so the row's time identity is the window the SENT spec asked
7
+ for. The decoders for those surfaces stamp every record with the
8
+ synthesized ``windowStartDate``/``windowEndDate`` keys, copied VERBATIM
9
+ from the sent spec's own window params.
10
+
11
+ The stamp keys are OUR vocabulary, deliberately PROVIDER-UNIFORM: they
12
+ are synthesized, not mirrored, so uniformity costs no wire fidelity and
13
+ buys one recognizable window-identity shape across providers (the
14
+ Samsara fuel-energy pair and the Motive utilization pair both land
15
+ ``window_start``/``window_end`` on their models). That is why this
16
+ helper is shared across provider decoder modules -- a deliberate,
17
+ narrow exception to the decoders' blast-radius-over-DRY rule, which
18
+ covers provider ENVELOPE logic: only the param NAMES vary per provider,
19
+ and each caller passes its own.
20
+ """
21
+
22
+ from typing import Final
23
+
24
+ from fleetpull.exceptions import ProviderResponseError
25
+ from fleetpull.network.contract import RequestSpec
26
+ from fleetpull.vocabulary import JsonObject
27
+
28
+ __all__: list[str] = [
29
+ 'WINDOW_END_KEY',
30
+ 'WINDOW_START_KEY',
31
+ 'window_stamp_from_sent_spec',
32
+ ]
33
+
34
+ # The synthesized window-identity keys merged onto each report record.
35
+ # Chosen collision-free against every walked report census (no
36
+ # time-shaped key on any window-grain surface's wire rows), and named
37
+ # after what they carry: the window that was asked of the provider.
38
+ WINDOW_START_KEY: Final[str] = 'windowStartDate'
39
+ WINDOW_END_KEY: Final[str] = 'windowEndDate'
40
+
41
+
42
+ def window_stamp_from_sent_spec(
43
+ sent: RequestSpec, *, start_param: str, end_param: str
44
+ ) -> JsonObject:
45
+ """The window-identity keys the sent spec contributes to its records.
46
+
47
+ Copied VERBATIM from the sent spec's own window params --
48
+ wire-truthful: the stamp is exactly what was asked of the provider,
49
+ whose answer each rollup row is. A sent spec lacking either param is
50
+ a wiring bug (every window-grain builder renders both), and silently
51
+ unstamped rows would strip the rows' time identity -- so it fails
52
+ loudly instead.
53
+
54
+ Args:
55
+ sent: The spec that produced the page being decoded.
56
+ start_param: The provider's window-start param name (e.g.
57
+ Samsara's ``startDate``, Motive's ``start_date``).
58
+ end_param: The provider's window-end param name.
59
+
60
+ Returns:
61
+ ``{'windowStartDate': ..., 'windowEndDate': ...}``, values
62
+ verbatim from the sent params.
63
+
64
+ Raises:
65
+ ProviderResponseError: The sent spec lacks either window param.
66
+ """
67
+ params = sent.params or {}
68
+ if start_param not in params or end_param not in params:
69
+ raise ProviderResponseError(
70
+ detail=(
71
+ f'sent spec lacks the {start_param!r}/{end_param!r} window '
72
+ 'params to stamp report rows with'
73
+ )
74
+ )
75
+ return {
76
+ WINDOW_START_KEY: params[start_param],
77
+ WINDOW_END_KEY: params[end_param],
78
+ }