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.
Files changed (115) hide show
  1. {pam_python-0.2.9/pam_python.egg-info → pam_python-0.2.11}/PKG-INFO +1 -1
  2. {pam_python-0.2.9 → pam_python-0.2.11}/pam/__init__.py +1 -1
  3. pam_python-0.2.11/pam/datetime_utils.py +20 -0
  4. {pam_python-0.2.9 → pam_python-0.2.11}/pam/interface_task_manager.py +1 -0
  5. {pam_python-0.2.9 → pam_python-0.2.11}/pam/logger.py +3 -1
  6. {pam_python-0.2.9 → pam_python-0.2.11}/pam/models/data_input_v2.py +3 -9
  7. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/manager.py +30 -2
  8. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/record.py +5 -3
  9. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/store.py +22 -3
  10. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/transport.py +1 -0
  11. {pam_python-0.2.9 → pam_python-0.2.11}/pam/task_manager.py +2 -1
  12. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/AGENT.md +107 -9
  13. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/REPORTS.md +19 -6
  14. {pam_python-0.2.9 → pam_python-0.2.11}/pam/tester_task.py +22 -1
  15. {pam_python-0.2.9 → pam_python-0.2.11/pam_python.egg-info}/PKG-INFO +1 -1
  16. {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/SOURCES.txt +2 -0
  17. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_cli_templates.py +17 -0
  18. pam_python-0.2.11/tests/test_datetime_utils.py +43 -0
  19. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_report_store.py +58 -6
  20. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_range_bar_report.py +12 -2
  21. {pam_python-0.2.9 → pam_python-0.2.11}/LICENSE.txt +0 -0
  22. {pam_python-0.2.9 → pam_python-0.2.11}/README.md +0 -0
  23. {pam_python-0.2.9 → pam_python-0.2.11}/pam/api.py +0 -0
  24. {pam_python-0.2.9 → pam_python-0.2.11}/pam/cli.py +0 -0
  25. {pam_python-0.2.9 → pam_python-0.2.11}/pam/models/__init__.py +0 -0
  26. {pam_python-0.2.9 → pam_python-0.2.11}/pam/models/request_command.py +0 -0
  27. {pam_python-0.2.9 → pam_python-0.2.11}/pam/models/sqlite_download.py +0 -0
  28. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/__init__.py +0 -0
  29. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/area.py +0 -0
  30. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/axis.py +0 -0
  31. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/bar.py +0 -0
  32. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/box_plot.py +0 -0
  33. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/bubble.py +0 -0
  34. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/candlestick.py +0 -0
  35. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/collection.py +0 -0
  36. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/definition.py +0 -0
  37. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/donut.py +0 -0
  38. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/errors.py +0 -0
  39. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/fields.py +0 -0
  40. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/heatmap.py +0 -0
  41. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/line.py +0 -0
  42. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/metric.py +0 -0
  43. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/pie.py +0 -0
  44. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/polar_area.py +0 -0
  45. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/radar.py +0 -0
  46. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/radial_bar.py +0 -0
  47. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/range_area.py +0 -0
  48. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/range_bar.py +0 -0
  49. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/range_transform.py +0 -0
  50. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/report.py +0 -0
  51. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/scatter.py +0 -0
  52. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/schema.py +0 -0
  53. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/table.py +0 -0
  54. {pam_python-0.2.9 → pam_python-0.2.11}/pam/reports/treemap.py +0 -0
  55. {pam_python-0.2.9 → pam_python-0.2.11}/pam/request_file_format.py +0 -0
  56. {pam_python-0.2.9 → pam_python-0.2.11}/pam/result_batch_uploader.py +0 -0
  57. {pam_python-0.2.9 → pam_python-0.2.11}/pam/server.py +0 -0
  58. {pam_python-0.2.9 → pam_python-0.2.11}/pam/service.py +0 -0
  59. {pam_python-0.2.9 → pam_python-0.2.11}/pam/sqlite.py +0 -0
  60. {pam_python-0.2.9 → pam_python-0.2.11}/pam/temp_file_utils.py +0 -0
  61. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/buildcmd/pamb +0 -0
  62. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/buildcmd/pamb-base.sh +0 -0
  63. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/dockerignore.tmpl +0 -0
  64. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/gitignore.tmpl +0 -0
  65. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/main.tmpl +0 -0
  66. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/pylintrc.tmpl +0 -0
  67. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/run_unit_test.bat +0 -0
  68. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/run_unit_test.ps1 +0 -0
  69. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/init/run_unit_test.sh +0 -0
  70. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/pip/Dockerfile +0 -0
  71. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/pip/requirements.txt +0 -0
  72. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/uv/Dockerfile +0 -0
  73. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/uv/pyproject.toml +0 -0
  74. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/project/uv/python-version +0 -0
  75. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/common/service.yaml +0 -0
  76. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/pandas/functions.tmpl +0 -0
  77. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/pandas/service.test.tmpl +0 -0
  78. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/pandas/service_class.tmpl +0 -0
  79. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/polars/functions.tmpl +0 -0
  80. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/polars/service.test.tmpl +0 -0
  81. {pam_python-0.2.9 → pam_python-0.2.11}/pam/templates/service/polars/service_class.tmpl +0 -0
  82. {pam_python-0.2.9 → pam_python-0.2.11}/pam/utils.py +0 -0
  83. {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/dependency_links.txt +0 -0
  84. {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/entry_points.txt +0 -0
  85. {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/requires.txt +0 -0
  86. {pam_python-0.2.9 → pam_python-0.2.11}/pam_python.egg-info/top_level.txt +0 -0
  87. {pam_python-0.2.9 → pam_python-0.2.11}/pyproject.toml +0 -0
  88. {pam_python-0.2.9 → pam_python-0.2.11}/setup.cfg +0 -0
  89. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_api.py +0 -0
  90. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_data_error_v2.py +0 -0
  91. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_data_input_v2.py +0 -0
  92. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_data_timeout_v2.py +0 -0
  93. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_range_transform.py +0 -0
  94. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_request_data_v2.py +0 -0
  95. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_result_batch_uploader.py +0 -0
  96. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_service_dataframe_output.py +0 -0
  97. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_sqlite_download_v2.py +0 -0
  98. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_temp_file_utils.py +0 -0
  99. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_area_report.py +0 -0
  100. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_bar_report.py +0 -0
  101. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_box_plot_report.py +0 -0
  102. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_bubble_report.py +0 -0
  103. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_candlestick_report.py +0 -0
  104. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_donut_report.py +0 -0
  105. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_heatmap_report.py +0 -0
  106. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_line_report.py +0 -0
  107. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_metric_report.py +0 -0
  108. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_pie_report.py +0 -0
  109. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_polar_area_report.py +0 -0
  110. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_radar_report.py +0 -0
  111. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_radial_bar_report.py +0 -0
  112. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_range_area_report.py +0 -0
  113. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_scatter_report.py +0 -0
  114. {pam_python-0.2.9 → pam_python-0.2.11}/tests/test_typed_table_report.py +0 -0
  115. {pam_python-0.2.9 → pam_python-0.2.11}/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.9
3
+ Version: 0.2.11
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
@@ -1,3 +1,3 @@
1
1
  """PAM Data Plugin framework."""
2
2
 
3
- __version__ = "0.2.9"
3
+ __version__ = "0.2.11"
@@ -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)
@@ -79,5 +79,6 @@ class ITaskManager(ABC):
79
79
  service: Service,
80
80
  report_key: str,
81
81
  pointer: dict,
82
+ tracker_name: str,
82
83
  ):
83
84
  pass
@@ -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).isoformat(timespec="milliseconds")
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 = _parse_pam_timestamp(start)
384
- end_time = _parse_pam_timestamp(end)
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(self, file_name: str, shared: bool = False) -> ReportStore:
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(self, file_name: str, shared: bool = False) -> ReportStore:
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 = datetime.fromisoformat(value.replace("Z", "+00:00"))
79
+ parsed = parse_pam_datetime(value)
79
80
  except ValueError as exc:
80
81
  raise ReportStoreValidationError(
81
- f"Timestamp value is not ISO-8601: {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, or ISO-8601 string"
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).isoformat()
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(path, file_name, shared, transport, cleanup_on_close)
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(path, file_name, shared, transport, cleanup_on_close)
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()
@@ -51,6 +51,7 @@ class ReportStoreTransport:
51
51
  self._service,
52
52
  report_key,
53
53
  pointer,
54
+ tracker_name=store.tracker_name,
54
55
  ):
55
56
  raise ReportStoreUploadError(
56
57
  f"Failed to publish pointer for report '{report_key}'"
@@ -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. Always call `_upload_result`; the system handles it.
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
- Timezone may vary by customer data (usually Bangkok time).
150
- You may sometimes be asked for the exact time zone if it is logically important for the program.
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
- # 4. Persist State Between Runs (sqlite/duckdb)
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
- ## Managed reports are a separate contract
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
- For managed reports, read `REPORTS.md` and use only one of its typed report builders.
443
- Report types outside that closed contract require a newer framework and CMS release.
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
- # 5. Batch upload policy (DataFrame batching)
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
- # 6. Memory / RAM safety policy (Data Plugin)
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("reports.sqlite")
130
+ store = self.reports.create(
131
+ "reports.sqlite",
132
+ tracker_name="Customer Report Group",
133
+ )
131
134
  else:
132
- store = self.reports.open("reports.sqlite")
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("reports.sqlite")
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-01T09:00:00+07:00",
1131
- "end": "2026-08-05T18:00:00+07:00",
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[[str, dict], bool]
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pam-python
3
- Version: 0.2.9
3
+ Version: 0.2.11
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
@@ -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((store.file_name, tuple(changed_report_keys)))
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(self, service, report_key, pointer):
73
- self.pointers.append((report_key, pointer))
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, "test-report-a", pointer
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-01T09:00:00+07:00",
54
- "end": "2026-08-05T18:00:00+07:00",
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