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.
- fleetpull/__init__.py +36 -0
- fleetpull/api/__init__.py +32 -0
- fleetpull/api/auth_ingress.py +216 -0
- fleetpull/api/catalog.py +139 -0
- fleetpull/api/fetch.py +229 -0
- fleetpull/api/identity.py +90 -0
- fleetpull/api/sync.py +735 -0
- fleetpull/cli.py +146 -0
- fleetpull/config/__init__.py +47 -0
- fleetpull/config/base.py +18 -0
- fleetpull/config/example.py +90 -0
- fleetpull/config/geotab.py +78 -0
- fleetpull/config/http.py +32 -0
- fleetpull/config/loading.py +195 -0
- fleetpull/config/logger.py +139 -0
- fleetpull/config/providers.py +520 -0
- fleetpull/config/rate_limit.py +50 -0
- fleetpull/config/resolution.py +156 -0
- fleetpull/config/retry.py +69 -0
- fleetpull/config/root.py +157 -0
- fleetpull/config/sections.py +146 -0
- fleetpull/endpoints/__init__.py +24 -0
- fleetpull/endpoints/geotab/__init__.py +12 -0
- fleetpull/endpoints/geotab/_requests.py +427 -0
- fleetpull/endpoints/geotab/annotation_logs.py +73 -0
- fleetpull/endpoints/geotab/audits.py +70 -0
- fleetpull/endpoints/geotab/devices.py +79 -0
- fleetpull/endpoints/geotab/driver_changes.py +73 -0
- fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
- fleetpull/endpoints/geotab/dvir_logs.py +76 -0
- fleetpull/endpoints/geotab/exception_events.py +119 -0
- fleetpull/endpoints/geotab/fault_data.py +72 -0
- fleetpull/endpoints/geotab/fill_ups.py +79 -0
- fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
- fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
- fleetpull/endpoints/geotab/log_records.py +74 -0
- fleetpull/endpoints/geotab/media_files.py +74 -0
- fleetpull/endpoints/geotab/shipment_logs.py +71 -0
- fleetpull/endpoints/geotab/status_data.py +71 -0
- fleetpull/endpoints/geotab/text_messages.py +77 -0
- fleetpull/endpoints/geotab/trips.py +102 -0
- fleetpull/endpoints/geotab/users.py +81 -0
- fleetpull/endpoints/motive/__init__.py +12 -0
- fleetpull/endpoints/motive/_spec_builders.py +94 -0
- fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
- fleetpull/endpoints/motive/driving_periods.py +85 -0
- fleetpull/endpoints/motive/groups.py +61 -0
- fleetpull/endpoints/motive/idle_events.py +94 -0
- fleetpull/endpoints/motive/users.py +64 -0
- fleetpull/endpoints/motive/vehicle_locations.py +170 -0
- fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
- fleetpull/endpoints/motive/vehicles.py +106 -0
- fleetpull/endpoints/registry.py +280 -0
- fleetpull/endpoints/samsara/__init__.py +12 -0
- fleetpull/endpoints/samsara/_spec_builders.py +202 -0
- fleetpull/endpoints/samsara/addresses.py +77 -0
- fleetpull/endpoints/samsara/asset_locations.py +217 -0
- fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
- fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
- fleetpull/endpoints/samsara/drivers.py +169 -0
- fleetpull/endpoints/samsara/engine_states.py +114 -0
- fleetpull/endpoints/samsara/gps_readings.py +113 -0
- fleetpull/endpoints/samsara/idling_events.py +195 -0
- fleetpull/endpoints/samsara/odometer_readings.py +116 -0
- fleetpull/endpoints/samsara/trips.py +217 -0
- fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
- fleetpull/endpoints/samsara/vehicles.py +121 -0
- fleetpull/endpoints/shared/__init__.py +57 -0
- fleetpull/endpoints/shared/base.py +529 -0
- fleetpull/endpoints/shared/request_shape.py +227 -0
- fleetpull/endpoints/shared/resume.py +74 -0
- fleetpull/endpoints/shared/spec_builders.py +59 -0
- fleetpull/endpoints/shared/sync_mode.py +159 -0
- fleetpull/endpoints/shared/url_paths.py +149 -0
- fleetpull/exceptions.py +320 -0
- fleetpull/incremental/__init__.py +27 -0
- fleetpull/incremental/cursor.py +66 -0
- fleetpull/incremental/resolution.py +152 -0
- fleetpull/incremental/seed.py +53 -0
- fleetpull/incremental/window.py +94 -0
- fleetpull/logger/__init__.py +5 -0
- fleetpull/logger/setup.py +145 -0
- fleetpull/model_contract/__init__.py +6 -0
- fleetpull/model_contract/coercions.py +33 -0
- fleetpull/model_contract/response.py +45 -0
- fleetpull/models/__init__.py +7 -0
- fleetpull/models/geotab/__init__.py +166 -0
- fleetpull/models/geotab/annotation_log.py +109 -0
- fleetpull/models/geotab/audit.py +56 -0
- fleetpull/models/geotab/device.py +217 -0
- fleetpull/models/geotab/driver_change.py +102 -0
- fleetpull/models/geotab/duty_status_log.py +200 -0
- fleetpull/models/geotab/dvir_log.py +184 -0
- fleetpull/models/geotab/exception_event.py +135 -0
- fleetpull/models/geotab/fault_data.py +168 -0
- fleetpull/models/geotab/fill_up.py +189 -0
- fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
- fleetpull/models/geotab/fuel_tax_detail.py +150 -0
- fleetpull/models/geotab/log_record.py +68 -0
- fleetpull/models/geotab/media_file.py +133 -0
- fleetpull/models/geotab/shared.py +221 -0
- fleetpull/models/geotab/shipment_log.py +105 -0
- fleetpull/models/geotab/status_data.py +108 -0
- fleetpull/models/geotab/text_message.py +125 -0
- fleetpull/models/geotab/trip.py +162 -0
- fleetpull/models/geotab/user.py +187 -0
- fleetpull/models/motive/__init__.py +43 -0
- fleetpull/models/motive/driver_idle_rollup.py +109 -0
- fleetpull/models/motive/driving_period.py +82 -0
- fleetpull/models/motive/group.py +49 -0
- fleetpull/models/motive/idle_event.py +77 -0
- fleetpull/models/motive/shared.py +192 -0
- fleetpull/models/motive/user.py +217 -0
- fleetpull/models/motive/vehicle.py +162 -0
- fleetpull/models/motive/vehicle_location.py +127 -0
- fleetpull/models/motive/vehicle_utilization.py +128 -0
- fleetpull/models/samsara/__init__.py +108 -0
- fleetpull/models/samsara/address.py +149 -0
- fleetpull/models/samsara/asset_location.py +131 -0
- fleetpull/models/samsara/driver.py +232 -0
- fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
- fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
- fleetpull/models/samsara/engine_state.py +92 -0
- fleetpull/models/samsara/gps_reading.py +146 -0
- fleetpull/models/samsara/idling_event.py +174 -0
- fleetpull/models/samsara/odometer_reading.py +90 -0
- fleetpull/models/samsara/trip.py +199 -0
- fleetpull/models/samsara/vehicle.py +167 -0
- fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
- fleetpull/network/__init__.py +9 -0
- fleetpull/network/auth/__init__.py +15 -0
- fleetpull/network/auth/authenticate.py +271 -0
- fleetpull/network/auth/manager.py +223 -0
- fleetpull/network/auth/models.py +57 -0
- fleetpull/network/auth/strategies.py +169 -0
- fleetpull/network/classifiers/__init__.py +11 -0
- fleetpull/network/classifiers/geotab.py +145 -0
- fleetpull/network/classifiers/motive.py +79 -0
- fleetpull/network/classifiers/samsara.py +80 -0
- fleetpull/network/client/__init__.py +25 -0
- fleetpull/network/client/page.py +32 -0
- fleetpull/network/client/profile.py +27 -0
- fleetpull/network/client/registry.py +103 -0
- fleetpull/network/client/registry_base.py +150 -0
- fleetpull/network/client/runtime.py +44 -0
- fleetpull/network/client/transport.py +381 -0
- fleetpull/network/contract/__init__.py +50 -0
- fleetpull/network/contract/auth.py +48 -0
- fleetpull/network/contract/classifier.py +189 -0
- fleetpull/network/contract/envelope_fetcher.py +33 -0
- fleetpull/network/contract/envelopes.py +189 -0
- fleetpull/network/contract/outcome.py +43 -0
- fleetpull/network/contract/page_decoder.py +101 -0
- fleetpull/network/contract/request.py +97 -0
- fleetpull/network/decoders/__init__.py +40 -0
- fleetpull/network/decoders/_window_stamp.py +78 -0
- fleetpull/network/decoders/geotab.py +309 -0
- fleetpull/network/decoders/motive.py +204 -0
- fleetpull/network/decoders/motive_reports.py +114 -0
- fleetpull/network/decoders/samsara.py +343 -0
- fleetpull/network/decoders/samsara_reports.py +119 -0
- fleetpull/network/decoders/single_page.py +55 -0
- fleetpull/network/limits/__init__.py +13 -0
- fleetpull/network/limits/bucket_math.py +58 -0
- fleetpull/network/limits/limiter.py +153 -0
- fleetpull/network/limits/registry.py +96 -0
- fleetpull/network/posture/__init__.py +6 -0
- fleetpull/network/posture/client_options.py +96 -0
- fleetpull/network/retry/__init__.py +13 -0
- fleetpull/network/retry/decision.py +155 -0
- fleetpull/network/tls/__init__.py +5 -0
- fleetpull/network/tls/truststore_context.py +35 -0
- fleetpull/orchestrator/__init__.py +30 -0
- fleetpull/orchestrator/backfill.py +127 -0
- fleetpull/orchestrator/batch.py +155 -0
- fleetpull/orchestrator/bisection.py +271 -0
- fleetpull/orchestrator/drivers.py +350 -0
- fleetpull/orchestrator/entry.py +347 -0
- fleetpull/orchestrator/executors.py +128 -0
- fleetpull/orchestrator/fanout.py +163 -0
- fleetpull/orchestrator/feed_drive.py +266 -0
- fleetpull/orchestrator/metadata_projection.py +195 -0
- fleetpull/orchestrator/outcome.py +53 -0
- fleetpull/orchestrator/recording.py +85 -0
- fleetpull/orchestrator/resume.py +143 -0
- fleetpull/orchestrator/roster_harvest.py +108 -0
- fleetpull/orchestrator/roster_refresh.py +363 -0
- fleetpull/orchestrator/runner.py +262 -0
- fleetpull/orchestrator/shape_resolution.py +221 -0
- fleetpull/orchestrator/spine.py +166 -0
- fleetpull/orchestrator/streaming.py +153 -0
- fleetpull/orchestrator/unit_loop.py +273 -0
- fleetpull/orchestrator/watermark_drive.py +455 -0
- fleetpull/paths/__init__.py +13 -0
- fleetpull/paths/datasets.py +38 -0
- fleetpull/paths/partitions.py +41 -0
- fleetpull/paths/resolution.py +98 -0
- fleetpull/polars_typing/__init__.py +20 -0
- fleetpull/py.typed +0 -0
- fleetpull/records/__init__.py +19 -0
- fleetpull/records/convert.py +53 -0
- fleetpull/records/dataframe.py +63 -0
- fleetpull/records/event_time.py +70 -0
- fleetpull/records/fields.py +190 -0
- fleetpull/records/flatten.py +54 -0
- fleetpull/records/roster_members.py +74 -0
- fleetpull/records/schema.py +73 -0
- fleetpull/records/validation.py +62 -0
- fleetpull/resources/__init__.py +10 -0
- fleetpull/resources/config.example.yaml +222 -0
- fleetpull/roster/__init__.py +16 -0
- fleetpull/roster/definition.py +46 -0
- fleetpull/roster/key.py +40 -0
- fleetpull/roster/registry.py +93 -0
- fleetpull/state/__init__.py +36 -0
- fleetpull/state/cursors.py +417 -0
- fleetpull/state/database.py +463 -0
- fleetpull/state/migrations.py +430 -0
- fleetpull/state/reconcile.py +127 -0
- fleetpull/state/rosters.py +159 -0
- fleetpull/state/run_ledger.py +572 -0
- fleetpull/state/work_units.py +648 -0
- fleetpull/storage/__init__.py +30 -0
- fleetpull/storage/append.py +187 -0
- fleetpull/storage/atomic.py +161 -0
- fleetpull/storage/files.py +176 -0
- fleetpull/storage/frames.py +98 -0
- fleetpull/storage/metadata.py +161 -0
- fleetpull/storage/partitioned.py +205 -0
- fleetpull/storage/pruning.py +169 -0
- fleetpull/storage/read.py +35 -0
- fleetpull/storage/result.py +34 -0
- fleetpull/storage/single_file.py +130 -0
- fleetpull/storage/splitting.py +88 -0
- fleetpull/storage/staging.py +178 -0
- fleetpull/storage/writers.py +145 -0
- fleetpull/timing/__init__.py +21 -0
- fleetpull/timing/canon.py +92 -0
- fleetpull/timing/clock.py +192 -0
- fleetpull/timing/codec.py +122 -0
- fleetpull/timing/sleeper.py +65 -0
- fleetpull/vocabulary/__init__.py +16 -0
- fleetpull/vocabulary/json_types.py +20 -0
- fleetpull/vocabulary/provider.py +29 -0
- fleetpull/vocabulary/quota_scope.py +47 -0
- fleetpull/vocabulary/response_category.py +32 -0
- fleetpull-0.1.0.dist-info/METADATA +248 -0
- fleetpull-0.1.0.dist-info/RECORD +252 -0
- fleetpull-0.1.0.dist-info/WHEEL +5 -0
- fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
- fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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
|