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.
Files changed (252) hide show
  1. fleetpull/__init__.py +36 -0
  2. fleetpull/api/__init__.py +32 -0
  3. fleetpull/api/auth_ingress.py +216 -0
  4. fleetpull/api/catalog.py +139 -0
  5. fleetpull/api/fetch.py +229 -0
  6. fleetpull/api/identity.py +90 -0
  7. fleetpull/api/sync.py +735 -0
  8. fleetpull/cli.py +146 -0
  9. fleetpull/config/__init__.py +47 -0
  10. fleetpull/config/base.py +18 -0
  11. fleetpull/config/example.py +90 -0
  12. fleetpull/config/geotab.py +78 -0
  13. fleetpull/config/http.py +32 -0
  14. fleetpull/config/loading.py +195 -0
  15. fleetpull/config/logger.py +139 -0
  16. fleetpull/config/providers.py +520 -0
  17. fleetpull/config/rate_limit.py +50 -0
  18. fleetpull/config/resolution.py +156 -0
  19. fleetpull/config/retry.py +69 -0
  20. fleetpull/config/root.py +157 -0
  21. fleetpull/config/sections.py +146 -0
  22. fleetpull/endpoints/__init__.py +24 -0
  23. fleetpull/endpoints/geotab/__init__.py +12 -0
  24. fleetpull/endpoints/geotab/_requests.py +427 -0
  25. fleetpull/endpoints/geotab/annotation_logs.py +73 -0
  26. fleetpull/endpoints/geotab/audits.py +70 -0
  27. fleetpull/endpoints/geotab/devices.py +79 -0
  28. fleetpull/endpoints/geotab/driver_changes.py +73 -0
  29. fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
  30. fleetpull/endpoints/geotab/dvir_logs.py +76 -0
  31. fleetpull/endpoints/geotab/exception_events.py +119 -0
  32. fleetpull/endpoints/geotab/fault_data.py +72 -0
  33. fleetpull/endpoints/geotab/fill_ups.py +79 -0
  34. fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
  35. fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
  36. fleetpull/endpoints/geotab/log_records.py +74 -0
  37. fleetpull/endpoints/geotab/media_files.py +74 -0
  38. fleetpull/endpoints/geotab/shipment_logs.py +71 -0
  39. fleetpull/endpoints/geotab/status_data.py +71 -0
  40. fleetpull/endpoints/geotab/text_messages.py +77 -0
  41. fleetpull/endpoints/geotab/trips.py +102 -0
  42. fleetpull/endpoints/geotab/users.py +81 -0
  43. fleetpull/endpoints/motive/__init__.py +12 -0
  44. fleetpull/endpoints/motive/_spec_builders.py +94 -0
  45. fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
  46. fleetpull/endpoints/motive/driving_periods.py +85 -0
  47. fleetpull/endpoints/motive/groups.py +61 -0
  48. fleetpull/endpoints/motive/idle_events.py +94 -0
  49. fleetpull/endpoints/motive/users.py +64 -0
  50. fleetpull/endpoints/motive/vehicle_locations.py +170 -0
  51. fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
  52. fleetpull/endpoints/motive/vehicles.py +106 -0
  53. fleetpull/endpoints/registry.py +280 -0
  54. fleetpull/endpoints/samsara/__init__.py +12 -0
  55. fleetpull/endpoints/samsara/_spec_builders.py +202 -0
  56. fleetpull/endpoints/samsara/addresses.py +77 -0
  57. fleetpull/endpoints/samsara/asset_locations.py +217 -0
  58. fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
  59. fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
  60. fleetpull/endpoints/samsara/drivers.py +169 -0
  61. fleetpull/endpoints/samsara/engine_states.py +114 -0
  62. fleetpull/endpoints/samsara/gps_readings.py +113 -0
  63. fleetpull/endpoints/samsara/idling_events.py +195 -0
  64. fleetpull/endpoints/samsara/odometer_readings.py +116 -0
  65. fleetpull/endpoints/samsara/trips.py +217 -0
  66. fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
  67. fleetpull/endpoints/samsara/vehicles.py +121 -0
  68. fleetpull/endpoints/shared/__init__.py +57 -0
  69. fleetpull/endpoints/shared/base.py +529 -0
  70. fleetpull/endpoints/shared/request_shape.py +227 -0
  71. fleetpull/endpoints/shared/resume.py +74 -0
  72. fleetpull/endpoints/shared/spec_builders.py +59 -0
  73. fleetpull/endpoints/shared/sync_mode.py +159 -0
  74. fleetpull/endpoints/shared/url_paths.py +149 -0
  75. fleetpull/exceptions.py +320 -0
  76. fleetpull/incremental/__init__.py +27 -0
  77. fleetpull/incremental/cursor.py +66 -0
  78. fleetpull/incremental/resolution.py +152 -0
  79. fleetpull/incremental/seed.py +53 -0
  80. fleetpull/incremental/window.py +94 -0
  81. fleetpull/logger/__init__.py +5 -0
  82. fleetpull/logger/setup.py +145 -0
  83. fleetpull/model_contract/__init__.py +6 -0
  84. fleetpull/model_contract/coercions.py +33 -0
  85. fleetpull/model_contract/response.py +45 -0
  86. fleetpull/models/__init__.py +7 -0
  87. fleetpull/models/geotab/__init__.py +166 -0
  88. fleetpull/models/geotab/annotation_log.py +109 -0
  89. fleetpull/models/geotab/audit.py +56 -0
  90. fleetpull/models/geotab/device.py +217 -0
  91. fleetpull/models/geotab/driver_change.py +102 -0
  92. fleetpull/models/geotab/duty_status_log.py +200 -0
  93. fleetpull/models/geotab/dvir_log.py +184 -0
  94. fleetpull/models/geotab/exception_event.py +135 -0
  95. fleetpull/models/geotab/fault_data.py +168 -0
  96. fleetpull/models/geotab/fill_up.py +189 -0
  97. fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
  98. fleetpull/models/geotab/fuel_tax_detail.py +150 -0
  99. fleetpull/models/geotab/log_record.py +68 -0
  100. fleetpull/models/geotab/media_file.py +133 -0
  101. fleetpull/models/geotab/shared.py +221 -0
  102. fleetpull/models/geotab/shipment_log.py +105 -0
  103. fleetpull/models/geotab/status_data.py +108 -0
  104. fleetpull/models/geotab/text_message.py +125 -0
  105. fleetpull/models/geotab/trip.py +162 -0
  106. fleetpull/models/geotab/user.py +187 -0
  107. fleetpull/models/motive/__init__.py +43 -0
  108. fleetpull/models/motive/driver_idle_rollup.py +109 -0
  109. fleetpull/models/motive/driving_period.py +82 -0
  110. fleetpull/models/motive/group.py +49 -0
  111. fleetpull/models/motive/idle_event.py +77 -0
  112. fleetpull/models/motive/shared.py +192 -0
  113. fleetpull/models/motive/user.py +217 -0
  114. fleetpull/models/motive/vehicle.py +162 -0
  115. fleetpull/models/motive/vehicle_location.py +127 -0
  116. fleetpull/models/motive/vehicle_utilization.py +128 -0
  117. fleetpull/models/samsara/__init__.py +108 -0
  118. fleetpull/models/samsara/address.py +149 -0
  119. fleetpull/models/samsara/asset_location.py +131 -0
  120. fleetpull/models/samsara/driver.py +232 -0
  121. fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
  122. fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
  123. fleetpull/models/samsara/engine_state.py +92 -0
  124. fleetpull/models/samsara/gps_reading.py +146 -0
  125. fleetpull/models/samsara/idling_event.py +174 -0
  126. fleetpull/models/samsara/odometer_reading.py +90 -0
  127. fleetpull/models/samsara/trip.py +199 -0
  128. fleetpull/models/samsara/vehicle.py +167 -0
  129. fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
  130. fleetpull/network/__init__.py +9 -0
  131. fleetpull/network/auth/__init__.py +15 -0
  132. fleetpull/network/auth/authenticate.py +271 -0
  133. fleetpull/network/auth/manager.py +223 -0
  134. fleetpull/network/auth/models.py +57 -0
  135. fleetpull/network/auth/strategies.py +169 -0
  136. fleetpull/network/classifiers/__init__.py +11 -0
  137. fleetpull/network/classifiers/geotab.py +145 -0
  138. fleetpull/network/classifiers/motive.py +79 -0
  139. fleetpull/network/classifiers/samsara.py +80 -0
  140. fleetpull/network/client/__init__.py +25 -0
  141. fleetpull/network/client/page.py +32 -0
  142. fleetpull/network/client/profile.py +27 -0
  143. fleetpull/network/client/registry.py +103 -0
  144. fleetpull/network/client/registry_base.py +150 -0
  145. fleetpull/network/client/runtime.py +44 -0
  146. fleetpull/network/client/transport.py +381 -0
  147. fleetpull/network/contract/__init__.py +50 -0
  148. fleetpull/network/contract/auth.py +48 -0
  149. fleetpull/network/contract/classifier.py +189 -0
  150. fleetpull/network/contract/envelope_fetcher.py +33 -0
  151. fleetpull/network/contract/envelopes.py +189 -0
  152. fleetpull/network/contract/outcome.py +43 -0
  153. fleetpull/network/contract/page_decoder.py +101 -0
  154. fleetpull/network/contract/request.py +97 -0
  155. fleetpull/network/decoders/__init__.py +40 -0
  156. fleetpull/network/decoders/_window_stamp.py +78 -0
  157. fleetpull/network/decoders/geotab.py +309 -0
  158. fleetpull/network/decoders/motive.py +204 -0
  159. fleetpull/network/decoders/motive_reports.py +114 -0
  160. fleetpull/network/decoders/samsara.py +343 -0
  161. fleetpull/network/decoders/samsara_reports.py +119 -0
  162. fleetpull/network/decoders/single_page.py +55 -0
  163. fleetpull/network/limits/__init__.py +13 -0
  164. fleetpull/network/limits/bucket_math.py +58 -0
  165. fleetpull/network/limits/limiter.py +153 -0
  166. fleetpull/network/limits/registry.py +96 -0
  167. fleetpull/network/posture/__init__.py +6 -0
  168. fleetpull/network/posture/client_options.py +96 -0
  169. fleetpull/network/retry/__init__.py +13 -0
  170. fleetpull/network/retry/decision.py +155 -0
  171. fleetpull/network/tls/__init__.py +5 -0
  172. fleetpull/network/tls/truststore_context.py +35 -0
  173. fleetpull/orchestrator/__init__.py +30 -0
  174. fleetpull/orchestrator/backfill.py +127 -0
  175. fleetpull/orchestrator/batch.py +155 -0
  176. fleetpull/orchestrator/bisection.py +271 -0
  177. fleetpull/orchestrator/drivers.py +350 -0
  178. fleetpull/orchestrator/entry.py +347 -0
  179. fleetpull/orchestrator/executors.py +128 -0
  180. fleetpull/orchestrator/fanout.py +163 -0
  181. fleetpull/orchestrator/feed_drive.py +266 -0
  182. fleetpull/orchestrator/metadata_projection.py +195 -0
  183. fleetpull/orchestrator/outcome.py +53 -0
  184. fleetpull/orchestrator/recording.py +85 -0
  185. fleetpull/orchestrator/resume.py +143 -0
  186. fleetpull/orchestrator/roster_harvest.py +108 -0
  187. fleetpull/orchestrator/roster_refresh.py +363 -0
  188. fleetpull/orchestrator/runner.py +262 -0
  189. fleetpull/orchestrator/shape_resolution.py +221 -0
  190. fleetpull/orchestrator/spine.py +166 -0
  191. fleetpull/orchestrator/streaming.py +153 -0
  192. fleetpull/orchestrator/unit_loop.py +273 -0
  193. fleetpull/orchestrator/watermark_drive.py +455 -0
  194. fleetpull/paths/__init__.py +13 -0
  195. fleetpull/paths/datasets.py +38 -0
  196. fleetpull/paths/partitions.py +41 -0
  197. fleetpull/paths/resolution.py +98 -0
  198. fleetpull/polars_typing/__init__.py +20 -0
  199. fleetpull/py.typed +0 -0
  200. fleetpull/records/__init__.py +19 -0
  201. fleetpull/records/convert.py +53 -0
  202. fleetpull/records/dataframe.py +63 -0
  203. fleetpull/records/event_time.py +70 -0
  204. fleetpull/records/fields.py +190 -0
  205. fleetpull/records/flatten.py +54 -0
  206. fleetpull/records/roster_members.py +74 -0
  207. fleetpull/records/schema.py +73 -0
  208. fleetpull/records/validation.py +62 -0
  209. fleetpull/resources/__init__.py +10 -0
  210. fleetpull/resources/config.example.yaml +222 -0
  211. fleetpull/roster/__init__.py +16 -0
  212. fleetpull/roster/definition.py +46 -0
  213. fleetpull/roster/key.py +40 -0
  214. fleetpull/roster/registry.py +93 -0
  215. fleetpull/state/__init__.py +36 -0
  216. fleetpull/state/cursors.py +417 -0
  217. fleetpull/state/database.py +463 -0
  218. fleetpull/state/migrations.py +430 -0
  219. fleetpull/state/reconcile.py +127 -0
  220. fleetpull/state/rosters.py +159 -0
  221. fleetpull/state/run_ledger.py +572 -0
  222. fleetpull/state/work_units.py +648 -0
  223. fleetpull/storage/__init__.py +30 -0
  224. fleetpull/storage/append.py +187 -0
  225. fleetpull/storage/atomic.py +161 -0
  226. fleetpull/storage/files.py +176 -0
  227. fleetpull/storage/frames.py +98 -0
  228. fleetpull/storage/metadata.py +161 -0
  229. fleetpull/storage/partitioned.py +205 -0
  230. fleetpull/storage/pruning.py +169 -0
  231. fleetpull/storage/read.py +35 -0
  232. fleetpull/storage/result.py +34 -0
  233. fleetpull/storage/single_file.py +130 -0
  234. fleetpull/storage/splitting.py +88 -0
  235. fleetpull/storage/staging.py +178 -0
  236. fleetpull/storage/writers.py +145 -0
  237. fleetpull/timing/__init__.py +21 -0
  238. fleetpull/timing/canon.py +92 -0
  239. fleetpull/timing/clock.py +192 -0
  240. fleetpull/timing/codec.py +122 -0
  241. fleetpull/timing/sleeper.py +65 -0
  242. fleetpull/vocabulary/__init__.py +16 -0
  243. fleetpull/vocabulary/json_types.py +20 -0
  244. fleetpull/vocabulary/provider.py +29 -0
  245. fleetpull/vocabulary/quota_scope.py +47 -0
  246. fleetpull/vocabulary/response_category.py +32 -0
  247. fleetpull-0.1.0.dist-info/METADATA +248 -0
  248. fleetpull-0.1.0.dist-info/RECORD +252 -0
  249. fleetpull-0.1.0.dist-info/WHEEL +5 -0
  250. fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
  251. fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
  252. fleetpull-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,161 @@
1
+ # src/fleetpull/storage/metadata.py
2
+ """The per-endpoint ``metadata.json`` projection: render and write the snapshot.
3
+
4
+ ``metadata.json`` is a generated human-readable snapshot of one endpoint's
5
+ last successful run, written beside its data after the run commits (DESIGN
6
+ §3). It is never read by the program — SQLite stays the single source of
7
+ truth (§5) — so this module is a pure projection surface: a frozen carrier
8
+ of already-committed facts (``MetadataSnapshot``), a deterministic JSON
9
+ render, and an atomic file write. The orchestrator supplies every fact as a
10
+ plain value (strings, counts, datetimes); no SQLite, no state types, no
11
+ cursor union crosses this boundary, which is what keeps the §11
12
+ storage/state separation intact.
13
+
14
+ The write is temp-then-rename atomic (the same same-filesystem rename
15
+ doctrine as ``atomic.py``) and deliberately does not create the endpoint
16
+ directory: an absent directory means no data ever landed there, an upstream
17
+ bug that must surface as the ``OSError`` the caller's posture handles, not
18
+ be papered over with a metadata file for a dataset that does not exist.
19
+ """
20
+
21
+ import json
22
+ from dataclasses import dataclass
23
+ from datetime import date, datetime
24
+ from pathlib import Path
25
+
26
+ from fleetpull.storage.atomic import atomic_write_text
27
+ from fleetpull.timing import to_iso8601
28
+
29
+ __all__: list[str] = [
30
+ 'MetadataSnapshot',
31
+ 'render_metadata_json',
32
+ 'write_metadata_json',
33
+ ]
34
+
35
+ # The per-endpoint snapshot file name (DESIGN §3).
36
+ _METADATA_FILE_NAME: str = 'metadata.json'
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class MetadataSnapshot:
41
+ """One endpoint's last-successful-run facts, ready to render.
42
+
43
+ Every field is a plain value the orchestrator has already committed
44
+ elsewhere (ledger, cursor store, write report) — this carrier holds the
45
+ projection, never the state types themselves.
46
+
47
+ Attributes:
48
+ provider: The provider directory name (e.g. ``'motive'``).
49
+ endpoint: The endpoint name (e.g. ``'vehicles'``).
50
+ sync_mode: The endpoint's sync-mode label — ``'snapshot'``,
51
+ ``'watermark'``, or ``'feed'``.
52
+ generated_at: The projection instant, timezone-aware UTC.
53
+ records_fetched: Records fetched across the run.
54
+ rows_written: Rows written to disk this run.
55
+ duplicates_dropped: Exact-duplicate rows removed at write time.
56
+ files_written: Parquet files written this run.
57
+ deleted_partitions: The date partitions pruned this run.
58
+ window_start: The run's resolved window start, or ``None`` when the
59
+ run had no window (a snapshot run).
60
+ window_end: The run's resolved window end, or ``None`` when the run
61
+ had no window.
62
+ cursor_kind: The stored cursor's kind (``'date_watermark'`` or
63
+ ``'feed_token'``), or ``None`` when no cursor is stored.
64
+ cursor_value: The stored cursor's serialized value, or ``None`` when
65
+ no cursor is stored.
66
+ """
67
+
68
+ provider: str
69
+ endpoint: str
70
+ sync_mode: str
71
+ generated_at: datetime
72
+ records_fetched: int
73
+ rows_written: int
74
+ duplicates_dropped: int
75
+ files_written: int
76
+ deleted_partitions: tuple[date, ...]
77
+ window_start: datetime | None
78
+ window_end: datetime | None
79
+ cursor_kind: str | None
80
+ cursor_value: str | None
81
+
82
+
83
+ def _optional_iso8601(moment: datetime | None) -> str | None:
84
+ """Render an optional UTC datetime as ISO-8601, passing ``None`` through.
85
+
86
+ Args:
87
+ moment: A timezone-aware UTC datetime, or ``None``.
88
+
89
+ Returns:
90
+ The ISO-8601 ``Z`` string, or ``None``.
91
+
92
+ Raises:
93
+ ValueError: ``moment`` is naive or non-UTC (from ``to_iso8601``).
94
+ """
95
+ return None if moment is None else to_iso8601(moment)
96
+
97
+
98
+ def render_metadata_json(snapshot: MetadataSnapshot) -> str:
99
+ """Render a snapshot as the ``metadata.json`` document text.
100
+
101
+ A pure, deterministic render: datetimes via the timing codec's ISO-8601
102
+ form, dates via ``isoformat``, two-space indentation, one trailing
103
+ newline. ``schema_version`` names the document shape so a human diffing
104
+ files across package versions can tell a shape change from a data change.
105
+
106
+ Args:
107
+ snapshot: The committed run facts to render.
108
+
109
+ Returns:
110
+ The complete JSON document text, trailing newline included.
111
+
112
+ Raises:
113
+ ValueError: A snapshot datetime is naive or non-UTC (from the codec).
114
+ """
115
+ cursor = (
116
+ None
117
+ if snapshot.cursor_kind is None
118
+ else {'kind': snapshot.cursor_kind, 'value': snapshot.cursor_value}
119
+ )
120
+ document = {
121
+ 'schema_version': 1,
122
+ 'provider': snapshot.provider,
123
+ 'endpoint': snapshot.endpoint,
124
+ 'sync_mode': snapshot.sync_mode,
125
+ 'generated_at': to_iso8601(snapshot.generated_at),
126
+ 'last_run': {
127
+ 'records_fetched': snapshot.records_fetched,
128
+ 'rows_written': snapshot.rows_written,
129
+ 'duplicates_dropped': snapshot.duplicates_dropped,
130
+ 'files_written': snapshot.files_written,
131
+ 'deleted_partitions': [
132
+ deleted_date.isoformat() for deleted_date in snapshot.deleted_partitions
133
+ ],
134
+ 'window_start': _optional_iso8601(snapshot.window_start),
135
+ 'window_end': _optional_iso8601(snapshot.window_end),
136
+ },
137
+ 'cursor': cursor,
138
+ }
139
+ return json.dumps(document, indent=2) + '\n'
140
+
141
+
142
+ def write_metadata_json(endpoint_directory: Path, text: str) -> None:
143
+ """Atomically write ``text`` as the endpoint directory's ``metadata.json``.
144
+
145
+ ``atomic_write_text``'s temp-then-rename, aimed at the projection's fixed
146
+ file name. The endpoint directory is deliberately not created: an absent
147
+ directory means no data ever landed — an upstream bug surfaced here as
148
+ ``OSError``, not silenced by a ``mkdir``.
149
+
150
+ Args:
151
+ endpoint_directory: The endpoint's existing output directory.
152
+ text: The complete document text (from ``render_metadata_json``).
153
+
154
+ Raises:
155
+ OSError: The write or rename failed — including a missing endpoint
156
+ directory (the temp is cleaned up first).
157
+
158
+ Side Effects:
159
+ Writes and renames files inside ``endpoint_directory``.
160
+ """
161
+ atomic_write_text(text, endpoint_directory / _METADATA_FILE_NAME)
@@ -0,0 +1,205 @@
1
+ # src/fleetpull/storage/partitioned.py
2
+ """The date-partitioned writer family: hive ``date=YYYY-MM-DD`` partitions.
3
+
4
+ ``PartitionedWriter`` is the family's shared substance -- stage each piece as
5
+ date-split shards, fold each touched date's shards into its ``part.parquet``,
6
+ prune where the cell prunes -- and ``WatermarkPartitionedWriter`` is the
7
+ shipped ``(DATE_PARTITIONED, WatermarkMode)`` cell (replace each covered
8
+ partition, prune the covered-but-empty dates; DESIGN §3/§4). The feed cell is
9
+ deliberately NOT in this family: it appends numbered part files with per-write
10
+ durability and no window (``storage/append.py``). ``select_writer``
11
+ (``storage/writers.py``) is the routing face that constructs these.
12
+ """
13
+
14
+ from abc import ABC, abstractmethod
15
+ from datetime import date
16
+ from pathlib import Path
17
+
18
+ import polars as pl
19
+
20
+ from fleetpull.incremental import DateWindow
21
+ from fleetpull.storage.files import partition_part_file
22
+ from fleetpull.storage.pruning import prune_window_partitions, window_dates
23
+ from fleetpull.storage.read import read_parquet_if_exists
24
+ from fleetpull.storage.result import WriteResult
25
+ from fleetpull.storage.staging import (
26
+ clear_partition_staging,
27
+ compact_partition,
28
+ stage_shard,
29
+ )
30
+
31
+ __all__: list[str] = ['PartitionedWriter', 'WatermarkPartitionedWriter']
32
+
33
+
34
+ class PartitionedWriter(ABC):
35
+ """Writers whose dataset is hive ``date=YYYY-MM-DD`` partitions.
36
+
37
+ Shared substance: each ``write`` stages its piece as date-split shards
38
+ (``stage_shard``); ``finalize`` folds each touched date's shards into that
39
+ date's ``part.parquet`` (``compact_partition``) and reports. Two per-cell
40
+ decisions are the subclass's: whether compaction folds in the existing
41
+ partition (``_reads_existing`` -- fold-in cells would, replace cells do
42
+ not), and whether the run prunes the covered-but-empty dates (``_prunes``
43
+ -- a watermark refresh authoritatively replaces its window, so it prunes).
44
+ The ABC owns the staging and the finalize orchestration; the per-partition
45
+ compaction is the shared ``compact_partition`` it drives.
46
+
47
+ ``write`` carries the window tripwire: every staged partition date must lie
48
+ in ``window_dates(window)``. Upstream, the resume window is day-aligned at
49
+ resolution and the orchestrator window-filters each batch before writing,
50
+ so a staged date outside the window means an upstream boundary was missed
51
+ -- the require-inside half of the normalize-at-boundary doctrine (the
52
+ canonical-UTC stance), raised loudly here rather than silently replacing or
53
+ pruning a partition the run had no right to touch.
54
+ """
55
+
56
+ @property
57
+ @abstractmethod
58
+ def _reads_existing(self) -> bool:
59
+ """Whether compaction folds the existing ``part.parquet`` into the result."""
60
+ ...
61
+
62
+ @property
63
+ @abstractmethod
64
+ def _prunes(self) -> bool:
65
+ """Whether ``finalize`` deletes the window's covered-but-empty partitions."""
66
+ ...
67
+
68
+ def __init__(
69
+ self,
70
+ target_dir: Path,
71
+ event_time_column: str,
72
+ window: DateWindow,
73
+ *,
74
+ drop_duplicates: bool = True,
75
+ ) -> None:
76
+ """Bind the writer to an endpoint directory, event-time column, and window.
77
+
78
+ Clears any stale shards a prior crashed run left under the window's covered
79
+ dates before staging anything, so a later ``compact_partition`` folds only
80
+ this run's shards, not a superseded row's pre-edit version. A covered date
81
+ the clear empties (a crash before any ``part.parquet`` existed) has its
82
+ now-empty ``date=`` directory removed too, upholding the
83
+ no-empty-partition-directory invariant (DESIGN §3/§14).
84
+
85
+ Assumes no overlapping writer for the same endpoint and window runs
86
+ concurrently: the construction-time clear is destructive, so overlapping
87
+ runs could delete each other's live staging. Orchestration must prevent
88
+ overlapping runs for one endpoint.
89
+
90
+ Args:
91
+ target_dir: The endpoint directory holding the ``date=`` partitions.
92
+ event_time_column: The UTC datetime column whose date keys the
93
+ partitions.
94
+ window: The run's half-open resume window -- the prune's bound and the
95
+ covered-date set the construction-time staging clear sweeps.
96
+ drop_duplicates: Whether compaction drops exact-duplicate rows
97
+ (``storage.drop_exact_duplicates``, default on).
98
+
99
+ Side Effects:
100
+ Removes any existing ``staging/`` directory under the window's covered
101
+ dates, and any ``date=`` directory the clear leaves empty.
102
+ """
103
+ self._target_dir = target_dir
104
+ self._event_time_column = event_time_column
105
+ self._window = window
106
+ self._drop_duplicates = drop_duplicates
107
+ self._covered_dates: frozenset[date] = frozenset(window_dates(window))
108
+ self._written_dates: set[date] = set()
109
+ clear_partition_staging(target_dir, self._covered_dates)
110
+
111
+ def write(self, frame: pl.DataFrame) -> None:
112
+ """Stage one fetched piece as date-split shards, inside the window only.
113
+
114
+ Args:
115
+ frame: One fetched piece (e.g. one vehicle's rows over the window).
116
+
117
+ Raises:
118
+ ValueError: A staged partition date lies outside the window's
119
+ covered dates -- an upstream window filter missed rows (the
120
+ interior tripwire; see the class docstring). The run fails
121
+ loudly; the orphaned out-of-window shards are inert (``.shard``
122
+ files are invisible to hive ``*.parquet`` reads) until a later
123
+ run whose window covers that date sweeps them at construction.
124
+
125
+ Side Effects:
126
+ Writes ``.shard`` files under the touched dates' staging directories.
127
+ """
128
+ touched_dates = stage_shard(self._target_dir, frame, self._event_time_column)
129
+ out_of_window = touched_dates - self._covered_dates
130
+ if out_of_window:
131
+ raise ValueError(
132
+ f'staged partition dates outside the resume window: '
133
+ f'{sorted(out_of_window)} not in {sorted(self._covered_dates)} '
134
+ f'-- an upstream window filter missed these rows'
135
+ )
136
+ self._written_dates.update(touched_dates)
137
+
138
+ def finalize(self) -> WriteResult:
139
+ """Compact each partition, clear staging, prune if the cell prunes, and report.
140
+
141
+ Returns:
142
+ The write report -- rows and duplicates summed across the compacted
143
+ partitions, ``files_written`` the partition count, and
144
+ ``deleted_partitions`` the pruned dates (empty when the cell does not
145
+ prune).
146
+
147
+ Side Effects:
148
+ Writes each touched ``part.parquet`` and clears its staging; deletes
149
+ the pruned partition directories.
150
+ """
151
+ rows_written = 0
152
+ duplicates_dropped = 0
153
+ for partition_date in sorted(self._written_dates):
154
+ existing = (
155
+ read_parquet_if_exists(
156
+ partition_part_file(self._target_dir, partition_date)
157
+ )
158
+ if self._reads_existing
159
+ else None
160
+ )
161
+ result = compact_partition(
162
+ self._target_dir,
163
+ partition_date,
164
+ existing,
165
+ drop_duplicates=self._drop_duplicates,
166
+ )
167
+ rows_written += result.rows_written
168
+ duplicates_dropped += result.duplicates_dropped
169
+ clear_partition_staging(self._target_dir, self._written_dates)
170
+ deleted_partitions = (
171
+ tuple(
172
+ prune_window_partitions(
173
+ self._target_dir, self._window, self._written_dates
174
+ )
175
+ )
176
+ if self._prunes
177
+ else ()
178
+ )
179
+ return WriteResult(
180
+ rows_written=rows_written,
181
+ duplicates_dropped=duplicates_dropped,
182
+ files_written=len(self._written_dates),
183
+ deleted_partitions=deleted_partitions,
184
+ )
185
+
186
+
187
+ class WatermarkPartitionedWriter(PartitionedWriter):
188
+ """``watermark`` + ``date_partitioned``: replace each covered partition, prune.
189
+
190
+ The floored window refetches each covered date in full, so compaction replaces
191
+ that date's ``part.parquet`` outright -- no existing read -- and the run prunes
192
+ the covered dates that returned empty (a provider that deleted or edited
193
+ records). Late, corrected, and deleted records all land through replacement,
194
+ never a row-level merge (DESIGN §3/§4).
195
+ """
196
+
197
+ @property
198
+ def _reads_existing(self) -> bool:
199
+ """Replace, never fold in the prior partition."""
200
+ return False
201
+
202
+ @property
203
+ def _prunes(self) -> bool:
204
+ """The window is authoritatively replaced, so empty-refetch dates prune."""
205
+ return True
@@ -0,0 +1,169 @@
1
+ # src/fleetpull/storage/pruning.py
2
+ """Date-partition pruning: drop the partitions a refresh window covers but did
3
+ not write.
4
+
5
+ The delete half of the date-partitioned write path's two-step (write the fetched
6
+ partitions, then delete the covered-but-unwritten ones). A watermark refresh
7
+ authoritatively replaces its window ``[start, end)``: every ``date=`` partition
8
+ the window covers must, after the run, hold exactly this run's data for that date.
9
+ A covered date the fetch did not write is therefore stale -- the provider deleted
10
+ or edited every record it once held -- so its partition directory must go, the
11
+ directory-grain analogue of the row-level delete-by-window (DESIGN §3/§4).
12
+
13
+ Four stateless single-concern functions compose the prune, none aware of *when* in
14
+ the run they run. The driver sequences the delete after the full per-vehicle
15
+ fan-out, once ``written_dates`` is complete -- a partition holds the whole fleet's
16
+ rows for its date, so the written set is not final until the last vehicle is
17
+ processed -- but these functions take the finished set as a given:
18
+
19
+ - ``window_dates`` -- the covered calendar dates of a window (pure date math).
20
+ - ``existing_partition_dates`` -- which of a candidate date set exist on disk (the
21
+ filesystem probe, candidate-driven, never a directory scan).
22
+ - ``delete_partition`` -- remove one partition directory (the single delete).
23
+ - ``prune_window_partitions`` -- the composer: covered, on disk, minus written,
24
+ deleted.
25
+
26
+ The set arithmetic is ``window_dates(window) ∩ {on disk} - {written}``. The
27
+ ``∩ window_dates`` term is the safety leash: it bounds the delete to the refresh
28
+ window, so history *outside* the window is never touched. Deleting on
29
+ ``existed - written`` alone would erase every partition outside the window -- a
30
+ data-loss bug -- which is why the window intersection lives inside the composer,
31
+ enforced once here rather than trusted to every caller. The probe is
32
+ candidate-driven (``is_dir`` only the window's own dates), so its cost is
33
+ O(window), never O(dataset): listing the endpoint directory is the O(dataset) scan
34
+ partitioning exists to avoid.
35
+ """
36
+
37
+ import shutil
38
+ from collections.abc import Collection
39
+ from datetime import date, timedelta
40
+ from pathlib import Path
41
+
42
+ from fleetpull.incremental import DateWindow
43
+ from fleetpull.storage.files import partition_dir
44
+
45
+ __all__: list[str] = [
46
+ 'delete_partition',
47
+ 'existing_partition_dates',
48
+ 'prune_window_partitions',
49
+ 'window_dates',
50
+ ]
51
+
52
+
53
+ def window_dates(window: DateWindow) -> list[date]:
54
+ """The calendar dates a half-open ``[start, end)`` window covers.
55
+
56
+ A partition ``date=d`` is covered iff some instant of that day lies in
57
+ ``[start, end)`` -- the dates ``start.date()`` through the window's
58
+ ``last_covered_date`` inclusive (the half-open edge and its one-microsecond
59
+ derivation are stated once, on ``DateWindow``).
60
+
61
+ Args:
62
+ window: The half-open ``[start, end)`` resume window.
63
+
64
+ Returns:
65
+ The covered dates in ascending order, ``start.date()`` first. Always at
66
+ least one date, since ``window`` guarantees ``start < end``.
67
+
68
+ Side Effects:
69
+ None -- pure function.
70
+ """
71
+ first = window.start.date()
72
+ last = window.last_covered_date
73
+ span_days = (last - first).days + 1
74
+ return [first + timedelta(days=offset) for offset in range(span_days)]
75
+
76
+
77
+ def existing_partition_dates(
78
+ endpoint_dir: Path, candidate_dates: Collection[date]
79
+ ) -> set[date]:
80
+ """Which of ``candidate_dates`` have a partition directory on disk.
81
+
82
+ Candidate-driven: probes only the ``date=`` directories for the dates handed
83
+ in (``is_dir`` per candidate), never lists ``endpoint_dir``. The cost is
84
+ therefore O(candidates), not O(dataset) -- the point of anchoring the prune to
85
+ the refresh window rather than scanning the tree (DESIGN §3).
86
+
87
+ Args:
88
+ endpoint_dir: The endpoint directory holding the ``date=`` partitions.
89
+ candidate_dates: The dates to probe (the window's covered dates).
90
+
91
+ Returns:
92
+ The subset of ``candidate_dates`` whose partition directory exists. Empty
93
+ when none exist (e.g. a first run, or an endpoint directory not yet
94
+ created).
95
+
96
+ Side Effects:
97
+ None -- reads directory existence only; creates and writes nothing.
98
+ """
99
+ return {
100
+ candidate_date
101
+ for candidate_date in candidate_dates
102
+ if partition_dir(endpoint_dir, candidate_date).is_dir()
103
+ }
104
+
105
+
106
+ def delete_partition(endpoint_dir: Path, partition_date: date) -> None:
107
+ """Remove one date-partition directory and everything under it.
108
+
109
+ Deletes the whole ``date=YYYY-MM-DD`` directory (the part file and any temp
110
+ siblings), not just the part file, so no empty partition directory is left
111
+ behind -- a present-but-empty ``date=`` directory would read as a date with
112
+ data when scanned, the invariant ``split_by_date`` upholds by never creating
113
+ empty partitions (DESIGN §3).
114
+
115
+ Strict: the directory must exist. A missing directory is a caller bug -- the
116
+ composer deletes only dates ``existing_partition_dates`` just confirmed present
117
+ -- and a silent no-op on a *delete* would hide that logic error, so the
118
+ underlying ``rmtree`` is allowed to raise.
119
+
120
+ Args:
121
+ endpoint_dir: The endpoint directory holding the ``date=`` partitions.
122
+ partition_date: The calendar date whose partition directory to remove.
123
+
124
+ Side Effects:
125
+ Recursively deletes the partition directory from the filesystem.
126
+
127
+ Raises:
128
+ FileNotFoundError: The partition directory does not exist.
129
+ OSError: The directory could not be removed (permissions, etc.).
130
+ """
131
+ shutil.rmtree(partition_dir(endpoint_dir, partition_date))
132
+
133
+
134
+ def prune_window_partitions(
135
+ endpoint_dir: Path, window: DateWindow, written_dates: Collection[date]
136
+ ) -> list[date]:
137
+ """Delete the partitions ``window`` covers but this run did not write.
138
+
139
+ Computes the stale set itself -- ``window_dates(window)`` intersected with the
140
+ partitions present on disk, minus ``written_dates`` -- and deletes each. The
141
+ intersection with the window is the safety leash that keeps the delete inside
142
+ the refresh window (DESIGN §3); it lives here, not in the caller, so the bound
143
+ is enforced in one tested place. Usually returns empty: in steady state every
144
+ covered date gets data, so nothing is stale. That rarity is exactly why the
145
+ path must be airtight -- a directory delete that almost never fires is where a
146
+ latent bug hides.
147
+
148
+ Args:
149
+ endpoint_dir: The endpoint directory holding the ``date=`` partitions.
150
+ window: The half-open ``[start, end)`` window this run refreshed.
151
+ written_dates: The dates this run wrote a partition for (the keys of the
152
+ per-date split). Order and duplicates do not matter.
153
+
154
+ Returns:
155
+ The dates whose partitions were deleted, ascending. Empty when no covered
156
+ partition was stale.
157
+
158
+ Side Effects:
159
+ Deletes stale partition directories from the filesystem.
160
+
161
+ Raises:
162
+ OSError: A stale partition directory could not be removed.
163
+ """
164
+ covered = window_dates(window)
165
+ present = existing_partition_dates(endpoint_dir, covered)
166
+ stale = sorted(present - set(written_dates))
167
+ for stale_date in stale:
168
+ delete_partition(endpoint_dir, stale_date)
169
+ return stale
@@ -0,0 +1,35 @@
1
+ # src/fleetpull/storage/read.py
2
+ """Existence-tolerant parquet reads: the read sibling of the atomic write.
3
+
4
+ A writer that combines this run's records with what is already on disk reads the
5
+ prior file through here; a first run -- no file yet -- comes back as ``None``
6
+ rather than an error, so the writer's combine handles the empty case the same way
7
+ every time. The built cells all replace or append rather than combine (the
8
+ snapshot writer rewrites the single file; the partitioned watermark cell replaces
9
+ its partitions; the append-log feed cell only ever adds new part files), so no
10
+ writer reads today; the one combining cell -- the single-file watermark cell --
11
+ remains unbuilt.
12
+ """
13
+
14
+ from pathlib import Path
15
+
16
+ import polars as pl
17
+
18
+ __all__: list[str] = ['read_parquet_if_exists']
19
+
20
+
21
+ def read_parquet_if_exists(path: Path) -> pl.DataFrame | None:
22
+ """Read a parquet file, or ``None`` if it does not exist.
23
+
24
+ Args:
25
+ path: The parquet file to read.
26
+
27
+ Returns:
28
+ The frame, or ``None`` when ``path`` does not exist (a first run).
29
+
30
+ Side Effects:
31
+ Reads ``path`` if present.
32
+ """
33
+ if path.exists():
34
+ return pl.read_parquet(path)
35
+ return None
@@ -0,0 +1,34 @@
1
+ # src/fleetpull/storage/result.py
2
+ """The storage write report.
3
+
4
+ ``WriteResult`` is what a ``DatasetWriter.finalize`` returns -- the run ledger
5
+ reads it (via the orchestrator, not storage): the per-run write stats a
6
+ consumer inspects to know what a sync produced.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import date
11
+
12
+ __all__: list[str] = ['WriteResult']
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class WriteResult:
17
+ """What one endpoint's write produced this run.
18
+
19
+ Attributes:
20
+ rows_written: Rows written to disk this run. For ``single`` (a full
21
+ rewrite) this is the whole dataset; for ``date_partitioned`` it is the
22
+ rows across the touched partitions, not the dataset total.
23
+ duplicates_dropped: Exact-duplicate rows removed at write time.
24
+ files_written: Parquet files written -- ``1`` for ``single``, the count of
25
+ touched partitions for ``date_partitioned``.
26
+ deleted_partitions: The date partitions deleted this run -- the
27
+ covered-but-empty dates a date-partitioned watermark refresh prunes.
28
+ Empty for every other cell.
29
+ """
30
+
31
+ rows_written: int
32
+ duplicates_dropped: int
33
+ files_written: int
34
+ deleted_partitions: tuple[date, ...] = ()