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,33 @@
|
|
|
1
|
+
# src/fleetpull/network/contract/envelope_fetcher.py
|
|
2
|
+
"""The single-request fetch surface: one spec in, one parsed envelope out.
|
|
3
|
+
|
|
4
|
+
The contract-layer face of ``TransportClient.fetch_envelope`` — the
|
|
5
|
+
non-paging single request that still rides the whole per-attempt pipeline
|
|
6
|
+
(auth prepare, one limiter token per attempt, retry, classification).
|
|
7
|
+
Declared here so declaration-layer consumers (the ``CompletenessCheck``
|
|
8
|
+
protocol's implementations foremost) can type against the surface without
|
|
9
|
+
importing the client package; ``TransportClient`` satisfies it structurally.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from fleetpull.network.contract.request import RequestSpec
|
|
15
|
+
from fleetpull.vocabulary import JsonValue
|
|
16
|
+
|
|
17
|
+
__all__: list[str] = ['EnvelopeFetcher']
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EnvelopeFetcher(Protocol):
|
|
21
|
+
"""The one-method single-request surface (``TransportClient``'s shape)."""
|
|
22
|
+
|
|
23
|
+
def fetch_envelope(self, spec: RequestSpec, quota_scope: str) -> JsonValue:
|
|
24
|
+
"""Execute one request outside any page loop; return its envelope.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
spec: The credential-less request to execute.
|
|
28
|
+
quota_scope: The rate-limit scope key the attempt spends from.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The parsed success envelope; interpretation is the caller's.
|
|
32
|
+
"""
|
|
33
|
+
...
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# src/fleetpull/network/contract/envelopes.py
|
|
2
|
+
"""Validate-or-raise for provider response envelopes.
|
|
3
|
+
|
|
4
|
+
Relocated here from ``page_decoder.py`` at its second consumer (the
|
|
5
|
+
GeoTab authenticator): the composition — ``model_validate`` and, on
|
|
6
|
+
failure, raise ``ProviderResponseError`` carrying Pydantic's complaint
|
|
7
|
+
— is contract-layer semantics, not page-decoding semantics, so it is
|
|
8
|
+
named and homed neutrally where both consumers can reach it without a
|
|
9
|
+
name lie.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
15
|
+
|
|
16
|
+
from fleetpull.exceptions import ProviderResponseError
|
|
17
|
+
from fleetpull.vocabulary import JsonObject, JsonValue
|
|
18
|
+
|
|
19
|
+
__all__: list[str] = [
|
|
20
|
+
'StrictEnvelopeSlice',
|
|
21
|
+
'require_child_object',
|
|
22
|
+
'require_record_list',
|
|
23
|
+
'unwrap_record_objects',
|
|
24
|
+
'validated_envelope_slice',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StrictEnvelopeSlice(BaseModel):
|
|
29
|
+
"""The base every private envelope-slice model subclasses.
|
|
30
|
+
|
|
31
|
+
The one home of the slice-model policy, so its rationale is stated once:
|
|
32
|
+
``strict=True`` refuses type drift on the values the consumer acts on
|
|
33
|
+
(providers elsewhere stringify numerics, and a boolean must never pass as
|
|
34
|
+
an integer); ``extra='ignore'`` tolerates the envelope fields a slice
|
|
35
|
+
does not name (a slice reads its few load-bearing keys, never the whole
|
|
36
|
+
envelope); ``frozen=True`` keeps a validated slice immutable, like every
|
|
37
|
+
internal carrier.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(frozen=True, extra='ignore', strict=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validated_envelope_slice[ModelT: BaseModel](
|
|
44
|
+
model_type: type[ModelT], envelope: JsonValue
|
|
45
|
+
) -> ModelT:
|
|
46
|
+
"""
|
|
47
|
+
Validate a response envelope against a private slice model,
|
|
48
|
+
translating failure into the contract's single-action raise.
|
|
49
|
+
|
|
50
|
+
Shared rather than reimplemented per consumer because the
|
|
51
|
+
composition — validate, and on failure raise
|
|
52
|
+
``ProviderResponseError`` carrying Pydantic's complaint — is layer
|
|
53
|
+
semantics, not provider behavior.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
model_type: The consumer's private envelope-slice model.
|
|
57
|
+
envelope: The parsed response body (any shape; a non-dict
|
|
58
|
+
fails validation like any other malformation).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The validated slice.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ProviderResponseError: When the envelope does not satisfy the
|
|
65
|
+
slice model.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
return model_type.model_validate(envelope)
|
|
69
|
+
except ValidationError as error:
|
|
70
|
+
raise ProviderResponseError(
|
|
71
|
+
detail=f'malformed response envelope: {error}'
|
|
72
|
+
) from error
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _require_json_object(value: JsonValue) -> JsonObject:
|
|
76
|
+
"""Return ``value`` narrowed to a JSON object, or raise.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
value: The parsed value under inspection.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The value as a JSON object.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ProviderResponseError: When the value is not a JSON object.
|
|
86
|
+
"""
|
|
87
|
+
if not isinstance(value, dict):
|
|
88
|
+
raise ProviderResponseError(detail='response envelope is not a JSON object')
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def require_child_object(envelope: JsonValue, key: str) -> JsonObject:
|
|
93
|
+
"""Return the JSON object at a top-level envelope key.
|
|
94
|
+
|
|
95
|
+
The nested-container step for envelopes whose record list sits one
|
|
96
|
+
level down: validates, in order, that the envelope is a JSON object,
|
|
97
|
+
``key`` is present, and its value is itself a JSON object.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
envelope: The parsed response body.
|
|
101
|
+
key: The top-level key whose value is the container object.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The JSON object at ``key``.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ProviderResponseError: When the envelope is not an object, ``key``
|
|
108
|
+
is absent, or its value is not a JSON object — each with a
|
|
109
|
+
message naming the failure.
|
|
110
|
+
"""
|
|
111
|
+
response = _require_json_object(envelope)
|
|
112
|
+
if key not in response:
|
|
113
|
+
raise ProviderResponseError(
|
|
114
|
+
detail=f'response envelope is missing the record key {key!r}'
|
|
115
|
+
)
|
|
116
|
+
child = response[key]
|
|
117
|
+
if not isinstance(child, dict):
|
|
118
|
+
raise ProviderResponseError(detail=f'record key {key!r} is not a JSON object')
|
|
119
|
+
return child
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def require_record_list(envelope: JsonValue, key: str) -> list[JsonObject]:
|
|
123
|
+
"""Return the record list at a top-level envelope key.
|
|
124
|
+
|
|
125
|
+
Validates, in order: the envelope is a JSON object; ``key`` is
|
|
126
|
+
present; its value is a list; every element is a JSON object.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
envelope: The parsed response body.
|
|
130
|
+
key: The top-level key whose value is the record list.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The record list at ``key``, each element a JSON object.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ProviderResponseError: When the envelope is not an object,
|
|
137
|
+
``key`` is absent, its value is not a list, or an element is
|
|
138
|
+
not a JSON object — each with a message naming the failure.
|
|
139
|
+
"""
|
|
140
|
+
response = _require_json_object(envelope)
|
|
141
|
+
if key not in response:
|
|
142
|
+
raise ProviderResponseError(
|
|
143
|
+
detail=f'response envelope is missing the record key {key!r}'
|
|
144
|
+
)
|
|
145
|
+
records = response[key]
|
|
146
|
+
if not isinstance(records, list):
|
|
147
|
+
raise ProviderResponseError(detail=f'record key {key!r} is not a list')
|
|
148
|
+
for record in records:
|
|
149
|
+
if not isinstance(record, dict):
|
|
150
|
+
raise ProviderResponseError(
|
|
151
|
+
detail=f'record under {key!r} is not a JSON object'
|
|
152
|
+
)
|
|
153
|
+
return cast(list[JsonObject], records)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def unwrap_record_objects(
|
|
157
|
+
wrappers: list[JsonObject], item_key: str
|
|
158
|
+
) -> list[JsonObject]:
|
|
159
|
+
"""Lift the inner object out of each single-key record wrapper.
|
|
160
|
+
|
|
161
|
+
Motive returns each record wrapped — ``{"vehicle": {...}}`` inside
|
|
162
|
+
the ``vehicles`` list; this lifts the inner object out of every
|
|
163
|
+
wrapper. Each wrapper must carry ``item_key`` and its value must be
|
|
164
|
+
a JSON object.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
wrappers: The wrapper objects (already validated as objects).
|
|
168
|
+
item_key: The key inside each wrapper holding the record.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The unwrapped record objects.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
ProviderResponseError: When a wrapper lacks ``item_key`` or its
|
|
175
|
+
value is not a JSON object.
|
|
176
|
+
"""
|
|
177
|
+
records: list[JsonObject] = []
|
|
178
|
+
for wrapper in wrappers:
|
|
179
|
+
if item_key not in wrapper:
|
|
180
|
+
raise ProviderResponseError(
|
|
181
|
+
detail=f'record wrapper is missing the item key {item_key!r}'
|
|
182
|
+
)
|
|
183
|
+
inner = wrapper[item_key]
|
|
184
|
+
if not isinstance(inner, dict):
|
|
185
|
+
raise ProviderResponseError(
|
|
186
|
+
detail=f'record under {item_key!r} is not a JSON object'
|
|
187
|
+
)
|
|
188
|
+
records.append(inner)
|
|
189
|
+
return records
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# src/fleetpull/network/contract/outcome.py
|
|
2
|
+
"""The carrier for a classifier's verdict on one response.
|
|
3
|
+
|
|
4
|
+
Produced exclusively by ``ResponseClassifier`` implementations;
|
|
5
|
+
consumed by the client, which dispatches on ``category`` (the
|
|
6
|
+
``ResponseCategory`` vocabulary lives in ``fleetpull.vocabulary``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from fleetpull.vocabulary import JsonValue, ResponseCategory
|
|
12
|
+
|
|
13
|
+
__all__: list[str] = ['ClassifiedResponse']
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ClassifiedResponse:
|
|
18
|
+
"""
|
|
19
|
+
A classifier's verdict on one response or transport failure.
|
|
20
|
+
|
|
21
|
+
Fields are inert outside their category (the established
|
|
22
|
+
required-with-default-inert pattern).
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
category: What the client does next.
|
|
26
|
+
retry_after_seconds: Meaningful only for RATE_LIMITED; None
|
|
27
|
+
means the provider sent no usable hint and the client
|
|
28
|
+
applies its fallback penalty.
|
|
29
|
+
detail: Human-readable context for failure paths. Decisions
|
|
30
|
+
never read it.
|
|
31
|
+
parsed_body: The parsed equivalent of the response's body text
|
|
32
|
+
— the whole document, not extracted records. Populated only
|
|
33
|
+
when classification itself required parsing the body; None
|
|
34
|
+
on SUCCESS means the classifier did not parse and the
|
|
35
|
+
client must. Inert outside SUCCESS. Excluded from ``repr``
|
|
36
|
+
because it can hold a multi-megabyte structure that no log
|
|
37
|
+
line may embed.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
category: ResponseCategory
|
|
41
|
+
retry_after_seconds: float | None = None
|
|
42
|
+
detail: str | None = None
|
|
43
|
+
parsed_body: JsonValue | None = field(default=None, repr=False)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# src/fleetpull/network/contract/page_decoder.py
|
|
2
|
+
"""The page-decoder contract: interpret a response envelope once,
|
|
3
|
+
yielding its records and its pagination verdict together.
|
|
4
|
+
|
|
5
|
+
Supersedes the split ``PaginationStrategy`` + ``RecordExtractor``: a
|
|
6
|
+
provider's envelope is parsed in exactly one place, so the records and
|
|
7
|
+
the continuation cursor are read from a single validated view rather
|
|
8
|
+
than re-interpreted downstream. Implementations live in the sibling
|
|
9
|
+
``network/decoders/`` package and reach this surface through the
|
|
10
|
+
contract face; they deliberately share no concrete behavior
|
|
11
|
+
(blast-radius over DRY).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from fleetpull.network.contract.request import RequestSpec
|
|
18
|
+
from fleetpull.vocabulary import JsonObject, JsonValue
|
|
19
|
+
|
|
20
|
+
__all__: list[str] = ['DecodedPage', 'PageAdvance', 'PageDecoder']
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class PageAdvance:
|
|
25
|
+
"""
|
|
26
|
+
A decoder's verdict on one completed page.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
next_spec: The request for the next page, or None when
|
|
30
|
+
pagination is complete.
|
|
31
|
+
durable_progress: Cursor progress that must outlive the fetch
|
|
32
|
+
(GeoTab's ``toVersion``; the state layer's FeedToken commit
|
|
33
|
+
value). None for providers whose cursors are fetch-private
|
|
34
|
+
(the established inert pattern). Carried on EVERY page
|
|
35
|
+
including the terminal one — the terminal page's value is
|
|
36
|
+
the resume point.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
next_spec: RequestSpec | None
|
|
40
|
+
durable_progress: str | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class DecodedPage:
|
|
45
|
+
"""One decoded page: its records and its pagination verdict.
|
|
46
|
+
|
|
47
|
+
Composes ``PageAdvance`` rather than re-declaring ``next_spec`` /
|
|
48
|
+
``durable_progress`` — the meaning of "next transient request plus
|
|
49
|
+
durable cursor progress" stays defined in exactly one place.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
records: The page's records, each a JSON object.
|
|
53
|
+
advance: The pagination verdict (continue/complete plus durable
|
|
54
|
+
progress) for the page just decoded.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
records: list[JsonObject]
|
|
58
|
+
advance: PageAdvance
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PageDecoder(Protocol):
|
|
62
|
+
"""Per-provider envelope interpreter: records and pagination in one pass.
|
|
63
|
+
|
|
64
|
+
A plain Protocol (no ``@runtime_checkable``) — conformance is a
|
|
65
|
+
static obligation checked by mypy at the binding site, not an
|
|
66
|
+
isinstance gate.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
70
|
+
"""Decorate the base spec for page one.
|
|
71
|
+
|
|
72
|
+
Absorbs the former ``PaginationStrategy.first_request`` so that
|
|
73
|
+
pagination configuration (page size, the first-page cursor
|
|
74
|
+
rule) stays inside the decoder and the spec builder stays
|
|
75
|
+
pagination-blind.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
spec: The endpoint's base request spec.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The spec to send for the first page.
|
|
82
|
+
"""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
86
|
+
"""Interpret one response envelope into records and a verdict.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
sent: The spec that produced this page; supplies whatever
|
|
90
|
+
the continuation request is derived from.
|
|
91
|
+
envelope: The parsed response body.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The page's records and its pagination verdict.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ProviderResponseError: When the envelope's record-bearing or
|
|
98
|
+
pagination shape is structurally violating (the §8
|
|
99
|
+
stance).
|
|
100
|
+
"""
|
|
101
|
+
...
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# src/fleetpull/network/contract/request.py
|
|
2
|
+
"""Request description: the spec endpoint definitions build, auth
|
|
3
|
+
strategies transform, and the client executes.
|
|
4
|
+
|
|
5
|
+
A ``RequestSpec`` is a pure description of one HTTP request — no
|
|
6
|
+
transport, no credentials until an ``AuthStrategy`` injects them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Self
|
|
13
|
+
|
|
14
|
+
from fleetpull.vocabulary import JsonValue
|
|
15
|
+
|
|
16
|
+
__all__: list[str] = [
|
|
17
|
+
'HttpMethod',
|
|
18
|
+
'RequestSpec',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HttpMethod(StrEnum):
|
|
23
|
+
"""HTTP methods fleetpull uses.
|
|
24
|
+
|
|
25
|
+
GET and POST only — fleetpull never writes to providers. Add
|
|
26
|
+
members only when a real endpoint needs them.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
GET = 'GET'
|
|
30
|
+
POST = 'POST'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class RequestSpec:
|
|
35
|
+
"""
|
|
36
|
+
Pure description of one HTTP request.
|
|
37
|
+
|
|
38
|
+
Built by endpoint definitions, transformed by auth strategies,
|
|
39
|
+
executed by the client. Frozen prevents rebinding, not mutation of
|
|
40
|
+
the contained mappings — they are treated as immutable by
|
|
41
|
+
convention.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
method: The HTTP method.
|
|
45
|
+
url: Fully qualified request URL.
|
|
46
|
+
headers: Request headers (empty by default).
|
|
47
|
+
params: Query parameters, or None.
|
|
48
|
+
json_body: JSON request body (the GeoTab JSON-RPC envelope
|
|
49
|
+
lives here), or None.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
method: HttpMethod
|
|
53
|
+
url: str
|
|
54
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
55
|
+
params: Mapping[str, str] | None = None
|
|
56
|
+
json_body: Mapping[str, JsonValue] | None = None
|
|
57
|
+
|
|
58
|
+
def with_extra_headers(self, extra: Mapping[str, str]) -> Self:
|
|
59
|
+
"""
|
|
60
|
+
Return a NEW spec whose headers merge existing and ``extra``.
|
|
61
|
+
|
|
62
|
+
``extra`` wins on key collisions. The original spec is
|
|
63
|
+
unchanged.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
extra: Headers to add or override.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A new ``RequestSpec`` with the merged headers; every other
|
|
70
|
+
field is preserved.
|
|
71
|
+
"""
|
|
72
|
+
return replace(self, headers={**self.headers, **extra})
|
|
73
|
+
|
|
74
|
+
def with_merged_params(self, overrides: Mapping[str, str]) -> Self:
|
|
75
|
+
"""
|
|
76
|
+
Copy of this spec with ``overrides`` merged into ``params``
|
|
77
|
+
(add-or-replace per key; existing keys not named are kept).
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
overrides: Query parameters to add or replace.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The new spec; ``self`` is unchanged.
|
|
84
|
+
"""
|
|
85
|
+
return replace(self, params={**(self.params or {}), **overrides})
|
|
86
|
+
|
|
87
|
+
def with_json_body(self, json_body: Mapping[str, JsonValue]) -> Self:
|
|
88
|
+
"""
|
|
89
|
+
Copy of this spec with ``json_body`` replaced wholesale.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
json_body: The replacement body.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The new spec; ``self`` is unchanged.
|
|
96
|
+
"""
|
|
97
|
+
return replace(self, json_body=json_body)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# src/fleetpull/network/decoders/__init__.py
|
|
2
|
+
"""Per-provider page decoders: envelope interpreters that yield records
|
|
3
|
+
and a pagination verdict in one pass.
|
|
4
|
+
|
|
5
|
+
Implementations of the ``PageDecoder`` protocol (in
|
|
6
|
+
``network/contract/page_decoder.py``); peers of the contract surface,
|
|
7
|
+
imported by bindings through this face. Each decoder is independent --
|
|
8
|
+
provider envelopes evolve separately (blast-radius over DRY). The one
|
|
9
|
+
shared package-internal piece is the window stamp (``_window_stamp.py``):
|
|
10
|
+
the synthesized window-identity keys are our own provider-uniform
|
|
11
|
+
vocabulary, not envelope logic.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from fleetpull.network.decoders.geotab import (
|
|
15
|
+
GeotabFeedPageDecoder,
|
|
16
|
+
GeotabGetPageDecoder,
|
|
17
|
+
)
|
|
18
|
+
from fleetpull.network.decoders.motive import (
|
|
19
|
+
MotiveWrappedListPageDecoder,
|
|
20
|
+
MotiveWrappedSinglePageDecoder,
|
|
21
|
+
)
|
|
22
|
+
from fleetpull.network.decoders.motive_reports import MotiveWindowReportPageDecoder
|
|
23
|
+
from fleetpull.network.decoders.samsara import (
|
|
24
|
+
SamsaraCursorPageDecoder,
|
|
25
|
+
SamsaraVehicleSeriesPageDecoder,
|
|
26
|
+
)
|
|
27
|
+
from fleetpull.network.decoders.samsara_reports import SamsaraWindowReportPageDecoder
|
|
28
|
+
from fleetpull.network.decoders.single_page import SinglePageDecoder
|
|
29
|
+
|
|
30
|
+
__all__: list[str] = [
|
|
31
|
+
'GeotabFeedPageDecoder',
|
|
32
|
+
'GeotabGetPageDecoder',
|
|
33
|
+
'MotiveWindowReportPageDecoder',
|
|
34
|
+
'MotiveWrappedListPageDecoder',
|
|
35
|
+
'MotiveWrappedSinglePageDecoder',
|
|
36
|
+
'SamsaraCursorPageDecoder',
|
|
37
|
+
'SamsaraVehicleSeriesPageDecoder',
|
|
38
|
+
'SamsaraWindowReportPageDecoder',
|
|
39
|
+
'SinglePageDecoder',
|
|
40
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# src/fleetpull/network/decoders/_window_stamp.py
|
|
2
|
+
"""The shared window stamp for window-grain report decoders.
|
|
3
|
+
|
|
4
|
+
Window-grain rollup surfaces return rows carrying NO event time of any
|
|
5
|
+
kind -- each row is the provider's aggregate over exactly the requested
|
|
6
|
+
window, so the row's time identity is the window the SENT spec asked
|
|
7
|
+
for. The decoders for those surfaces stamp every record with the
|
|
8
|
+
synthesized ``windowStartDate``/``windowEndDate`` keys, copied VERBATIM
|
|
9
|
+
from the sent spec's own window params.
|
|
10
|
+
|
|
11
|
+
The stamp keys are OUR vocabulary, deliberately PROVIDER-UNIFORM: they
|
|
12
|
+
are synthesized, not mirrored, so uniformity costs no wire fidelity and
|
|
13
|
+
buys one recognizable window-identity shape across providers (the
|
|
14
|
+
Samsara fuel-energy pair and the Motive utilization pair both land
|
|
15
|
+
``window_start``/``window_end`` on their models). That is why this
|
|
16
|
+
helper is shared across provider decoder modules -- a deliberate,
|
|
17
|
+
narrow exception to the decoders' blast-radius-over-DRY rule, which
|
|
18
|
+
covers provider ENVELOPE logic: only the param NAMES vary per provider,
|
|
19
|
+
and each caller passes its own.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
from fleetpull.exceptions import ProviderResponseError
|
|
25
|
+
from fleetpull.network.contract import RequestSpec
|
|
26
|
+
from fleetpull.vocabulary import JsonObject
|
|
27
|
+
|
|
28
|
+
__all__: list[str] = [
|
|
29
|
+
'WINDOW_END_KEY',
|
|
30
|
+
'WINDOW_START_KEY',
|
|
31
|
+
'window_stamp_from_sent_spec',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# The synthesized window-identity keys merged onto each report record.
|
|
35
|
+
# Chosen collision-free against every walked report census (no
|
|
36
|
+
# time-shaped key on any window-grain surface's wire rows), and named
|
|
37
|
+
# after what they carry: the window that was asked of the provider.
|
|
38
|
+
WINDOW_START_KEY: Final[str] = 'windowStartDate'
|
|
39
|
+
WINDOW_END_KEY: Final[str] = 'windowEndDate'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def window_stamp_from_sent_spec(
|
|
43
|
+
sent: RequestSpec, *, start_param: str, end_param: str
|
|
44
|
+
) -> JsonObject:
|
|
45
|
+
"""The window-identity keys the sent spec contributes to its records.
|
|
46
|
+
|
|
47
|
+
Copied VERBATIM from the sent spec's own window params --
|
|
48
|
+
wire-truthful: the stamp is exactly what was asked of the provider,
|
|
49
|
+
whose answer each rollup row is. A sent spec lacking either param is
|
|
50
|
+
a wiring bug (every window-grain builder renders both), and silently
|
|
51
|
+
unstamped rows would strip the rows' time identity -- so it fails
|
|
52
|
+
loudly instead.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
sent: The spec that produced the page being decoded.
|
|
56
|
+
start_param: The provider's window-start param name (e.g.
|
|
57
|
+
Samsara's ``startDate``, Motive's ``start_date``).
|
|
58
|
+
end_param: The provider's window-end param name.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
``{'windowStartDate': ..., 'windowEndDate': ...}``, values
|
|
62
|
+
verbatim from the sent params.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ProviderResponseError: The sent spec lacks either window param.
|
|
66
|
+
"""
|
|
67
|
+
params = sent.params or {}
|
|
68
|
+
if start_param not in params or end_param not in params:
|
|
69
|
+
raise ProviderResponseError(
|
|
70
|
+
detail=(
|
|
71
|
+
f'sent spec lacks the {start_param!r}/{end_param!r} window '
|
|
72
|
+
'params to stamp report rows with'
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return {
|
|
76
|
+
WINDOW_START_KEY: params[start_param],
|
|
77
|
+
WINDOW_END_KEY: params[end_param],
|
|
78
|
+
}
|