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,343 @@
|
|
|
1
|
+
# src/fleetpull/network/decoders/samsara.py
|
|
2
|
+
"""Samsara page decoders: the cursor walk and its series-unnesting
|
|
3
|
+
composition for the vehicle-stats surfaces
|
|
4
|
+
(sources: scrubbed provider-behavior verification, June 2026; cursor
|
|
5
|
+
contract from provider documentation, proven live 2026-07-17 -- the
|
|
6
|
+
advance continued across a real page boundary with no overlap or loss,
|
|
7
|
+
and the terminal page carried ``hasNextPage: false`` beside an
|
|
8
|
+
EMPTY-STRING ``endCursor``, the shape the continuation guard below is
|
|
9
|
+
calibrated against).
|
|
10
|
+
|
|
11
|
+
Records arrive as a top-level list under a per-endpoint key; the
|
|
12
|
+
``pagination`` block carries ``endCursor``/``hasNextPage``. The first
|
|
13
|
+
page sends ``limit`` and no ``after``; subsequent pages send
|
|
14
|
+
``after=<endCursor>`` (merged onto the sent spec, so ``limit``
|
|
15
|
+
persists). Decoder logic deliberately resembles its siblings without
|
|
16
|
+
sharing code; the cursor verdict is written once here
|
|
17
|
+
(``cursor_page_advance``, exported so ``samsara_reports.py`` shares it)
|
|
18
|
+
and consumed by every decoder that walks the cursor.
|
|
19
|
+
The one deliberate cross-module share is the window stamp
|
|
20
|
+
(``_window_stamp.py``): the synthesized keys are our own
|
|
21
|
+
provider-uniform vocabulary, not envelope logic.
|
|
22
|
+
|
|
23
|
+
``SamsaraVehicleSeriesPageDecoder`` composes the cursor decoder by
|
|
24
|
+
delegation for ``/fleet/vehicles/stats/history``, whose cursor walks
|
|
25
|
+
the VEHICLE axis while each vehicle record nests a per-type reading
|
|
26
|
+
series (probe-settled 2026-07-20, DESIGN section 8).
|
|
27
|
+
|
|
28
|
+
The window-stamping report family for the fuel-energy report surfaces
|
|
29
|
+
(``SamsaraWindowReportPageDecoder``) lives in ``samsara_reports.py``,
|
|
30
|
+
sharing this module's cursor verdict and first-page shape.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Final
|
|
35
|
+
|
|
36
|
+
from pydantic import Field
|
|
37
|
+
|
|
38
|
+
from fleetpull.exceptions import ProviderResponseError
|
|
39
|
+
from fleetpull.network.contract import (
|
|
40
|
+
DecodedPage,
|
|
41
|
+
PageAdvance,
|
|
42
|
+
RequestSpec,
|
|
43
|
+
StrictEnvelopeSlice,
|
|
44
|
+
require_record_list,
|
|
45
|
+
validated_envelope_slice,
|
|
46
|
+
)
|
|
47
|
+
from fleetpull.vocabulary import JsonObject, JsonValue
|
|
48
|
+
|
|
49
|
+
__all__: list[str] = [
|
|
50
|
+
'SamsaraCursorPageDecoder',
|
|
51
|
+
'SamsaraVehicleSeriesPageDecoder',
|
|
52
|
+
'cursor_page_advance',
|
|
53
|
+
'first_page_spec',
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# Wire-protocol tokens: Final constants, not an enum. Deliberately unshared.
|
|
57
|
+
_AFTER_PARAM: Final[str] = 'after'
|
|
58
|
+
_LIMIT_PARAM: Final[str] = 'limit'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _SamsaraPageEcho(StrictEnvelopeSlice):
|
|
62
|
+
"""The pagination block Samsara returns on every page."""
|
|
63
|
+
|
|
64
|
+
has_next_page: bool = Field(alias='hasNextPage')
|
|
65
|
+
end_cursor: str | None = Field(default=None, alias='endCursor')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _SamsaraEnvelope(StrictEnvelopeSlice):
|
|
69
|
+
"""Envelope slice: locates the echo; the record key is ignored."""
|
|
70
|
+
|
|
71
|
+
pagination: _SamsaraPageEcho
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cursor_page_advance(sent: RequestSpec, envelope: JsonValue) -> PageAdvance:
|
|
75
|
+
"""Compute one page's cursor verdict from its ``pagination`` echo.
|
|
76
|
+
|
|
77
|
+
The one cursor contract every Samsara walk shares, written once for
|
|
78
|
+
the decoders in this module and the report family beside it
|
|
79
|
+
(``samsara_reports.py`` -- a same-provider extraction, not a
|
|
80
|
+
cross-provider abstraction): terminal on ``hasNextPage: false``;
|
|
81
|
+
otherwise ``after=<endCursor>`` merges onto the SENT spec, so every
|
|
82
|
+
first-request parameter (``limit``, a window, a fixed selector)
|
|
83
|
+
persists across the whole walk. ``durable_progress`` is always
|
|
84
|
+
``None`` -- Samsara cursors are fetch-private.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
sent: The spec that produced this page.
|
|
88
|
+
envelope: The parsed response body.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The page's pagination verdict.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ProviderResponseError: The ``pagination`` block is structurally
|
|
95
|
+
violating, including continuation promised without a cursor.
|
|
96
|
+
"""
|
|
97
|
+
echo = validated_envelope_slice(_SamsaraEnvelope, envelope).pagination
|
|
98
|
+
if not echo.has_next_page:
|
|
99
|
+
return PageAdvance(next_spec=None, durable_progress=None)
|
|
100
|
+
if echo.end_cursor is None or echo.end_cursor == '':
|
|
101
|
+
# Continuation promised without a cursor: silently finishing
|
|
102
|
+
# here would truncate data -- the one failure mode a fetch
|
|
103
|
+
# library must never have.
|
|
104
|
+
raise ProviderResponseError(
|
|
105
|
+
detail='hasNextPage is true but endCursor is missing or empty'
|
|
106
|
+
)
|
|
107
|
+
next_spec = sent.with_merged_params({_AFTER_PARAM: echo.end_cursor})
|
|
108
|
+
return PageAdvance(next_spec=next_spec, durable_progress=None)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def first_page_spec(spec: RequestSpec, results_limit: int) -> RequestSpec:
|
|
112
|
+
"""Page one's spec: ``limit`` merged, and deliberately NO ``after``.
|
|
113
|
+
|
|
114
|
+
The first-request shape every Samsara cursor walk sends, written once
|
|
115
|
+
for this module's decoders and the report family beside it: the shared
|
|
116
|
+
cursor verdict's ``after`` merge layers onto this spec, so the limit
|
|
117
|
+
persists across every subsequent page.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
spec: The endpoint's base first request.
|
|
121
|
+
results_limit: The per-page record count to request via ``limit``.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The first request with ``limit`` merged.
|
|
125
|
+
"""
|
|
126
|
+
return spec.with_merged_params({_LIMIT_PARAM: str(results_limit)})
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class SamsaraCursorPageDecoder:
|
|
131
|
+
"""Decode Samsara's top-level-list pages and cursor.
|
|
132
|
+
|
|
133
|
+
Attributes:
|
|
134
|
+
records_key: The top-level key holding the record list.
|
|
135
|
+
results_limit: The per-page record count requested via the
|
|
136
|
+
``limit`` query parameter (pagination parameters are the
|
|
137
|
+
decoder's, per the ``StaticGetSpecBuilder`` seam).
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
records_key: str
|
|
141
|
+
results_limit: int
|
|
142
|
+
|
|
143
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
144
|
+
"""Send page one via the shared first-page shape (``first_page_spec``)."""
|
|
145
|
+
return first_page_spec(spec, self.results_limit)
|
|
146
|
+
|
|
147
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
148
|
+
"""Extract the records and compute the cursor verdict.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
sent: The spec that produced this page.
|
|
152
|
+
envelope: The parsed response body.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The records and the pagination verdict; ``durable_progress``
|
|
156
|
+
is always None -- Samsara cursors are fetch-private.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ProviderResponseError: When the record-bearing shape or the
|
|
160
|
+
cursor block is structurally violating, including
|
|
161
|
+
continuation promised without a cursor.
|
|
162
|
+
"""
|
|
163
|
+
records = require_record_list(envelope, self.records_key)
|
|
164
|
+
return DecodedPage(records=records, advance=cursor_page_advance(sent, envelope))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# The vehicle-stats wire keys the unnesting reads (2026-07-20 capture):
|
|
168
|
+
# per-vehicle identity is `id`/`name` plus the `externalIds` object's
|
|
169
|
+
# literal DOTTED keys `samsara.serial`/`samsara.vin`.
|
|
170
|
+
_VEHICLE_ID_SOURCE_KEY: Final[str] = 'id'
|
|
171
|
+
_VEHICLE_NAME_SOURCE_KEY: Final[str] = 'name'
|
|
172
|
+
_EXTERNAL_IDS_KEY: Final[str] = 'externalIds'
|
|
173
|
+
_SERIAL_SOURCE_KEY: Final[str] = 'samsara.serial'
|
|
174
|
+
_VIN_SOURCE_KEY: Final[str] = 'samsara.vin'
|
|
175
|
+
|
|
176
|
+
# The synthesized identity keys merged onto each emitted reading. Chosen
|
|
177
|
+
# to be collision-free against every series key observed in the
|
|
178
|
+
# 2026-07-20 census (`time`, `value`, `latitude`, `longitude`,
|
|
179
|
+
# `headingDegrees`, `speedMilesPerHour`, `isEcuSpeed`, `reverseGeo`,
|
|
180
|
+
# `address`), so the reading-keys-win merge order below never actually
|
|
181
|
+
# discards an identity key.
|
|
182
|
+
_VEHICLE_ID_KEY: Final[str] = 'vehicleId'
|
|
183
|
+
_VEHICLE_NAME_KEY: Final[str] = 'vehicleName'
|
|
184
|
+
_VEHICLE_SERIAL_KEY: Final[str] = 'vehicleSerial'
|
|
185
|
+
_VEHICLE_VIN_KEY: Final[str] = 'vehicleVin'
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _synthesized_identity(vehicle: JsonObject) -> JsonObject:
|
|
189
|
+
"""The identity keys one vehicle record contributes to its readings.
|
|
190
|
+
|
|
191
|
+
Every key is synthesized ONLY when its source is present -- the
|
|
192
|
+
omit-absent-keys posture: a vehicle without ``externalIds`` (or
|
|
193
|
+
without a dotted key inside it) contributes readings without the
|
|
194
|
+
corresponding synthesized key, never a null.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
vehicle: One vehicle record from the page's record list.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The synthesized identity keys, wire values verbatim.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
ProviderResponseError: ``externalIds`` is present but not a JSON
|
|
204
|
+
object -- silently dropping serial/vin there would hide the
|
|
205
|
+
malformation.
|
|
206
|
+
"""
|
|
207
|
+
identity: JsonObject = {}
|
|
208
|
+
if _VEHICLE_ID_SOURCE_KEY in vehicle:
|
|
209
|
+
identity[_VEHICLE_ID_KEY] = vehicle[_VEHICLE_ID_SOURCE_KEY]
|
|
210
|
+
if _VEHICLE_NAME_SOURCE_KEY in vehicle:
|
|
211
|
+
identity[_VEHICLE_NAME_KEY] = vehicle[_VEHICLE_NAME_SOURCE_KEY]
|
|
212
|
+
if _EXTERNAL_IDS_KEY not in vehicle:
|
|
213
|
+
return identity
|
|
214
|
+
external_ids = vehicle[_EXTERNAL_IDS_KEY]
|
|
215
|
+
if not isinstance(external_ids, dict):
|
|
216
|
+
raise ProviderResponseError(
|
|
217
|
+
detail=f'vehicle {_EXTERNAL_IDS_KEY!r} is not a JSON object'
|
|
218
|
+
)
|
|
219
|
+
if _SERIAL_SOURCE_KEY in external_ids:
|
|
220
|
+
identity[_VEHICLE_SERIAL_KEY] = external_ids[_SERIAL_SOURCE_KEY]
|
|
221
|
+
if _VIN_SOURCE_KEY in external_ids:
|
|
222
|
+
identity[_VEHICLE_VIN_KEY] = external_ids[_VIN_SOURCE_KEY]
|
|
223
|
+
return identity
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True, slots=True)
|
|
227
|
+
class SamsaraVehicleSeriesPageDecoder:
|
|
228
|
+
"""Decode vehicle-stats pages into flat per-reading records.
|
|
229
|
+
|
|
230
|
+
The series-unnesting composition over ``SamsaraCursorPageDecoder``
|
|
231
|
+
for ``GET /fleet/vehicles/stats/history`` (probe-settled
|
|
232
|
+
2026-07-20): the cursor walks the VEHICLE axis within the fixed
|
|
233
|
+
window (three consecutive live pages showed zero vehicle-id
|
|
234
|
+
overlap), and each vehicle record nests one reading series under
|
|
235
|
+
the requested stat type's key. Composition is by DELEGATION: an
|
|
236
|
+
inner cursor decoder handles ``first_request`` and the whole
|
|
237
|
+
pagination verdict verbatim -- this class only unnests the inner
|
|
238
|
+
page's vehicle records.
|
|
239
|
+
|
|
240
|
+
The unnesting contract: for each vehicle record, for each element
|
|
241
|
+
of ``vehicle[series_key]``, emit one flat record carrying the
|
|
242
|
+
reading's keys verbatim plus the SYNTHESIZED identity keys
|
|
243
|
+
``vehicleId``/``vehicleName``/``vehicleSerial``/``vehicleVin``
|
|
244
|
+
(sourced from ``id``/``name`` and the ``externalIds`` object's
|
|
245
|
+
literal dotted ``samsara.serial``/``samsara.vin`` keys). Identity
|
|
246
|
+
keys are synthesized only when their source is present; reading
|
|
247
|
+
keys win any collision -- impossible by census, since the
|
|
248
|
+
synthesized names were chosen collision-free against every observed
|
|
249
|
+
series key. A vehicle whose series array is missing or empty
|
|
250
|
+
contributes zero records.
|
|
251
|
+
|
|
252
|
+
This decoder is Samsara-stats-specific by evidence, not a generic
|
|
253
|
+
flattener: the identity-key sourcing, the dotted ``externalIds``
|
|
254
|
+
keys, and the one-series-per-record shape are this surface's
|
|
255
|
+
captured facts, and generalizing beyond them would encode structure
|
|
256
|
+
no probe has shown.
|
|
257
|
+
|
|
258
|
+
Attributes:
|
|
259
|
+
records_key: The top-level key holding the vehicle-record list
|
|
260
|
+
(forwarded to the inner cursor decoder).
|
|
261
|
+
results_limit: The per-page vehicle count requested via
|
|
262
|
+
``limit`` (forwarded to the inner cursor decoder).
|
|
263
|
+
series_key: The per-vehicle key holding this endpoint's reading
|
|
264
|
+
series -- the requested stat type's wire name.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
records_key: str
|
|
268
|
+
results_limit: int
|
|
269
|
+
series_key: str
|
|
270
|
+
|
|
271
|
+
def _cursor_decoder(self) -> SamsaraCursorPageDecoder:
|
|
272
|
+
"""The inner cursor decoder pagination delegates to."""
|
|
273
|
+
return SamsaraCursorPageDecoder(
|
|
274
|
+
records_key=self.records_key, results_limit=self.results_limit
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
278
|
+
"""Delegate page one verbatim to the inner cursor decoder."""
|
|
279
|
+
return self._cursor_decoder().first_request(spec)
|
|
280
|
+
|
|
281
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
282
|
+
"""Unnest the inner page's vehicles into flat reading records.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
sent: The spec that produced this page.
|
|
286
|
+
envelope: The parsed response body.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
One record per reading (the unnesting contract, class
|
|
290
|
+
docstring); the pagination advance passes through the inner
|
|
291
|
+
decoder untouched.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
ProviderResponseError: When the inner decode raises (the
|
|
295
|
+
cursor contract's guards, including continuation
|
|
296
|
+
promised without a cursor), or when a present series
|
|
297
|
+
value or element is structurally violating.
|
|
298
|
+
"""
|
|
299
|
+
inner_page = self._cursor_decoder().decode_page(sent, envelope)
|
|
300
|
+
readings = [
|
|
301
|
+
reading
|
|
302
|
+
for vehicle in inner_page.records
|
|
303
|
+
for reading in self._unnest_vehicle(vehicle)
|
|
304
|
+
]
|
|
305
|
+
return DecodedPage(records=readings, advance=inner_page.advance)
|
|
306
|
+
|
|
307
|
+
def _unnest_vehicle(self, vehicle: JsonObject) -> list[JsonObject]:
|
|
308
|
+
"""One vehicle record's flat reading records (possibly none).
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
vehicle: One vehicle record from the inner page.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
One flat record per series element; empty when the series
|
|
315
|
+
is missing or empty (only carriers are returned per
|
|
316
|
+
requested type in capture, but absence stays a zero-record
|
|
317
|
+
vehicle, never an error).
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
ProviderResponseError: A present series value is not a
|
|
321
|
+
list, or a series element is not a JSON object.
|
|
322
|
+
"""
|
|
323
|
+
if self.series_key not in vehicle:
|
|
324
|
+
return []
|
|
325
|
+
series = vehicle[self.series_key]
|
|
326
|
+
if not isinstance(series, list):
|
|
327
|
+
raise ProviderResponseError(
|
|
328
|
+
detail=f'vehicle series {self.series_key!r} is not a list'
|
|
329
|
+
)
|
|
330
|
+
identity = _synthesized_identity(vehicle)
|
|
331
|
+
readings: list[JsonObject] = []
|
|
332
|
+
for element in series:
|
|
333
|
+
if not isinstance(element, dict):
|
|
334
|
+
raise ProviderResponseError(
|
|
335
|
+
detail=(
|
|
336
|
+
f'vehicle series {self.series_key!r} element is not a '
|
|
337
|
+
'JSON object'
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
# Reading keys win the merge (collision-free by census; the
|
|
341
|
+
# class docstring records the naming choice).
|
|
342
|
+
readings.append({**identity, **element})
|
|
343
|
+
return readings
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# src/fleetpull/network/decoders/samsara_reports.py
|
|
2
|
+
"""The Samsara window-report decoder family: the fuel-energy report surfaces.
|
|
3
|
+
|
|
4
|
+
``SamsaraWindowReportPageDecoder`` decodes the fuel-energy report surfaces
|
|
5
|
+
(``/fleet/reports/{vehicles,drivers}/fuel-energy``; probe-settled 2026-07-21,
|
|
6
|
+
DESIGN section 8), whose record list nests one level deeper (``data`` is an
|
|
7
|
+
OBJECT holding the list under ``vehicleReports``/``driverReports``) and whose
|
|
8
|
+
rows carry NO event-time key of any kind -- each row is the provider's rollup
|
|
9
|
+
over exactly the requested window, so the decoder stamps every report with the
|
|
10
|
+
window the SENT spec asked for. Pagination and the first-page shape are the
|
|
11
|
+
sibling cursor module's (``samsara.py``: ``cursor_page_advance`` /
|
|
12
|
+
``first_page_spec``); the window stamp is the shared provider-uniform
|
|
13
|
+
vocabulary (``_window_stamp.py``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
from fleetpull.network.contract import (
|
|
20
|
+
DecodedPage,
|
|
21
|
+
RequestSpec,
|
|
22
|
+
require_child_object,
|
|
23
|
+
require_record_list,
|
|
24
|
+
)
|
|
25
|
+
from fleetpull.network.decoders._window_stamp import window_stamp_from_sent_spec
|
|
26
|
+
from fleetpull.network.decoders.samsara import cursor_page_advance, first_page_spec
|
|
27
|
+
from fleetpull.vocabulary import JsonValue
|
|
28
|
+
|
|
29
|
+
__all__: list[str] = ['SamsaraWindowReportPageDecoder']
|
|
30
|
+
|
|
31
|
+
# The fuel-energy report surfaces' window wire params (2026-07-21
|
|
32
|
+
# capture): these surfaces take startDate/endDate NAMES -- unlike every
|
|
33
|
+
# other probed Samsara vertical's startTime/endTime -- while accepting
|
|
34
|
+
# full RFC3339 datetimes despite the names. The shared window-stamp
|
|
35
|
+
# helper (`_window_stamp.py`) reads them back off the SENT spec to stamp
|
|
36
|
+
# each report row with the provider-uniform synthesized keys.
|
|
37
|
+
_WINDOW_START_PARAM: Final[str] = 'startDate'
|
|
38
|
+
_WINDOW_END_PARAM: Final[str] = 'endDate'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class SamsaraWindowReportPageDecoder:
|
|
43
|
+
"""Decode fuel-energy report pages into window-stamped records.
|
|
44
|
+
|
|
45
|
+
The decoder for ``GET /fleet/reports/{vehicles,drivers}/fuel-energy``
|
|
46
|
+
(probe-settled 2026-07-21, DESIGN section 8), whose envelope differs
|
|
47
|
+
from the flat cursor surfaces twice over:
|
|
48
|
+
|
|
49
|
+
- **The record list is NESTED.** ``data`` is an OBJECT whose only
|
|
50
|
+
key is the per-surface report key (``vehicleReports`` /
|
|
51
|
+
``driverReports``), each a list of report objects -- extracted
|
|
52
|
+
with the same structural-violation loudness ``require_record_list``
|
|
53
|
+
gives flat lists.
|
|
54
|
+
- **The rollup grain is the request window.** Report rows carry NO
|
|
55
|
+
event-time key of any kind; each row is the provider's aggregate
|
|
56
|
+
over exactly the requested window (widening the window GREW
|
|
57
|
+
per-entity metrics, and day rollups are NOT additive into wider
|
|
58
|
+
windows -- 89/267 mismatched). So the decoder stamps every report
|
|
59
|
+
with the synthesized keys ``windowStartDate``/``windowEndDate``,
|
|
60
|
+
copied verbatim from the SENT spec's own ``startDate``/``endDate``
|
|
61
|
+
params -- the stats triple's synthesized-identity-keys precedent,
|
|
62
|
+
sourced from the sent spec rather than the record. The stamp wins
|
|
63
|
+
any (census-impossible) key collision: it is the row's REQUIRED
|
|
64
|
+
time identity, and a colliding future wire key must never silently
|
|
65
|
+
supplant what was actually asked of the provider -- the inverse of
|
|
66
|
+
the series decoder's reading-keys-win order, where the synthesized
|
|
67
|
+
keys are auxiliary attribution.
|
|
68
|
+
|
|
69
|
+
Pagination is the standard cursor contract, shared via the sibling
|
|
70
|
+
module's ``cursor_page_advance`` (real at scale: a 2-day
|
|
71
|
+
vehicle window walked 3 pages/267 reports); ``first_request``
|
|
72
|
+
injects ``limit`` exactly as the cursor decoder does.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
records_key: The top-level key holding the report container
|
|
76
|
+
object (``'data'``).
|
|
77
|
+
report_key: The container key holding this surface's report
|
|
78
|
+
list (``'vehicleReports'`` / ``'driverReports'``).
|
|
79
|
+
results_limit: The per-page record count requested via the
|
|
80
|
+
``limit`` query parameter (pagination parameters are the
|
|
81
|
+
decoder's, per the ``StaticGetSpecBuilder`` seam).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
records_key: str
|
|
85
|
+
report_key: str
|
|
86
|
+
results_limit: int
|
|
87
|
+
|
|
88
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
89
|
+
"""Send page one via the shared first-page shape (``first_page_spec``)."""
|
|
90
|
+
return first_page_spec(spec, self.results_limit)
|
|
91
|
+
|
|
92
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
93
|
+
"""Extract the nested reports, stamp each with the sent window.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
sent: The spec that produced this page.
|
|
97
|
+
envelope: The parsed response body.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
One record per report, each carrying the synthesized
|
|
101
|
+
``windowStartDate``/``windowEndDate`` keys (class
|
|
102
|
+
docstring); the pagination verdict is the shared cursor
|
|
103
|
+
contract's.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ProviderResponseError: The sent spec lacks a window param
|
|
107
|
+
(a wiring bug -- never silently unstamped rows), the
|
|
108
|
+
nested record-bearing shape is structurally violating,
|
|
109
|
+
or the cursor block is (including continuation promised
|
|
110
|
+
without a cursor).
|
|
111
|
+
"""
|
|
112
|
+
window_stamp = window_stamp_from_sent_spec(
|
|
113
|
+
sent, start_param=_WINDOW_START_PARAM, end_param=_WINDOW_END_PARAM
|
|
114
|
+
)
|
|
115
|
+
reports = require_record_list(
|
|
116
|
+
require_child_object(envelope, self.records_key), self.report_key
|
|
117
|
+
)
|
|
118
|
+
stamped = [{**report, **window_stamp} for report in reports]
|
|
119
|
+
return DecodedPage(records=stamped, advance=cursor_page_advance(sent, envelope))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# src/fleetpull/network/decoders/single_page.py
|
|
2
|
+
"""Single-page decoder: top-level-list records, no pagination.
|
|
3
|
+
|
|
4
|
+
For unpaginated endpoints -- replaces any is-paginated flag. The first
|
|
5
|
+
request is the base spec unchanged and every page is terminal. Decoder
|
|
6
|
+
logic deliberately resembles its siblings without sharing code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from fleetpull.network.contract import (
|
|
12
|
+
DecodedPage,
|
|
13
|
+
PageAdvance,
|
|
14
|
+
RequestSpec,
|
|
15
|
+
require_record_list,
|
|
16
|
+
)
|
|
17
|
+
from fleetpull.vocabulary import JsonValue
|
|
18
|
+
|
|
19
|
+
__all__: list[str] = ['SinglePageDecoder']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class SinglePageDecoder:
|
|
24
|
+
"""Decode a single unpaginated page of top-level-list records.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
records_key: The top-level key holding the record list.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
records_key: str
|
|
31
|
+
|
|
32
|
+
def first_request(self, spec: RequestSpec) -> RequestSpec:
|
|
33
|
+
"""Return the base spec unchanged."""
|
|
34
|
+
return spec
|
|
35
|
+
|
|
36
|
+
def decode_page(self, sent: RequestSpec, envelope: JsonValue) -> DecodedPage:
|
|
37
|
+
"""Extract the records; the page is always terminal.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
sent: The spec that produced this page (unused; there is no
|
|
41
|
+
continuation).
|
|
42
|
+
envelope: The parsed response body.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The records and a terminal verdict.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ProviderResponseError: When the record-bearing shape is
|
|
49
|
+
structurally violating.
|
|
50
|
+
"""
|
|
51
|
+
records = require_record_list(envelope, self.records_key)
|
|
52
|
+
return DecodedPage(
|
|
53
|
+
records=records,
|
|
54
|
+
advance=PageAdvance(next_spec=None, durable_progress=None),
|
|
55
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Rate limiting: token-bucket limiter and registry, one per quota scope."""
|
|
2
|
+
|
|
3
|
+
from fleetpull.network.limits.limiter import QuotaScopeLimiter
|
|
4
|
+
from fleetpull.network.limits.registry import (
|
|
5
|
+
RateLimiterRegistry,
|
|
6
|
+
rate_limits_from_configs,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = [
|
|
10
|
+
'QuotaScopeLimiter',
|
|
11
|
+
'RateLimiterRegistry',
|
|
12
|
+
'rate_limits_from_configs',
|
|
13
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# src/fleetpull/network/limits/bucket_math.py
|
|
2
|
+
"""Pure token-bucket arithmetic.
|
|
3
|
+
|
|
4
|
+
Stateless functions with no clock, no threading, and no state — just math.
|
|
5
|
+
They exist so the bucket arithmetic is exhaustively testable single-threaded
|
|
6
|
+
with plain assertions, leaving ``limiter.py`` to hold only state and thread
|
|
7
|
+
choreography.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__all__: list[str] = ['refill_tokens', 'seconds_until_available']
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def refill_tokens(
|
|
14
|
+
current_tokens: float,
|
|
15
|
+
elapsed_seconds: float,
|
|
16
|
+
refill_rate_per_second: float,
|
|
17
|
+
capacity: float,
|
|
18
|
+
) -> float:
|
|
19
|
+
"""Compute the token count after lazily refilling for elapsed time.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
current_tokens: Tokens in the bucket before the refill.
|
|
23
|
+
elapsed_seconds: Seconds since the last refill (must be >= 0).
|
|
24
|
+
refill_rate_per_second: Tokens added per second.
|
|
25
|
+
capacity: Maximum tokens the bucket holds.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
``min(capacity, current_tokens + elapsed_seconds * refill_rate_per_second)``.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If ``elapsed_seconds`` is negative. A monotonic clock can
|
|
32
|
+
never produce one; if it appears, something upstream is broken
|
|
33
|
+
and must fail loudly.
|
|
34
|
+
"""
|
|
35
|
+
if elapsed_seconds < 0:
|
|
36
|
+
raise ValueError(f'elapsed_seconds must be non-negative, got {elapsed_seconds}')
|
|
37
|
+
return min(capacity, current_tokens + elapsed_seconds * refill_rate_per_second)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def seconds_until_available(
|
|
41
|
+
current_tokens: float,
|
|
42
|
+
refill_rate_per_second: float,
|
|
43
|
+
tokens_needed: float = 1.0,
|
|
44
|
+
) -> float:
|
|
45
|
+
"""Compute the exact wait until the bucket holds ``tokens_needed`` tokens.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
current_tokens: Tokens currently in the bucket.
|
|
49
|
+
refill_rate_per_second: Tokens added per second.
|
|
50
|
+
tokens_needed: Tokens required before proceeding.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
0.0 if ``current_tokens >= tokens_needed``; otherwise the seconds
|
|
54
|
+
needed for the deficit to refill at ``refill_rate_per_second``.
|
|
55
|
+
"""
|
|
56
|
+
if current_tokens >= tokens_needed:
|
|
57
|
+
return 0.0
|
|
58
|
+
return (tokens_needed - current_tokens) / refill_rate_per_second
|