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,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
|