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,81 @@
1
+ # src/fleetpull/models/geotab/fuel_and_energy_used.py
2
+ """GeoTab FuelAndEnergyUsed response model (``GetFeed`` on that ``typeName``).
3
+
4
+ Written from the 2026-07-21 live probe session, never from docs. A
5
+ FuelAndEnergyUsed record is one provider-calculated per-trip fuel/energy
6
+ usage total — a CALCULATED feed: past records re-emit under a higher
7
+ ``version`` on reprocessing, stored as emitted and reconciled by
8
+ ``(id, max version)`` (DESIGN §4).
9
+
10
+ ``FuelUsed`` is NOT ported: on the probed tenant it was observed
11
+ IDENTICAL to this surface (same ids, same values, week-wide), and the
12
+ provider documents this type as its successor — porting both would ship
13
+ one dataset twice (DESIGN §8).
14
+
15
+ THE ESTIMATES-ONLY-TENANT CAVEAT (DESIGN §8): the probed tenant has NO
16
+ fuel-transaction (fuel-card) integration, so every fuel value on this
17
+ surface is provider-derived from telemetry — estimates, not
18
+ transactions. The census cannot speak for integrated tenants.
19
+
20
+ Requiredness posture: the census is a large uniform whole-page total —
21
+ 2,000/2,000 records carried every key — so every field is required with
22
+ no nullable arm (none was observed). The census is a TENANT-SCOPED
23
+ observation. ``confidence`` observed ``'None'`` on 1,994/2,000 and
24
+ ``'FuelUsedInconsistent'`` on 6 — census-open, a plain str. Mixed
25
+ int-or-float wire numerics (``totalFuelUsed``,
26
+ ``totalIdlingFuelUsedL``) are modeled ``float``; ``dateTime`` (the
27
+ event time) is recovered tz-aware by validation, the GeoTab sibling
28
+ idiom.
29
+ """
30
+
31
+ from datetime import datetime
32
+
33
+ from pydantic import ConfigDict
34
+ from pydantic.alias_generators import to_camel
35
+
36
+ from fleetpull.model_contract import ResponseModel
37
+
38
+ __all__: list[str] = ['FuelAndEnergyUsed', 'FuelAndEnergyUsedDeviceRef']
39
+
40
+
41
+ class FuelAndEnergyUsedDeviceRef(ResponseModel):
42
+ """The usage record's device reference: the id alone, on every record."""
43
+
44
+ model_config = ConfigDict(alias_generator=to_camel)
45
+
46
+ id: str
47
+
48
+
49
+ class FuelAndEnergyUsed(ResponseModel):
50
+ """One GeoTab per-trip fuel/energy usage total.
51
+
52
+ A pure mirror of the 2,000/2,000 whole-page census: seven keys, all
53
+ present and non-null on every record, so all seven are required.
54
+ The estimates-only-tenant caveat and the ``FuelUsed`` non-port are
55
+ the module docstring's.
56
+
57
+ Attributes:
58
+ confidence: The provider's confidence token — ``'None'`` on
59
+ nearly every census record, ``'FuelUsedInconsistent'`` on
60
+ the rest (census-open plain str).
61
+ date_time: The usage total's UTC instant — the endpoint's event
62
+ time.
63
+ device: The vehicle unit's reference.
64
+ id: GeoTab's record id.
65
+ total_fuel_used: Fuel used over the covered trip, liters (the wire key totalIdlingFuelUsedL's own L suffix -- unit evidence on the wire, not a docs assumption) (mixed
66
+ int-or-float on the wire, modeled float).
67
+ total_idling_fuel_used_l: Idling fuel used over the covered
68
+ trip, liters (mixed int-or-float, modeled float).
69
+ version: The record's version token — the calculated-feed
70
+ reconcile key beside ``id``.
71
+ """
72
+
73
+ model_config = ConfigDict(alias_generator=to_camel)
74
+
75
+ confidence: str
76
+ date_time: datetime
77
+ device: FuelAndEnergyUsedDeviceRef
78
+ id: str
79
+ total_fuel_used: float
80
+ total_idling_fuel_used_l: float
81
+ version: str
@@ -0,0 +1,150 @@
1
+ # src/fleetpull/models/geotab/fuel_tax_detail.py
2
+ """GeoTab FuelTaxDetail response model (``GetFeed`` on ``typeName: FuelTaxDetail``).
3
+
4
+ Written from the 2026-07-21 live probe session, never from docs. A
5
+ FuelTaxDetail is one provider-calculated IFTA jurisdiction segment — a
6
+ vehicle's continuous travel within one jurisdiction, bracketed by its
7
+ enter/exit instants and odometer readings — a CALCULATED feed, stored as
8
+ emitted (DESIGN §4). Its version identity is a LIST: ``versions``
9
+ carries 16-hex component version tokens (mirrored ``list[str]``, a
10
+ §9-supported list-of-scalar) rather than the single ``version`` its
11
+ feed siblings carry — the consumer's ``(id, max version)`` reconcile
12
+ reads the re-emitted row's whole token list as the fresher edition.
13
+
14
+ THE ESTIMATES-ONLY-TENANT CAVEAT (DESIGN §8): the probed tenant has NO
15
+ fuel-transaction (fuel-card) integration, so every fuel value on this
16
+ surface is provider-derived from telemetry — estimates, not
17
+ transactions. The census cannot speak for integrated tenants.
18
+
19
+ Requiredness posture: the census is a uniform whole-page total — every
20
+ key present on all sampled records (100-300 per key across the probed
21
+ pages) — so every field is required, with the arms exactly as observed:
22
+
23
+ - ``driver`` arrives as either the object reference or the bare
24
+ ``"UnknownDriverId"`` sentinel string; the shared
25
+ ``bare_id_to_reference`` coercion (the shipped Trip mechanism) lifts
26
+ the bare form to ``{"id": <string>}``, so ``is_driver`` is null
27
+ exactly on sentinel rows.
28
+ - The hourly arrays (``hourlyGpsOdometer``, ``hourlyLatitude``, and
29
+ kin) may be EMPTY lists — present on every record, sometimes with no
30
+ elements (``hasHourlyData`` false) — mirrored as list-of-scalar
31
+ fields, never demoted to nullable.
32
+
33
+ ``enterTime`` (the event time — the segment materializes where it
34
+ begins) and ``exitTime`` are recovered tz-aware by validation, the
35
+ GeoTab sibling idiom. Mixed int-or-float wire numerics
36
+ (``enterOdometer``, ``exitOdometer``, the ``hourlyOdometer`` elements)
37
+ are modeled ``float``. ``authority`` and ``jurisdiction`` are
38
+ census-scoped open vocabularies — plain strs, never enums.
39
+ """
40
+
41
+ from datetime import datetime
42
+ from typing import Annotated
43
+
44
+ from pydantic import BeforeValidator, ConfigDict
45
+ from pydantic.alias_generators import to_camel
46
+
47
+ from fleetpull.model_contract import ResponseModel
48
+ from fleetpull.models.geotab.shared import bare_id_to_reference
49
+
50
+ __all__: list[str] = [
51
+ 'FuelTaxDetail',
52
+ 'FuelTaxDetailDeviceRef',
53
+ 'FuelTaxDetailDriverRef',
54
+ ]
55
+
56
+
57
+ class FuelTaxDetailDeviceRef(ResponseModel):
58
+ """The segment's device reference: the id alone, on every record."""
59
+
60
+ model_config = ConfigDict(alias_generator=to_camel)
61
+
62
+ id: str
63
+
64
+
65
+ class FuelTaxDetailDriverRef(ResponseModel):
66
+ """The segment's driver reference.
67
+
68
+ Arrives as an object or the bare ``"UnknownDriverId"`` sentinel
69
+ string; the ``FuelTaxDetail.driver`` field's coercion lifts the
70
+ bare form to ``{"id": <string>}``, so ``is_driver`` is null exactly
71
+ on sentinel rows.
72
+ """
73
+
74
+ model_config = ConfigDict(alias_generator=to_camel)
75
+
76
+ id: str
77
+ is_driver: bool | None = None
78
+
79
+
80
+ class FuelTaxDetail(ResponseModel):
81
+ """One GeoTab IFTA jurisdiction segment.
82
+
83
+ A pure mirror of the whole-page census: every modeled key present on
84
+ every record, so everything is required; the observed arms (the
85
+ ``driver`` string-or-object sentinel, the possibly-empty hourly
86
+ arrays) are the module docstring's.
87
+
88
+ Attributes:
89
+ authority: The taxing authority token (census-open plain str).
90
+ device: The vehicle unit's reference.
91
+ driver: The driver reference; the bare ``"UnknownDriverId"``
92
+ sentinel lands as ``driver.id`` verbatim.
93
+ enter_gps_odometer: The GPS-derived odometer at segment entry.
94
+ enter_latitude: Latitude at segment entry.
95
+ enter_longitude: Longitude at segment entry.
96
+ enter_odometer: The odometer at segment entry.
97
+ enter_time: Segment entry (UTC) — the endpoint's event time.
98
+ exit_gps_odometer: The GPS-derived odometer at segment exit.
99
+ exit_latitude: Latitude at segment exit.
100
+ exit_longitude: Longitude at segment exit.
101
+ exit_odometer: The odometer at segment exit.
102
+ exit_time: Segment exit (UTC).
103
+ has_hourly_data: Whether the hourly arrays carry elements.
104
+ hourly_gps_odometer: Per-hour GPS-derived odometer readings
105
+ (may be empty).
106
+ hourly_is_odometer_interpolated: Per-hour interpolation flags
107
+ (may be empty).
108
+ hourly_latitude: Per-hour latitudes (may be empty).
109
+ hourly_longitude: Per-hour longitudes (may be empty).
110
+ hourly_odometer: Per-hour odometer readings (may be empty).
111
+ id: GeoTab's record id.
112
+ is_cluster_odometer: Whether the odometer is cluster-sourced.
113
+ is_enter_odometer_interpolated: Whether the entry odometer is
114
+ interpolated.
115
+ is_exit_odometer_interpolated: Whether the exit odometer is
116
+ interpolated.
117
+ is_negligible: The provider's negligible-segment flag.
118
+ jurisdiction: The jurisdiction token (census-open plain str).
119
+ versions: The 16-hex component version tokens — this type's
120
+ list-shaped version identity (the module docstring).
121
+ """
122
+
123
+ model_config = ConfigDict(alias_generator=to_camel)
124
+
125
+ authority: str
126
+ device: FuelTaxDetailDeviceRef
127
+ driver: Annotated[FuelTaxDetailDriverRef, BeforeValidator(bare_id_to_reference)]
128
+ enter_gps_odometer: float
129
+ enter_latitude: float
130
+ enter_longitude: float
131
+ enter_odometer: float
132
+ enter_time: datetime
133
+ exit_gps_odometer: float
134
+ exit_latitude: float
135
+ exit_longitude: float
136
+ exit_odometer: float
137
+ exit_time: datetime
138
+ has_hourly_data: bool
139
+ hourly_gps_odometer: list[float]
140
+ hourly_is_odometer_interpolated: list[bool]
141
+ hourly_latitude: list[float]
142
+ hourly_longitude: list[float]
143
+ hourly_odometer: list[float]
144
+ id: str
145
+ is_cluster_odometer: bool
146
+ is_enter_odometer_interpolated: bool
147
+ is_exit_odometer_interpolated: bool
148
+ is_negligible: bool
149
+ jurisdiction: str
150
+ versions: list[str]
@@ -0,0 +1,68 @@
1
+ # src/fleetpull/models/geotab/log_record.py
2
+ """GeoTab LogRecord response model (``GetFeed`` on ``typeName: LogRecord``).
3
+
4
+ Written from the 2026-07-21 live probe session, never from docs. A
5
+ LogRecord is one GPS reading from a device's telemetry stream — the
6
+ ACTIVE feed archetype: records are emitted once, never re-emitted, and
7
+ carry NO per-record ``version`` (unlike every calculated feed and unlike
8
+ the otherwise-parallel ``StatusData``), so append-only storage is
9
+ trivially complete and the consumer reconciles by ``id`` alone
10
+ (DESIGN §4).
11
+
12
+ Requiredness posture: the census is a large uniform whole-page total —
13
+ 2,000/2,000 records carried every key — so every field is required with
14
+ no nullable arm (none was observed). The census is a TENANT-SCOPED
15
+ observation (DESIGN §8): it proves this tenant's shapes at capture time,
16
+ never other tenants'. Volume on the probed tenant exceeds 50,000
17
+ records/day (a 50,000-record page did not cover one day), which is why
18
+ the leaf declares the 50,000 protocol-maximum ``resultsLimit``.
19
+
20
+ ``dateTime`` (the event time) arrives as an RFC3339 ``Z`` string and is
21
+ recovered tz-aware by validation, the GeoTab sibling idiom; ``speed`` is
22
+ a bare int on all 2,000 census records and is mirrored verbatim (the
23
+ odometer_readings bare-int precedent — an integral wire value is not
24
+ widened speculatively).
25
+ """
26
+
27
+ from datetime import datetime
28
+
29
+ from pydantic import ConfigDict
30
+ from pydantic.alias_generators import to_camel
31
+
32
+ from fleetpull.model_contract import ResponseModel
33
+
34
+ __all__: list[str] = ['LogRecord', 'LogRecordDeviceRef']
35
+
36
+
37
+ class LogRecordDeviceRef(ResponseModel):
38
+ """The reading's device reference: the id alone, on every record."""
39
+
40
+ model_config = ConfigDict(alias_generator=to_camel)
41
+
42
+ id: str
43
+
44
+
45
+ class LogRecord(ResponseModel):
46
+ """One GeoTab GPS reading from the LogRecord feed.
47
+
48
+ A pure mirror of the 2,000/2,000 whole-page census: six keys, all
49
+ present and non-null on every record, so all six are required.
50
+
51
+ Attributes:
52
+ date_time: The reading's UTC instant — the endpoint's event time.
53
+ device: The emitting vehicle unit's reference.
54
+ id: GeoTab's record id (ids and feed versions share one counter
55
+ space — DESIGN §8).
56
+ latitude: GPS latitude in decimal degrees.
57
+ longitude: GPS longitude in decimal degrees.
58
+ speed: Speed in km/h (the provider speed-unit settlement: the Trip delta-arithmetic check, DESIGN section 8, 2026-07-13 -- same provider telemetry family, not a docs assumption) — a bare int on the wire, mirrored verbatim.
59
+ """
60
+
61
+ model_config = ConfigDict(alias_generator=to_camel)
62
+
63
+ date_time: datetime
64
+ device: LogRecordDeviceRef
65
+ id: str
66
+ latitude: float
67
+ longitude: float
68
+ speed: int
@@ -0,0 +1,133 @@
1
+ # src/fleetpull/models/geotab/media_file.py
2
+ """GeoTab MediaFile response model (``GetFeed`` on ``typeName: MediaFile``).
3
+
4
+ Written from the 2026-07-21 feed wave three SCALE census (walked at
5
+ scale at the probed tenant), never from docs. A MediaFile is one media
6
+ attachment (image, video, ...) captured by a device or driver — a
7
+ versioned feed, so re-emission under newer ``version`` tokens is
8
+ expected and the consumer reconciles by ``(id, max version)``
9
+ (DESIGN §4).
10
+
11
+ THIN EVIDENCE CAVEAT: only 55 records over a 730-day window at this
12
+ tenant — genuinely thin data. The model is conservative accordingly;
13
+ every arm below is what those 55 records showed, and the census cannot
14
+ speak for a tenant that uses media at volume.
15
+
16
+ NO ``dateTime`` key — the event time is ``fromDate`` (the media start,
17
+ 55/55), so the binding anchors ``event_time_column='from_date'`` and
18
+ ``from_date`` is a REQUIRED datetime (storage partitions on it).
19
+
20
+ Requiredness posture (the wave-two conservative stance, DESIGN §8):
21
+ structural requiredness is limited to the record identity — ``id``,
22
+ ``fromDate`` (the event time), and ``version`` — and every other field
23
+ is optional. Both ``device`` and ``driver`` are OPTIONAL: a media
24
+ file's primary entity is ambiguous — it may attach to a device or a
25
+ driver — so neither is promoted to a required primary ref. The observed
26
+ arms and exclusions:
27
+
28
+ - ``device`` is PROVEN MIXED object-or-string (42 string / 13 object at
29
+ scale); ``driver`` was string-only observed (55/55). Both ride the
30
+ shared ``bare_id_to_reference`` lift, so both land as ``*__id`` —
31
+ the mixed ``device`` because the census proved both arms, the
32
+ string-only ``driver`` defensively (the census-scope lesson: a
33
+ tenant census cannot prove the object arm absent either).
34
+ - THREE DOCUMENTED EXCLUSIONS (the ``defectList.children`` doctrine,
35
+ DESIGN §8): ``metaData`` (empty object on all 55), ``tags`` (empty
36
+ list on all 55), and ``thumbnails`` (empty list on all 55). Their
37
+ element/content shape is unobservable at this tenant, the records
38
+ layer supports only observable shapes, and ``extra='ignore'`` absorbs
39
+ them wire-side (pinned: a record populating any of the three still
40
+ validates). REVISIT when a tenant populates them: capture the shape
41
+ and model it then.
42
+
43
+ ``fromDate`` and ``toDate`` are recovered tz-aware by validation, the
44
+ GeoTab sibling idiom. ``mediaType``, ``name``, ``solutionId``, and
45
+ ``status`` are census-open strings.
46
+ """
47
+
48
+ from datetime import datetime
49
+ from typing import Annotated
50
+
51
+ from pydantic import BeforeValidator, ConfigDict
52
+ from pydantic.alias_generators import to_camel
53
+
54
+ from fleetpull.model_contract import ResponseModel
55
+ from fleetpull.models.geotab.shared import bare_id_to_reference
56
+
57
+ __all__: list[str] = [
58
+ 'MediaFile',
59
+ 'MediaFileDeviceRef',
60
+ 'MediaFileDriverRef',
61
+ ]
62
+
63
+
64
+ class MediaFileDeviceRef(ResponseModel):
65
+ """The capturing device's reference.
66
+
67
+ PROVEN mixed on this census (42 bare-string / 13 ``{id}`` object);
68
+ the shared coercion lifts the bare form to ``{"id": <string>}``, so
69
+ both arms land as ``device__id``.
70
+ """
71
+
72
+ model_config = ConfigDict(alias_generator=to_camel)
73
+
74
+ id: str
75
+
76
+
77
+ class MediaFileDriverRef(ResponseModel):
78
+ """The capturing driver's reference.
79
+
80
+ String-only observed at scale (55/55 bare strings); the shared
81
+ coercion lifts the bare form, and an unobserved object arm would
82
+ pass through defensively (the census-scope lesson). Both arms land
83
+ as ``driver__id``.
84
+ """
85
+
86
+ model_config = ConfigDict(alias_generator=to_camel)
87
+
88
+ id: str
89
+
90
+
91
+ class MediaFile(ResponseModel):
92
+ """One GeoTab media attachment from the MediaFile feed.
93
+
94
+ The wave-two conservative mirror on thin evidence (the module
95
+ docstring's posture): ``id`` / ``from_date`` / ``version`` required,
96
+ both refs and everything else optional; ``metaData`` / ``tags`` /
97
+ ``thumbnails`` documented-excluded.
98
+
99
+ Attributes:
100
+ device: The capturing device's reference — proven
101
+ object-or-string, both arms landing as ``device__id``.
102
+ Optional (the ambiguous-primary-entity choice).
103
+ driver: The capturing driver's reference (string-only observed;
104
+ defensively lifted). Optional (the ambiguous-primary-entity
105
+ choice).
106
+ from_date: The media's UTC start — the endpoint's event time (in
107
+ place of the absent ``dateTime``).
108
+ id: GeoTab's record id.
109
+ media_type: The media type token (census-open str).
110
+ name: The media file name (census-open str).
111
+ solution_id: The originating solution id (census-open str).
112
+ status: The media status token (census-open str).
113
+ to_date: The media's UTC end.
114
+ version: The record's version token — the reconcile key beside
115
+ ``id``.
116
+ """
117
+
118
+ model_config = ConfigDict(alias_generator=to_camel)
119
+
120
+ device: Annotated[
121
+ MediaFileDeviceRef | None, BeforeValidator(bare_id_to_reference)
122
+ ] = None
123
+ driver: Annotated[
124
+ MediaFileDriverRef | None, BeforeValidator(bare_id_to_reference)
125
+ ] = None
126
+ from_date: datetime
127
+ id: str
128
+ media_type: str | None = None
129
+ name: str | None = None
130
+ solution_id: str | None = None
131
+ status: str | None = None
132
+ to_date: datetime | None = None
133
+ version: str
@@ -0,0 +1,221 @@
1
+ # src/fleetpull/models/geotab/shared.py
2
+ """Shared GeoTab boundary-model machinery: TimeSpan parsing, reference
3
+ coercion, and the nested-location model trio.
4
+
5
+ GeoTab serializes every duration as a .NET TimeSpan string
6
+ (``[d.]hh:mm:ss[.f{1,7}]`` -- captured 2026-07-13: ``"00:05:01"``,
7
+ ``"00:03:42.3630000"``, ``"4.16:41:16"``, ``"21:04:17"``), and reference
8
+ fields may arrive as either a bare known-id sentinel string
9
+ (``"UnknownDriverId"``) or an object (``{"id": ..., "isDriver": true}``).
10
+ Both shapes are structural wire facts shared across many GeoTab entities
11
+ (any model with a duration or a reference field), so their coercions
12
+ live here beside each other, consumed through ``Annotated`` field
13
+ aliases -- never as per-model parsing logic. The consumer set is not
14
+ enumerated here: it grows with every ported entity, so the list of
15
+ importers (``grep`` for ``bare_id_to_reference`` / ``GeotabTimeSpan``)
16
+ is the source of truth, not a snapshot that goes stale.
17
+
18
+ The nested-location trio (``GeotabAddressedLocation`` wrapping an
19
+ optional ``GeotabCoordinate`` and an optional ``GeotabPostalAddress``)
20
+ is the third shared shape, consumed on ``DutyStatusLog`` and ``DVIRLog``
21
+ -- two consumers at birth, so it lives here (the second-consumer
22
+ threshold) rather than per-model. The wrapper carries the DOUBLE-NESTED
23
+ ``{location: {x, y}}`` COORDINATE arm OR an ``{address:
24
+ {formattedAddress}}`` arm: the 2026-07-21 feed-wave-two census (nested
25
+ blocks sampled at 200) saw only the coordinate arm, but a 24,860-block
26
+ LIVE-PROOF walk (2026-07-21) found the wrapper carries the coordinate
27
+ arm on 24,846 blocks and the address arm on 14 (mutually exclusive at
28
+ that scale) -- the fourth time an at-scale walk found an arm a bounded
29
+ census missed (the ``StatusData.controller`` lesson). Both arms are
30
+ optional on the wrapper; a wrapper with neither is unobserved but
31
+ representable.
32
+
33
+ ``GeotabTimeSpan`` deliberately bakes nullability into the alias
34
+ (``Annotated[timedelta | None, ...]`` rather than
35
+ ``Annotated[timedelta, ...] | None``): Pydantic lifts ``Annotated``
36
+ metadata into ``FieldInfo`` only when it is the annotation's top level,
37
+ so this form is the one where the records field walk sees the bare
38
+ ``timedelta | None`` leaf it derives a ``Duration`` column from --
39
+ union-nesting the ``Annotated`` would hide the leaf inside metadata the
40
+ walk rejects. Every union-of-observed-fields model wants the
41
+ nullability anyway.
42
+ """
43
+
44
+ import re
45
+ from datetime import timedelta
46
+ from typing import Annotated, Final
47
+
48
+ from pydantic import BeforeValidator, Field
49
+
50
+ from fleetpull.model_contract import ResponseModel
51
+ from fleetpull.vocabulary import JsonValue
52
+
53
+ __all__: list[str] = [
54
+ 'GeotabAddressedLocation',
55
+ 'GeotabCoordinate',
56
+ 'GeotabPostalAddress',
57
+ 'GeotabTimeSpan',
58
+ 'bare_id_to_reference',
59
+ 'parse_timespan',
60
+ ]
61
+
62
+
63
+ class GeotabCoordinate(ResponseModel):
64
+ """The inner coordinate block of a nested GeoTab location.
65
+
66
+ GeoTab's ``x`` is LONGITUDE and ``y`` is LATITUDE (the provider's
67
+ map-plane convention, consistent with the shipped ``FillUp``
68
+ location); both arrive as floats (bare-int arms lift losslessly
69
+ under lax coercion). Required within the block: a coordinate block
70
+ without its coordinates is a shape change and must fail loudly.
71
+ """
72
+
73
+ x: float
74
+ y: float
75
+
76
+
77
+ class GeotabPostalAddress(ResponseModel):
78
+ """The inner address block of a nested GeoTab location.
79
+
80
+ The wrapper's address arm, observed only on ``DutyStatusLog`` in
81
+ the 24,860-block live-proof walk (14 blocks). Only
82
+ ``formattedAddress`` was observed on the block; other GeoTab
83
+ address keys (city, state, ...) are absorbed by ``extra='ignore'``
84
+ until a walk observes them. Required within the block on the same
85
+ loud-failure logic as the coordinates: a present address block
86
+ missing its one observed key is a shape change.
87
+ """
88
+
89
+ formatted_address: str = Field(alias='formattedAddress')
90
+
91
+
92
+ class GeotabAddressedLocation(ResponseModel):
93
+ """The nested GeoTab location wrapper: a coordinate arm or an address arm.
94
+
95
+ Carries the double-nested ``{location: {x, y}}`` coordinate block
96
+ (``location``) OR the ``{address: {formattedAddress}}`` block
97
+ (``address``) -- both optional, mutually exclusive at the observed
98
+ scale (module docstring: the live-proof walk found 24,846
99
+ coordinate arms and 14 address arms, none carrying both). The
100
+ consuming models (``DutyStatusLog``, ``DVIRLog``) carry the wrapper
101
+ itself as an optional field.
102
+ """
103
+
104
+ location: GeotabCoordinate | None = None
105
+ address: GeotabPostalAddress | None = None
106
+
107
+
108
+ # The .NET TimeSpan grammar: optional day prefix, exactly-two-digit
109
+ # fields, 1-7 fractional digits (100 ns ticks). Range checks (hh <= 23,
110
+ # mm/ss <= 59) are numeric, below -- a regex range would misread 29.
111
+ _TIMESPAN_PATTERN: Final[re.Pattern[str]] = re.compile(
112
+ r'^(?:(?P<days>\d+)\.)?'
113
+ r'(?P<hours>\d{2}):(?P<minutes>\d{2}):(?P<seconds>\d{2})'
114
+ r'(?:\.(?P<ticks>\d{1,7}))?$'
115
+ )
116
+
117
+ _MAX_HOURS: Final[int] = 23
118
+ _MAX_MINUTES: Final[int] = 59
119
+ _MAX_SECONDS: Final[int] = 59
120
+
121
+ # One microsecond is ten 100 ns ticks; a 7-digit fraction is a full
122
+ # tick count, shorter fractions right-pad to it.
123
+ _TICK_DIGITS: Final[int] = 7
124
+ _TICKS_PER_MICROSECOND: Final[int] = 10
125
+
126
+
127
+ def parse_timespan(value: str) -> timedelta:
128
+ """Parse a .NET TimeSpan string into a ``timedelta``.
129
+
130
+ Accepts exactly the grammar ``[d.]hh:mm:ss[.f{1,7}]`` -- a
131
+ non-negative day count, two-digit hours 00-23, two-digit minutes and
132
+ seconds 00-59, and 1-7 fractional digits of decimal seconds (100 ns
133
+ ticks, truncated to microseconds; every captured seventh digit is
134
+ zero, so no observed value loses precision). Anything else --
135
+ negative spans, malformed shapes, empty strings -- fails loudly: a
136
+ duration this parser has never seen should fail validation, not
137
+ pass mangled.
138
+
139
+ Args:
140
+ value: The wire TimeSpan string.
141
+
142
+ Returns:
143
+ The equivalent ``timedelta``.
144
+
145
+ Raises:
146
+ ValueError: ``value`` does not match the grammar or a field is
147
+ out of range; the message names the offending string.
148
+ """
149
+ match = _TIMESPAN_PATTERN.match(value)
150
+ if match is None:
151
+ raise ValueError(f'not a .NET TimeSpan string: {value!r}')
152
+ hours = int(match['hours'])
153
+ minutes = int(match['minutes'])
154
+ seconds = int(match['seconds'])
155
+ if hours > _MAX_HOURS or minutes > _MAX_MINUTES or seconds > _MAX_SECONDS:
156
+ raise ValueError(f'TimeSpan field out of range: {value!r}')
157
+ ticks_text = match['ticks']
158
+ microseconds = (
159
+ int(ticks_text.ljust(_TICK_DIGITS, '0')) // _TICKS_PER_MICROSECOND
160
+ if ticks_text is not None
161
+ else 0
162
+ )
163
+ return timedelta(
164
+ days=int(match['days'] or 0),
165
+ hours=hours,
166
+ minutes=minutes,
167
+ seconds=seconds,
168
+ microseconds=microseconds,
169
+ )
170
+
171
+
172
+ def _coerce_timespan(value: JsonValue | timedelta) -> timedelta | None:
173
+ """The ``GeotabTimeSpan`` ingress: parse strings, pass parsed values.
174
+
175
+ Args:
176
+ value: The raw wire value, or an already-validated value on a
177
+ Pydantic revalidation path.
178
+
179
+ Returns:
180
+ ``None`` for ``None`` (the alias is nullable), a ``timedelta``
181
+ passthrough (idempotent validation), or the parsed string.
182
+
183
+ Raises:
184
+ ValueError: A string that is not a TimeSpan, or any other type.
185
+ """
186
+ if value is None:
187
+ return None
188
+ if isinstance(value, timedelta):
189
+ return value
190
+ if isinstance(value, str):
191
+ return parse_timespan(value)
192
+ raise ValueError(f'expected a .NET TimeSpan string, got {type(value).__name__}')
193
+
194
+
195
+ # The duration field type every GeoTab model uses. Plain assignment, not
196
+ # a `type` statement: Pydantic must evaluate the Annotated form eagerly
197
+ # for the metadata lift the module docstring describes.
198
+ GeotabTimeSpan = Annotated[timedelta | None, BeforeValidator(_coerce_timespan)]
199
+
200
+
201
+ def bare_id_to_reference(value: JsonValue) -> JsonValue:
202
+ """Lift a bare reference-id string into the object form.
203
+
204
+ GeoTab reference fields carry either an object (``{"id": ...}``,
205
+ possibly with siblings) or a bare known-id sentinel string
206
+ (``"UnknownDriverId"``). This coercion is structural and
207
+ sentinel-agnostic: ANY bare string becomes ``{"id": <string>}``,
208
+ the string preserved verbatim, so the sentinel lands as the
209
+ reference's id and the object form passes through untouched.
210
+
211
+ Args:
212
+ value: The raw wire value of a reference field.
213
+
214
+ Returns:
215
+ ``{'id': value}`` for a bare string; ``value`` unchanged
216
+ otherwise (objects validate against the reference model, and
217
+ anything else fails there, loudly).
218
+ """
219
+ if isinstance(value, str):
220
+ return {'id': value}
221
+ return value