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,309 @@
|
|
|
1
|
+
# src/fleetpull/network/decoders/geotab.py
|
|
2
|
+
"""GeoTab page decoders: GetFeed (toVersion cursor) and seek-paging Get.
|
|
3
|
+
|
|
4
|
+
Feed pagination over JSON-RPC (scrubbed provider-behavior verification,
|
|
5
|
+
June 2026): the request body's ``params`` carry ``typeName``,
|
|
6
|
+
``resultsLimit``, and either ``search`` (the historical bootstrap) or
|
|
7
|
+
``fromVersion`` (the resume cursor); the ``result`` is
|
|
8
|
+
``{"data": [...], "toVersion": str}``. ``toVersion`` is the durable
|
|
9
|
+
cursor and surfaces from every page including the terminal one.
|
|
10
|
+
|
|
11
|
+
Seek paging over plain ``Get`` (captured 2026-07-09): ``Get`` silently
|
|
12
|
+
hard-caps at 5,000 records with no continuation signal (``GetCountOf``
|
|
13
|
+
5,666 vs the capped 5,000), so a capped entity pages by ``sort`` on
|
|
14
|
+
``id`` ascending -- each advance sets ``sort.offset`` to the last
|
|
15
|
+
returned record's ``id``; termination is the empty result list (a short
|
|
16
|
+
page is NOT terminal, deliberately unlike the feed rule). The ``result``
|
|
17
|
+
is a plain record list.
|
|
18
|
+
|
|
19
|
+
Decoder logic deliberately resembles its siblings without sharing code
|
|
20
|
+
across providers.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from collections.abc import Mapping
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Final
|
|
26
|
+
|
|
27
|
+
from pydantic import Field
|
|
28
|
+
|
|
29
|
+
from fleetpull.exceptions import ProviderResponseError
|
|
30
|
+
from fleetpull.network.contract import (
|
|
31
|
+
DecodedPage,
|
|
32
|
+
PageAdvance,
|
|
33
|
+
RequestSpec,
|
|
34
|
+
StrictEnvelopeSlice,
|
|
35
|
+
validated_envelope_slice,
|
|
36
|
+
)
|
|
37
|
+
from fleetpull.vocabulary import JsonObject, JsonValue
|
|
38
|
+
|
|
39
|
+
__all__: list[str] = ['GeotabFeedPageDecoder', 'GeotabGetPageDecoder']
|
|
40
|
+
|
|
41
|
+
# Wire-protocol tokens: Final constants, not an enum. Deliberately unshared
|
|
42
|
+
# with other providers' decoder modules.
|
|
43
|
+
_PARAMS_KEY: Final[str] = 'params'
|
|
44
|
+
_RESULTS_LIMIT_KEY: Final[str] = 'resultsLimit'
|
|
45
|
+
_SEARCH_KEY: Final[str] = 'search'
|
|
46
|
+
_FROM_VERSION_KEY: Final[str] = 'fromVersion'
|
|
47
|
+
_SORT_KEY: Final[str] = 'sort'
|
|
48
|
+
_OFFSET_KEY: Final[str] = 'offset'
|
|
49
|
+
_ID_KEY: Final[str] = 'id'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _GeotabFeedResult(StrictEnvelopeSlice):
|
|
53
|
+
"""GetFeed's result: the records and the durable cursor.
|
|
54
|
+
|
|
55
|
+
``data`` is typed as a list of JSON objects so the decoder's
|
|
56
|
+
``list[JsonObject]`` records return is honored at validation time.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
data: list[JsonObject]
|
|
60
|
+
to_version: str = Field(alias='toVersion')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _GeotabFeedEnvelope(StrictEnvelopeSlice):
|
|
64
|
+
"""Envelope slice: locates the feed result."""
|
|
65
|
+
|
|
66
|
+
result: _GeotabFeedResult
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _params_from_body(
|
|
70
|
+
sent: RequestSpec, method_label: str
|
|
71
|
+
) -> tuple[Mapping[str, JsonValue], Mapping[str, JsonValue], int]:
|
|
72
|
+
"""Locate the sent spec's JSON-RPC body, ``params``, and ``resultsLimit``.
|
|
73
|
+
|
|
74
|
+
The sent body is fleetpull's own construction, not provider data, so
|
|
75
|
+
malformation here is a caller bug -- stdlib ``ValueError``, no slice
|
|
76
|
+
model -- and the body-is-present guard lives here too, stated once.
|
|
77
|
+
Shared by both GeoTab decoders (same module, same provider).
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
sent: The spec under inspection.
|
|
81
|
+
method_label: The method-class word for the error messages
|
|
82
|
+
(``'feed'`` / ``'Get'``).
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The JSON-RPC body, its ``params`` mapping, and the integer
|
|
86
|
+
``resultsLimit``.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: When the spec carries no JSON-RPC body, or ``params``
|
|
90
|
+
or ``resultsLimit`` is missing or mistyped.
|
|
91
|
+
"""
|
|
92
|
+
if sent.json_body is None:
|
|
93
|
+
raise ValueError(f'GeoTab {method_label} requests require a JSON-RPC body')
|
|
94
|
+
sent_body: Mapping[str, JsonValue] = sent.json_body
|
|
95
|
+
params_value: JsonValue = sent_body.get(_PARAMS_KEY)
|
|
96
|
+
if not isinstance(params_value, Mapping):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f'GeoTab {method_label} request body must carry a {_PARAMS_KEY!r} mapping'
|
|
99
|
+
)
|
|
100
|
+
results_limit: JsonValue = params_value.get(_RESULTS_LIMIT_KEY)
|
|
101
|
+
# bool is a subclass of int; a True resultsLimit is a bug, not 1.
|
|
102
|
+
if not isinstance(results_limit, int) or isinstance(results_limit, bool):
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f'GeoTab {method_label} request params must carry an integer '
|
|
105
|
+
f'{_RESULTS_LIMIT_KEY!r}'
|
|
106
|
+
)
|
|
107
|
+
return sent_body, params_value, results_limit
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class GeotabFeedPageDecoder:
|
|
112
|
+
"""Decode GeoTab GetFeed pages and advance by ``toVersion``.
|
|
113
|
+
|
|
114
|
+
No configuration fields: ``resultsLimit`` is read from the sent
|
|
115
|
+
body, so decoder-versus-endpoint divergence is structurally
|
|
116
|
+
impossible.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
120
|
+
"""Return the spec unchanged.
|
|
121
|
+
|
|
122
|
+
The base spec already carries the bootstrap ``search.fromDate``
|
|
123
|
+
or resume ``fromVersion`` -- state-layer input, not the
|
|
124
|
+
decoder's concern.
|
|
125
|
+
"""
|
|
126
|
+
return spec
|
|
127
|
+
|
|
128
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
129
|
+
"""Extract the feed records and compute the version verdict.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
sent: The spec that produced this page; its body supplies
|
|
133
|
+
``resultsLimit`` and seeds the next page's body.
|
|
134
|
+
envelope: The parsed response body.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
The feed records and the verdict. ``durable_progress``
|
|
138
|
+
carries ``toVersion`` on EVERY page, terminal included.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ProviderResponseError: When the feed result is structurally
|
|
142
|
+
violating.
|
|
143
|
+
ValueError: When ``sent`` is malformed for this decoder -- a
|
|
144
|
+
caller bug, deliberately stdlib.
|
|
145
|
+
"""
|
|
146
|
+
sent_body, sent_params, results_limit = _params_from_body(sent, 'feed')
|
|
147
|
+
feed = validated_envelope_slice(_GeotabFeedEnvelope, envelope).result
|
|
148
|
+
if len(feed.data) < results_limit:
|
|
149
|
+
# The terminal page still carries the resume point.
|
|
150
|
+
return DecodedPage(
|
|
151
|
+
records=feed.data,
|
|
152
|
+
advance=PageAdvance(next_spec=None, durable_progress=feed.to_version),
|
|
153
|
+
)
|
|
154
|
+
# Advance by cursor: fromVersion replaces search. A full page
|
|
155
|
+
# whose toVersion equals the version it was asked from cannot
|
|
156
|
+
# advance -- continuing would refetch the identical page forever
|
|
157
|
+
# (unbounded duplicate growth at the append cell), so the
|
|
158
|
+
# forward-progress violation is surfaced loudly instead. Never
|
|
159
|
+
# observed live (probed full pages always advanced); the guard
|
|
160
|
+
# exists for wedged providers and replaying proxies.
|
|
161
|
+
if feed.to_version == sent_params.get(_FROM_VERSION_KEY):
|
|
162
|
+
raise ProviderResponseError(
|
|
163
|
+
detail=(
|
|
164
|
+
f'GetFeed returned a full page without advancing '
|
|
165
|
+
f'toVersion ({feed.to_version!r}) -- a stalled feed '
|
|
166
|
+
f'would loop forever'
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
next_params: dict[str, JsonValue] = {
|
|
170
|
+
param_name: param_value
|
|
171
|
+
for param_name, param_value in sent_params.items()
|
|
172
|
+
if param_name != _SEARCH_KEY
|
|
173
|
+
}
|
|
174
|
+
next_params[_FROM_VERSION_KEY] = feed.to_version
|
|
175
|
+
next_body: dict[str, JsonValue] = {**sent_body, _PARAMS_KEY: next_params}
|
|
176
|
+
return DecodedPage(
|
|
177
|
+
records=feed.data,
|
|
178
|
+
advance=PageAdvance(
|
|
179
|
+
next_spec=sent.with_json_body(next_body),
|
|
180
|
+
durable_progress=feed.to_version,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _GeotabGetEnvelope(StrictEnvelopeSlice):
|
|
186
|
+
"""Envelope slice: ``Get`` returns records as a plain top-level list."""
|
|
187
|
+
|
|
188
|
+
result: list[JsonObject]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _sort_from_params(sent_params: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
|
|
192
|
+
"""Locate the Get request's ``sort`` mapping; its absence is a caller bug.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
sent_params: The sent body's ``params`` mapping.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The ``sort`` mapping the advance rewrites.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
ValueError: When ``sort`` is missing or not a mapping.
|
|
202
|
+
"""
|
|
203
|
+
sort_value: JsonValue = sent_params.get(_SORT_KEY)
|
|
204
|
+
if not isinstance(sort_value, Mapping):
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f'GeoTab Get request params must carry a {_SORT_KEY!r} mapping'
|
|
207
|
+
)
|
|
208
|
+
return sort_value
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _last_record_id(records: list[JsonObject]) -> str:
|
|
212
|
+
"""The last returned record's ``id`` -- the next page's seek offset.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
records: The page's records, non-empty.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The trailing record's string ``id``.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ProviderResponseError: When the trailing record lacks a string
|
|
222
|
+
``id`` -- provider data violating the seek contract, not a
|
|
223
|
+
caller bug.
|
|
224
|
+
"""
|
|
225
|
+
last_id: JsonValue = records[-1].get(_ID_KEY)
|
|
226
|
+
if not isinstance(last_id, str):
|
|
227
|
+
raise ProviderResponseError(
|
|
228
|
+
detail=(
|
|
229
|
+
f'GeoTab Get seek paging requires a string {_ID_KEY!r} on '
|
|
230
|
+
f'every record; the last record of the page carries '
|
|
231
|
+
f'{type(last_id).__name__}'
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return last_id
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True, slots=True)
|
|
238
|
+
class GeotabGetPageDecoder:
|
|
239
|
+
"""Decode plain ``Get`` pages and advance by id-sorted seek offset.
|
|
240
|
+
|
|
241
|
+
Plain ``Get`` silently hard-caps at 5,000 records with no
|
|
242
|
+
continuation signal (captured 2026-07-09), so the walk seeks: the
|
|
243
|
+
spec builder's first request sorts by ``id`` ascending with an
|
|
244
|
+
explicit null ``offset``, and each advance sets ``sort.offset`` to
|
|
245
|
+
the last returned record's ``id``. Termination is the EMPTY result
|
|
246
|
+
list only -- a short page still advances (unlike the feed rule),
|
|
247
|
+
because the cap makes page fullness meaningless as a completion
|
|
248
|
+
signal. ``lastId`` is never written, by construction: the probe
|
|
249
|
+
bodies carried ``"lastId": null`` (tolerated), but the settled
|
|
250
|
+
decision (DESIGN section 8, decision 1) is to never send the key --
|
|
251
|
+
the docs name it an ``ArgumentException`` beside id-sort.
|
|
252
|
+
|
|
253
|
+
No configuration fields: ``resultsLimit`` lives only in the sent
|
|
254
|
+
body, so decoder-versus-endpoint divergence is structurally
|
|
255
|
+
impossible (the feed precedent).
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
259
|
+
"""Return the spec unchanged.
|
|
260
|
+
|
|
261
|
+
The base spec already carries ``resultsLimit`` and the initial
|
|
262
|
+
``sort`` (``sortBy: id``, explicit null ``offset``) -- the spec
|
|
263
|
+
builder's concern, not the decoder's.
|
|
264
|
+
"""
|
|
265
|
+
return spec
|
|
266
|
+
|
|
267
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
268
|
+
"""Extract the page's records and compute the seek verdict.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
sent: The spec that produced this page; its body's ``sort``
|
|
272
|
+
seeds the next page's offset.
|
|
273
|
+
envelope: The parsed response body.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
The page's records and the verdict: an empty page
|
|
277
|
+
terminates; any records advance with ``sort.offset`` set to
|
|
278
|
+
the last record's ``id``. ``durable_progress`` is always
|
|
279
|
+
``None`` -- the seek offset is fetch-private, never a resume
|
|
280
|
+
cursor.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
ProviderResponseError: When the envelope is structurally
|
|
284
|
+
violating, or a record lacks the string ``id`` the seek
|
|
285
|
+
contract stands on.
|
|
286
|
+
ValueError: When ``sent`` is malformed for this decoder -- a
|
|
287
|
+
caller bug, deliberately stdlib.
|
|
288
|
+
"""
|
|
289
|
+
sent_body, sent_params, _results_limit = _params_from_body(sent, 'Get')
|
|
290
|
+
sent_sort = _sort_from_params(sent_params)
|
|
291
|
+
records = validated_envelope_slice(_GeotabGetEnvelope, envelope).result
|
|
292
|
+
if not records:
|
|
293
|
+
return DecodedPage(
|
|
294
|
+
records=[],
|
|
295
|
+
advance=PageAdvance(next_spec=None, durable_progress=None),
|
|
296
|
+
)
|
|
297
|
+
next_sort: dict[str, JsonValue] = {
|
|
298
|
+
**sent_sort,
|
|
299
|
+
_OFFSET_KEY: _last_record_id(records),
|
|
300
|
+
}
|
|
301
|
+
next_params: dict[str, JsonValue] = {**sent_params, _SORT_KEY: next_sort}
|
|
302
|
+
next_body: dict[str, JsonValue] = {**sent_body, _PARAMS_KEY: next_params}
|
|
303
|
+
return DecodedPage(
|
|
304
|
+
records=records,
|
|
305
|
+
advance=PageAdvance(
|
|
306
|
+
next_spec=sent.with_json_body(next_body),
|
|
307
|
+
durable_progress=None,
|
|
308
|
+
),
|
|
309
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# src/fleetpull/network/decoders/motive.py
|
|
2
|
+
"""Motive page decoders: wrapped-list records, paginated and single-page
|
|
3
|
+
(sources: scrubbed provider-behavior verification, June 2026).
|
|
4
|
+
|
|
5
|
+
Records arrive as a list of single-key wrappers under a per-endpoint
|
|
6
|
+
top-level key -- ``{"vehicles": [{"vehicle": {...}}, ...]}`` -- and the
|
|
7
|
+
``pagination`` block carries ``page_no``/``per_page``/``total``. Decoder
|
|
8
|
+
logic deliberately resembles its siblings without sharing code:
|
|
9
|
+
provider decoders evolve independently (blast-radius over DRY). WITHIN
|
|
10
|
+
this module the wrapped-list extraction and the offset-page verdict are
|
|
11
|
+
each written once (``_unwrap_wrapped_list`` / ``_offset_page_advance``)
|
|
12
|
+
and shared by every decoder that speaks them.
|
|
13
|
+
|
|
14
|
+
The window-stamping report composition for the utilization rollup
|
|
15
|
+
surfaces (``MotiveWindowReportPageDecoder``) lives in
|
|
16
|
+
``motive_reports.py``, delegating to this module's wrapped-list decoder.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Final
|
|
21
|
+
|
|
22
|
+
from fleetpull.network.contract import (
|
|
23
|
+
DecodedPage,
|
|
24
|
+
PageAdvance,
|
|
25
|
+
RequestSpec,
|
|
26
|
+
StrictEnvelopeSlice,
|
|
27
|
+
require_record_list,
|
|
28
|
+
unwrap_record_objects,
|
|
29
|
+
validated_envelope_slice,
|
|
30
|
+
)
|
|
31
|
+
from fleetpull.vocabulary import JsonObject, JsonValue
|
|
32
|
+
|
|
33
|
+
__all__: list[str] = [
|
|
34
|
+
'MotiveWrappedListPageDecoder',
|
|
35
|
+
'MotiveWrappedSinglePageDecoder',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Wire-protocol tokens: Final constants, not an enum -- nothing
|
|
39
|
+
# dispatches over these. Per-provider and deliberately unshared.
|
|
40
|
+
_PAGE_NO_PARAM: Final[str] = 'page_no'
|
|
41
|
+
_PER_PAGE_PARAM: Final[str] = 'per_page'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _MotivePageEcho(StrictEnvelopeSlice):
|
|
45
|
+
"""The pagination block Motive returns on every page."""
|
|
46
|
+
|
|
47
|
+
page_no: int
|
|
48
|
+
per_page: int
|
|
49
|
+
total: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _MotiveEnvelope(StrictEnvelopeSlice):
|
|
53
|
+
"""Envelope slice: locates the echo; the record key is ignored."""
|
|
54
|
+
|
|
55
|
+
pagination: _MotivePageEcho
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _unwrap_wrapped_list(
|
|
59
|
+
envelope: JsonValue, list_key: str, item_key: str
|
|
60
|
+
) -> list[JsonObject]:
|
|
61
|
+
"""Extract and unwrap one page's wrapped-list records.
|
|
62
|
+
|
|
63
|
+
The one Motive record-bearing shape, written once for the decoders
|
|
64
|
+
in this module (a same-file extraction, not a cross-provider
|
|
65
|
+
abstraction): the wrapper list under ``list_key``, each wrapper's
|
|
66
|
+
record under ``item_key``.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
envelope: The parsed response body.
|
|
70
|
+
list_key: The top-level key holding the wrapper list.
|
|
71
|
+
item_key: The key inside each wrapper holding the record.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The unwrapped record objects.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ProviderResponseError: The record-bearing shape is structurally
|
|
78
|
+
violating (missing list key, non-list value, missing item
|
|
79
|
+
key, or a non-object record).
|
|
80
|
+
"""
|
|
81
|
+
wrappers = require_record_list(envelope, list_key)
|
|
82
|
+
return unwrap_record_objects(wrappers, item_key)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _offset_page_advance(sent: RequestSpec, envelope: JsonValue) -> PageAdvance:
|
|
86
|
+
"""Compute one page's offset verdict from its ``pagination`` echo.
|
|
87
|
+
|
|
88
|
+
The one page-numbered cursor contract every paginated Motive walk
|
|
89
|
+
shares, written once for the decoders in this module: terminal when
|
|
90
|
+
``page_no * per_page >= total``; otherwise ``page_no + 1`` at the
|
|
91
|
+
echoed size merges onto the SENT spec, so every first-request
|
|
92
|
+
parameter (a window, a fixed selector) persists across the whole
|
|
93
|
+
walk. ``durable_progress`` is always ``None`` -- Motive offsets are
|
|
94
|
+
fetch-private.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
sent: The spec that produced this page.
|
|
98
|
+
envelope: The parsed response body.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The page's pagination verdict.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ProviderResponseError: The ``pagination`` block is structurally
|
|
105
|
+
violating.
|
|
106
|
+
"""
|
|
107
|
+
echo = validated_envelope_slice(_MotiveEnvelope, envelope).pagination
|
|
108
|
+
if echo.page_no * echo.per_page >= echo.total:
|
|
109
|
+
return PageAdvance(next_spec=None, durable_progress=None)
|
|
110
|
+
next_spec = sent.with_merged_params(
|
|
111
|
+
{
|
|
112
|
+
_PAGE_NO_PARAM: str(echo.page_no + 1),
|
|
113
|
+
_PER_PAGE_PARAM: str(echo.per_page),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
return PageAdvance(next_spec=next_spec, durable_progress=None)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class MotiveWrappedListPageDecoder:
|
|
121
|
+
"""Decode Motive's wrapped-list pages and page-numbered cursor.
|
|
122
|
+
|
|
123
|
+
Attributes:
|
|
124
|
+
list_key: The top-level key holding the wrapper list.
|
|
125
|
+
item_key: The key inside each wrapper holding the record.
|
|
126
|
+
per_page: The page size sent on the first request.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
list_key: str
|
|
130
|
+
item_key: str
|
|
131
|
+
per_page: int
|
|
132
|
+
|
|
133
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
134
|
+
"""Send page one with ``page_no=1`` and the configured size."""
|
|
135
|
+
return spec.with_merged_params(
|
|
136
|
+
{_PAGE_NO_PARAM: '1', _PER_PAGE_PARAM: str(self.per_page)}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
140
|
+
"""Extract the wrapped records and compute the page verdict.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
sent: The spec that produced this page.
|
|
144
|
+
envelope: The parsed response body.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The unwrapped records and the pagination verdict.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ProviderResponseError: When the record-bearing shape or the
|
|
151
|
+
pagination block is structurally violating.
|
|
152
|
+
"""
|
|
153
|
+
records = _unwrap_wrapped_list(envelope, self.list_key, self.item_key)
|
|
154
|
+
return DecodedPage(
|
|
155
|
+
records=records, advance=_offset_page_advance(sent, envelope)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class MotiveWrappedSinglePageDecoder:
|
|
161
|
+
"""Decode a single unpaginated page of Motive's wrapped-list records.
|
|
162
|
+
|
|
163
|
+
The non-paginated sibling of ``MotiveWrappedListPageDecoder``: the same
|
|
164
|
+
wrapped-list shape (a list of single-key wrappers under ``list_key``, each
|
|
165
|
+
holding the record under ``item_key``), but the endpoint returns one page and
|
|
166
|
+
no ``pagination`` block, so the first request is the base spec unchanged and
|
|
167
|
+
every page is terminal. ``SinglePageDecoder`` does not fit -- it reads a
|
|
168
|
+
top-level record list and never strips the per-item wrapper.
|
|
169
|
+
|
|
170
|
+
Attributes:
|
|
171
|
+
list_key: The top-level key holding the wrapper list.
|
|
172
|
+
item_key: The key inside each wrapper holding the record.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
list_key: str
|
|
176
|
+
item_key: str
|
|
177
|
+
|
|
178
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
179
|
+
"""Return the base spec unchanged -- the endpoint is unpaginated."""
|
|
180
|
+
return spec
|
|
181
|
+
|
|
182
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
183
|
+
"""Unwrap the wrapped records; the page is always terminal.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
sent: The spec that produced this page (unused; no continuation).
|
|
187
|
+
envelope: The parsed response body.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The unwrapped records and a terminal verdict.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ProviderResponseError: When the record-bearing shape is structurally
|
|
194
|
+
violating (missing list key, non-list value, missing item key, or
|
|
195
|
+
a non-object record).
|
|
196
|
+
|
|
197
|
+
Side Effects:
|
|
198
|
+
None.
|
|
199
|
+
"""
|
|
200
|
+
records = _unwrap_wrapped_list(envelope, self.list_key, self.item_key)
|
|
201
|
+
return DecodedPage(
|
|
202
|
+
records=records,
|
|
203
|
+
advance=PageAdvance(next_spec=None, durable_progress=None),
|
|
204
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# src/fleetpull/network/decoders/motive_reports.py
|
|
2
|
+
"""The Motive window-report decoder family: the utilization rollup surfaces.
|
|
3
|
+
|
|
4
|
+
``MotiveWindowReportPageDecoder`` decodes the utilization rollup surfaces
|
|
5
|
+
(``/v2/vehicle_utilization``, ``/v2/driver_utilization``; probe-settled
|
|
6
|
+
2026-07-21, DESIGN section 8), whose rows carry NO event-time key of any kind
|
|
7
|
+
-- each row is the provider's rollup over exactly the requested window, so the
|
|
8
|
+
decoder stamps every unwrapped record with the window the SENT spec asked for.
|
|
9
|
+
Envelope extraction and pagination are the sibling wrapped-list decoder's
|
|
10
|
+
(``motive.py``), composed by delegation; the window stamp is the shared
|
|
11
|
+
provider-uniform vocabulary (``_window_stamp.py``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
from fleetpull.network.contract import DecodedPage, RequestSpec
|
|
18
|
+
from fleetpull.network.decoders._window_stamp import window_stamp_from_sent_spec
|
|
19
|
+
from fleetpull.network.decoders.motive import MotiveWrappedListPageDecoder
|
|
20
|
+
from fleetpull.vocabulary import JsonValue
|
|
21
|
+
|
|
22
|
+
__all__: list[str] = ['MotiveWindowReportPageDecoder']
|
|
23
|
+
|
|
24
|
+
# The utilization report surfaces' window wire params (2026-07-21
|
|
25
|
+
# capture): the day-granular date-label pair every windowed Motive
|
|
26
|
+
# surface takes, inclusive on both ends and interpreted on COMPANY-LOCAL
|
|
27
|
+
# day boundaries on these surfaces. The shared window-stamp helper
|
|
28
|
+
# (`_window_stamp.py`) reads them back off the SENT spec to stamp each
|
|
29
|
+
# rollup row with the provider-uniform synthesized keys.
|
|
30
|
+
_WINDOW_START_PARAM: Final[str] = 'start_date'
|
|
31
|
+
_WINDOW_END_PARAM: Final[str] = 'end_date'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class MotiveWindowReportPageDecoder:
|
|
36
|
+
"""Decode window-grain rollup pages into window-stamped records.
|
|
37
|
+
|
|
38
|
+
The decoder for ``GET /v2/vehicle_utilization`` and
|
|
39
|
+
``GET /v2/driver_utilization`` (probe-settled 2026-07-21, DESIGN
|
|
40
|
+
section 8): the standard Motive wrapped-list envelope and
|
|
41
|
+
page-numbered cursor -- composed by DELEGATION to an inner
|
|
42
|
+
``MotiveWrappedListPageDecoder`` (the Samsara series-decoder
|
|
43
|
+
pattern), which handles ``first_request`` and the whole
|
|
44
|
+
extraction-and-verdict pass verbatim -- plus
|
|
45
|
+
the window-grain difference the Samsara fuel-energy pair
|
|
46
|
+
established, applied to the inner page's records:
|
|
47
|
+
|
|
48
|
+
**The rollup grain is the request window.** Rows carry NO date or
|
|
49
|
+
time identity of any kind; each row is the provider's aggregate over
|
|
50
|
+
exactly the requested inclusive ``start_date``/``end_date`` label
|
|
51
|
+
pair (a 1-day and a 6-day request each returned one rollup row per
|
|
52
|
+
entity). So the decoder stamps every unwrapped record with the
|
|
53
|
+
synthesized ``windowStartDate``/``windowEndDate`` keys, copied
|
|
54
|
+
verbatim from the SENT spec's own ``start_date``/``end_date`` params
|
|
55
|
+
-- the shared window-stamp vocabulary (``_window_stamp.py``),
|
|
56
|
+
sourced from the sent spec rather than the record. The stamp wins
|
|
57
|
+
any (census-impossible) key collision: it is the row's REQUIRED time
|
|
58
|
+
identity, and a colliding future wire key must never silently
|
|
59
|
+
supplant what was actually asked of the provider. A sent spec
|
|
60
|
+
lacking either param raises loudly -- a wiring bug surfaced, never
|
|
61
|
+
silently unstamped rows.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
list_key: The top-level key holding the wrapper list
|
|
65
|
+
(``'vehicle_utilizations'`` / ``'driver_idle_rollups'``).
|
|
66
|
+
item_key: The key inside each wrapper holding the record
|
|
67
|
+
(``'vehicle_utilization'`` / ``'driver_idle_rollup'``).
|
|
68
|
+
per_page: The page size sent on the first request.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
list_key: str
|
|
72
|
+
item_key: str
|
|
73
|
+
per_page: int
|
|
74
|
+
|
|
75
|
+
def _list_decoder(self) -> MotiveWrappedListPageDecoder:
|
|
76
|
+
"""The inner wrapped-list decoder extraction and pagination delegate to."""
|
|
77
|
+
return MotiveWrappedListPageDecoder(
|
|
78
|
+
list_key=self.list_key, item_key=self.item_key, per_page=self.per_page
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
82
|
+
"""Delegate page one verbatim to the inner wrapped-list decoder.
|
|
83
|
+
|
|
84
|
+
The offset advance merges onto the sent spec, so the builder's
|
|
85
|
+
``start_date``/``end_date`` window persists across every page --
|
|
86
|
+
and with it, the stamp.
|
|
87
|
+
"""
|
|
88
|
+
return self._list_decoder().first_request(spec)
|
|
89
|
+
|
|
90
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
91
|
+
"""Stamp the inner page's unwrapped records with the sent window.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
sent: The spec that produced this page.
|
|
95
|
+
envelope: The parsed response body.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
One record per rollup row, each carrying the synthesized
|
|
99
|
+
``windowStartDate``/``windowEndDate`` keys (class
|
|
100
|
+
docstring); the pagination advance passes through the inner
|
|
101
|
+
decoder untouched.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ProviderResponseError: The sent spec lacks a window param
|
|
105
|
+
(a wiring bug -- never silently unstamped rows), the
|
|
106
|
+
record-bearing shape is structurally violating, or the
|
|
107
|
+
pagination block is.
|
|
108
|
+
"""
|
|
109
|
+
window_stamp = window_stamp_from_sent_spec(
|
|
110
|
+
sent, start_param=_WINDOW_START_PARAM, end_param=_WINDOW_END_PARAM
|
|
111
|
+
)
|
|
112
|
+
inner_page = self._list_decoder().decode_page(sent, envelope)
|
|
113
|
+
stamped = [{**record, **window_stamp} for record in inner_page.records]
|
|
114
|
+
return DecodedPage(records=stamped, advance=inner_page.advance)
|