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,455 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/watermark_drive.py
|
|
2
|
+
"""The watermark drive: the run executor's plan-and-drive unit-loop arm.
|
|
3
|
+
|
|
4
|
+
``WatermarkDrive`` drives one watermark endpoint's run (DESIGN sections 4/5):
|
|
5
|
+
re-claim any incomplete work units and drive them (``backfill_unit_workers``
|
|
6
|
+
concurrently), then resolve the residual window exactly as before --
|
|
7
|
+
watermark less lookback (floored), else coverage frontier, else the
|
|
8
|
+
cold-start anchor, against the cutoff trailing edge -- plan it into
|
|
9
|
+
``backfill_chunk_days`` units (or the endpoint's declared
|
|
10
|
+
``fixed_unit_days``, which wins over config), and drive those. Every unit is
|
|
11
|
+
its own transaction: fetch the unit's window (the fan-out threads unchanged
|
|
12
|
+
within it), write parquet, record the ledger row, and mark the unit done
|
|
13
|
+
with its folded observation. The watermark advances on the PREFIX-ADVANCE
|
|
14
|
+
rule (2026-07-20): after each completion, to the maximum observation across
|
|
15
|
+
the contiguous done-prefix, through the cursor store's atomic forward-only
|
|
16
|
+
write -- so units may complete in any order and every persisted watermark is
|
|
17
|
+
still true at every instant; a crash resumes from the work-units ledger
|
|
18
|
+
instead of refetching the window. The pure resume decisions live in
|
|
19
|
+
``orchestrator/resume.py``, the per-batch transform in
|
|
20
|
+
``orchestrator/batch.py``, and the claim choreography in
|
|
21
|
+
``orchestrator/unit_loop.py``, so the drive only orchestrates -- read state,
|
|
22
|
+
call pure functions, write state.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Iterator, Sequence
|
|
27
|
+
from datetime import datetime, timedelta
|
|
28
|
+
from functools import partial
|
|
29
|
+
|
|
30
|
+
from fleetpull.endpoints.shared import EndpointDefinition, WatermarkMode
|
|
31
|
+
from fleetpull.exceptions import ConfigurationError
|
|
32
|
+
from fleetpull.incremental import (
|
|
33
|
+
DateWindow,
|
|
34
|
+
resolve_resume_start,
|
|
35
|
+
resolve_trailing_edge,
|
|
36
|
+
window_or_none,
|
|
37
|
+
)
|
|
38
|
+
from fleetpull.model_contract import ResponseModel
|
|
39
|
+
from fleetpull.orchestrator.backfill import plan_backfill_units
|
|
40
|
+
from fleetpull.orchestrator.batch import ProcessedBatch, WindowContext
|
|
41
|
+
from fleetpull.orchestrator.drivers import RequestDriver
|
|
42
|
+
from fleetpull.orchestrator.outcome import CaughtUp, Executed, RunOutcome
|
|
43
|
+
from fleetpull.orchestrator.recording import recorded_run
|
|
44
|
+
from fleetpull.orchestrator.resume import resolve_watermark_start
|
|
45
|
+
from fleetpull.orchestrator.spine import RunnerSpine
|
|
46
|
+
from fleetpull.orchestrator.streaming import (
|
|
47
|
+
BatchObserver,
|
|
48
|
+
drain_batches,
|
|
49
|
+
observe_batches,
|
|
50
|
+
stream_processed_batches,
|
|
51
|
+
)
|
|
52
|
+
from fleetpull.orchestrator.unit_loop import UnitCrew, drive_claimable_units
|
|
53
|
+
from fleetpull.storage import WriteResult
|
|
54
|
+
from fleetpull.timing import to_iso8601
|
|
55
|
+
from fleetpull.vocabulary import Provider
|
|
56
|
+
|
|
57
|
+
__all__: list[str] = ['WatermarkDrive']
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _window_context(
|
|
63
|
+
definition: EndpointDefinition[ResponseModel], window: DateWindow, now: datetime
|
|
64
|
+
) -> WindowContext:
|
|
65
|
+
"""Build the per-batch transform context, asserting the event-time column.
|
|
66
|
+
|
|
67
|
+
A watermark endpoint always declares an ``event_time_column`` (the endpoint
|
|
68
|
+
definition forbids otherwise); this narrows it for the type checker and fails
|
|
69
|
+
loudly if that invariant is ever broken.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
definition: The watermark endpoint being run.
|
|
73
|
+
window: The run's half-open window.
|
|
74
|
+
now: The run instant (the future-event guard's bound).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The ``WindowContext`` for ``process_batch``.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ConfigurationError: The endpoint declares no event-time column.
|
|
81
|
+
"""
|
|
82
|
+
event_time_column = definition.event_time_column
|
|
83
|
+
if event_time_column is None:
|
|
84
|
+
raise ConfigurationError(
|
|
85
|
+
'watermark endpoint has no event_time_column',
|
|
86
|
+
provider=definition.provider.value,
|
|
87
|
+
endpoint=definition.name,
|
|
88
|
+
)
|
|
89
|
+
return WindowContext(window=window, now=now, event_time_column=event_time_column)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _merge_executed(outcomes: Sequence[Executed]) -> Executed:
|
|
93
|
+
"""Fold the driven units' outcomes into the invocation's one ``Executed``.
|
|
94
|
+
|
|
95
|
+
Counts sum; the pruned partitions concatenate in drive order (a residual
|
|
96
|
+
unit re-covering a leftover unit's dates may repeat one -- the report is
|
|
97
|
+
informational, never consumed as a set).
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
outcomes: The per-unit outcomes, in drive order; at least one.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The aggregated ``Executed``.
|
|
104
|
+
"""
|
|
105
|
+
observations = [
|
|
106
|
+
outcome.latest_observed
|
|
107
|
+
for outcome in outcomes
|
|
108
|
+
if outcome.latest_observed is not None
|
|
109
|
+
]
|
|
110
|
+
return Executed(
|
|
111
|
+
records_fetched=sum(outcome.records_fetched for outcome in outcomes),
|
|
112
|
+
write=WriteResult(
|
|
113
|
+
rows_written=sum(outcome.write.rows_written for outcome in outcomes),
|
|
114
|
+
duplicates_dropped=sum(
|
|
115
|
+
outcome.write.duplicates_dropped for outcome in outcomes
|
|
116
|
+
),
|
|
117
|
+
files_written=sum(outcome.write.files_written for outcome in outcomes),
|
|
118
|
+
deleted_partitions=tuple(
|
|
119
|
+
deleted_date
|
|
120
|
+
for outcome in outcomes
|
|
121
|
+
for deleted_date in outcome.write.deleted_partitions
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
latest_observed=max(observations) if observations else None,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class WatermarkDrive:
|
|
129
|
+
"""Drives one watermark endpoint's run through the plan-and-drive loop.
|
|
130
|
+
|
|
131
|
+
Constructed once by ``EndpointRunner.__init__`` from the shared spine;
|
|
132
|
+
``run`` takes the endpoint, its request driver, its mode, and the
|
|
133
|
+
optional per-frame observer.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self, spine: RunnerSpine) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Args:
|
|
139
|
+
spine: The shared drive kit -- collaborators plus the runner's
|
|
140
|
+
writer-factory and projection seams.
|
|
141
|
+
"""
|
|
142
|
+
self._spine = spine
|
|
143
|
+
|
|
144
|
+
def run(
|
|
145
|
+
self,
|
|
146
|
+
definition: EndpointDefinition[ResponseModel],
|
|
147
|
+
driver: RequestDriver,
|
|
148
|
+
mode: WatermarkMode,
|
|
149
|
+
observer: BatchObserver | None,
|
|
150
|
+
) -> RunOutcome:
|
|
151
|
+
"""Run the watermark arm: the plan-and-drive unit loop.
|
|
152
|
+
|
|
153
|
+
Incomplete units outrank the watermark: orphaned ``claimed`` units are
|
|
154
|
+
reset (an in-progress unit found at run start is by definition
|
|
155
|
+
orphaned -- fleetpull assumes a single driver per state database) and
|
|
156
|
+
every claimable unit is re-claimed and driven,
|
|
157
|
+
``backfill_unit_workers`` at a time. Then the residual window is
|
|
158
|
+
resolved exactly as before and planned into units
|
|
159
|
+
(``_plan_residual_units``), and those are driven the same
|
|
160
|
+
way. Each unit commits
|
|
161
|
+
independently; the watermark advances per completion across the
|
|
162
|
+
contiguous done-prefix (the prefix-advance rule, so out-of-order
|
|
163
|
+
completions never overstate it); a failing unit returns to a
|
|
164
|
+
claimable state and fails the endpoint after in-flight siblings
|
|
165
|
+
finish. An invocation that drove nothing is ``CaughtUp``
|
|
166
|
+
-- the resume point had reached the trailing edge, or every planned
|
|
167
|
+
unit was already complete. After the last unit commits, the merged
|
|
168
|
+
outcome projects into ``metadata.json`` (post-commit, best-effort);
|
|
169
|
+
a ``CaughtUp`` invocation writes nothing.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
definition: The watermark endpoint to run.
|
|
173
|
+
driver: The request driver supplying each unit's batches.
|
|
174
|
+
mode: The endpoint's watermark mode (lookback and cutoff).
|
|
175
|
+
observer: The optional per-frame hook, applied within every unit.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
``Executed`` aggregated over the driven units, or ``CaughtUp``
|
|
179
|
+
when nothing was driven.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
ConfigurationError: A stored watermark dated after ``now`` (Guard A), a
|
|
183
|
+
cross-mode feed cursor on this endpoint, or a missing event-time
|
|
184
|
+
column.
|
|
185
|
+
FleetpullError: A fetch, validation, write, guard, or completion failure
|
|
186
|
+
-- the failed unit's run is recorded failed, the unit returns to a
|
|
187
|
+
claimable state, and the error re-raises.
|
|
188
|
+
"""
|
|
189
|
+
provider = definition.provider
|
|
190
|
+
name = definition.name
|
|
191
|
+
state = self._spine.state
|
|
192
|
+
# The cursor guards (Guard A, cross-mode) fire before any unit
|
|
193
|
+
# drives; the residual resolution below re-derives this value after
|
|
194
|
+
# the leftover units have advanced the cursor.
|
|
195
|
+
resolve_watermark_start(
|
|
196
|
+
state.cursors.get_cursor(provider, name),
|
|
197
|
+
mode.lookback,
|
|
198
|
+
self._spine.clock.now_utc(),
|
|
199
|
+
provider,
|
|
200
|
+
name,
|
|
201
|
+
)
|
|
202
|
+
state.units.reset_claimed_to_pending(provider, name)
|
|
203
|
+
commit_prefix = partial(self._commit_watermark_prefix, provider, name)
|
|
204
|
+
# Heal a crash that landed between a unit's done-mark and its prefix
|
|
205
|
+
# commit: the prefix read is cheap and the advance is atomic and
|
|
206
|
+
# forward-only, so an up-to-date cursor makes this a no-op.
|
|
207
|
+
commit_prefix()
|
|
208
|
+
crew = UnitCrew(
|
|
209
|
+
queue=state.units,
|
|
210
|
+
provider=provider,
|
|
211
|
+
endpoint=name,
|
|
212
|
+
drive_unit=partial(self._drive_unit, definition, driver, observer),
|
|
213
|
+
commit_prefix=commit_prefix,
|
|
214
|
+
)
|
|
215
|
+
workers = self._spine.sync.backfill_unit_workers
|
|
216
|
+
outcomes = drive_claimable_units(crew, workers=workers)
|
|
217
|
+
residual = self._resolve_residual_window(definition, mode)
|
|
218
|
+
if residual is not None:
|
|
219
|
+
self._plan_residual_units(definition, mode, residual)
|
|
220
|
+
outcomes.extend(drive_claimable_units(crew, workers=workers))
|
|
221
|
+
if not outcomes:
|
|
222
|
+
logger.info('caught up: provider=%s endpoint=%s', provider.value, name)
|
|
223
|
+
return CaughtUp()
|
|
224
|
+
merged = _merge_executed(outcomes)
|
|
225
|
+
# The residual window is the run's resolved window; a run that only
|
|
226
|
+
# re-drove leftover units resolved none, and its projection carries a
|
|
227
|
+
# null window.
|
|
228
|
+
self._spine.projection.project(definition, merged, window=residual)
|
|
229
|
+
return merged
|
|
230
|
+
|
|
231
|
+
def _commit_watermark_prefix(self, provider: Provider, name: str) -> None:
|
|
232
|
+
"""Advance the watermark across the contiguous done-prefix.
|
|
233
|
+
|
|
234
|
+
The prefix-advance rule's commit (DESIGN section 5, 2026-07-20):
|
|
235
|
+
read the maximum observation over the endpoint's contiguous
|
|
236
|
+
done-prefix and advance the watermark to it through the store's
|
|
237
|
+
atomic forward-only write. Invoked after every unit completion (and
|
|
238
|
+
once at run start, healing a crash between a done-mark and its
|
|
239
|
+
commit); concurrent invocations are safe -- the guard lives inside
|
|
240
|
+
the store's statement, so a stale prefix read can never write the
|
|
241
|
+
cursor backward.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
provider: The provider whose watermark to commit.
|
|
245
|
+
name: The endpoint whose watermark to commit.
|
|
246
|
+
|
|
247
|
+
Side Effects:
|
|
248
|
+
May advance the endpoint's cursor row.
|
|
249
|
+
"""
|
|
250
|
+
state = self._spine.state
|
|
251
|
+
observation = state.units.done_prefix_observation(provider, name)
|
|
252
|
+
if observation is not None:
|
|
253
|
+
state.cursors.advance_watermark_forward(provider, name, observation)
|
|
254
|
+
|
|
255
|
+
def _resolve_residual_window(
|
|
256
|
+
self,
|
|
257
|
+
definition: EndpointDefinition[ResponseModel],
|
|
258
|
+
mode: WatermarkMode,
|
|
259
|
+
) -> DateWindow | None:
|
|
260
|
+
"""Resolve the not-yet-planned residual window, exactly as the resume chain.
|
|
261
|
+
|
|
262
|
+
The same resolution the whole-window arm performed, run after the
|
|
263
|
+
leftover units have driven: the stored watermark less the lookback
|
|
264
|
+
margin (floored to its UTC midnight), else the coverage frontier,
|
|
265
|
+
else the cold-start anchor -- against the cutoff-held trailing edge.
|
|
266
|
+
Both bounds are midnight-aligned by construction, which is what makes
|
|
267
|
+
the result plannable into whole-day units.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
definition: The watermark endpoint being run.
|
|
271
|
+
mode: The endpoint's watermark mode (lookback and cutoff).
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
The residual ``DateWindow``, or ``None`` when the resume point
|
|
275
|
+
has reached the trailing edge (nothing new to plan).
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
ConfigurationError: A stored watermark dated after ``now`` (Guard
|
|
279
|
+
A) or a cross-mode feed cursor (from
|
|
280
|
+
``resolve_watermark_start``).
|
|
281
|
+
"""
|
|
282
|
+
state = self._spine.state
|
|
283
|
+
now = self._spine.clock.now_utc()
|
|
284
|
+
end = resolve_trailing_edge(now, mode.cutoff)
|
|
285
|
+
stored = state.cursors.get_cursor(definition.provider, definition.name)
|
|
286
|
+
watermark_start = resolve_watermark_start(
|
|
287
|
+
stored, mode.lookback, now, definition.provider, definition.name
|
|
288
|
+
)
|
|
289
|
+
frontier = state.recorder.coverage_frontier(
|
|
290
|
+
definition.provider, definition.name
|
|
291
|
+
)
|
|
292
|
+
start = resolve_resume_start(
|
|
293
|
+
watermark_start, frontier, self._spine.sync.default_start_datetime
|
|
294
|
+
)
|
|
295
|
+
return window_or_none(start, end)
|
|
296
|
+
|
|
297
|
+
def _plan_residual_units(
|
|
298
|
+
self,
|
|
299
|
+
definition: EndpointDefinition[ResponseModel],
|
|
300
|
+
mode: WatermarkMode,
|
|
301
|
+
residual: DateWindow,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Tile the residual window into work units and enqueue them.
|
|
304
|
+
|
|
305
|
+
The residual-planning step of the plan-and-drive loop: release the
|
|
306
|
+
window's committed ``done`` units back to plannable (the
|
|
307
|
+
release-then-enqueue pairing -- the lookback margin deliberately
|
|
308
|
+
re-covers committed days, and day-aligned tiles re-tile onto
|
|
309
|
+
identical unit keys, which the idempotent enqueue would otherwise
|
|
310
|
+
collapse onto and never re-fetch), pick the unit width (the
|
|
311
|
+
endpoint's declared ``fixed_unit_days`` wins over config -- on a
|
|
312
|
+
window-grain rollup surface the unit width is part of the row's
|
|
313
|
+
meaning, so it must never float with ``sync.backfill_chunk_days``;
|
|
314
|
+
config remains the default for endpoints declaring ``None``), tile
|
|
315
|
+
the window into units, enqueue them, and narrate the plan.
|
|
316
|
+
|
|
317
|
+
This is the ONE sanctioned row-deletion site: the claim loop has
|
|
318
|
+
drained (a failing leftover aborts the run before planning), so
|
|
319
|
+
every released row is committed history, and the same call
|
|
320
|
+
re-tiles the released span hole-free -- the prefix rule's
|
|
321
|
+
gap-unreachability re-derivation (DESIGN §5, 2026-07-21).
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
definition: The watermark endpoint being run.
|
|
325
|
+
mode: The endpoint's watermark mode (the fixed unit width, when
|
|
326
|
+
declared).
|
|
327
|
+
residual: The resolved residual window to plan.
|
|
328
|
+
|
|
329
|
+
Side Effects:
|
|
330
|
+
Releases the window's done units, enqueues the planned units,
|
|
331
|
+
and emits one INFO line.
|
|
332
|
+
"""
|
|
333
|
+
provider = definition.provider
|
|
334
|
+
name = definition.name
|
|
335
|
+
released_units = self._spine.state.units.release_done_units(
|
|
336
|
+
provider, name, window=residual
|
|
337
|
+
)
|
|
338
|
+
chunk_days = (
|
|
339
|
+
self._spine.sync.backfill_chunk_days
|
|
340
|
+
if mode.fixed_unit_days is None
|
|
341
|
+
else mode.fixed_unit_days
|
|
342
|
+
)
|
|
343
|
+
chunk = timedelta(days=chunk_days)
|
|
344
|
+
claimable_units = self._spine.state.units.enqueue(
|
|
345
|
+
plan_backfill_units(provider, name, residual, chunk)
|
|
346
|
+
)
|
|
347
|
+
logger.info(
|
|
348
|
+
'window planned: provider=%s endpoint=%s window_start=%s '
|
|
349
|
+
'window_end=%s claimable_units=%d released_units=%d',
|
|
350
|
+
provider.value,
|
|
351
|
+
name,
|
|
352
|
+
to_iso8601(residual.start),
|
|
353
|
+
to_iso8601(residual.end),
|
|
354
|
+
claimable_units,
|
|
355
|
+
released_units,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _drive_unit(
|
|
359
|
+
self,
|
|
360
|
+
definition: EndpointDefinition[ResponseModel],
|
|
361
|
+
driver: RequestDriver,
|
|
362
|
+
observer: BatchObserver | None,
|
|
363
|
+
window: DateWindow,
|
|
364
|
+
) -> Executed:
|
|
365
|
+
"""Drive one unit: fetch its window, write, record.
|
|
366
|
+
|
|
367
|
+
The per-unit transaction the claim loop invokes: build the unit's
|
|
368
|
+
batch stream (the fan-out threads the unit's (member x window) pieces
|
|
369
|
+
on the ``FetchPool``, unchanged) and run it through the commit spine.
|
|
370
|
+
The unit's folded observation rides the returned outcome; the
|
|
371
|
+
watermark advance is the unit loop's prefix commit, never this
|
|
372
|
+
drive's.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
definition: The watermark endpoint being run.
|
|
376
|
+
driver: The request driver supplying the unit's batches.
|
|
377
|
+
observer: The optional per-frame hook.
|
|
378
|
+
window: The unit's half-open window.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
The unit's ``Executed`` outcome, its ``latest_observed`` carrying
|
|
382
|
+
the folded in-window maximum (or ``None`` for an empty unit).
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
FleetpullError: A fetch, validation, write, or completion failure
|
|
386
|
+
-- the unit's run is recorded failed and the error re-raised.
|
|
387
|
+
"""
|
|
388
|
+
client = self._spine.clients.client_for(definition.provider)
|
|
389
|
+
now = self._spine.clock.now_utc()
|
|
390
|
+
context = _window_context(definition, window, now)
|
|
391
|
+
batches = observe_batches(
|
|
392
|
+
stream_processed_batches(
|
|
393
|
+
definition, driver, client, resume=window, context=context
|
|
394
|
+
),
|
|
395
|
+
observer,
|
|
396
|
+
)
|
|
397
|
+
return self._execute_window(definition, batches, context)
|
|
398
|
+
|
|
399
|
+
def _execute_window(
|
|
400
|
+
self,
|
|
401
|
+
definition: EndpointDefinition[ResponseModel],
|
|
402
|
+
batches: Iterator[ProcessedBatch],
|
|
403
|
+
context: WindowContext,
|
|
404
|
+
) -> Executed:
|
|
405
|
+
"""Run one window: open, consume/write/finalize, complete.
|
|
406
|
+
|
|
407
|
+
The per-unit commit spine. It consumes an already-built
|
|
408
|
+
processed-batch stream (the caller constructs it, wrapping in the
|
|
409
|
+
batch observer where one applies), so the spine is blind to fetch
|
|
410
|
+
mechanics. Opens a window run, writes each batch's in-window frame,
|
|
411
|
+
folds the observed maximum, finalizes, and completes the run. The
|
|
412
|
+
fold rides the returned outcome; the watermark advance belongs to
|
|
413
|
+
the unit loop's prefix commit. The per-unit crash order is parquet
|
|
414
|
+
-> ``complete_run`` (the ledger, here) -> ``mark_done`` (observed)
|
|
415
|
+
-> prefix commit (both one level up): a crash after completion but
|
|
416
|
+
before the done-mark leaves the unit claimable, so the next
|
|
417
|
+
invocation re-drives it whole before resolving any residual --
|
|
418
|
+
unit-gating plus the run-start prefix heal replace the retired
|
|
419
|
+
cursor-before-completion ordering (DESIGN section 14).
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
definition: The endpoint being run.
|
|
423
|
+
batches: The unit's processed-batch stream, ready to consume.
|
|
424
|
+
context: The window, run instant, and event-time column for the
|
|
425
|
+
per-batch transform.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
``Executed`` with the fetched-row count, the write report, and
|
|
429
|
+
the folded in-window maximum.
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
FleetpullError: A fetch, validation, write, or completion failure -- the
|
|
433
|
+
run is recorded failed and the original error re-raised.
|
|
434
|
+
"""
|
|
435
|
+
window = context.window
|
|
436
|
+
recorder = self._spine.state.recorder
|
|
437
|
+
run_id = recorder.start_window_run(
|
|
438
|
+
definition.provider, definition.name, window=window
|
|
439
|
+
)
|
|
440
|
+
with recorded_run(recorder, run_id):
|
|
441
|
+
writer = self._spine.make_writer(definition, window)
|
|
442
|
+
records_fetched, latest_observed = drain_batches(batches, writer)
|
|
443
|
+
write = writer.finalize()
|
|
444
|
+
# The cursor deliberately does NOT advance here: the unit's
|
|
445
|
+
# observation rides the outcome to the unit loop, which records
|
|
446
|
+
# it at mark_done and advances the watermark across the
|
|
447
|
+
# contiguous done-prefix only (the prefix-advance rule, DESIGN
|
|
448
|
+
# section 5) -- a per-unit advance would overstate coverage the
|
|
449
|
+
# moment units complete out of order.
|
|
450
|
+
recorder.complete_run(run_id, row_count=records_fetched)
|
|
451
|
+
return Executed(
|
|
452
|
+
records_fetched=records_fetched,
|
|
453
|
+
write=write,
|
|
454
|
+
latest_observed=latest_observed,
|
|
455
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# src/fleetpull/paths/__init__.py
|
|
2
|
+
"""Filesystem path expansion, normalization, and dataset-layout utilities."""
|
|
3
|
+
|
|
4
|
+
from fleetpull.paths.datasets import endpoint_directory
|
|
5
|
+
from fleetpull.paths.partitions import date_partition_segment
|
|
6
|
+
from fleetpull.paths.resolution import PathInput, resolve_path
|
|
7
|
+
|
|
8
|
+
__all__: list[str] = [
|
|
9
|
+
'PathInput',
|
|
10
|
+
'date_partition_segment',
|
|
11
|
+
'endpoint_directory',
|
|
12
|
+
'resolve_path',
|
|
13
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# src/fleetpull/paths/datasets.py
|
|
2
|
+
"""Dataset-layout path construction: locate an endpoint's directory under a
|
|
3
|
+
dataset root.
|
|
4
|
+
|
|
5
|
+
The shared, filesystem-neutral half of storage path-building. The parquet
|
|
6
|
+
writers and the metadata projection's caller (the runner, which resolves the
|
|
7
|
+
directory and hands it to ``storage.write_metadata_json``) locate an
|
|
8
|
+
endpoint's directory through this one function, so the construction lives in
|
|
9
|
+
``paths`` -- pure and shared. Like the rest of ``paths``, it never touches
|
|
10
|
+
the filesystem; directory creation is the writing layer's concern.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from fleetpull.paths.resolution import PathInput, resolve_path
|
|
16
|
+
|
|
17
|
+
__all__: list[str] = ['endpoint_directory']
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def endpoint_directory(dataset_root: PathInput, provider: str, endpoint: str) -> Path:
|
|
21
|
+
"""Build the directory holding one endpoint's output files.
|
|
22
|
+
|
|
23
|
+
The dataset is laid out one directory per ``(provider, endpoint)`` under the
|
|
24
|
+
root (DESIGN §3): ``{root}/{provider}/{endpoint}/``. The provider and endpoint
|
|
25
|
+
are passed as their directory-name strings (e.g. ``definition.provider.value``
|
|
26
|
+
and ``definition.name`` at the call site), so ``paths`` need not import the
|
|
27
|
+
vocabulary or endpoints layers.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
dataset_root: The dataset root path, normalized via ``resolve_path``.
|
|
31
|
+
provider: The provider directory name (e.g. ``'motive'``).
|
|
32
|
+
endpoint: The endpoint directory name (e.g. ``'vehicles'``).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The absolute, normalized endpoint directory path. Not created -- the
|
|
36
|
+
writing layer creates it.
|
|
37
|
+
"""
|
|
38
|
+
return resolve_path(dataset_root) / provider / endpoint
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# src/fleetpull/paths/partitions.py
|
|
2
|
+
"""Hive date-partition directory-name construction: the ``date=YYYY-MM-DD``
|
|
3
|
+
segment a date-partitioned dataset uses.
|
|
4
|
+
|
|
5
|
+
The shared, filesystem-neutral translation from a partition ``date`` to the
|
|
6
|
+
hive directory-name segment that encodes it, built for the write path (the
|
|
7
|
+
storage layer joins it under an endpoint directory). The segment grammar is
|
|
8
|
+
pinned by a direct test of this function's output; a strict inverse parser
|
|
9
|
+
existed here but was deleted with no production caller -- a future
|
|
10
|
+
directory-reading layer re-derives it from the pinned grammar if one ever
|
|
11
|
+
lands.
|
|
12
|
+
|
|
13
|
+
A pure leaf -- stdlib ``date`` only, imports nothing internal, never touches the
|
|
14
|
+
filesystem (directory creation and scanning are the writing/reading layers'
|
|
15
|
+
concerns). It lives in ``paths`` rather than ``storage`` because the hive
|
|
16
|
+
``date=`` convention is a shared structural fact about the dataset layout -- the
|
|
17
|
+
storage write path builds it and BigQuery external tables read it natively --
|
|
18
|
+
not parquet-specific arithmetic like the ``part.parquet`` filename, which is
|
|
19
|
+
storage's.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from datetime import date
|
|
23
|
+
|
|
24
|
+
__all__: list[str] = ['date_partition_segment']
|
|
25
|
+
|
|
26
|
+
_PARTITION_PREFIX: str = 'date='
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def date_partition_segment(partition_date: date) -> str:
|
|
30
|
+
"""Build the hive partition directory-name segment for a date.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
partition_date: The partition's calendar date.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The hive segment ``'date=YYYY-MM-DD'`` (e.g. ``'date=2026-06-01'``).
|
|
37
|
+
|
|
38
|
+
Side Effects:
|
|
39
|
+
None -- pure function.
|
|
40
|
+
"""
|
|
41
|
+
return f'{_PARTITION_PREFIX}{partition_date.isoformat()}'
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# src/fleetpull/paths/resolution.py
|
|
2
|
+
"""Path expansion and lexical absolute-path normalization.
|
|
3
|
+
|
|
4
|
+
The resolution leaf of the ``paths`` package — a dependency-free utility used by
|
|
5
|
+
config, state, storage, and anywhere else that needs to turn a user-provided path
|
|
6
|
+
into an absolute, normalized ``Path``. It performs only lexical resolution —
|
|
7
|
+
expanding ``~`` and anchoring relative paths to the current working directory —
|
|
8
|
+
and never touches the filesystem: no existence check, no directory creation, no
|
|
9
|
+
symlink dereference. Domain meaning (is this a file or a directory? must it exist?
|
|
10
|
+
is it on local disk?) belongs to the layer that knows what the path is for.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
__all__: list[str] = ['PathInput', 'resolve_path']
|
|
17
|
+
|
|
18
|
+
type PathInput = str | Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_path(value: PathInput) -> Path:
|
|
22
|
+
"""
|
|
23
|
+
Expand and lexically normalize a user-provided filesystem path.
|
|
24
|
+
|
|
25
|
+
Resolution order:
|
|
26
|
+
1. Reject unsupported types and empty or whitespace-only strings.
|
|
27
|
+
2. Expand ``~`` and ``~user`` to the home directory via
|
|
28
|
+
:meth:`Path.expanduser`.
|
|
29
|
+
3. Anchor a still-relative path to the current working directory.
|
|
30
|
+
4. Lexically normalize — collapse ``.``, ``..``, and redundant
|
|
31
|
+
separators — via :func:`os.path.normpath`, without touching the
|
|
32
|
+
filesystem.
|
|
33
|
+
|
|
34
|
+
Purely lexical: no filesystem access, no symlink dereference, no existence
|
|
35
|
+
check, and no directory creation. An intentional symlink in the path is
|
|
36
|
+
preserved rather than canonicalized, and a path to something that does not
|
|
37
|
+
exist resolves fine — the caller decides what existence and on-disk
|
|
38
|
+
suitability mean.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
value: User-provided path. ``str`` and :class:`Path` are accepted; empty
|
|
42
|
+
or whitespace-only strings are rejected rather than silently resolving
|
|
43
|
+
to the current directory.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
An absolute, expanded, lexically normalized path.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
TypeError: ``value`` is not a ``str`` or :class:`Path`.
|
|
50
|
+
ValueError: ``value`` is empty or whitespace-only, or ``~`` cannot be
|
|
51
|
+
expanded (no resolvable home directory).
|
|
52
|
+
|
|
53
|
+
Side Effects:
|
|
54
|
+
Reads the current user's home directory and the current working
|
|
55
|
+
directory. Does not touch the target path on the filesystem.
|
|
56
|
+
"""
|
|
57
|
+
raw_path: str = _coerce_nonempty_str(value)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
expanded_path: Path = Path(raw_path).expanduser()
|
|
61
|
+
except RuntimeError as exc:
|
|
62
|
+
raise ValueError(f'path {raw_path!r} could not expand user home') from exc
|
|
63
|
+
|
|
64
|
+
anchored_path: Path
|
|
65
|
+
if expanded_path.is_absolute():
|
|
66
|
+
anchored_path = expanded_path
|
|
67
|
+
else:
|
|
68
|
+
anchored_path = Path.cwd() / expanded_path
|
|
69
|
+
|
|
70
|
+
normalized_path: str = os.path.normpath(anchored_path)
|
|
71
|
+
return Path(normalized_path)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _coerce_nonempty_str(value: PathInput) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Convert a supported path value to a non-empty string.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
value: Path ``str`` or :class:`Path` value.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Non-empty path string.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
TypeError: ``value`` is not a ``str`` or :class:`Path`.
|
|
86
|
+
ValueError: ``value`` is an empty or whitespace-only string.
|
|
87
|
+
|
|
88
|
+
Side Effects:
|
|
89
|
+
None.
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(value, str | Path):
|
|
92
|
+
raise TypeError(f'path value must be str or Path, got {type(value).__name__}')
|
|
93
|
+
|
|
94
|
+
raw_path: str = str(value)
|
|
95
|
+
if raw_path.strip() == '':
|
|
96
|
+
raise ValueError('path value must not be empty')
|
|
97
|
+
|
|
98
|
+
return raw_path
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# src/fleetpull/polars_typing/__init__.py
|
|
2
|
+
"""The single sanctioned boundary for Polars type aliases that lack a public
|
|
3
|
+
equivalent.
|
|
4
|
+
|
|
5
|
+
Polars exposes some annotation types only under the private ``polars._typing``
|
|
6
|
+
module (``ParquetCompression`` and others to come). A strict-typing project needs
|
|
7
|
+
the exact types -- a hand-mirrored ``Literal`` can silently drift from what Polars
|
|
8
|
+
actually accepts -- but importing a private module across the codebase scatters
|
|
9
|
+
the risk. Quarantining those imports here makes a Polars relocation a one-file
|
|
10
|
+
fix, the same blast-radius isolation the package applies elsewhere; the locked
|
|
11
|
+
Polars version keeps any such upgrade deliberate. Add aliases here as needed.
|
|
12
|
+
|
|
13
|
+
A one-file subpackage (not a root module) so an internal compat shim stays out of
|
|
14
|
+
the user-facing package root, and so importers reach it through a package face
|
|
15
|
+
rather than an underscore-prefixed module name (CLAUDE.md).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from polars._typing import ParquetCompression
|
|
19
|
+
|
|
20
|
+
__all__: list[str] = ['ParquetCompression']
|
fleetpull/py.typed
ADDED
|
File without changes
|