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.
Files changed (120) hide show
  1. {pam_python-0.2.5/pam_python.egg-info → pam_python-0.2.6}/PKG-INFO +19 -2
  2. {pam_python-0.2.5 → pam_python-0.2.6}/README.md +18 -1
  3. {pam_python-0.2.5 → pam_python-0.2.6}/pam/__init__.py +1 -1
  4. {pam_python-0.2.5 → pam_python-0.2.6}/pam/api.py +18 -1
  5. {pam_python-0.2.5 → pam_python-0.2.6}/pam/interface_task_manager.py +14 -0
  6. pam_python-0.2.6/pam/models/data_input_v2.py +424 -0
  7. pam_python-0.2.6/pam/models/request_command.py +363 -0
  8. {pam_python-0.2.5 → pam_python-0.2.6}/pam/server.py +13 -0
  9. {pam_python-0.2.5 → pam_python-0.2.6}/pam/service.py +35 -0
  10. {pam_python-0.2.5 → pam_python-0.2.6}/pam/task_manager.py +110 -2
  11. {pam_python-0.2.5 → pam_python-0.2.6}/pam/temp_file_utils.py +60 -0
  12. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/AGENT.md +44 -1
  13. pam_python-0.2.6/pam/templates/service/pandas/functions.tmpl +25 -0
  14. pam_python-0.2.6/pam/templates/service/pandas/service.test.tmpl +220 -0
  15. pam_python-0.2.6/pam/templates/service/pandas/service_class.tmpl +99 -0
  16. pam_python-0.2.6/pam/templates/service/polars/functions.tmpl +24 -0
  17. pam_python-0.2.6/pam/templates/service/polars/service.test.tmpl +212 -0
  18. pam_python-0.2.6/pam/templates/service/polars/service_class.tmpl +98 -0
  19. {pam_python-0.2.5 → pam_python-0.2.6}/pam/tester_task.py +62 -5
  20. {pam_python-0.2.5 → pam_python-0.2.6/pam_python.egg-info}/PKG-INFO +19 -2
  21. {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/SOURCES.txt +7 -0
  22. pam_python-0.2.6/tests/test_api.py +44 -0
  23. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_cli_templates.py +39 -10
  24. pam_python-0.2.6/tests/test_data_error_v2.py +126 -0
  25. pam_python-0.2.6/tests/test_data_input_v2.py +686 -0
  26. pam_python-0.2.6/tests/test_data_timeout_v2.py +75 -0
  27. pam_python-0.2.6/tests/test_request_data_v2.py +116 -0
  28. pam_python-0.2.6/tests/test_temp_file_utils.py +61 -0
  29. pam_python-0.2.5/pam/models/request_command.py +0 -188
  30. pam_python-0.2.5/pam/templates/service/pandas/functions.tmpl +0 -13
  31. pam_python-0.2.5/pam/templates/service/pandas/service.test.tmpl +0 -126
  32. pam_python-0.2.5/pam/templates/service/pandas/service_class.tmpl +0 -78
  33. pam_python-0.2.5/pam/templates/service/polars/functions.tmpl +0 -11
  34. pam_python-0.2.5/pam/templates/service/polars/service.test.tmpl +0 -126
  35. pam_python-0.2.5/pam/templates/service/polars/service_class.tmpl +0 -78
  36. {pam_python-0.2.5 → pam_python-0.2.6}/LICENSE.txt +0 -0
  37. {pam_python-0.2.5 → pam_python-0.2.6}/pam/cli.py +0 -0
  38. {pam_python-0.2.5 → pam_python-0.2.6}/pam/logger.py +0 -0
  39. {pam_python-0.2.5 → pam_python-0.2.6}/pam/models/__init__.py +0 -0
  40. {pam_python-0.2.5 → pam_python-0.2.6}/pam/models/sqlite_download.py +0 -0
  41. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/__init__.py +0 -0
  42. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/area.py +0 -0
  43. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/axis.py +0 -0
  44. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/bar.py +0 -0
  45. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/box_plot.py +0 -0
  46. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/bubble.py +0 -0
  47. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/candlestick.py +0 -0
  48. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/collection.py +0 -0
  49. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/definition.py +0 -0
  50. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/donut.py +0 -0
  51. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/errors.py +0 -0
  52. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/fields.py +0 -0
  53. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/heatmap.py +0 -0
  54. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/line.py +0 -0
  55. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/manager.py +0 -0
  56. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/metric.py +0 -0
  57. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/pie.py +0 -0
  58. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/polar_area.py +0 -0
  59. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/radar.py +0 -0
  60. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/radial_bar.py +0 -0
  61. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/range_area.py +0 -0
  62. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/range_bar.py +0 -0
  63. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/range_transform.py +0 -0
  64. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/record.py +0 -0
  65. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/report.py +0 -0
  66. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/scatter.py +0 -0
  67. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/schema.py +0 -0
  68. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/store.py +0 -0
  69. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/table.py +0 -0
  70. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/transport.py +0 -0
  71. {pam_python-0.2.5 → pam_python-0.2.6}/pam/reports/treemap.py +0 -0
  72. {pam_python-0.2.5 → pam_python-0.2.6}/pam/request_file_format.py +0 -0
  73. {pam_python-0.2.5 → pam_python-0.2.6}/pam/result_batch_uploader.py +0 -0
  74. {pam_python-0.2.5 → pam_python-0.2.6}/pam/sqlite.py +0 -0
  75. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/buildcmd/pamb +0 -0
  76. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/buildcmd/pamb-base.sh +0 -0
  77. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/REPORTS.md +0 -0
  78. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/dockerignore.tmpl +0 -0
  79. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/gitignore.tmpl +0 -0
  80. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/main.tmpl +0 -0
  81. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/pylintrc.tmpl +0 -0
  82. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/run_unit_test.bat +0 -0
  83. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/run_unit_test.ps1 +0 -0
  84. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/init/run_unit_test.sh +0 -0
  85. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/pip/Dockerfile +0 -0
  86. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/pip/requirements.txt +0 -0
  87. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/uv/Dockerfile +0 -0
  88. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/uv/pyproject.toml +0 -0
  89. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/project/uv/python-version +0 -0
  90. {pam_python-0.2.5 → pam_python-0.2.6}/pam/templates/service/common/service.yaml +0 -0
  91. {pam_python-0.2.5 → pam_python-0.2.6}/pam/utils.py +0 -0
  92. {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/dependency_links.txt +0 -0
  93. {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/entry_points.txt +0 -0
  94. {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/requires.txt +0 -0
  95. {pam_python-0.2.5 → pam_python-0.2.6}/pam_python.egg-info/top_level.txt +0 -0
  96. {pam_python-0.2.5 → pam_python-0.2.6}/pyproject.toml +0 -0
  97. {pam_python-0.2.5 → pam_python-0.2.6}/setup.cfg +0 -0
  98. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_range_transform.py +0 -0
  99. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_report_store.py +0 -0
  100. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_result_batch_uploader.py +0 -0
  101. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_service_dataframe_output.py +0 -0
  102. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_sqlite_download_v2.py +0 -0
  103. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_area_report.py +0 -0
  104. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_bar_report.py +0 -0
  105. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_box_plot_report.py +0 -0
  106. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_bubble_report.py +0 -0
  107. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_candlestick_report.py +0 -0
  108. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_donut_report.py +0 -0
  109. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_heatmap_report.py +0 -0
  110. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_line_report.py +0 -0
  111. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_metric_report.py +0 -0
  112. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_pie_report.py +0 -0
  113. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_polar_area_report.py +0 -0
  114. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_radar_report.py +0 -0
  115. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_radial_bar_report.py +0 -0
  116. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_range_area_report.py +0 -0
  117. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_range_bar_report.py +0 -0
  118. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_scatter_report.py +0 -0
  119. {pam_python-0.2.5 → pam_python-0.2.6}/tests/test_typed_table_report.py +0 -0
  120. {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.5
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 temp files manually.
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 temp files manually.
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
 
@@ -1,3 +1,3 @@
1
1
  """PAM Data Plugin framework."""
2
2
 
3
- __version__ = "0.2.5"
3
+ __version__ = "0.2.6"
@@ -26,7 +26,12 @@ class API:
26
26
  response.raise_for_status()
27
27
  return response
28
28
  except requests.RequestException as e:
29
- log(f"HTTP POST request failed. URL: {url}, Error: {e}")
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