pam-python 0.2.9__tar.gz → 0.2.11__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.9/pam_python.egg-info → pam_python-0.2.11}/PKG-INFO +1 -1
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/__init__.py +1 -1
- pam_python-0.2.11/pam/datetime_utils.py +20 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/interface_task_manager.py +1 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/logger.py +3 -1
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/models/data_input_v2.py +3 -9
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/manager.py +30 -2
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/record.py +5 -3
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/store.py +22 -3
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/transport.py +1 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/task_manager.py +2 -1
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/AGENT.md +107 -9
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/REPORTS.md +19 -6
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/tester_task.py +22 -1
- {pam_python-0.2.9 → pam_python-0.2.11/pam_python.egg-info}/PKG-INFO +1 -1
- {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/SOURCES.txt +2 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_cli_templates.py +17 -0
- pam_python-0.2.11/tests/test_datetime_utils.py +43 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_report_store.py +58 -6
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_range_bar_report.py +12 -2
- {pam_python-0.2.9 → pam_python-0.2.11}/LICENSE.txt +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/README.md +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/api.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/cli.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/models/__init__.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/models/request_command.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/models/sqlite_download.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/__init__.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/area.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/axis.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/bar.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/box_plot.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/bubble.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/candlestick.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/collection.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/definition.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/donut.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/errors.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/fields.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/heatmap.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/line.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/metric.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/pie.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/polar_area.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/radar.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/radial_bar.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/range_area.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/range_bar.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/range_transform.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/scatter.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/schema.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/table.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/treemap.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/request_file_format.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/result_batch_uploader.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/server.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/service.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/sqlite.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/temp_file_utils.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/buildcmd/pamb +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/buildcmd/pamb-base.sh +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/dockerignore.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/gitignore.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/main.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/pylintrc.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/run_unit_test.bat +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/run_unit_test.ps1 +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/run_unit_test.sh +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/pip/Dockerfile +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/pip/requirements.txt +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/uv/Dockerfile +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/uv/pyproject.toml +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/uv/python-version +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/common/service.yaml +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/pandas/functions.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/pandas/service.test.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/pandas/service_class.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/polars/functions.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/polars/service.test.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/polars/service_class.tmpl +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam/utils.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/dependency_links.txt +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/entry_points.txt +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/requires.txt +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/top_level.txt +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/pyproject.toml +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/setup.cfg +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_api.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_data_error_v2.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_data_input_v2.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_data_timeout_v2.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_range_transform.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_request_data_v2.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_result_batch_uploader.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_service_dataframe_output.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_sqlite_download_v2.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_temp_file_utils.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_area_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_bar_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_box_plot_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_bubble_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_candlestick_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_donut_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_heatmap_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_line_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_metric_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_pie_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_polar_area_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_radar_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_radial_bar_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_range_area_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_scatter_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_table_report.py +0 -0
- {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_treemap_report.py +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""PAM's canonical suffix-free UTC+0 datetime contract."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PAM_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_pam_datetime(value: str) -> datetime:
|
|
10
|
+
"""Parse an exact PAM datetime string and attach UTC without shifting it."""
|
|
11
|
+
return datetime.strptime(value, PAM_DATETIME_FORMAT).replace(tzinfo=timezone.utc)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_pam_datetime(value: datetime) -> str:
|
|
15
|
+
"""Format a datetime as PAM UTC+0 without a timezone suffix."""
|
|
16
|
+
if value.tzinfo is None:
|
|
17
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
18
|
+
else:
|
|
19
|
+
value = value.astimezone(timezone.utc)
|
|
20
|
+
return value.strftime(PAM_DATETIME_FORMAT)
|
|
@@ -4,9 +4,11 @@ from datetime import datetime, timezone
|
|
|
4
4
|
from threading import Lock
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
+
from pam.datetime_utils import format_pam_datetime
|
|
7
8
|
from pam.utils import log as stdout_log
|
|
8
9
|
from pam.service import Service
|
|
9
10
|
|
|
11
|
+
|
|
10
12
|
class Logger:
|
|
11
13
|
def __init__(self, service: Service, buffer_size: int = 20, source: str = "worker"):
|
|
12
14
|
self.service = service
|
|
@@ -92,4 +94,4 @@ class Logger:
|
|
|
92
94
|
|
|
93
95
|
@staticmethod
|
|
94
96
|
def _utc_now() -> str:
|
|
95
|
-
return datetime.now(timezone.utc)
|
|
97
|
+
return format_pam_datetime(datetime.now(timezone.utc))
|
|
@@ -2,12 +2,12 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import csv
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
-
from datetime import datetime, timezone
|
|
6
5
|
from hashlib import sha256
|
|
7
6
|
import json
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
from typing import Any, Iterator
|
|
10
9
|
|
|
10
|
+
from pam.datetime_utils import parse_pam_datetime
|
|
11
11
|
from pam.temp_file_utils import TempfileUtils
|
|
12
12
|
|
|
13
13
|
|
|
@@ -15,7 +15,6 @@ V2_PROTOCOL_VERSION = "data-plugin-input/v2"
|
|
|
15
15
|
V2_MANIFEST_FILE_NAME = "manifest.json"
|
|
16
16
|
V2_MAX_DATASETS = 5
|
|
17
17
|
V2_POPULATION_MODES = {"contacts", "event_any", "event_all"}
|
|
18
|
-
PAM_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
19
18
|
|
|
20
19
|
|
|
21
20
|
@dataclass(frozen=True)
|
|
@@ -365,11 +364,6 @@ def _required_optional_string(source: dict[str, Any], key: str) -> str:
|
|
|
365
364
|
return value
|
|
366
365
|
|
|
367
366
|
|
|
368
|
-
def _parse_pam_timestamp(value: str) -> datetime:
|
|
369
|
-
"""Parse PAM's suffix-free timestamp as UTC+0 without shifting it."""
|
|
370
|
-
return datetime.strptime(value, PAM_TIMESTAMP_FORMAT).replace(tzinfo=timezone.utc)
|
|
371
|
-
|
|
372
|
-
|
|
373
367
|
def _parse_time_window(raw_time_window: Any) -> DataInputV2TimeWindow | None:
|
|
374
368
|
if raw_time_window is None:
|
|
375
369
|
return None
|
|
@@ -380,8 +374,8 @@ def _parse_time_window(raw_time_window: Any) -> DataInputV2TimeWindow | None:
|
|
|
380
374
|
start_inclusive = _required_bool(raw_time_window, "start_inclusive")
|
|
381
375
|
end_inclusive = _required_bool(raw_time_window, "end_inclusive")
|
|
382
376
|
try:
|
|
383
|
-
start_time =
|
|
384
|
-
end_time =
|
|
377
|
+
start_time = parse_pam_datetime(start)
|
|
378
|
+
end_time = parse_pam_datetime(end)
|
|
385
379
|
except ValueError as error:
|
|
386
380
|
raise ValueError("V2 manifest time_window timestamps are invalid") from error
|
|
387
381
|
if start_time >= end_time:
|
|
@@ -31,9 +31,15 @@ class ReportStoreManager:
|
|
|
31
31
|
self._sleep = sleep_fn
|
|
32
32
|
self._stores = {}
|
|
33
33
|
|
|
34
|
-
def create(
|
|
34
|
+
def create(
|
|
35
|
+
self,
|
|
36
|
+
file_name: str,
|
|
37
|
+
shared: bool = False,
|
|
38
|
+
tracker_name: str | None = None,
|
|
39
|
+
) -> ReportStore:
|
|
35
40
|
"""Start a fresh local file without performing a download."""
|
|
36
41
|
self._validate_file_name(file_name)
|
|
42
|
+
tracker_name = self._resolve_tracker_name(tracker_name)
|
|
37
43
|
session_key = (file_name, shared)
|
|
38
44
|
self._assert_not_open(session_key)
|
|
39
45
|
path = self._new_local_path(file_name)
|
|
@@ -41,18 +47,29 @@ class ReportStoreManager:
|
|
|
41
47
|
path,
|
|
42
48
|
file_name,
|
|
43
49
|
shared,
|
|
50
|
+
tracker_name=tracker_name,
|
|
44
51
|
transport=self._transport,
|
|
45
52
|
cleanup_on_close=True,
|
|
46
53
|
)
|
|
47
54
|
self._stores[session_key] = store
|
|
48
55
|
return store
|
|
49
56
|
|
|
50
|
-
def open(
|
|
57
|
+
def open(
|
|
58
|
+
self,
|
|
59
|
+
file_name: str,
|
|
60
|
+
shared: bool = False,
|
|
61
|
+
tracker_name: str | None = None,
|
|
62
|
+
) -> ReportStore:
|
|
51
63
|
"""Resume remote state, or create only after an authoritative 404."""
|
|
52
64
|
self._validate_file_name(file_name)
|
|
65
|
+
tracker_name = self._resolve_tracker_name(tracker_name)
|
|
53
66
|
session_key = (file_name, shared)
|
|
54
67
|
existing = self._stores.get(session_key)
|
|
55
68
|
if existing is not None:
|
|
69
|
+
if existing.tracker_name != tracker_name:
|
|
70
|
+
raise ReportStoreLocalIOError(
|
|
71
|
+
"This report store is already open with a different tracker_name"
|
|
72
|
+
)
|
|
56
73
|
return existing
|
|
57
74
|
|
|
58
75
|
path = self._new_local_path(file_name)
|
|
@@ -64,6 +81,7 @@ class ReportStoreManager:
|
|
|
64
81
|
downloaded_path,
|
|
65
82
|
file_name,
|
|
66
83
|
shared,
|
|
84
|
+
tracker_name=tracker_name,
|
|
67
85
|
transport=self._transport,
|
|
68
86
|
cleanup_on_close=True,
|
|
69
87
|
)
|
|
@@ -75,6 +93,7 @@ class ReportStoreManager:
|
|
|
75
93
|
path,
|
|
76
94
|
file_name,
|
|
77
95
|
shared,
|
|
96
|
+
tracker_name=tracker_name,
|
|
78
97
|
transport=self._transport,
|
|
79
98
|
cleanup_on_close=True,
|
|
80
99
|
)
|
|
@@ -138,6 +157,15 @@ class ReportStoreManager:
|
|
|
138
157
|
"This report store is already open for the current service run"
|
|
139
158
|
)
|
|
140
159
|
|
|
160
|
+
def _resolve_tracker_name(self, tracker_name: str | None) -> str:
|
|
161
|
+
if tracker_name is None:
|
|
162
|
+
tracker_name = self._service.request.service_name
|
|
163
|
+
if not isinstance(tracker_name, str) or not tracker_name.strip():
|
|
164
|
+
raise ReportStoreLocalIOError(
|
|
165
|
+
"tracker_name must be a non-empty string"
|
|
166
|
+
)
|
|
167
|
+
return tracker_name.strip()
|
|
168
|
+
|
|
141
169
|
@staticmethod
|
|
142
170
|
def _validate_file_name(file_name: str) -> None:
|
|
143
171
|
if not isinstance(file_name, str) or not file_name.strip():
|
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass, field
|
|
|
6
6
|
from datetime import date, datetime, timezone
|
|
7
7
|
from typing import Any, Dict, Optional, Tuple
|
|
8
8
|
|
|
9
|
+
from pam.datetime_utils import parse_pam_datetime
|
|
9
10
|
from pam.reports.errors import ReportStoreValidationError
|
|
10
11
|
|
|
11
12
|
|
|
@@ -75,14 +76,15 @@ def normalize_timestamp(value: Any) -> Optional[int]:
|
|
|
75
76
|
parsed = datetime.combine(value, datetime.min.time(), tzinfo=timezone.utc)
|
|
76
77
|
elif isinstance(value, str) and value:
|
|
77
78
|
try:
|
|
78
|
-
parsed =
|
|
79
|
+
parsed = parse_pam_datetime(value)
|
|
79
80
|
except ValueError as exc:
|
|
80
81
|
raise ReportStoreValidationError(
|
|
81
|
-
f"Timestamp value
|
|
82
|
+
f"Timestamp value must use PAM UTC format YYYY-MM-DD HH:mm:ss: {value}"
|
|
82
83
|
) from exc
|
|
83
84
|
else:
|
|
84
85
|
raise ReportStoreValidationError(
|
|
85
|
-
"Timestamp values must be epoch milliseconds, date, datetime,
|
|
86
|
+
"Timestamp values must be epoch milliseconds, date, datetime, "
|
|
87
|
+
"or a PAM UTC string using YYYY-MM-DD HH:mm:ss"
|
|
86
88
|
)
|
|
87
89
|
if parsed.tzinfo is None:
|
|
88
90
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any, Iterable, List, Optional, Set
|
|
10
10
|
|
|
11
|
+
from pam.datetime_utils import format_pam_datetime
|
|
11
12
|
from pam.reports.errors import (
|
|
12
13
|
DuplicateReportRecordError,
|
|
13
14
|
ReportDefinitionConflictError,
|
|
@@ -48,7 +49,7 @@ from pam.reports.treemap import TreemapReport
|
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def _now() -> str:
|
|
51
|
-
return datetime.now(timezone.utc)
|
|
52
|
+
return format_pam_datetime(datetime.now(timezone.utc))
|
|
52
53
|
|
|
53
54
|
|
|
54
55
|
class ReportStore:
|
|
@@ -59,12 +60,14 @@ class ReportStore:
|
|
|
59
60
|
path: Path,
|
|
60
61
|
file_name: str,
|
|
61
62
|
shared: bool,
|
|
63
|
+
tracker_name: str = "",
|
|
62
64
|
transport=None,
|
|
63
65
|
cleanup_on_close: bool = False,
|
|
64
66
|
):
|
|
65
67
|
self.path = Path(path)
|
|
66
68
|
self.file_name = file_name
|
|
67
69
|
self.shared = shared
|
|
70
|
+
self.tracker_name = tracker_name
|
|
68
71
|
self._transport = transport
|
|
69
72
|
self._cleanup_on_close = cleanup_on_close
|
|
70
73
|
self._lock = threading.RLock()
|
|
@@ -86,6 +89,7 @@ class ReportStore:
|
|
|
86
89
|
path: Path,
|
|
87
90
|
file_name: str,
|
|
88
91
|
shared: bool,
|
|
92
|
+
tracker_name: str = "",
|
|
89
93
|
transport=None,
|
|
90
94
|
cleanup_on_close: bool = False,
|
|
91
95
|
):
|
|
@@ -96,7 +100,14 @@ class ReportStore:
|
|
|
96
100
|
except OSError as exc:
|
|
97
101
|
raise ReportStoreLocalIOError(str(exc)) from exc
|
|
98
102
|
|
|
99
|
-
store = cls(
|
|
103
|
+
store = cls(
|
|
104
|
+
path,
|
|
105
|
+
file_name,
|
|
106
|
+
shared,
|
|
107
|
+
tracker_name=tracker_name,
|
|
108
|
+
transport=transport,
|
|
109
|
+
cleanup_on_close=cleanup_on_close,
|
|
110
|
+
)
|
|
100
111
|
try:
|
|
101
112
|
with store._connection:
|
|
102
113
|
store._connection.executescript(CREATE_SCHEMA_SQL)
|
|
@@ -115,10 +126,18 @@ class ReportStore:
|
|
|
115
126
|
path: Path,
|
|
116
127
|
file_name: str,
|
|
117
128
|
shared: bool,
|
|
129
|
+
tracker_name: str = "",
|
|
118
130
|
transport=None,
|
|
119
131
|
cleanup_on_close: bool = False,
|
|
120
132
|
):
|
|
121
|
-
store = cls(
|
|
133
|
+
store = cls(
|
|
134
|
+
path,
|
|
135
|
+
file_name,
|
|
136
|
+
shared,
|
|
137
|
+
tracker_name=tracker_name,
|
|
138
|
+
transport=transport,
|
|
139
|
+
cleanup_on_close=cleanup_on_close,
|
|
140
|
+
)
|
|
122
141
|
try:
|
|
123
142
|
store._migrate_compatible_schema()
|
|
124
143
|
store.validate()
|
|
@@ -476,6 +476,7 @@ class TaskManager(ITaskManager):
|
|
|
476
476
|
service: Service,
|
|
477
477
|
report_key: str,
|
|
478
478
|
pointer: dict,
|
|
479
|
+
tracker_name: str,
|
|
479
480
|
) -> bool:
|
|
480
481
|
"""Synchronously publish one V2 pointer through the existing response API."""
|
|
481
482
|
adapter_id = service.get_adapter_id()
|
|
@@ -496,7 +497,7 @@ class TaskManager(ITaskManager):
|
|
|
496
497
|
writer.writeheader()
|
|
497
498
|
writer.writerow({
|
|
498
499
|
"customer": adapter_id,
|
|
499
|
-
"_tracker_name":
|
|
500
|
+
"_tracker_name": tracker_name,
|
|
500
501
|
column_name: json.dumps(pointer, ensure_ascii=False, sort_keys=True),
|
|
501
502
|
})
|
|
502
503
|
|
|
@@ -112,7 +112,10 @@ Notes
|
|
|
112
112
|
|
|
113
113
|
- All values are strings. Cast with defaults to avoid errors.
|
|
114
114
|
- `dry_run` is the only built-in parameter. If missing, it runs for real. If `dry_run="true"`, the service runs fully but does not upload results to CDP.
|
|
115
|
-
- You do not need to check `dry_run` manually.
|
|
115
|
+
- You do not need to check `dry_run` manually. Submit tabular results through
|
|
116
|
+
`ResultBatchUploader` and managed reports through `ReportStore.publish()`; the
|
|
117
|
+
framework suppresses their remote transport during a dry run. Never call the
|
|
118
|
+
private `_upload_result` path directly from plugin code.
|
|
116
119
|
|
|
117
120
|
**Important:** `on_start` must return quickly because CDP calls via HTTP. If it hangs, the client may time out and the job will be terminated. If work is long-running, move it to a thread and return from `on_start` first. This applies to all lifecycle functions.
|
|
118
121
|
|
|
@@ -146,8 +149,9 @@ Column meaning
|
|
|
146
149
|
|
|
147
150
|
Date format is always:
|
|
148
151
|
`"2025-09-11 13:07:38"`
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
The timezone of customer-owned source fields is an external data contract and may
|
|
153
|
+
vary by customer. Confirm it when business logic depends on calendar boundaries,
|
|
154
|
+
then convert explicitly. This does not change PAM's internal UTC+0 contract above.
|
|
151
155
|
|
|
152
156
|
---
|
|
153
157
|
|
|
@@ -238,6 +242,20 @@ the optional half-open `data.time_window` expose the captured run semantics.
|
|
|
238
242
|
The framework rejects callback-token/run-ID mismatches and contradictory event
|
|
239
243
|
population or time-window metadata before plugin code runs.
|
|
240
244
|
|
|
245
|
+
Data Input V2 and Report Store V2 are independent contracts. A service may use
|
|
246
|
+
Data Input V2 without producing managed reports, and report calculations are not
|
|
247
|
+
required to use Data Input V2.
|
|
248
|
+
|
|
249
|
+
For Data Input V2 pagination:
|
|
250
|
+
|
|
251
|
+
- Use `data.run_complete`, not legacy `req.is_end`, as the completion signal.
|
|
252
|
+
- Use only the opaque `data.next` cursor when requesting the next page.
|
|
253
|
+
- Materialize every lazy computation that still reads the page before calling
|
|
254
|
+
`data.cleanup()`.
|
|
255
|
+
- Treat a failed cleanup as an error before requesting the next page.
|
|
256
|
+
- If the service produces managed reports, do not publish a partially calculated
|
|
257
|
+
store on an intermediate page. Publish after all intended pages and writes finish.
|
|
258
|
+
|
|
241
259
|
---
|
|
242
260
|
|
|
243
261
|
## Upload Results to CDP
|
|
@@ -400,7 +418,7 @@ Use it for cleanup.
|
|
|
400
418
|
|
|
401
419
|
---
|
|
402
420
|
|
|
403
|
-
#
|
|
421
|
+
# 5. Persist State Between Runs (sqlite/duckdb)
|
|
404
422
|
|
|
405
423
|
Services can be killed and restarted at any time. If you need state across runs, use sqlite or duckdb via the provided API.
|
|
406
424
|
|
|
@@ -434,17 +452,97 @@ Notes
|
|
|
434
452
|
|
|
435
453
|
Note: `file_name` acts like a server key, while `sqlite_file` is the local file path.
|
|
436
454
|
|
|
437
|
-
|
|
455
|
+
# 6. Managed Report Store V2 Agent Contract
|
|
438
456
|
|
|
439
457
|
The low-level SQLite API above is for custom plugin state. Do not use it to invent
|
|
440
458
|
SQLite tables or JSON payloads for reports rendered by PAM CMS.
|
|
441
459
|
|
|
442
|
-
|
|
443
|
-
Report types outside that closed contract require a
|
|
460
|
+
Before implementing a managed report, read `REPORTS.md` completely and use only its
|
|
461
|
+
public typed report builders. Report types outside that closed contract require a
|
|
462
|
+
newer framework and CMS release. Do not use `_request_sqlite`, `_upload_sqlite`,
|
|
463
|
+
`_upload_report`, private declaration methods, raw SQLite schema changes, handwritten
|
|
464
|
+
renderer/schema identifiers, or handcrafted report-pointer CSV files for Report
|
|
465
|
+
Store V2.
|
|
466
|
+
|
|
467
|
+
## Store, tracker group, and report identity
|
|
468
|
+
|
|
469
|
+
These identifiers have different responsibilities:
|
|
470
|
+
|
|
471
|
+
- `file_name` identifies one managed SQLite store. One store may contain many report
|
|
472
|
+
definitions and their records.
|
|
473
|
+
- `tracker_name` is the stable, user-facing Analytics Tracker group shown in PAM CMS.
|
|
474
|
+
Related reports in the same store should normally use one shared tracker name.
|
|
475
|
+
- `report key` uniquely identifies one report inside the store and must remain stable.
|
|
476
|
+
Every changed report key is published as its own Analytics Tracker attribute, so
|
|
477
|
+
PAM CMS can list every report separately even though they share one tracker group
|
|
478
|
+
and one SQLite file.
|
|
479
|
+
|
|
480
|
+
Always pass a non-empty, customer-facing `tracker_name` to both `self.reports.open`
|
|
481
|
+
and `self.reports.create` when the service owns managed reports:
|
|
482
|
+
|
|
483
|
+
```python
|
|
484
|
+
REPORT_FILE = "reports.sqlite"
|
|
485
|
+
REPORT_TRACKER_NAME = "Customer Overall Reports"
|
|
486
|
+
|
|
487
|
+
fresh = self.request.get_runtime_bool("fresh", default=False)
|
|
488
|
+
if fresh:
|
|
489
|
+
self.report_store = self.reports.create(
|
|
490
|
+
REPORT_FILE,
|
|
491
|
+
tracker_name=REPORT_TRACKER_NAME,
|
|
492
|
+
)
|
|
493
|
+
else:
|
|
494
|
+
self.report_store = self.reports.open(
|
|
495
|
+
REPORT_FILE,
|
|
496
|
+
tracker_name=REPORT_TRACKER_NAME,
|
|
497
|
+
)
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
If `tracker_name` is omitted, the framework falls back to `request.service_name`,
|
|
501
|
+
but an explicit label is preferred for customer projects. Never generate one tracker
|
|
502
|
+
name per report merely to separate reports. Never write `_tracker_name` yourself;
|
|
503
|
+
the framework propagates the store's tracker name to every V2 pointer. Reopening the
|
|
504
|
+
same file in one service run with a different tracker name is an error.
|
|
505
|
+
|
|
506
|
+
The framework also publishes the service adapter ID as the Analytics Tracker owner.
|
|
507
|
+
PAM and PAM CMS use that metadata for Hard Refresh routing. Plugin code must not ask
|
|
508
|
+
a user to select an owner, encode the owner in `tracker_name` or `report key`, or add
|
|
509
|
+
an ad-hoc owner field to the pointer.
|
|
510
|
+
|
|
511
|
+
## Required lifecycle
|
|
512
|
+
|
|
513
|
+
1. Open the store once during service initialization. Use `create` only for an
|
|
514
|
+
explicit human-requested fresh reset. Normal first use calls `open`; the framework
|
|
515
|
+
creates a store only after an authoritative not-found response.
|
|
516
|
+
2. Do not catch authorization, transient download, or unknown remote-state errors and
|
|
517
|
+
replace the store. Let the framework retry or raise the typed error.
|
|
518
|
+
3. Declare every report once after opening the store. Multiple reports should share
|
|
519
|
+
the same `ReportStore` when they belong to the same customer report group.
|
|
520
|
+
4. Bind input datasets by their configured positions and complete all required page
|
|
521
|
+
calculations before publishing. Report calculations may accumulate in memory or
|
|
522
|
+
write incrementally to the local store, depending on the domain requirement.
|
|
523
|
+
5. For refreshed `DAY` or `MONTH` facts, delete the source-derived partition before
|
|
524
|
+
writing its complete replacement. Never derive a business partition from plugin
|
|
525
|
+
run time unless that is explicitly the source contract.
|
|
526
|
+
6. Call `self.report_store.publish()` once after the complete calculation succeeds,
|
|
527
|
+
then call `_exit()`. Do not publish after a calculation, validation, or cleanup
|
|
528
|
+
failure, and normally do not publish once per input page.
|
|
529
|
+
|
|
530
|
+
One successful `publish()` uploads the SQLite file once, then publishes one pointer
|
|
531
|
+
for every report key changed during the run. All of those pointers carry the same
|
|
532
|
+
stable tracker name. The framework handles `dry_run` and local-file cleanup; plugin
|
|
533
|
+
code must not reproduce the transport lifecycle.
|
|
534
|
+
|
|
535
|
+
An existing report key cannot silently change renderer, schema, field declaration,
|
|
536
|
+
granularity, or presentation contract. Use a deliberate new key or an explicitly
|
|
537
|
+
approved fresh reset when the compatibility contract changes.
|
|
538
|
+
|
|
539
|
+
Before using a report API, verify that the project's pinned `pam-python` release
|
|
540
|
+
contains it. Customer plugins consume the framework package from their dependency
|
|
541
|
+
manager; do not copy framework internals into the plugin as a workaround.
|
|
444
542
|
|
|
445
543
|
---
|
|
446
544
|
|
|
447
|
-
#
|
|
545
|
+
# 7. Batch upload policy (DataFrame batching)
|
|
448
546
|
|
|
449
547
|
## Goal
|
|
450
548
|
|
|
@@ -518,7 +616,7 @@ After retries are exhausted, the uploader logs the failed batch, records
|
|
|
518
616
|
|
|
519
617
|
---
|
|
520
618
|
|
|
521
|
-
#
|
|
619
|
+
# 8. Memory / RAM safety policy (Data Plugin)
|
|
522
620
|
|
|
523
621
|
## Goal
|
|
524
622
|
|
|
@@ -127,17 +127,29 @@ store; an authoritative not-found response creates the first store automatically
|
|
|
127
127
|
fresh = self.request.get_runtime_bool("fresh", default=False)
|
|
128
128
|
|
|
129
129
|
if fresh:
|
|
130
|
-
store = self.reports.create(
|
|
130
|
+
store = self.reports.create(
|
|
131
|
+
"reports.sqlite",
|
|
132
|
+
tracker_name="Customer Report Group",
|
|
133
|
+
)
|
|
131
134
|
else:
|
|
132
|
-
store = self.reports.open(
|
|
135
|
+
store = self.reports.open(
|
|
136
|
+
"reports.sqlite",
|
|
137
|
+
tracker_name="Customer Report Group",
|
|
138
|
+
)
|
|
133
139
|
```
|
|
134
140
|
|
|
141
|
+
`tracker_name` is the Analytics Tracker group label shown in PAM CMS. Keep it stable
|
|
142
|
+
for all reports in the store. If omitted, the framework uses the service name.
|
|
143
|
+
|
|
135
144
|
Declare reports once after opening the store. Append data across as many input
|
|
136
145
|
pages as required, then publish once after all intended work is complete.
|
|
137
146
|
|
|
138
147
|
```python
|
|
139
148
|
def on_start(self):
|
|
140
|
-
self.report_store = self.reports.open(
|
|
149
|
+
self.report_store = self.reports.open(
|
|
150
|
+
"reports.sqlite",
|
|
151
|
+
tracker_name="Customer Report Group",
|
|
152
|
+
)
|
|
141
153
|
self._declare_reports()
|
|
142
154
|
self._request_data(file_format=RequestFileFormat.PARQUET)
|
|
143
155
|
|
|
@@ -230,7 +242,8 @@ Initial value types:
|
|
|
230
242
|
- `number`
|
|
231
243
|
- `boolean`
|
|
232
244
|
- `date` — calendar date, serialized as `YYYY-MM-DD`
|
|
233
|
-
- `datetime` — timestamp normalized to epoch milliseconds internally
|
|
245
|
+
- `datetime` — timestamp normalized to epoch milliseconds internally; string
|
|
246
|
+
inputs must use PAM UTC format `YYYY-MM-DD HH:mm:ss`
|
|
234
247
|
|
|
235
248
|
Objects and arrays are not initial field types. A report that requires a complex
|
|
236
249
|
cell or point must first introduce an explicit framework and CMS contract.
|
|
@@ -1127,8 +1140,8 @@ ranges.append_ranges([
|
|
|
1127
1140
|
{
|
|
1128
1141
|
"task": "Phase A",
|
|
1129
1142
|
"group": "Project 1",
|
|
1130
|
-
"start": "2026-08-
|
|
1131
|
-
"end": "2026-08-
|
|
1143
|
+
"start": "2026-08-01 09:00:00",
|
|
1144
|
+
"end": "2026-08-05 18:00:00",
|
|
1132
1145
|
},
|
|
1133
1146
|
], partition_key="2026-08")
|
|
1134
1147
|
```
|
|
@@ -18,7 +18,7 @@ UploadSqliteCallbackType = Callable[[str, bool, str], Optional[str]]
|
|
|
18
18
|
UploadResultCallbackType = Callable[[str], None]
|
|
19
19
|
UploadReportCallbackType = Callable[[str], None]
|
|
20
20
|
RequestReportStoreCallbackType = Callable[[str, bool, str], SQLiteDownloadResult]
|
|
21
|
-
PublishReportPointerCallbackType = Callable[
|
|
21
|
+
PublishReportPointerCallbackType = Callable[..., bool]
|
|
22
22
|
|
|
23
23
|
class TesterTask(ITaskManager):
|
|
24
24
|
"""
|
|
@@ -289,8 +289,29 @@ class TesterTask(ITaskManager):
|
|
|
289
289
|
service: Service,
|
|
290
290
|
report_key: str,
|
|
291
291
|
pointer: dict,
|
|
292
|
+
tracker_name: str,
|
|
292
293
|
) -> bool:
|
|
293
294
|
if self.publish_report_pointer_callback is not None:
|
|
295
|
+
parameters = list(
|
|
296
|
+
inspect.signature(self.publish_report_pointer_callback).parameters.values()
|
|
297
|
+
)
|
|
298
|
+
accepts_varargs = any(
|
|
299
|
+
parameter.kind is inspect.Parameter.VAR_POSITIONAL
|
|
300
|
+
for parameter in parameters
|
|
301
|
+
)
|
|
302
|
+
positional_count = sum(
|
|
303
|
+
parameter.kind in (
|
|
304
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
305
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
306
|
+
)
|
|
307
|
+
for parameter in parameters
|
|
308
|
+
)
|
|
309
|
+
if accepts_varargs or positional_count >= 3:
|
|
310
|
+
return self.publish_report_pointer_callback(
|
|
311
|
+
report_key,
|
|
312
|
+
pointer,
|
|
313
|
+
tracker_name,
|
|
314
|
+
)
|
|
294
315
|
return self.publish_report_pointer_callback(report_key, pointer)
|
|
295
316
|
print("Publish Report Store V2 pointer callback is not set.")
|
|
296
317
|
return False
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
pam/__init__.py
|
|
5
5
|
pam/api.py
|
|
6
6
|
pam/cli.py
|
|
7
|
+
pam/datetime_utils.py
|
|
7
8
|
pam/interface_task_manager.py
|
|
8
9
|
pam/logger.py
|
|
9
10
|
pam/request_file_format.py
|
|
@@ -84,6 +85,7 @@ tests/test_cli_templates.py
|
|
|
84
85
|
tests/test_data_error_v2.py
|
|
85
86
|
tests/test_data_input_v2.py
|
|
86
87
|
tests/test_data_timeout_v2.py
|
|
88
|
+
tests/test_datetime_utils.py
|
|
87
89
|
tests/test_range_transform.py
|
|
88
90
|
tests/test_report_store.py
|
|
89
91
|
tests/test_request_data_v2.py
|
|
@@ -47,6 +47,23 @@ class CLITemplateTest(unittest.TestCase):
|
|
|
47
47
|
self.assertIn("Customer-based Pagination Contract", agent_guide)
|
|
48
48
|
self.assertIn("same customer-ID cohort", agent_guide)
|
|
49
49
|
self.assertIn("customer-cohort cursor", agent_guide)
|
|
50
|
+
normalized_agent_guide = " ".join(agent_guide.split())
|
|
51
|
+
self.assertIn(
|
|
52
|
+
"Data Input V2 and Report Store V2 are independent contracts",
|
|
53
|
+
normalized_agent_guide,
|
|
54
|
+
)
|
|
55
|
+
self.assertIn(
|
|
56
|
+
"One store may contain many report",
|
|
57
|
+
normalized_agent_guide,
|
|
58
|
+
)
|
|
59
|
+
self.assertIn(
|
|
60
|
+
"Never generate one tracker name per report",
|
|
61
|
+
normalized_agent_guide,
|
|
62
|
+
)
|
|
63
|
+
self.assertIn(
|
|
64
|
+
"publishes one pointer for every report key changed",
|
|
65
|
+
normalized_agent_guide,
|
|
66
|
+
)
|
|
50
67
|
self.assertTrue((root / "run_unit_test.sh").is_file())
|
|
51
68
|
self.assertTrue(os.access(root / "run_unit_test.sh", os.X_OK))
|
|
52
69
|
self.assertTrue((root / "run_unit_test.ps1").is_file())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
|
|
4
|
+
from pam.datetime_utils import format_pam_datetime, parse_pam_datetime
|
|
5
|
+
from pam.logger import Logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PAMDateTimeUtilsTest(unittest.TestCase):
|
|
9
|
+
def test_round_trip_uses_suffix_free_utc_format(self):
|
|
10
|
+
parsed = parse_pam_datetime("2026-03-31 02:37:44")
|
|
11
|
+
|
|
12
|
+
self.assertEqual(timezone.utc, parsed.tzinfo)
|
|
13
|
+
self.assertEqual("2026-03-31 02:37:44", format_pam_datetime(parsed))
|
|
14
|
+
|
|
15
|
+
def test_format_converts_aware_datetime_to_utc(self):
|
|
16
|
+
value = datetime(
|
|
17
|
+
2026,
|
|
18
|
+
3,
|
|
19
|
+
31,
|
|
20
|
+
9,
|
|
21
|
+
37,
|
|
22
|
+
44,
|
|
23
|
+
tzinfo=timezone(timedelta(hours=7)),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
self.assertEqual("2026-03-31 02:37:44", format_pam_datetime(value))
|
|
27
|
+
|
|
28
|
+
def test_parse_rejects_rfc3339(self):
|
|
29
|
+
with self.assertRaises(ValueError):
|
|
30
|
+
parse_pam_datetime("2026-03-31T02:37:44Z")
|
|
31
|
+
|
|
32
|
+
def test_structured_logger_uses_pam_utc_format(self):
|
|
33
|
+
logged_at = Logger._utc_now()
|
|
34
|
+
|
|
35
|
+
self.assertEqual(19, len(logged_at))
|
|
36
|
+
self.assertEqual(
|
|
37
|
+
logged_at,
|
|
38
|
+
format_pam_datetime(parse_pam_datetime(logged_at)),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
unittest.main()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import csv
|
|
2
2
|
import importlib
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
import shutil
|
|
5
6
|
import sqlite3
|
|
6
7
|
import tempfile
|
|
@@ -15,6 +16,7 @@ from pam.reports import (
|
|
|
15
16
|
ReportDefinitionConflictError,
|
|
16
17
|
ReportStore,
|
|
17
18
|
ReportStoreDownloadError,
|
|
19
|
+
ReportStoreLocalIOError,
|
|
18
20
|
ReportStoreManager,
|
|
19
21
|
ReportStoreValidationError,
|
|
20
22
|
)
|
|
@@ -44,7 +46,9 @@ class FakeTransport:
|
|
|
44
46
|
return result
|
|
45
47
|
|
|
46
48
|
def publish(self, store, changed_report_keys):
|
|
47
|
-
self.publish_calls.append(
|
|
49
|
+
self.publish_calls.append(
|
|
50
|
+
(store.file_name, store.tracker_name, tuple(changed_report_keys))
|
|
51
|
+
)
|
|
48
52
|
|
|
49
53
|
|
|
50
54
|
def make_service():
|
|
@@ -69,8 +73,14 @@ class FakeTaskManager:
|
|
|
69
73
|
self.uploads.append((file_name, shared, sqlite_file))
|
|
70
74
|
return file_name
|
|
71
75
|
|
|
72
|
-
def service_publish_report_pointer_v2(
|
|
73
|
-
self
|
|
76
|
+
def service_publish_report_pointer_v2(
|
|
77
|
+
self,
|
|
78
|
+
service,
|
|
79
|
+
report_key,
|
|
80
|
+
pointer,
|
|
81
|
+
tracker_name,
|
|
82
|
+
):
|
|
83
|
+
self.pointers.append((report_key, pointer, tracker_name))
|
|
74
84
|
return True
|
|
75
85
|
|
|
76
86
|
|
|
@@ -136,6 +146,23 @@ class ReportStoreTest(unittest.TestCase):
|
|
|
136
146
|
definition_json="{}",
|
|
137
147
|
)
|
|
138
148
|
|
|
149
|
+
def test_metadata_timestamps_use_pam_utc_format(self):
|
|
150
|
+
self.report()
|
|
151
|
+
self.store._append_records(
|
|
152
|
+
"test-report-a",
|
|
153
|
+
[ReportRecord(dimensions={"customer": "contact-1"}, measures={})],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
timestamps = self.store._connection.execute(
|
|
157
|
+
"SELECT created_at, updated_at FROM report_definitions "
|
|
158
|
+
"UNION ALL SELECT created_at, updated_at FROM report_records"
|
|
159
|
+
).fetchall()
|
|
160
|
+
pattern = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")
|
|
161
|
+
self.assertTrue(timestamps)
|
|
162
|
+
for created_at, updated_at in timestamps:
|
|
163
|
+
self.assertRegex(created_at, pattern)
|
|
164
|
+
self.assertRegex(updated_at, pattern)
|
|
165
|
+
|
|
139
166
|
with self.assertRaises(ReportStoreValidationError):
|
|
140
167
|
self.store._declare_report(
|
|
141
168
|
key="invalid-range",
|
|
@@ -259,7 +286,7 @@ class ReportStoreTest(unittest.TestCase):
|
|
|
259
286
|
self.store.publish()
|
|
260
287
|
|
|
261
288
|
self.assertEqual([
|
|
262
|
-
("reports.sqlite", ("test-report-a", "test-report-b"))
|
|
289
|
+
("reports.sqlite", "", ("test-report-a", "test-report-b"))
|
|
263
290
|
], self.transport.publish_calls)
|
|
264
291
|
|
|
265
292
|
def test_generated_consumer_contract_uses_public_framework_api(self):
|
|
@@ -320,9 +347,28 @@ class ReportStoreManagerTest(unittest.TestCase):
|
|
|
320
347
|
store = manager.create("reports.sqlite")
|
|
321
348
|
|
|
322
349
|
self.assertTrue(store.path.is_file())
|
|
350
|
+
self.assertEqual("test-service", store.tracker_name)
|
|
323
351
|
self.assertEqual([], transport.download_calls)
|
|
324
352
|
manager.close()
|
|
325
353
|
|
|
354
|
+
def test_create_accepts_explicit_tracker_name(self):
|
|
355
|
+
transport = FakeTransport()
|
|
356
|
+
manager = ReportStoreManager(self.service, transport=transport)
|
|
357
|
+
|
|
358
|
+
store = manager.create(
|
|
359
|
+
"reports.sqlite",
|
|
360
|
+
tracker_name="Customer Report Group",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
self.assertEqual("Customer Report Group", store.tracker_name)
|
|
364
|
+
manager.close()
|
|
365
|
+
|
|
366
|
+
def test_create_rejects_empty_tracker_name(self):
|
|
367
|
+
manager = ReportStoreManager(self.service, transport=FakeTransport())
|
|
368
|
+
|
|
369
|
+
with self.assertRaises(ReportStoreLocalIOError):
|
|
370
|
+
manager.create("reports.sqlite", tracker_name=" ")
|
|
371
|
+
|
|
326
372
|
def test_discard_local_removes_only_open_local_store_and_allows_recreate(self):
|
|
327
373
|
transport = FakeTransport()
|
|
328
374
|
manager = ReportStoreManager(self.service, transport=transport)
|
|
@@ -429,6 +475,7 @@ class ReportStoreTransportTest(unittest.TestCase):
|
|
|
429
475
|
store = SimpleNamespace(
|
|
430
476
|
file_name="reports.sqlite",
|
|
431
477
|
shared=False,
|
|
478
|
+
tracker_name="Customer Report Group",
|
|
432
479
|
path=Path("/tmp/reports.sqlite"),
|
|
433
480
|
)
|
|
434
481
|
|
|
@@ -436,10 +483,11 @@ class ReportStoreTransportTest(unittest.TestCase):
|
|
|
436
483
|
|
|
437
484
|
self.assertEqual(1, len(service.task_manager.uploads))
|
|
438
485
|
self.assertEqual(2, len(service.task_manager.pointers))
|
|
439
|
-
report_key, pointer = service.task_manager.pointers[0]
|
|
486
|
+
report_key, pointer, tracker_name = service.task_manager.pointers[0]
|
|
440
487
|
self.assertEqual("test-report-a", report_key)
|
|
441
488
|
self.assertEqual("REPORT_STORE_V2", pointer["protocol"])
|
|
442
489
|
self.assertEqual("adapter-1", pointer["namespace"])
|
|
490
|
+
self.assertEqual("Customer Report Group", tracker_name)
|
|
443
491
|
|
|
444
492
|
def test_task_manager_wraps_pointer_in_existing_report_csv_format(self):
|
|
445
493
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
@@ -477,7 +525,10 @@ class ReportStoreTransportTest(unittest.TestCase):
|
|
|
477
525
|
|
|
478
526
|
try:
|
|
479
527
|
published = manager.service_publish_report_pointer_v2(
|
|
480
|
-
service,
|
|
528
|
+
service,
|
|
529
|
+
"test-report-a",
|
|
530
|
+
pointer,
|
|
531
|
+
tracker_name="Customer Report Group",
|
|
481
532
|
)
|
|
482
533
|
finally:
|
|
483
534
|
TempfileUtils.temp_datasource_path = previous_temp_path
|
|
@@ -485,6 +536,7 @@ class ReportStoreTransportTest(unittest.TestCase):
|
|
|
485
536
|
self.assertTrue(published)
|
|
486
537
|
self.assertEqual(request.response_api, captured["endpoint"])
|
|
487
538
|
self.assertEqual("adapter-1", captured["row"]["customer"])
|
|
539
|
+
self.assertEqual("Customer Report Group", captured["row"]["_tracker_name"])
|
|
488
540
|
self.assertEqual(
|
|
489
541
|
pointer,
|
|
490
542
|
json.loads(captured["row"]["data_test-report-a_no_index"]),
|
|
@@ -50,8 +50,8 @@ class TypedRangeBarReportTest(unittest.TestCase):
|
|
|
50
50
|
[{
|
|
51
51
|
"task": "Phase A",
|
|
52
52
|
"group": "Project 1",
|
|
53
|
-
"start": "2026-08-
|
|
54
|
-
"end": "2026-08-
|
|
53
|
+
"start": "2026-08-01 09:00:00",
|
|
54
|
+
"end": "2026-08-05 18:00:00",
|
|
55
55
|
}],
|
|
56
56
|
"2026-08",
|
|
57
57
|
)
|
|
@@ -61,6 +61,16 @@ class TypedRangeBarReportTest(unittest.TestCase):
|
|
|
61
61
|
measures = json.loads(row[0])
|
|
62
62
|
self.assertIsInstance(measures["start"], int)
|
|
63
63
|
self.assertGreater(measures["end"], measures["start"])
|
|
64
|
+
with self.assertRaises(ReportStoreValidationError):
|
|
65
|
+
report.append_ranges(
|
|
66
|
+
[{
|
|
67
|
+
"task": "Wrong format",
|
|
68
|
+
"group": "Project 1",
|
|
69
|
+
"start": "2026-08-01T09:00:00Z",
|
|
70
|
+
"end": "2026-08-05T18:00:00Z",
|
|
71
|
+
}],
|
|
72
|
+
"2026-08",
|
|
73
|
+
)
|
|
64
74
|
with self.assertRaises(ReportStoreValidationError):
|
|
65
75
|
report.append_ranges(
|
|
66
76
|
[{"task": "Bad", "group": "Project 1", "start": 20, "end": 10}],
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|