pam-python 0.2.5__tar.gz → 0.2.6__tar.gz
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.
- {pam_python-0.2.5/pam_python.egg-info → pam_python-0.2.6}/PKG-INFO +19 -2
- {pam_python-0.2.5 → pam_python-0.2.6}/README.md +18 -1
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/__init__.py +1 -1
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/api.py +18 -1
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/interface_task_manager.py +14 -0
- pam_python-0.2.6/pam/models/data_input_v2.py +424 -0
- pam_python-0.2.6/pam/models/request_command.py +363 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/server.py +13 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/service.py +35 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/task_manager.py +110 -2
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/temp_file_utils.py +60 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/AGENT.md +44 -1
- pam_python-0.2.6/pam/templates/service/pandas/functions.tmpl +25 -0
- pam_python-0.2.6/pam/templates/service/pandas/service.test.tmpl +220 -0
- pam_python-0.2.6/pam/templates/service/pandas/service_class.tmpl +99 -0
- pam_python-0.2.6/pam/templates/service/polars/functions.tmpl +24 -0
- pam_python-0.2.6/pam/templates/service/polars/service.test.tmpl +212 -0
- pam_python-0.2.6/pam/templates/service/polars/service_class.tmpl +98 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/tester_task.py +62 -5
- {pam_python-0.2.5 → pam_python-0.2.6/pam_python.egg-info}/PKG-INFO +19 -2
- {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/SOURCES.txt +7 -0
- pam_python-0.2.6/tests/test_api.py +44 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_cli_templates.py +39 -10
- pam_python-0.2.6/tests/test_data_error_v2.py +126 -0
- pam_python-0.2.6/tests/test_data_input_v2.py +686 -0
- pam_python-0.2.6/tests/test_data_timeout_v2.py +75 -0
- pam_python-0.2.6/tests/test_request_data_v2.py +116 -0
- pam_python-0.2.6/tests/test_temp_file_utils.py +61 -0
- pam_python-0.2.5/pam/models/request_command.py +0 -188
- pam_python-0.2.5/pam/templates/service/pandas/functions.tmpl +0 -13
- pam_python-0.2.5/pam/templates/service/pandas/service.test.tmpl +0 -126
- pam_python-0.2.5/pam/templates/service/pandas/service_class.tmpl +0 -78
- pam_python-0.2.5/pam/templates/service/polars/functions.tmpl +0 -11
- pam_python-0.2.5/pam/templates/service/polars/service.test.tmpl +0 -126
- pam_python-0.2.5/pam/templates/service/polars/service_class.tmpl +0 -78
- {pam_python-0.2.5 → pam_python-0.2.6}/LICENSE.txt +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/cli.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/logger.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/models/__init__.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/models/sqlite_download.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/__init__.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/area.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/axis.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/bar.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/box_plot.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/bubble.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/candlestick.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/collection.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/definition.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/donut.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/errors.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/fields.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/heatmap.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/line.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/manager.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/metric.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/pie.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/polar_area.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/radar.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/radial_bar.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/range_area.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/range_bar.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/range_transform.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/record.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/scatter.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/schema.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/store.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/table.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/transport.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/treemap.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/request_file_format.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/result_batch_uploader.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/sqlite.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/buildcmd/pamb +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/buildcmd/pamb-base.sh +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/REPORTS.md +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/dockerignore.tmpl +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/gitignore.tmpl +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/main.tmpl +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/pylintrc.tmpl +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/run_unit_test.bat +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/run_unit_test.ps1 +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/run_unit_test.sh +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/pip/Dockerfile +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/pip/requirements.txt +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/uv/Dockerfile +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/uv/pyproject.toml +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/uv/python-version +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/service/common/service.yaml +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam/utils.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/dependency_links.txt +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/entry_points.txt +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/requires.txt +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/top_level.txt +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/pyproject.toml +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/setup.cfg +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_range_transform.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_report_store.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_result_batch_uploader.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_service_dataframe_output.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_sqlite_download_v2.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_area_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_bar_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_box_plot_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_bubble_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_candlestick_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_donut_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_heatmap_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_line_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_metric_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_pie_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_polar_area_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_radar_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_radial_bar_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_range_area_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_range_bar_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_scatter_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_table_report.py +0 -0
- {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_treemap_report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pam-python
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Pam Python Library
|
|
5
5
|
Author-email: Narongrit Kanhanoi <narongrit@pams.ai>
|
|
6
6
|
Project-URL: Homepage, https://github.com/heart/pam-python
|
|
@@ -126,6 +126,17 @@ The runtime calls your service in two main phases.
|
|
|
126
126
|
- `req.file_format` is the typed format sent by PAM
|
|
127
127
|
- Should also return quickly (use a thread if needed)
|
|
128
128
|
|
|
129
|
+
For customer-aligned Data Plugin Input V2, use `_request_data_v2(...)` and
|
|
130
|
+
`req.data_input_v2`. The framework verifies the manifest and every physical
|
|
131
|
+
part before plugin code sees them, and provides lazy Polars scans for CSV and
|
|
132
|
+
Parquet (plus streaming row iterators for CSV).
|
|
133
|
+
See [Data Plugin Input V2](docs/data-plugin-input-v2.md).
|
|
134
|
+
|
|
135
|
+
New Pandas and Polars service projects are generated on the V2 lane by default;
|
|
136
|
+
legacy source can continue using V1 unchanged. For materialized Pandas frames,
|
|
137
|
+
use `data.read_population_pandas()` and
|
|
138
|
+
`data.dataset_at(position).read_pandas()`.
|
|
139
|
+
|
|
129
140
|
When your service is done:
|
|
130
141
|
|
|
131
142
|
- Upload ordinary CDP result rows through `ResultBatchUploader`
|
|
@@ -135,12 +146,15 @@ When your service is done:
|
|
|
135
146
|
---
|
|
136
147
|
|
|
137
148
|
**Using Temp Files Correctly**
|
|
138
|
-
Temp storage is managed by the framework. Do not delete
|
|
149
|
+
Temp storage is managed by the framework. Do not delete individual files
|
|
150
|
+
manually. V2 code may release one completed page with
|
|
151
|
+
`req.data_input_v2.cleanup()`; otherwise scheduled cleanup retains ownership.
|
|
139
152
|
|
|
140
153
|
Standard helpers:
|
|
141
154
|
|
|
142
155
|
- `TempfileUtils.get_temp_path_for_service(self, self.service_name)`
|
|
143
156
|
- `TempfileUtils.get_temp_file_name_for_service(self, self.service_name, prefix, extension)`
|
|
157
|
+
- `TempfileUtils.remove_dataset_bundle(path)` for one framework-owned page
|
|
144
158
|
|
|
145
159
|
Notes:
|
|
146
160
|
|
|
@@ -269,6 +283,9 @@ Environment variables you can set:
|
|
|
269
283
|
- `TEMP_DATASOURCE_PATH` (default `/app/data/data_sources`)
|
|
270
284
|
- `TEMP_CLEAN_DAYS` (default `10`)
|
|
271
285
|
- `TEMP_CLEAN_INTERVAL_HOURS` (default `6`, set empty to disable periodic cleanup)
|
|
286
|
+
- `PAM_DATA_INPUT_V2_MAX_ZIP_BYTES` (default `1 GiB`)
|
|
287
|
+
- `PAM_DATA_INPUT_V2_MAX_UNCOMPRESSED_BYTES` (default `2 GiB`)
|
|
288
|
+
- `PAM_DATA_INPUT_V2_MAX_FILES` (default `10000`)
|
|
272
289
|
|
|
273
290
|
---
|
|
274
291
|
|
|
@@ -106,6 +106,17 @@ The runtime calls your service in two main phases.
|
|
|
106
106
|
- `req.file_format` is the typed format sent by PAM
|
|
107
107
|
- Should also return quickly (use a thread if needed)
|
|
108
108
|
|
|
109
|
+
For customer-aligned Data Plugin Input V2, use `_request_data_v2(...)` and
|
|
110
|
+
`req.data_input_v2`. The framework verifies the manifest and every physical
|
|
111
|
+
part before plugin code sees them, and provides lazy Polars scans for CSV and
|
|
112
|
+
Parquet (plus streaming row iterators for CSV).
|
|
113
|
+
See [Data Plugin Input V2](docs/data-plugin-input-v2.md).
|
|
114
|
+
|
|
115
|
+
New Pandas and Polars service projects are generated on the V2 lane by default;
|
|
116
|
+
legacy source can continue using V1 unchanged. For materialized Pandas frames,
|
|
117
|
+
use `data.read_population_pandas()` and
|
|
118
|
+
`data.dataset_at(position).read_pandas()`.
|
|
119
|
+
|
|
109
120
|
When your service is done:
|
|
110
121
|
|
|
111
122
|
- Upload ordinary CDP result rows through `ResultBatchUploader`
|
|
@@ -115,12 +126,15 @@ When your service is done:
|
|
|
115
126
|
---
|
|
116
127
|
|
|
117
128
|
**Using Temp Files Correctly**
|
|
118
|
-
Temp storage is managed by the framework. Do not delete
|
|
129
|
+
Temp storage is managed by the framework. Do not delete individual files
|
|
130
|
+
manually. V2 code may release one completed page with
|
|
131
|
+
`req.data_input_v2.cleanup()`; otherwise scheduled cleanup retains ownership.
|
|
119
132
|
|
|
120
133
|
Standard helpers:
|
|
121
134
|
|
|
122
135
|
- `TempfileUtils.get_temp_path_for_service(self, self.service_name)`
|
|
123
136
|
- `TempfileUtils.get_temp_file_name_for_service(self, self.service_name, prefix, extension)`
|
|
137
|
+
- `TempfileUtils.remove_dataset_bundle(path)` for one framework-owned page
|
|
124
138
|
|
|
125
139
|
Notes:
|
|
126
140
|
|
|
@@ -249,6 +263,9 @@ Environment variables you can set:
|
|
|
249
263
|
- `TEMP_DATASOURCE_PATH` (default `/app/data/data_sources`)
|
|
250
264
|
- `TEMP_CLEAN_DAYS` (default `10`)
|
|
251
265
|
- `TEMP_CLEAN_INTERVAL_HOURS` (default `6`, set empty to disable periodic cleanup)
|
|
266
|
+
- `PAM_DATA_INPUT_V2_MAX_ZIP_BYTES` (default `1 GiB`)
|
|
267
|
+
- `PAM_DATA_INPUT_V2_MAX_UNCOMPRESSED_BYTES` (default `2 GiB`)
|
|
268
|
+
- `PAM_DATA_INPUT_V2_MAX_FILES` (default `10000`)
|
|
252
269
|
|
|
253
270
|
---
|
|
254
271
|
|
|
@@ -26,7 +26,12 @@ class API:
|
|
|
26
26
|
response.raise_for_status()
|
|
27
27
|
return response
|
|
28
28
|
except requests.RequestException as e:
|
|
29
|
-
|
|
29
|
+
response_body = self.__error_response_body(e)
|
|
30
|
+
response_detail = f", Response: {response_body}" if response_body else ""
|
|
31
|
+
log(
|
|
32
|
+
f"HTTP POST request failed. URL: {url}, Error: {e}"
|
|
33
|
+
f"{response_detail}"
|
|
34
|
+
)
|
|
30
35
|
return None
|
|
31
36
|
|
|
32
37
|
def http_upload(self, url: str, file_path: str, payload: dict = None) -> requests.Response | None:
|
|
@@ -186,3 +191,15 @@ class API:
|
|
|
186
191
|
def close(self):
|
|
187
192
|
"""Close the session."""
|
|
188
193
|
self.session.close()
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def __error_response_body(error: requests.RequestException, limit: int = 2000) -> str:
|
|
197
|
+
"""Return a bounded server error body for diagnostics."""
|
|
198
|
+
response = error.response
|
|
199
|
+
if response is None:
|
|
200
|
+
return ""
|
|
201
|
+
|
|
202
|
+
body = response.text.strip()
|
|
203
|
+
if len(body) <= limit:
|
|
204
|
+
return body
|
|
205
|
+
return f"{body[:limit]}... (truncated)"
|
|
@@ -15,6 +15,10 @@ class ITaskManager(ABC):
|
|
|
15
15
|
def on_dataset_input(self, req: RequestCommand):
|
|
16
16
|
pass
|
|
17
17
|
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def on_dataset_error(self, req: RequestCommand) -> bool:
|
|
20
|
+
pass
|
|
21
|
+
|
|
18
22
|
@abstractmethod
|
|
19
23
|
def start_service(self, service_class, req: RequestCommand, service_name):
|
|
20
24
|
pass
|
|
@@ -33,6 +37,16 @@ class ITaskManager(ABC):
|
|
|
33
37
|
):
|
|
34
38
|
pass
|
|
35
39
|
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def service_request_data_v2(
|
|
42
|
+
self,
|
|
43
|
+
service: Service,
|
|
44
|
+
page,
|
|
45
|
+
filter_contact_ids=None,
|
|
46
|
+
file_format: RequestFileFormat = RequestFileFormat.CSV,
|
|
47
|
+
):
|
|
48
|
+
pass
|
|
49
|
+
|
|
36
50
|
@abstractmethod
|
|
37
51
|
def service_request_sqlite(self, service: Service, file_name: str = "", is_shared: bool = False):
|
|
38
52
|
pass
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterator
|
|
10
|
+
|
|
11
|
+
from pam.temp_file_utils import TempfileUtils
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
V2_PROTOCOL_VERSION = "data-plugin-input/v2"
|
|
15
|
+
V2_MANIFEST_FILE_NAME = "manifest.json"
|
|
16
|
+
V2_MAX_DATASETS = 5
|
|
17
|
+
V2_POPULATION_MODES = {"contacts", "event_any", "event_all"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class DataInputV2File:
|
|
22
|
+
path: str
|
|
23
|
+
format: str
|
|
24
|
+
row_count: int
|
|
25
|
+
size_bytes: int
|
|
26
|
+
sha256: str
|
|
27
|
+
|
|
28
|
+
def iter_csv_rows(self) -> Iterator[dict[str, str]]:
|
|
29
|
+
"""Yield rows without loading this complete physical part into memory."""
|
|
30
|
+
if self.format != "csv":
|
|
31
|
+
raise ValueError(f"V2 file format is not CSV: {self.format}")
|
|
32
|
+
with open(self.path, "r", encoding="utf-8-sig", newline="") as input_file:
|
|
33
|
+
yield from csv.DictReader(input_file)
|
|
34
|
+
|
|
35
|
+
def scan_polars(self) -> Any:
|
|
36
|
+
"""Return a lazy Polars scan selected from verified manifest metadata."""
|
|
37
|
+
pl = _import_polars()
|
|
38
|
+
if self.format == "parquet":
|
|
39
|
+
return pl.scan_parquet(self.path)
|
|
40
|
+
if self.format == "csv":
|
|
41
|
+
return pl.scan_csv(self.path)
|
|
42
|
+
raise ValueError(f"V2 file format is unsupported: {self.format}")
|
|
43
|
+
|
|
44
|
+
def read_pandas(self) -> Any:
|
|
45
|
+
"""Materialize this verified file with the reader declared by metadata."""
|
|
46
|
+
pd = _import_pandas()
|
|
47
|
+
if self.format == "parquet":
|
|
48
|
+
return pd.read_parquet(self.path)
|
|
49
|
+
if self.format == "csv":
|
|
50
|
+
return pd.read_csv(self.path)
|
|
51
|
+
raise ValueError(f"V2 file format is unsupported: {self.format}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class DataInputV2Dataset:
|
|
56
|
+
position: int
|
|
57
|
+
name: str
|
|
58
|
+
type: str
|
|
59
|
+
format: str
|
|
60
|
+
row_count: int
|
|
61
|
+
event_names: tuple[str, ...]
|
|
62
|
+
parts: tuple[DataInputV2File, ...]
|
|
63
|
+
|
|
64
|
+
def iter_rows(self) -> Iterator[dict[str, str]]:
|
|
65
|
+
"""Stream this logical dataset across its ordered physical CSV parts."""
|
|
66
|
+
if self.format.lower() != "csv":
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"V2 dataset format is not supported for row iteration: "
|
|
69
|
+
f"{self.format}"
|
|
70
|
+
)
|
|
71
|
+
for part in self.parts:
|
|
72
|
+
yield from part.iter_csv_rows()
|
|
73
|
+
|
|
74
|
+
def scan_polars(self) -> Any:
|
|
75
|
+
"""Return one lazy frame across all ordered CSV or Parquet parts."""
|
|
76
|
+
pl = _import_polars()
|
|
77
|
+
if not self.parts:
|
|
78
|
+
return pl.LazyFrame(schema={"id": pl.String})
|
|
79
|
+
scans = [part.scan_polars() for part in self.parts]
|
|
80
|
+
if len(scans) == 1:
|
|
81
|
+
return scans[0]
|
|
82
|
+
return pl.concat(scans, how="diagonal_relaxed")
|
|
83
|
+
|
|
84
|
+
def read_pandas(self) -> Any:
|
|
85
|
+
"""Materialize and concatenate all verified parts using their metadata."""
|
|
86
|
+
pd = _import_pandas()
|
|
87
|
+
if not self.parts:
|
|
88
|
+
return pd.DataFrame({"id": pd.Series(dtype="string")})
|
|
89
|
+
frames = [part.read_pandas() for part in self.parts]
|
|
90
|
+
if len(frames) == 1:
|
|
91
|
+
return frames[0]
|
|
92
|
+
return pd.concat(frames, ignore_index=True, sort=False)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class DataInputV2TimeWindow:
|
|
97
|
+
start: str
|
|
98
|
+
end: str
|
|
99
|
+
start_inclusive: bool
|
|
100
|
+
end_inclusive: bool
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class DataInputV2:
|
|
105
|
+
manifest_path: str
|
|
106
|
+
run_id: str
|
|
107
|
+
batch_id: str
|
|
108
|
+
batch_number: int
|
|
109
|
+
consistency: str
|
|
110
|
+
population_mode: str
|
|
111
|
+
max_contacts_per_batch: int
|
|
112
|
+
time_window: DataInputV2TimeWindow | None
|
|
113
|
+
population: DataInputV2File
|
|
114
|
+
datasets: tuple[DataInputV2Dataset, ...]
|
|
115
|
+
run_complete: bool
|
|
116
|
+
next: str
|
|
117
|
+
|
|
118
|
+
def iter_population_rows(self) -> Iterator[dict[str, str]]:
|
|
119
|
+
"""Stream the population file for this customer-aligned logical page."""
|
|
120
|
+
yield from self.population.iter_csv_rows()
|
|
121
|
+
|
|
122
|
+
def scan_population_polars(self) -> Any:
|
|
123
|
+
"""Return a lazy Polars scan for the authoritative population file."""
|
|
124
|
+
return self.population.scan_polars()
|
|
125
|
+
|
|
126
|
+
def read_population_pandas(self) -> Any:
|
|
127
|
+
"""Materialize the authoritative population using manifest metadata."""
|
|
128
|
+
return self.population.read_pandas()
|
|
129
|
+
|
|
130
|
+
def cleanup(self) -> bool:
|
|
131
|
+
"""Delete this page's extracted files and source ZIP.
|
|
132
|
+
|
|
133
|
+
Polars lazy frames must be collected before calling this method. When
|
|
134
|
+
cleanup is not called, the normal framework schedule retains ownership
|
|
135
|
+
of removing the files later.
|
|
136
|
+
"""
|
|
137
|
+
return TempfileUtils.remove_dataset_bundle(self.manifest_path)
|
|
138
|
+
|
|
139
|
+
def dataset_at(self, position: int) -> DataInputV2Dataset:
|
|
140
|
+
"""Bind one configured dataset by its zero-based contract position."""
|
|
141
|
+
if position < 0 or position >= len(self.datasets):
|
|
142
|
+
raise IndexError(f"V2 dataset position is out of range: {position}")
|
|
143
|
+
return self.datasets[position]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_data_input_v2(input_files: list[str]) -> DataInputV2:
|
|
147
|
+
files_by_name = _validate_flat_input_files(input_files)
|
|
148
|
+
manifest_path = files_by_name.get(V2_MANIFEST_FILE_NAME)
|
|
149
|
+
if manifest_path is None:
|
|
150
|
+
raise ValueError("V2 input is missing manifest.json")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
|
|
154
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
155
|
+
raise ValueError("V2 manifest is invalid") from error
|
|
156
|
+
if not isinstance(manifest, dict):
|
|
157
|
+
raise ValueError("V2 manifest must be an object")
|
|
158
|
+
if manifest.get("protocol_version") != V2_PROTOCOL_VERSION:
|
|
159
|
+
raise ValueError("V2 manifest protocol_version is unsupported")
|
|
160
|
+
if manifest.get("flow_version") != "v2":
|
|
161
|
+
raise ValueError("V2 manifest flow_version is invalid")
|
|
162
|
+
if manifest.get("batch_complete") is not True:
|
|
163
|
+
raise ValueError("V2 manifest batch is incomplete")
|
|
164
|
+
consistency = _required_string(manifest, "consistency")
|
|
165
|
+
if consistency != "eventual":
|
|
166
|
+
raise ValueError("V2 manifest consistency is unsupported")
|
|
167
|
+
time_window = _parse_time_window(manifest.get("time_window"))
|
|
168
|
+
|
|
169
|
+
declared_names: set[str] = set()
|
|
170
|
+
population_block = _required_dict(manifest, "population")
|
|
171
|
+
population_mode = _required_string(population_block, "mode")
|
|
172
|
+
if population_mode not in V2_POPULATION_MODES:
|
|
173
|
+
raise ValueError("V2 manifest population mode is invalid")
|
|
174
|
+
max_contacts_per_batch = _required_positive_int(
|
|
175
|
+
population_block,
|
|
176
|
+
"max_contacts_per_batch",
|
|
177
|
+
)
|
|
178
|
+
population_format = _required_format(population_block, "format")
|
|
179
|
+
population_file = _parse_and_verify_file(
|
|
180
|
+
_required_dict(population_block, "file"),
|
|
181
|
+
files_by_name,
|
|
182
|
+
declared_names,
|
|
183
|
+
population_format,
|
|
184
|
+
)
|
|
185
|
+
contact_count = _required_non_negative_int(population_block, "contact_count")
|
|
186
|
+
if population_file.row_count != contact_count:
|
|
187
|
+
raise ValueError("V2 population row_count does not match contact_count")
|
|
188
|
+
if contact_count > max_contacts_per_batch:
|
|
189
|
+
raise ValueError("V2 population contact_count exceeds configured batch maximum")
|
|
190
|
+
|
|
191
|
+
raw_datasets = manifest.get("datasets")
|
|
192
|
+
if not isinstance(raw_datasets, list) or len(raw_datasets) > V2_MAX_DATASETS:
|
|
193
|
+
raise ValueError("V2 manifest datasets must be a list of at most five items")
|
|
194
|
+
datasets: list[DataInputV2Dataset] = []
|
|
195
|
+
positions: set[int] = set()
|
|
196
|
+
for raw_dataset in raw_datasets:
|
|
197
|
+
if not isinstance(raw_dataset, dict):
|
|
198
|
+
raise ValueError("V2 dataset entry must be an object")
|
|
199
|
+
if raw_dataset.get("complete") is not True:
|
|
200
|
+
raise ValueError("V2 dataset is incomplete")
|
|
201
|
+
position = _required_non_negative_int(raw_dataset, "position")
|
|
202
|
+
if position in positions:
|
|
203
|
+
raise ValueError("V2 dataset position is duplicated")
|
|
204
|
+
positions.add(position)
|
|
205
|
+
dataset_format = _required_format(raw_dataset, "format")
|
|
206
|
+
raw_parts = raw_dataset.get("parts")
|
|
207
|
+
if not isinstance(raw_parts, list):
|
|
208
|
+
raise ValueError("V2 dataset parts must be a list")
|
|
209
|
+
parts = tuple(
|
|
210
|
+
_parse_and_verify_file(
|
|
211
|
+
part,
|
|
212
|
+
files_by_name,
|
|
213
|
+
declared_names,
|
|
214
|
+
dataset_format,
|
|
215
|
+
)
|
|
216
|
+
for part in raw_parts
|
|
217
|
+
)
|
|
218
|
+
row_count = _required_non_negative_int(raw_dataset, "row_count")
|
|
219
|
+
if sum(part.row_count for part in parts) != row_count:
|
|
220
|
+
raise ValueError("V2 dataset part row counts do not match dataset row_count")
|
|
221
|
+
event_names = raw_dataset.get("event_names", [])
|
|
222
|
+
if not isinstance(event_names, list) or not all(
|
|
223
|
+
isinstance(event_name, str) for event_name in event_names
|
|
224
|
+
):
|
|
225
|
+
raise ValueError("V2 dataset event_names must be a list of strings")
|
|
226
|
+
datasets.append(
|
|
227
|
+
DataInputV2Dataset(
|
|
228
|
+
position=position,
|
|
229
|
+
name=_required_string(raw_dataset, "name"),
|
|
230
|
+
type=_required_string(raw_dataset, "type"),
|
|
231
|
+
format=dataset_format,
|
|
232
|
+
row_count=row_count,
|
|
233
|
+
event_names=tuple(event_names),
|
|
234
|
+
parts=parts,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
datasets.sort(key=lambda dataset: dataset.position)
|
|
239
|
+
if [dataset.position for dataset in datasets] != list(range(len(datasets))):
|
|
240
|
+
raise ValueError("V2 dataset positions must be contiguous and zero-based")
|
|
241
|
+
has_event_dataset = any(dataset.type == "event" for dataset in datasets)
|
|
242
|
+
if has_event_dataset and time_window is None:
|
|
243
|
+
raise ValueError("V2 event datasets require a time_window")
|
|
244
|
+
if population_mode != "contacts" and not has_event_dataset:
|
|
245
|
+
raise ValueError("V2 event population mode requires an event dataset")
|
|
246
|
+
|
|
247
|
+
actual_data_names = set(files_by_name) - {V2_MANIFEST_FILE_NAME}
|
|
248
|
+
if declared_names != actual_data_names:
|
|
249
|
+
missing = declared_names - actual_data_names
|
|
250
|
+
extra = actual_data_names - declared_names
|
|
251
|
+
raise ValueError(f"V2 manifest file set mismatch: missing={missing}, extra={extra}")
|
|
252
|
+
|
|
253
|
+
return DataInputV2(
|
|
254
|
+
manifest_path=manifest_path,
|
|
255
|
+
run_id=_required_string(manifest, "run_id"),
|
|
256
|
+
batch_id=_required_string(manifest, "batch_id"),
|
|
257
|
+
batch_number=_required_positive_int(manifest, "batch_number"),
|
|
258
|
+
consistency=consistency,
|
|
259
|
+
population_mode=population_mode,
|
|
260
|
+
max_contacts_per_batch=max_contacts_per_batch,
|
|
261
|
+
time_window=time_window,
|
|
262
|
+
population=population_file,
|
|
263
|
+
datasets=tuple(datasets),
|
|
264
|
+
run_complete=_required_bool(manifest, "run_complete"),
|
|
265
|
+
next=_required_optional_string(manifest, "next"),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _validate_flat_input_files(input_files: list[str]) -> dict[str, str]:
|
|
270
|
+
files_by_name: dict[str, str] = {}
|
|
271
|
+
parent: Path | None = None
|
|
272
|
+
for raw_path in input_files:
|
|
273
|
+
path = Path(raw_path).resolve()
|
|
274
|
+
if not path.is_file():
|
|
275
|
+
raise ValueError("V2 input contains a missing file")
|
|
276
|
+
if parent is None:
|
|
277
|
+
parent = path.parent
|
|
278
|
+
elif path.parent != parent:
|
|
279
|
+
raise ValueError("V2 input files must share one extraction directory")
|
|
280
|
+
if path.name in files_by_name:
|
|
281
|
+
raise ValueError("V2 input contains a duplicate file name")
|
|
282
|
+
files_by_name[path.name] = str(path)
|
|
283
|
+
return files_by_name
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_and_verify_file(
|
|
287
|
+
raw_file: Any,
|
|
288
|
+
files_by_name: dict[str, str],
|
|
289
|
+
declared_names: set[str],
|
|
290
|
+
file_format: str,
|
|
291
|
+
) -> DataInputV2File:
|
|
292
|
+
if not isinstance(raw_file, dict):
|
|
293
|
+
raise ValueError("V2 file metadata must be an object")
|
|
294
|
+
relative_path = _required_string(raw_file, "path")
|
|
295
|
+
if Path(relative_path).name != relative_path or "/" in relative_path or "\\" in relative_path:
|
|
296
|
+
raise ValueError("V2 file path must be a safe flat file name")
|
|
297
|
+
if relative_path == V2_MANIFEST_FILE_NAME or relative_path in declared_names:
|
|
298
|
+
raise ValueError("V2 file path is duplicated or reserved")
|
|
299
|
+
actual_path = files_by_name.get(relative_path)
|
|
300
|
+
if actual_path is None:
|
|
301
|
+
raise ValueError(f"V2 declared file is missing: {relative_path}")
|
|
302
|
+
if Path(relative_path).suffix.lower() != f".{file_format}":
|
|
303
|
+
raise ValueError(f"V2 file extension does not match format: {relative_path}")
|
|
304
|
+
|
|
305
|
+
expected_size = _required_non_negative_int(raw_file, "size_bytes")
|
|
306
|
+
actual_size = Path(actual_path).stat().st_size
|
|
307
|
+
if actual_size != expected_size:
|
|
308
|
+
raise ValueError(f"V2 file size mismatch: {relative_path}")
|
|
309
|
+
expected_hash = _required_string(raw_file, "sha256").lower()
|
|
310
|
+
actual_hash = _sha256_file(actual_path)
|
|
311
|
+
if actual_hash != expected_hash:
|
|
312
|
+
raise ValueError(f"V2 file SHA-256 mismatch: {relative_path}")
|
|
313
|
+
|
|
314
|
+
declared_names.add(relative_path)
|
|
315
|
+
return DataInputV2File(
|
|
316
|
+
path=actual_path,
|
|
317
|
+
format=file_format,
|
|
318
|
+
row_count=_required_non_negative_int(raw_file, "row_count"),
|
|
319
|
+
size_bytes=actual_size,
|
|
320
|
+
sha256=actual_hash,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _sha256_file(file_path: str) -> str:
|
|
325
|
+
digest = sha256()
|
|
326
|
+
with open(file_path, "rb") as file:
|
|
327
|
+
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
|
328
|
+
digest.update(chunk)
|
|
329
|
+
return digest.hexdigest()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _required_dict(source: dict[str, Any], key: str) -> dict[str, Any]:
|
|
333
|
+
value = source.get(key)
|
|
334
|
+
if not isinstance(value, dict):
|
|
335
|
+
raise ValueError(f"V2 manifest {key} must be an object")
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _required_string(source: dict[str, Any], key: str) -> str:
|
|
340
|
+
value = source.get(key)
|
|
341
|
+
if not isinstance(value, str) or not value:
|
|
342
|
+
raise ValueError(f"V2 manifest {key} must be a non-empty string")
|
|
343
|
+
return value
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _required_format(source: dict[str, Any], key: str) -> str:
|
|
347
|
+
value = _required_string(source, key).lower()
|
|
348
|
+
if value not in {"csv", "parquet"}:
|
|
349
|
+
raise ValueError(f"V2 manifest {key} must be csv or parquet")
|
|
350
|
+
return value
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _required_bool(source: dict[str, Any], key: str) -> bool:
|
|
354
|
+
value = source.get(key)
|
|
355
|
+
if not isinstance(value, bool):
|
|
356
|
+
raise ValueError(f"V2 manifest {key} must be a boolean")
|
|
357
|
+
return value
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _required_optional_string(source: dict[str, Any], key: str) -> str:
|
|
361
|
+
value = source.get(key)
|
|
362
|
+
if not isinstance(value, str):
|
|
363
|
+
raise ValueError(f"V2 manifest {key} must be a string")
|
|
364
|
+
return value
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _parse_time_window(raw_time_window: Any) -> DataInputV2TimeWindow | None:
|
|
368
|
+
if raw_time_window is None:
|
|
369
|
+
return None
|
|
370
|
+
if not isinstance(raw_time_window, dict):
|
|
371
|
+
raise ValueError("V2 manifest time_window must be an object")
|
|
372
|
+
start = _required_string(raw_time_window, "start")
|
|
373
|
+
end = _required_string(raw_time_window, "end")
|
|
374
|
+
start_inclusive = _required_bool(raw_time_window, "start_inclusive")
|
|
375
|
+
end_inclusive = _required_bool(raw_time_window, "end_inclusive")
|
|
376
|
+
try:
|
|
377
|
+
start_time = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
|
378
|
+
end_time = datetime.fromisoformat(end.replace("Z", "+00:00"))
|
|
379
|
+
except ValueError as error:
|
|
380
|
+
raise ValueError("V2 manifest time_window timestamps are invalid") from error
|
|
381
|
+
if start_time.tzinfo is None or end_time.tzinfo is None or start_time >= end_time:
|
|
382
|
+
raise ValueError("V2 manifest time_window order is invalid")
|
|
383
|
+
if not start_inclusive or end_inclusive:
|
|
384
|
+
raise ValueError("V2 manifest time_window must be half-open")
|
|
385
|
+
return DataInputV2TimeWindow(
|
|
386
|
+
start=start,
|
|
387
|
+
end=end,
|
|
388
|
+
start_inclusive=start_inclusive,
|
|
389
|
+
end_inclusive=end_inclusive,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _import_polars() -> Any:
|
|
394
|
+
try:
|
|
395
|
+
import polars as pl
|
|
396
|
+
except ImportError as error:
|
|
397
|
+
raise RuntimeError(
|
|
398
|
+
"Polars is required for V2 scan_polars(); add polars to the plugin dependencies"
|
|
399
|
+
) from error
|
|
400
|
+
return pl
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _import_pandas() -> Any:
|
|
404
|
+
try:
|
|
405
|
+
import pandas as pd
|
|
406
|
+
except ImportError as error:
|
|
407
|
+
raise RuntimeError(
|
|
408
|
+
"Pandas is required for V2 pandas readers; install the pandas dependency"
|
|
409
|
+
) from error
|
|
410
|
+
return pd
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _required_non_negative_int(source: dict[str, Any], key: str) -> int:
|
|
414
|
+
value = source.get(key)
|
|
415
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
416
|
+
raise ValueError(f"V2 manifest {key} must be a non-negative integer")
|
|
417
|
+
return value
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _required_positive_int(source: dict[str, Any], key: str) -> int:
|
|
421
|
+
value = _required_non_negative_int(source, key)
|
|
422
|
+
if value == 0:
|
|
423
|
+
raise ValueError(f"V2 manifest {key} must be a positive integer")
|
|
424
|
+
return value
|