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,19 @@
1
+ # src/fleetpull/records/__init__.py
2
+ """The records layer: validate response records and shape them into typed
3
+ Polars DataFrames. Per-record validation (``validate_records``) and the
4
+ model-to-DataFrame conversion (``models_to_dataframe``) build the frame; the
5
+ frame readers ``latest_event_time`` (the watermark candidate) and
6
+ ``extract_roster_members`` (the feeder's fan-out keys) observe it. The driver
7
+ composes validation with the network client."""
8
+
9
+ from fleetpull.records.convert import models_to_dataframe
10
+ from fleetpull.records.event_time import latest_event_time
11
+ from fleetpull.records.roster_members import extract_roster_members
12
+ from fleetpull.records.validation import validate_records
13
+
14
+ __all__: list[str] = [
15
+ 'extract_roster_members',
16
+ 'latest_event_time',
17
+ 'models_to_dataframe',
18
+ 'validate_records',
19
+ ]
@@ -0,0 +1,53 @@
1
+ # src/fleetpull/records/convert.py
2
+ """The records composition: validated model instances to a DataFrame.
3
+
4
+ Ties the stage's pure steps together -- derive the schema from the model
5
+ class, flatten every instance against the shared field walk, build the
6
+ frame with the explicit schema, and normalize empty strings. Per-record
7
+ validation (raw dict to model) is separate (``records/validation.py``);
8
+ composing it with the network client is the driver's job, since records
9
+ does not import the client.
10
+ """
11
+
12
+ from collections.abc import Sequence
13
+ from typing import Any
14
+
15
+ import polars as pl
16
+
17
+ from fleetpull.model_contract import ResponseModel
18
+ from fleetpull.records.dataframe import build_dataframe, normalize_empty_strings
19
+ from fleetpull.records.fields import iter_flat_fields
20
+ from fleetpull.records.flatten import flatten_record
21
+ from fleetpull.records.schema import derive_schema
22
+
23
+ __all__: list[str] = ['models_to_dataframe']
24
+
25
+
26
+ def models_to_dataframe(
27
+ records: Sequence[ResponseModel], model_class: type[ResponseModel]
28
+ ) -> pl.DataFrame:
29
+ """Build a typed, flattened DataFrame from validated model instances.
30
+
31
+ Args:
32
+ records: The validated model instances to materialize. An empty
33
+ sequence yields a zero-row frame with the model's full schema.
34
+ model_class: The model class whose fields define the schema;
35
+ passed explicitly so an empty ``records`` still derives the
36
+ full column set.
37
+
38
+ Returns:
39
+ A DataFrame with one row per record, flattened columns in
40
+ declaration order, derived dtypes, and empty strings normalized to
41
+ null.
42
+
43
+ Raises:
44
+ TypeError: If any field annotation cannot be mapped to a dtype.
45
+ """
46
+ schema: dict[str, pl.DataType] = derive_schema(model_class)
47
+ flat_fields = tuple(iter_flat_fields(model_class))
48
+ # typing-justified: flattened rows carry heterogeneous model-field values
49
+ rows: list[dict[str, Any]] = [
50
+ flatten_record(record=record, flat_fields=flat_fields) for record in records
51
+ ]
52
+ dataframe: pl.DataFrame = build_dataframe(rows=rows, schema=schema)
53
+ return normalize_empty_strings(dataframe)
@@ -0,0 +1,63 @@
1
+ # src/fleetpull/records/dataframe.py
2
+ """DataFrame construction and missing-value normalization.
3
+
4
+ Builds a Polars DataFrame from flat rows using an explicit derived schema
5
+ (construct-with-schema, never infer-then-cast), and normalizes empty
6
+ strings to null at the DataFrame boundary -- the models preserve ``""``
7
+ faithfully from the wire, but the frame uses one uniform missing-value
8
+ representation.
9
+ """
10
+
11
+ from collections.abc import Sequence
12
+ from typing import Any
13
+
14
+ import polars as pl
15
+
16
+ __all__: list[str] = ['build_dataframe', 'normalize_empty_strings']
17
+
18
+
19
+ def build_dataframe(
20
+ # typing-justified: row values are heterogeneous model-field values
21
+ rows: Sequence[dict[str, Any]],
22
+ schema: dict[str, pl.DataType],
23
+ ) -> pl.DataFrame:
24
+ """Construct a typed DataFrame from flat rows and a derived schema.
25
+
26
+ Args:
27
+ rows: The flattened ``{column: value}`` rows. An empty sequence is
28
+ valid and yields a zero-row frame with the right columns and
29
+ dtypes.
30
+ schema: The ``{column: dtype}`` map from ``derive_schema``;
31
+ supplies both the column set/order and the dtypes.
32
+
33
+ Returns:
34
+ A DataFrame whose columns match the schema in declaration order,
35
+ each at its derived dtype.
36
+ """
37
+ if not rows:
38
+ return pl.DataFrame(schema=schema)
39
+ return pl.DataFrame(rows, schema=schema)
40
+
41
+
42
+ def normalize_empty_strings(dataframe: pl.DataFrame) -> pl.DataFrame:
43
+ """Replace ``""`` with null on every String column.
44
+
45
+ Args:
46
+ dataframe: The frame to normalize.
47
+
48
+ Returns:
49
+ The frame with empty strings nulled on String columns; other
50
+ dtypes are untouched (they cannot hold an empty string).
51
+ """
52
+ string_columns: list[str] = [
53
+ name for name, dtype in dataframe.schema.items() if dtype == pl.String()
54
+ ]
55
+ if not string_columns:
56
+ return dataframe
57
+ return dataframe.with_columns(
58
+ pl.when(pl.col(column).str.len_chars() == 0)
59
+ .then(None)
60
+ .otherwise(pl.col(column))
61
+ .alias(column)
62
+ for column in string_columns
63
+ )
@@ -0,0 +1,70 @@
1
+ # src/fleetpull/records/event_time.py
2
+ """Event-time observation over a records frame: the latest event timestamp.
3
+
4
+ The watermark candidate a successful watermark fetch produces -- the maximum
5
+ value of the endpoint's event-time column in the fetched records (DESIGN §5,
6
+ observed-data-only). After a fetch is persisted, the orchestrator reads this
7
+ maximum and, if it advances the stored watermark, commits it; the monotonic guard
8
+ and the wrapping into a ``DateWatermark`` are the orchestrator's and the state
9
+ layer's, not this function's -- it returns the raw ``datetime`` so it stays
10
+ ignorant of the cursor type.
11
+
12
+ A pure leaf over the records frame -- Polars, stdlib ``datetime``, and the timing
13
+ face's ``ensure_utc``. This is the one place a Python ``datetime`` is
14
+ materialized out of a Polars frame into domain code, so it is a
15
+ canonicalization ingress: Polars tags the extracted value
16
+ ``zoneinfo.ZoneInfo('UTC')``, not ``datetime.UTC``, and ``ensure_utc``
17
+ converts it so the interior's strict identity guards hold (the canonical-UTC
18
+ doctrine, ``timing/canon.py``). It reads a finished records frame (records'
19
+ output) rather than building one, which is why it sits beside the construction
20
+ modules rather than in ``incremental`` (a deliberately stdlib-only, Polars-free
21
+ leaf) or ``state`` (which the DESIGN keeps free of frame knowledge -- the
22
+ caller hands it the computed value).
23
+ """
24
+
25
+ from datetime import datetime
26
+
27
+ import polars as pl
28
+
29
+ from fleetpull.timing import ensure_utc
30
+
31
+ __all__: list[str] = ['latest_event_time']
32
+
33
+
34
+ def latest_event_time(frame: pl.DataFrame, event_time_column: str) -> datetime | None:
35
+ """The maximum value of a records frame's event-time column, canonical UTC.
36
+
37
+ Args:
38
+ frame: The fetched, validated records frame. ``event_time_column`` must be
39
+ a UTC datetime column.
40
+ event_time_column: Name of the UTC datetime column to take the maximum of
41
+ (e.g. ``'located_at'``).
42
+
43
+ Returns:
44
+ The latest (maximum) timestamp in ``event_time_column`` as canonical UTC
45
+ (``tzinfo is datetime.UTC``, by construction via ``ensure_utc``), or
46
+ ``None`` when the frame is empty (no observed events, hence no watermark
47
+ advance).
48
+
49
+ Raises:
50
+ polars.exceptions.ColumnNotFoundError: ``event_time_column`` is absent
51
+ from ``frame`` -- a caller bug, surfaced unguarded by Polars.
52
+ TypeError: ``event_time_column``'s maximum is present but not a
53
+ ``datetime`` (e.g. the column is a date or numeric), which would make
54
+ an invalid watermark -- raised loud rather than returned.
55
+ ValueError: ``event_time_column`` is a naive datetime column (from
56
+ ``ensure_utc`` -- an unzoned event time is ambiguous, never assumed
57
+ UTC).
58
+
59
+ Side Effects:
60
+ None -- pure function.
61
+ """
62
+ maximum = frame.get_column(event_time_column).max()
63
+ if maximum is None:
64
+ return None
65
+ if not isinstance(maximum, datetime):
66
+ raise TypeError(
67
+ f'expected a datetime maximum for column {event_time_column!r}, '
68
+ f'got {type(maximum).__name__}'
69
+ )
70
+ return ensure_utc(maximum)
@@ -0,0 +1,190 @@
1
+ # src/fleetpull/records/fields.py
2
+ """The shared field walk: classify each model field's annotation and
3
+ enumerate the flat leaf columns a model produces.
4
+
5
+ Schema derivation and record flattening both consume this one walk, so
6
+ the column NAME a field produces (the type side) and the value PULLED for
7
+ it (the value side) can never drift -- they are computed from a single
8
+ traversal. Nested models flatten with double-underscore-joined column
9
+ names; top-level fields keep their bare name.
10
+ """
11
+
12
+ import enum
13
+ import types
14
+ from collections.abc import Iterator
15
+ from dataclasses import dataclass
16
+ from datetime import date, datetime, timedelta
17
+ from typing import Any, Union, get_args, get_origin
18
+
19
+ from pydantic import BaseModel
20
+
21
+ __all__: list[str] = [
22
+ 'FieldKind',
23
+ 'FlatField',
24
+ 'classify_annotation',
25
+ 'iter_flat_fields',
26
+ ]
27
+
28
+ # Column-name join between a nested model and its child. Double, not
29
+ # single: field names already contain single underscores, so a single
30
+ # separator is ambiguous about the level boundary and lets a top-level
31
+ # field collide with a nested one. Module-private Final.
32
+ _NESTING_JOIN: str = '__'
33
+
34
+ # The closed scalar set. datetime resolves to a tz-aware microsecond
35
+ # dtype downstream and timedelta to a microsecond duration; the others
36
+ # to their obvious Polars scalars.
37
+ _SCALAR_TYPES: frozenset[type] = frozenset(
38
+ {int, float, str, bool, date, datetime, timedelta}
39
+ )
40
+
41
+
42
+ class FieldKind(enum.Enum):
43
+ """How a resolved field annotation maps to a column.
44
+
45
+ A closed set the walk and the schema both dispatch over.
46
+ """
47
+
48
+ SCALAR = enum.auto()
49
+ ENUM = enum.auto()
50
+ LIST_OF_SCALAR = enum.auto()
51
+ NESTED_MODEL = enum.auto()
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class FlatField:
56
+ """One leaf column a model produces.
57
+
58
+ Attributes:
59
+ column: The flattened column name (nested levels double-underscore
60
+ joined; a top-level field keeps its bare name).
61
+ kind: The leaf's classification (never NESTED_MODEL -- nesting is
62
+ resolved away into the column path).
63
+ resolved: The resolved leaf type -- the scalar ``type`` for SCALAR,
64
+ the ``Enum`` subclass for ENUM, the inner scalar ``type`` for
65
+ LIST_OF_SCALAR.
66
+ path: The attribute-access path from the root model to this leaf,
67
+ used to pull the value.
68
+ """
69
+
70
+ column: str
71
+ kind: FieldKind
72
+ resolved: type
73
+ path: tuple[str, ...]
74
+
75
+
76
+ def classify_annotation(
77
+ # typing-justified: annotation forms (unions, aliases) are not `type` values
78
+ annotation: Any,
79
+ owning_model_name: str,
80
+ field_name: str,
81
+ ) -> tuple[FieldKind, type]:
82
+ """Reduce a field annotation to a kind and its resolved leaf type.
83
+
84
+ Unwraps ``Optional`` / ``T | None``; rejects multi-arm unions. A
85
+ nested ``BaseModel`` is NESTED_MODEL (the walk recurses); an ``Enum``
86
+ subclass is ENUM; one of the closed scalars is SCALAR; ``list[scalar]``
87
+ is LIST_OF_SCALAR. Anything else -- ``Any``, ``dict``, ``Literal``,
88
+ a list of models, an untyped ``list`` -- is a derivation gap and
89
+ raises (fail fast; there is no override path yet).
90
+
91
+ Args:
92
+ annotation: The Pydantic ``FieldInfo.annotation`` to classify.
93
+ owning_model_name: The model class name, for error messages.
94
+ field_name: The field name, for error messages.
95
+
96
+ Returns:
97
+ ``(kind, resolved)`` -- the nested model class for NESTED_MODEL,
98
+ the enum class for ENUM, the scalar type for SCALAR, the inner
99
+ scalar type for LIST_OF_SCALAR.
100
+
101
+ Raises:
102
+ TypeError: When the annotation does not resolve to a supported
103
+ kind.
104
+ """
105
+ # typing-justified: union arms are arbitrary annotation forms
106
+ non_none: list[Any] = _strip_none_from_union(annotation)
107
+ if len(non_none) != 1:
108
+ raise TypeError(
109
+ f'{owning_model_name}.{field_name}: cannot derive a column from '
110
+ f'{annotation!r} -- a single non-None type is required, '
111
+ f'found {len(non_none)}.'
112
+ )
113
+ candidate: Any = non_none[0] # typing-justified: an arbitrary annotation form
114
+
115
+ if isinstance(candidate, type) and issubclass(candidate, BaseModel):
116
+ return (FieldKind.NESTED_MODEL, candidate)
117
+ if isinstance(candidate, type) and issubclass(candidate, enum.Enum):
118
+ return (FieldKind.ENUM, candidate)
119
+ if candidate in _SCALAR_TYPES:
120
+ return (FieldKind.SCALAR, candidate)
121
+ if get_origin(candidate) is list:
122
+ # typing-justified: get_args yields arbitrary annotation forms (typeshed)
123
+ inner: tuple[Any, ...] = get_args(candidate)
124
+ if len(inner) == 1 and inner[0] in _SCALAR_TYPES:
125
+ return (FieldKind.LIST_OF_SCALAR, inner[0])
126
+ raise TypeError(
127
+ f'{owning_model_name}.{field_name}: cannot derive a column from '
128
+ f'{annotation!r} -- only list[scalar] is supported, not lists '
129
+ f'of models or untyped lists.'
130
+ )
131
+ raise TypeError(
132
+ f'{owning_model_name}.{field_name}: cannot derive a column from '
133
+ f'{annotation!r} -- unsupported annotation (no schema-override path '
134
+ f'exists yet; model the shape or narrow the type).'
135
+ )
136
+
137
+
138
+ def iter_flat_fields(model_class: type[BaseModel]) -> Iterator[FlatField]:
139
+ """Yield one ``FlatField`` per leaf column the model produces.
140
+
141
+ Recurses into nested models, double-underscore joining each level into
142
+ the column name and extending the attribute path. Top-level scalar
143
+ fields keep their bare name and a single-element path.
144
+
145
+ Args:
146
+ model_class: The model class to walk.
147
+
148
+ Yields:
149
+ One ``FlatField`` per leaf, in declaration order (depth-first).
150
+
151
+ Raises:
152
+ TypeError: Propagated from ``classify_annotation`` for any field
153
+ whose annotation cannot be resolved.
154
+ """
155
+ yield from _walk(model_class, name_prefix=(), path_prefix=())
156
+
157
+
158
+ def _walk(
159
+ model_class: type[BaseModel],
160
+ name_prefix: tuple[str, ...],
161
+ path_prefix: tuple[str, ...],
162
+ ) -> Iterator[FlatField]:
163
+ """Depth-first leaf walk; see :func:`iter_flat_fields`."""
164
+ for field_name, field_info in model_class.model_fields.items():
165
+ kind, resolved = classify_annotation(
166
+ annotation=field_info.annotation,
167
+ owning_model_name=model_class.__name__,
168
+ field_name=field_name,
169
+ )
170
+ path: tuple[str, ...] = (*path_prefix, field_name)
171
+ if kind is FieldKind.NESTED_MODEL:
172
+ yield from _walk(
173
+ model_class=resolved,
174
+ name_prefix=(*name_prefix, field_name),
175
+ path_prefix=path,
176
+ )
177
+ else:
178
+ column: str = _NESTING_JOIN.join((*name_prefix, field_name))
179
+ yield FlatField(column=column, kind=kind, resolved=resolved, path=path)
180
+
181
+
182
+ # typing-justified: annotation forms (unions, aliases) in, annotation forms out
183
+ def _strip_none_from_union(annotation: Any) -> list[Any]:
184
+ """Return the non-``NoneType`` arms of a union, else ``[annotation]``."""
185
+ origin: Any = get_origin(annotation) # typing-justified: typeshed returns Any
186
+ if origin is Union or origin is types.UnionType:
187
+ return [
188
+ argument for argument in get_args(annotation) if argument is not type(None)
189
+ ]
190
+ return [annotation]
@@ -0,0 +1,54 @@
1
+ # src/fleetpull/records/flatten.py
2
+ """Record flattening: a model instance to a flat ``{column: value}`` row.
3
+
4
+ The value side of the shared field walk -- it pulls each leaf's value by
5
+ the attribute path the same walk produced, so column names and values are
6
+ guaranteed aligned. A ``None`` nested block yields ``None`` for all of its
7
+ leaf columns; an enum value is reduced to its plain string.
8
+ """
9
+
10
+ from collections.abc import Iterable
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from fleetpull.records.fields import FieldKind, FlatField
16
+
17
+ __all__: list[str] = ['flatten_record']
18
+
19
+
20
+ def flatten_record(
21
+ record: BaseModel, flat_fields: Iterable[FlatField]
22
+ ) -> dict[str, Any]: # typing-justified: model-field values are heterogeneous
23
+ """Pull one flat row of values from a model instance.
24
+
25
+ Args:
26
+ record: The validated model instance to flatten.
27
+ flat_fields: The leaf fields (from ``iter_flat_fields`` on the
28
+ record's class), reused across every record of a batch.
29
+
30
+ Returns:
31
+ A ``{column: value}`` row. A missing (``None``) nested block
32
+ yields ``None`` for each of its columns; enum values reduce to
33
+ ``str``; scalars, datetimes, and list values pass through.
34
+ """
35
+ row: dict[str, Any] = {} # typing-justified: heterogeneous model-field values
36
+ for flat_field in flat_fields:
37
+ value: Any = _pull(record, flat_field.path) # typing-justified: see _pull
38
+ if value is not None and flat_field.kind is FieldKind.ENUM:
39
+ # All current enums are StrEnum, so str(member) is the wire
40
+ # value ('active'), not 'VehicleStatus.ACTIVE'.
41
+ value = str(value)
42
+ row[flat_field.column] = value
43
+ return row
44
+
45
+
46
+ # typing-justified: getattr walks arbitrary model fields; the value is untyped
47
+ def _pull(record: BaseModel, path: tuple[str, ...]) -> Any:
48
+ """Walk the attribute path; short-circuit to ``None`` on a null block."""
49
+ current: Any = record # typing-justified: intermediate attribute-walk value
50
+ for attribute in path:
51
+ if current is None:
52
+ return None
53
+ current = getattr(current, attribute)
54
+ return current
@@ -0,0 +1,74 @@
1
+ # src/fleetpull/records/roster_members.py
2
+ """Roster-member extraction over a records frame: a column's distinct values as
3
+ strings.
4
+
5
+ The roster members a feeder listing produces -- the distinct values of one column of
6
+ the feeder's validated frame, stringified for use as roster members and URL-path
7
+ fan-out values. The refresh path reads this off the listed-and-validated feeder frame
8
+ and hands the members to ``reconcile``; mapping a member to anything else (a VIN) is
9
+ never needed -- the value is opaque.
10
+
11
+ A pure-ish leaf over the records frame -- Polars, stdlib logging, nothing internal;
12
+ it reads a finished records frame rather than building one, so it sits with the
13
+ records-layer extractors. A missing column raises ``ValueError`` -- a wiring bug
14
+ (the roster definition names a column the feeder frame lacks) the exception
15
+ hierarchy leaves to stdlib. Null and empty-string values are *filtered, loudly*
16
+ rather than raised: both are unfetchable members by construction (a null carries
17
+ no id; an empty string renders an unbuildable URL path), and excluding one garbage
18
+ record beats converting it into an outage for every other member's fan-out. The
19
+ filter logs a warning with the column and counts, so a provider emitting garbage
20
+ ids is visible without being fatal.
21
+ """
22
+
23
+ import logging
24
+
25
+ import polars as pl
26
+
27
+ __all__: list[str] = ['extract_roster_members']
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def extract_roster_members(frame: pl.DataFrame, column: str) -> set[str]:
33
+ """The distinct fetchable values of a frame column, as strings.
34
+
35
+ Reads ``column`` and returns its distinct values stringified (a numeric id
36
+ becomes its decimal string, ready for a roster member and a URL-path value).
37
+ A roster is an unordered set, so the result is a ``set`` and extraction order
38
+ is not preserved. Null and empty-string values are filtered with a warning --
39
+ unfetchable by construction, excluded rather than fatal -- so the returned
40
+ members are exactly the fan-out-usable ones.
41
+
42
+ Args:
43
+ frame: The fetched, validated feeder frame.
44
+ column: Name of the column whose distinct values are the members (e.g.
45
+ ``'vehicle_id'``).
46
+
47
+ Returns:
48
+ The distinct non-null, non-empty values of ``column`` as a set of
49
+ strings; an empty set when the frame is empty or holds no usable value.
50
+
51
+ Raises:
52
+ ValueError: ``column`` is absent from ``frame`` -- a wiring bug (the
53
+ roster definition names a column the feeder frame lacks), left to
54
+ stdlib rather than the operational hierarchy, which this leaf cannot
55
+ give provider context.
56
+
57
+ Side Effects:
58
+ Logs a warning when null or empty-string values were filtered.
59
+ """
60
+ if column not in frame.columns:
61
+ raise ValueError(f'roster source column {column!r} is not in the frame')
62
+ series = frame.get_column(column)
63
+ null_row_count = series.null_count()
64
+ stringified = series.drop_nulls().cast(pl.String)
65
+ empty_row_count = int((stringified.str.len_chars() == 0).sum())
66
+ if null_row_count or empty_row_count:
67
+ logger.warning(
68
+ 'filtered unfetchable members from roster source column %r: '
69
+ '%d null and %d empty-string value(s) excluded',
70
+ column,
71
+ null_row_count,
72
+ empty_row_count,
73
+ )
74
+ return {value for value in stringified.unique().to_list() if value != ''}
@@ -0,0 +1,73 @@
1
+ # src/fleetpull/records/schema.py
2
+ """Polars schema derivation: a model class to a ``{column: dtype}`` map.
3
+
4
+ Walks the shared field enumeration (``records/fields.py``) and maps each
5
+ leaf to a Polars dtype: ``int``/``float``/``str``/``bool`` to their
6
+ obvious scalars, ``date`` to a calendar-date column (a DATE-ONLY wire
7
+ value carries no instant to recover -- the Motive users ``joined_at``
8
+ precedent), ``datetime`` to tz-aware microsecond UTC, ``timedelta``
9
+ to a microsecond ``Duration``, enums to ``String``, ``list[scalar]`` to
10
+ ``List`` of the inner dtype. Depends only on the model class, not on any
11
+ data, so the schema is computable before a record is fetched. A leaf the
12
+ map cannot place raises (fail fast); there is no override path yet.
13
+ """
14
+
15
+ from datetime import date, datetime, timedelta
16
+
17
+ import polars as pl
18
+ from pydantic import BaseModel
19
+
20
+ from fleetpull.records.fields import FieldKind, FlatField, iter_flat_fields
21
+
22
+ __all__: list[str] = ['derive_schema']
23
+
24
+ # The closed scalar -> Polars dtype map. datetime is tz-aware microsecond
25
+ # UTC and timedelta a microsecond Duration, so every temporal column
26
+ # carries a uniform, parquet-friendly type.
27
+ _SCALAR_TO_POLARS: dict[type, pl.DataType] = {
28
+ int: pl.Int64(),
29
+ float: pl.Float64(),
30
+ str: pl.String(),
31
+ bool: pl.Boolean(),
32
+ date: pl.Date(),
33
+ datetime: pl.Datetime(time_unit='us', time_zone='UTC'),
34
+ timedelta: pl.Duration(time_unit='us'),
35
+ }
36
+
37
+
38
+ def derive_schema(model_class: type[BaseModel]) -> dict[str, pl.DataType]:
39
+ """Derive the Polars schema for a model's flattened columns.
40
+
41
+ Args:
42
+ model_class: The model whose flattened leaves define the columns.
43
+
44
+ Returns:
45
+ An insertion-ordered ``{column: dtype}`` map in field declaration
46
+ order. Scalars, datetimes, and timedeltas map directly; enums map
47
+ to ``pl.String``; ``list[scalar]`` maps to ``pl.List`` of the
48
+ inner scalar's dtype.
49
+
50
+ Raises:
51
+ TypeError: If a leaf annotation has no dtype mapping.
52
+ ValueError: If two leaves resolve to one column name -- a
53
+ structural impossibility the double-underscore join prevents,
54
+ raised as a guard that should never fire.
55
+ """
56
+ schema: dict[str, pl.DataType] = {}
57
+ for flat_field in iter_flat_fields(model_class):
58
+ if flat_field.column in schema:
59
+ raise ValueError(
60
+ f'{model_class.__name__}: duplicate flattened column '
61
+ f'{flat_field.column!r} -- two fields resolve to one name.'
62
+ )
63
+ schema[flat_field.column] = _leaf_to_dtype(flat_field)
64
+ return schema
65
+
66
+
67
+ def _leaf_to_dtype(flat_field: FlatField) -> pl.DataType:
68
+ """Map one resolved leaf to its Polars dtype."""
69
+ if flat_field.kind is FieldKind.ENUM:
70
+ return pl.String()
71
+ if flat_field.kind is FieldKind.LIST_OF_SCALAR:
72
+ return pl.List(_SCALAR_TO_POLARS[flat_field.resolved])
73
+ return _SCALAR_TO_POLARS[flat_field.resolved]
@@ -0,0 +1,62 @@
1
+ # src/fleetpull/records/validation.py
2
+ """Per-record validation: raw response dicts to typed model instances.
3
+
4
+ Validates each record dict into the endpoint's response model. Pydantic's
5
+ lax coercion (the model is non-strict) lands wire types; field-level
6
+ wire-cleaning, when an endpoint needs it, lives on the model as a
7
+ ``field_validator(mode='before')``, not here. Validation fails fast and
8
+ loud on the first bad record, with safe-to-log context.
9
+ """
10
+
11
+ from collections.abc import Sequence
12
+
13
+ from pydantic import ValidationError
14
+
15
+ from fleetpull.exceptions import ProviderResponseError
16
+ from fleetpull.model_contract import ResponseModel
17
+ from fleetpull.vocabulary import JsonObject
18
+
19
+ __all__: list[str] = ['validate_records']
20
+
21
+
22
+ def validate_records[ModelT: ResponseModel](
23
+ records: Sequence[JsonObject], model_class: type[ModelT]
24
+ ) -> list[ModelT]:
25
+ """Validate raw record dicts into typed model instances.
26
+
27
+ Args:
28
+ records: The raw record objects (a page's ``records``).
29
+ model_class: The response model to validate each record into --
30
+ bound to ``ResponseModel`` so every validated record carries the
31
+ package's response-model config policy.
32
+
33
+ Returns:
34
+ The validated model instances, one per input record, in order.
35
+
36
+ Raises:
37
+ ProviderResponseError: On the first record that fails validation,
38
+ naming the model, the record's position, and which fields
39
+ failed and how -- never the raw field values, so the error is
40
+ safe to log.
41
+ """
42
+ validated: list[ModelT] = []
43
+ for index, record in enumerate(records):
44
+ try:
45
+ validated.append(model_class.model_validate(record))
46
+ except ValidationError as error:
47
+ raise ProviderResponseError(
48
+ detail=(
49
+ f'{model_class.__name__} record {index} failed '
50
+ f'validation: {_safe_summary(error)}'
51
+ )
52
+ ) from None
53
+ return validated
54
+
55
+
56
+ def _safe_summary(error: ValidationError) -> str:
57
+ """Summarize a ValidationError without exposing raw field values."""
58
+ parts: list[str] = [
59
+ f'{".".join(str(item) for item in entry["loc"])} ({entry["type"]})'
60
+ for entry in error.errors()
61
+ ]
62
+ return '; '.join(parts)
@@ -0,0 +1,10 @@
1
+ # src/fleetpull/resources/__init__.py
2
+ """Packaged data resources shipped inside the wheel.
3
+
4
+ Holds ``config.example.yaml`` -- the annotated starter configuration a
5
+ user materializes with ``fleetpull init-config`` (read through
6
+ ``importlib.resources`` so it resolves in both a wheel and an editable
7
+ install). This package carries data, not code; it exports nothing.
8
+ """
9
+
10
+ __all__: list[str] = []