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,191 @@
1
+ # src/fleetpull/models/samsara/vehicle_fuel_energy_report.py
2
+ """Samsara VehicleFuelEnergyReport response model
3
+ (``GET /fleet/reports/vehicles/fuel-energy``, post-decoder
4
+ window-stamped grain).
5
+
6
+ Written from captured live responses (2026-07-20/21 probe session: a
7
+ 71/71 total census on the 1-day walk, structurally identical on the
8
+ 2-day 267-report walk), never from docs. The model mirrors the record
9
+ ``SamsaraWindowReportPageDecoder`` emits, and its two field families
10
+ have different provenance:
11
+
12
+ - ``window_start`` / ``window_end`` are DECODER-SYNTHESIZED
13
+ (``windowStartDate``/``windowEndDate``): report rows carry NO
14
+ event-time key of any kind, so the decoder stamps each row with the
15
+ window the SENT spec asked for, copied verbatim from its
16
+ ``startDate``/``endDate`` params. Contrast the stats triple's
17
+ synthesized identity keys, which are lifted from the RECORD's own
18
+ nested vehicle block -- these come from the request, because the
19
+ request window IS the row's time identity.
20
+ - Everything else is WIRE-VERBATIM: the metric core, the cost block,
21
+ and the ``vehicle`` ref, camelCase keys mirrored via aliases.
22
+
23
+ **NON-ADDITIVITY -- the rollup grain is the request window, proven
24
+ twice (2026-07-21).** Widening a 1-day window to 2 days GREW per-vehicle
25
+ metrics (36 of 47 vehicles shared between the 1-day walk and the
26
+ 2-day window's first page grew), and summing two adjacent day
27
+ rollups reproduced the two-day rollup on only 178 of 267 vehicles
28
+ (89/267 MISMATCHED across distance, engine run time, fuel, and energy).
29
+ Each row is the provider's answer for exactly its window, nothing else:
30
+ day rows MUST NOT be summed to reproduce a wider window's rollup. This
31
+ is why the binding declares ``fixed_unit_days=1`` -- the unit width is
32
+ part of the row's meaning and never floats with configuration.
33
+
34
+ Requiredness posture: the window stamps, the ``vehicle`` ref, and its
35
+ ``id`` are required STRUCTURALLY -- a rollup row without its window or
36
+ its entity is meaningless, so a future record omitting them must fail
37
+ loudly, never land an all-null row. The metric core (all eight metrics
38
+ plus ``estFuelEnergyCost``) is required on the WHOLE-WALK posture: the
39
+ census was total on every walked report (71/71 and, structurally, all
40
+ 267 of the 2-day walk), and a rollup surface that computes its metrics
41
+ per window has no absence mechanism to be conservative about -- a
42
+ missing metric would be a contract change worth a loud failure. The
43
+ ref's ``name``/``energyType``/``externalIds`` stay optional per the
44
+ conservative posture (one fleet's walk is not a whole-population oath).
45
+
46
+ ``efficiencyMpge``, ``estCarbonEmissionsKg``, and the cost ``amount``
47
+ are MIXED int|float on the wire -- modeled ``float``, lax coercion
48
+ lifting the int shape. ``energyType`` (observed only ``'fuel'``) and
49
+ ``currencyCode`` (observed only ``'USD'``, 100-report samples) are
50
+ census-open plain ``str``\\s, never enums. ``vehicle.externalIds``
51
+ carries the LITERAL DOTTED wire keys ``samsara.serial``/``samsara.vin``
52
+ (both str, 71/71) on the NESTED ref, mirrored via explicit aliases with
53
+ single-key independence -- the assignments precedent.
54
+
55
+ Wire keys are camelCase; fields are snake_case via the ``to_camel``
56
+ alias generator, except the decoder-synthesized window stamps and the
57
+ dotted external-id keys, which take explicit aliases.
58
+ """
59
+
60
+ from datetime import datetime
61
+
62
+ from pydantic import ConfigDict, Field
63
+ from pydantic.alias_generators import to_camel
64
+
65
+ from fleetpull.model_contract import ResponseModel
66
+
67
+ __all__: list[str] = [
68
+ 'VehicleFuelEnergyCost',
69
+ 'VehicleFuelEnergyExternalIds',
70
+ 'VehicleFuelEnergyReport',
71
+ 'VehicleFuelEnergyVehicleRef',
72
+ ]
73
+
74
+
75
+ class VehicleFuelEnergyCost(ResponseModel):
76
+ """The ``estFuelEnergyCost`` block: the window's estimated cost.
77
+
78
+ Both keys were 71/71 in census and required per the whole-walk
79
+ posture (module docstring).
80
+
81
+ Attributes:
82
+ amount: The monetary amount -- MIXED int|float on the wire,
83
+ modeled float.
84
+ currency_code: The currency code -- observed only ``'USD'`` on
85
+ a 100-report sample, census-open, so a plain ``str``.
86
+ """
87
+
88
+ model_config = ConfigDict(alias_generator=to_camel)
89
+
90
+ amount: float
91
+ currency_code: str
92
+
93
+
94
+ class VehicleFuelEnergyExternalIds(ResponseModel):
95
+ """The vehicle ref's ``externalIds`` block: namespaced external ids.
96
+
97
+ The wire keys are the LITERAL DOTTED ``samsara.serial`` and
98
+ ``samsara.vin`` (both str, 71/71 in census), mirrored via explicit
99
+ aliases on this NESTED object -- the assignments precedent. Each key
100
+ is independently optional (the conservative posture; the vehicles
101
+ surface proves ``externalIds`` variance exists in this fleet).
102
+
103
+ Attributes:
104
+ samsara_serial: The gateway serial (wire key ``samsara.serial``).
105
+ samsara_vin: The VIN (wire key ``samsara.vin``).
106
+ """
107
+
108
+ samsara_serial: str | None = Field(default=None, alias='samsara.serial')
109
+ samsara_vin: str | None = Field(default=None, alias='samsara.vin')
110
+
111
+
112
+ class VehicleFuelEnergyVehicleRef(ResponseModel):
113
+ """The ``vehicle`` block: the rollup's vehicle entity.
114
+
115
+ All four keys were 71/71 in census; only ``id`` is required by
116
+ structural judgment (a ref without an id references nothing), while
117
+ the rest stays optional per the conservative posture.
118
+
119
+ Attributes:
120
+ id: Samsara's vehicle id -- a string, mirrored as string.
121
+ name: The vehicle's display name.
122
+ energy_type: The vehicle's energy type -- observed only
123
+ ``'fuel'`` on a 100-report sample, census-open, so a plain
124
+ ``str``, never an enum.
125
+ external_ids: The dotted-key external-id block (wire key
126
+ ``externalIds``).
127
+ """
128
+
129
+ model_config = ConfigDict(alias_generator=to_camel)
130
+
131
+ id: str
132
+ name: str | None = None
133
+ energy_type: str | None = None
134
+ external_ids: VehicleFuelEnergyExternalIds | None = None
135
+
136
+
137
+ class VehicleFuelEnergyReport(ResponseModel):
138
+ """One vehicle's fuel-energy rollup over exactly one request window.
139
+
140
+ A pure mirror of the window-stamped post-decoder record (module
141
+ docstring: the window stamps are decoder-synthesized from the sent
142
+ spec; everything else is wire-verbatim). Field semantics and units
143
+ are Samsara's; no value is derived or interpreted here. Day rows
144
+ MUST NOT be summed to reproduce a wider window's rollup (89/267
145
+ mismatched -- module docstring).
146
+
147
+ Attributes:
148
+ window_start: The request window's start (decoder-synthesized
149
+ ``windowStartDate``, verbatim from the sent ``startDate``)
150
+ -- the event-time column: the row's time identity is the
151
+ window that produced it.
152
+ window_end: The request window's end (decoder-synthesized
153
+ ``windowEndDate``, verbatim from the sent ``endDate``).
154
+ vehicle: The rollup's vehicle entity.
155
+ distance_traveled_meters: Distance traveled in the window, in
156
+ meters, a bare int.
157
+ efficiency_mpge: Efficiency in MPGe -- MIXED int|float on the
158
+ wire, modeled float.
159
+ energy_used_kwh: Energy used in the window, in kWh, a bare int.
160
+ engine_idle_time_duration_ms: Engine idle time in the window,
161
+ in milliseconds, a bare int.
162
+ engine_run_time_duration_ms: Engine run time in the window, in
163
+ milliseconds, a bare int.
164
+ est_carbon_emissions_kg: Estimated carbon emissions in the
165
+ window, in kilograms -- MIXED int|float on the wire,
166
+ modeled float.
167
+ fuel_consumed_ml: Fuel consumed in the window, in milliliters,
168
+ a bare int.
169
+ est_fuel_energy_cost: The window's estimated fuel/energy cost
170
+ block.
171
+ """
172
+
173
+ model_config = ConfigDict(alias_generator=to_camel)
174
+
175
+ # Decoder-synthesized window identity (the sent spec's own window).
176
+ window_start: datetime = Field(alias='windowStartDate')
177
+ window_end: datetime = Field(alias='windowEndDate')
178
+
179
+ # The rollup's entity.
180
+ vehicle: VehicleFuelEnergyVehicleRef
181
+
182
+ # The wire-verbatim metric core (whole-walk required; provider
183
+ # units mirrored verbatim).
184
+ distance_traveled_meters: int
185
+ efficiency_mpge: float
186
+ energy_used_kwh: int
187
+ engine_idle_time_duration_ms: int
188
+ engine_run_time_duration_ms: int
189
+ est_carbon_emissions_kg: float
190
+ fuel_consumed_ml: int
191
+ est_fuel_energy_cost: VehicleFuelEnergyCost
@@ -0,0 +1,9 @@
1
+ """Transport layer: an organizational namespace.
2
+
3
+ Carries no aggregated surface of its own — the transport machinery lives
4
+ in its subpackages (``auth/``, ``contract/``, ``limits/``, ``retry/``,
5
+ ``tls/``), each with its own face. Callers import from those subpackages
6
+ directly.
7
+ """
8
+
9
+ __all__: list[str] = []
@@ -0,0 +1,15 @@
1
+ """GeoTab session lifecycle: single-flight authentication state."""
2
+
3
+ from fleetpull.network.auth.authenticate import build_geotab_authenticator
4
+ from fleetpull.network.auth.manager import GeotabSessionManager
5
+ from fleetpull.network.auth.models import AuthenticationResult, GeotabSession
6
+ from fleetpull.network.auth.strategies import GeotabSessionAuth, StaticHeaderAuth
7
+
8
+ __all__: list[str] = [
9
+ 'AuthenticationResult',
10
+ 'GeotabSession',
11
+ 'GeotabSessionAuth',
12
+ 'GeotabSessionManager',
13
+ 'StaticHeaderAuth',
14
+ 'build_geotab_authenticator',
15
+ ]
@@ -0,0 +1,271 @@
1
+ # src/fleetpull/network/auth/authenticate.py
2
+ """The real GeoTab ``Authenticate`` call: the manager's injectable.
3
+
4
+ A single-concern, single-shot, loop-free function behind a factory. The
5
+ session manager's injectable type is single-arg
6
+ (``Callable[[GeotabAuthConfig], AuthenticationResult]``), so the
7
+ transport dependencies — HTTP config, the limiter registry, the quota
8
+ scope — close over via a factory returning a named inner function.
9
+
10
+ This is the one module in ``network/auth/`` that imports httpx: it IS
11
+ the HTTP attempt the manager keeps at arm's length so the manager
12
+ itself stays pure state and choreography. Two actions only — fix
13
+ credentials (``AuthenticationError``) or fail loud
14
+ (``ProviderResponseError``); the classifier's vocabulary encodes the
15
+ CLIENT's dispatch and is deliberately not reused here. Every inbound
16
+ read flows through a private slice model; transport exceptions
17
+ propagate raw and untyped, because retry semantics for prepare-time
18
+ transport failures are the client's design question, not this
19
+ function's.
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ from collections.abc import Callable
25
+ from http import HTTPStatus
26
+ from typing import Final, NoReturn
27
+
28
+ import httpx
29
+ from pydantic import Field
30
+
31
+ from fleetpull.config import GeotabAuthConfig, HttpConfig
32
+ from fleetpull.exceptions import AuthenticationError, ProviderResponseError
33
+ from fleetpull.network.auth.models import AuthenticationResult
34
+ from fleetpull.network.contract import (
35
+ StrictEnvelopeSlice,
36
+ body_snippet,
37
+ validated_envelope_slice,
38
+ )
39
+ from fleetpull.network.limits import RateLimiterRegistry
40
+ from fleetpull.network.posture import new_http_client
41
+ from fleetpull.vocabulary import JsonValue
42
+
43
+ __all__: list[str] = ['build_geotab_authenticator']
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Wire-protocol tokens: Final constants, not an enum — nothing
48
+ # dispatches over these. Outbound body keys used in logic and the two
49
+ # inbound values we compare against; inbound envelope keys are consumed
50
+ # via the slice models' fields/aliases, never walked.
51
+ _APIV1_PATH: Final[str] = '/apiv1'
52
+ _METHOD_KEY: Final[str] = 'method'
53
+ _PARAMS_KEY: Final[str] = 'params'
54
+ _DATABASE_KEY: Final[str] = 'database'
55
+ _USER_NAME_KEY: Final[str] = 'userName'
56
+ _PASSWORD_KEY: Final[str] = 'password' # the JSON-RPC field name, not a secret
57
+ _AUTHENTICATE_METHOD: Final[str] = 'Authenticate'
58
+ _THIS_SERVER_PATH: Final[str] = 'ThisServer'
59
+ _INVALID_USER_TYPE: Final[str] = 'InvalidUserException'
60
+
61
+
62
+ class _AuthenticateCredentials(StrictEnvelopeSlice):
63
+ """The credentials block of a successful Authenticate result.
64
+
65
+ Only ``sessionId`` is consumed — ``database`` and ``userName`` are
66
+ carried from config by the manager, so they are deliberately ignored
67
+ here.
68
+ """
69
+
70
+ session_id: str = Field(alias='sessionId')
71
+
72
+
73
+ class _AuthenticateResult(StrictEnvelopeSlice):
74
+ """A successful Authenticate ``result``: credentials and the host path."""
75
+
76
+ credentials: _AuthenticateCredentials
77
+ path: str
78
+
79
+
80
+ class _AuthenticateErrorData(StrictEnvelopeSlice):
81
+ """The ``error.data`` block; ``type`` is the authoritative discriminator."""
82
+
83
+ type: str
84
+
85
+
86
+ class _AuthenticateError(StrictEnvelopeSlice):
87
+ """A failing Authenticate ``error`` envelope (inside HTTP 200)."""
88
+
89
+ message: str | None = None
90
+ data: _AuthenticateErrorData
91
+
92
+
93
+ class _AuthenticateEnvelope(StrictEnvelopeSlice):
94
+ """The JSON-RPC envelope slice: exactly one of error or result is meaningful."""
95
+
96
+ error: _AuthenticateError | None = None
97
+ result: _AuthenticateResult | None = None
98
+
99
+
100
+ def _build_authenticate_body(config: GeotabAuthConfig) -> dict[str, JsonValue]:
101
+ """
102
+ Build the JSON-RPC ``Authenticate`` body.
103
+
104
+ The ``SecretStr`` is extracted HERE and only here (the manager never
105
+ reads it), placed in the request body, and never logged.
106
+
107
+ Args:
108
+ config: Validated GeoTab authentication configuration.
109
+
110
+ Returns:
111
+ The JSON-RPC request body.
112
+ """
113
+ return {
114
+ _METHOD_KEY: _AUTHENTICATE_METHOD,
115
+ _PARAMS_KEY: {
116
+ _DATABASE_KEY: config.database,
117
+ _USER_NAME_KEY: config.username,
118
+ _PASSWORD_KEY: config.password.get_secret_value(),
119
+ },
120
+ }
121
+
122
+
123
+ def _raise_for_authenticate_error(error: _AuthenticateError) -> NoReturn:
124
+ """
125
+ Translate a GeoTab Authenticate error envelope into the right raise.
126
+
127
+ Args:
128
+ error: The validated error slice.
129
+
130
+ Raises:
131
+ AuthenticationError: For ``InvalidUserException`` — bad
132
+ credentials, the context-disambiguation principle (the same
133
+ type on a data call is a dead session, handled by the auth
134
+ strategy, not here).
135
+ ProviderResponseError: For any other error type — fail loud on
136
+ behavior never met on Authenticate. Trigger:
137
+ ``OverLimitException`` observed here despite the local
138
+ limiter would mean revisit.
139
+ """
140
+ if error.data.type == _INVALID_USER_TYPE:
141
+ raise AuthenticationError(detail=error.message)
142
+ detail: str = f'unexpected Authenticate error type: {error.data.type!r}'
143
+ if error.message is not None:
144
+ detail = f'{detail} ({error.message})'
145
+ raise ProviderResponseError(detail=detail)
146
+
147
+
148
+ def _resolve_server(result_path: str, config: GeotabAuthConfig) -> str:
149
+ """
150
+ Resolve the session's server from the Authenticate result path.
151
+
152
+ Args:
153
+ result_path: The ``result.path`` value from the envelope.
154
+ config: The configuration whose ``server`` was actually called.
155
+
156
+ Returns:
157
+ ``config.server`` when ``path`` is the ``ThisServer`` sentinel;
158
+ otherwise the returned host.
159
+ """
160
+ if result_path == _THIS_SERVER_PATH:
161
+ return config.server
162
+ # Redirects are handled-not-assumed: no capture shows one, but the
163
+ # protocol documents path as a possible alternate host.
164
+ logger.info('GeoTab Authenticate redirected to host %s', result_path)
165
+ return result_path
166
+
167
+
168
+ def _resolve_authenticate_outcome(
169
+ response: httpx.Response, config: GeotabAuthConfig
170
+ ) -> AuthenticationResult:
171
+ """
172
+ Turn one Authenticate HTTP response into a result or the right raise.
173
+
174
+ Args:
175
+ response: The completed Authenticate response.
176
+ config: The configuration that produced the request.
177
+
178
+ Returns:
179
+ The authentication result on success.
180
+
181
+ Raises:
182
+ AuthenticationError: On ``InvalidUserException`` (bad credentials).
183
+ ProviderResponseError: On a non-200 status, a non-JSON body, a
184
+ malformed envelope, or any unexpected error type.
185
+ """
186
+ if response.status_code != HTTPStatus.OK:
187
+ # v1 posture: Authenticate outcomes arrive in HTTP 200 per
188
+ # verification; anything else is the API not speaking its
189
+ # protocol. Loud-and-typed beats a retry loop against a 10/min
190
+ # auth quota. Re-litigate on the first observed Authenticate 5xx.
191
+ raise ProviderResponseError(
192
+ status_code=response.status_code, detail=body_snippet(response.text)
193
+ )
194
+ try:
195
+ parsed_body: JsonValue = json.loads(response.text)
196
+ except json.JSONDecodeError as error:
197
+ raise ProviderResponseError(
198
+ detail=f'unparseable (non-JSON) Authenticate body: '
199
+ f'{body_snippet(response.text)}'
200
+ ) from error
201
+
202
+ envelope = validated_envelope_slice(_AuthenticateEnvelope, parsed_body)
203
+ if envelope.error is not None:
204
+ _raise_for_authenticate_error(envelope.error)
205
+ if envelope.result is not None:
206
+ return AuthenticationResult(
207
+ session_id=envelope.result.credentials.session_id,
208
+ resolved_host=_resolve_server(envelope.result.path, config),
209
+ )
210
+ raise ProviderResponseError(
211
+ detail='malformed Authenticate envelope: neither result nor error present'
212
+ )
213
+
214
+
215
+ def build_geotab_authenticator(
216
+ http_config: HttpConfig,
217
+ limiter_registry: RateLimiterRegistry,
218
+ quota_scope: str,
219
+ ) -> Callable[[GeotabAuthConfig], AuthenticationResult]:
220
+ """
221
+ Build the real ``authenticate_fn`` the session manager consumes.
222
+
223
+ The quota scope arrives as a parameter — the composition root names
224
+ it — preserving the names-at-composition-root rule even inside
225
+ GeoTab-specific machinery. Authenticate is rate-limited at a fixed
226
+ 10/min outside tiering; the composition root configures a dedicated
227
+ scope in the registry, and an unconfigured scope propagates the
228
+ registry's ``UnknownQuotaScopeError`` naturally.
229
+
230
+ Args:
231
+ http_config: Timeouts and TLS posture for the call.
232
+ limiter_registry: The shared registry; the call takes a slot
233
+ under ``quota_scope``.
234
+ quota_scope: The dedicated Authenticate quota scope.
235
+
236
+ Returns:
237
+ A single-arg callable matching the manager's injectable type.
238
+ """
239
+
240
+ def authenticate(config: GeotabAuthConfig) -> AuthenticationResult:
241
+ """
242
+ Perform one ``Authenticate`` call and resolve its outcome.
243
+
244
+ Args:
245
+ config: Validated GeoTab authentication configuration.
246
+
247
+ Returns:
248
+ The authentication result on success.
249
+
250
+ Raises:
251
+ AuthenticationError: On bad credentials.
252
+ ProviderResponseError: On a non-200 status, a non-JSON body,
253
+ or a malformed/unexpected envelope.
254
+ UnknownQuotaScopeError: When ``quota_scope`` is unconfigured.
255
+ httpx.TransportError: On a transport failure — propagated raw
256
+ and loop-free; retry is the client's design question.
257
+ """
258
+ limiter = limiter_registry.get(quota_scope)
259
+ url: str = f'https://{config.server}{_APIV1_PATH}'
260
+ request_body: dict[str, JsonValue] = _build_authenticate_body(config)
261
+ # A fresh, context-managed client per call: Authenticate fires
262
+ # rarely behind the manager's single-flight, so connection reuse
263
+ # buys nothing worth a held resource.
264
+ with (
265
+ limiter.request_slot(),
266
+ new_http_client(http_config) as client,
267
+ ):
268
+ response = client.post(url, json=request_body)
269
+ return _resolve_authenticate_outcome(response, config)
270
+
271
+ return authenticate