pam-python 0.2.8__tar.gz → 0.2.10__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.8/pam_python.egg-info → pam_python-0.2.10}/PKG-INFO +1 -1
  2. {pam_python-0.2.8 → pam_python-0.2.10}/pam/__init__.py +1 -1
  3. pam_python-0.2.10/pam/datetime_utils.py +20 -0
  4. {pam_python-0.2.8 → pam_python-0.2.10}/pam/logger.py +3 -1
  5. {pam_python-0.2.8 → pam_python-0.2.10}/pam/models/data_input_v2.py +4 -4
  6. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/record.py +5 -3
  7. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/store.py +2 -1
  8. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/init/AGENT.md +8 -0
  9. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/init/REPORTS.md +4 -3
  10. {pam_python-0.2.8 → pam_python-0.2.10/pam_python.egg-info}/PKG-INFO +1 -1
  11. {pam_python-0.2.8 → pam_python-0.2.10}/pam_python.egg-info/SOURCES.txt +2 -0
  12. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_data_input_v2.py +24 -7
  13. pam_python-0.2.10/tests/test_datetime_utils.py +43 -0
  14. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_report_store.py +18 -0
  15. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_range_bar_report.py +12 -2
  16. {pam_python-0.2.8 → pam_python-0.2.10}/LICENSE.txt +0 -0
  17. {pam_python-0.2.8 → pam_python-0.2.10}/README.md +0 -0
  18. {pam_python-0.2.8 → pam_python-0.2.10}/pam/api.py +0 -0
  19. {pam_python-0.2.8 → pam_python-0.2.10}/pam/cli.py +0 -0
  20. {pam_python-0.2.8 → pam_python-0.2.10}/pam/interface_task_manager.py +0 -0
  21. {pam_python-0.2.8 → pam_python-0.2.10}/pam/models/__init__.py +0 -0
  22. {pam_python-0.2.8 → pam_python-0.2.10}/pam/models/request_command.py +0 -0
  23. {pam_python-0.2.8 → pam_python-0.2.10}/pam/models/sqlite_download.py +0 -0
  24. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/__init__.py +0 -0
  25. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/area.py +0 -0
  26. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/axis.py +0 -0
  27. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/bar.py +0 -0
  28. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/box_plot.py +0 -0
  29. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/bubble.py +0 -0
  30. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/candlestick.py +0 -0
  31. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/collection.py +0 -0
  32. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/definition.py +0 -0
  33. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/donut.py +0 -0
  34. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/errors.py +0 -0
  35. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/fields.py +0 -0
  36. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/heatmap.py +0 -0
  37. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/line.py +0 -0
  38. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/manager.py +0 -0
  39. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/metric.py +0 -0
  40. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/pie.py +0 -0
  41. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/polar_area.py +0 -0
  42. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/radar.py +0 -0
  43. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/radial_bar.py +0 -0
  44. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/range_area.py +0 -0
  45. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/range_bar.py +0 -0
  46. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/range_transform.py +0 -0
  47. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/report.py +0 -0
  48. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/scatter.py +0 -0
  49. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/schema.py +0 -0
  50. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/table.py +0 -0
  51. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/transport.py +0 -0
  52. {pam_python-0.2.8 → pam_python-0.2.10}/pam/reports/treemap.py +0 -0
  53. {pam_python-0.2.8 → pam_python-0.2.10}/pam/request_file_format.py +0 -0
  54. {pam_python-0.2.8 → pam_python-0.2.10}/pam/result_batch_uploader.py +0 -0
  55. {pam_python-0.2.8 → pam_python-0.2.10}/pam/server.py +0 -0
  56. {pam_python-0.2.8 → pam_python-0.2.10}/pam/service.py +0 -0
  57. {pam_python-0.2.8 → pam_python-0.2.10}/pam/sqlite.py +0 -0
  58. {pam_python-0.2.8 → pam_python-0.2.10}/pam/task_manager.py +0 -0
  59. {pam_python-0.2.8 → pam_python-0.2.10}/pam/temp_file_utils.py +0 -0
  60. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/buildcmd/pamb +0 -0
  61. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/buildcmd/pamb-base.sh +0 -0
  62. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/init/dockerignore.tmpl +0 -0
  63. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/init/gitignore.tmpl +0 -0
  64. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/init/main.tmpl +0 -0
  65. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/init/pylintrc.tmpl +0 -0
  66. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/init/run_unit_test.bat +0 -0
  67. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/init/run_unit_test.ps1 +0 -0
  68. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/init/run_unit_test.sh +0 -0
  69. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/project/pip/Dockerfile +0 -0
  70. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/project/pip/requirements.txt +0 -0
  71. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/project/uv/Dockerfile +0 -0
  72. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/project/uv/pyproject.toml +0 -0
  73. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/project/uv/python-version +0 -0
  74. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/service/common/service.yaml +0 -0
  75. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/service/pandas/functions.tmpl +0 -0
  76. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/service/pandas/service.test.tmpl +0 -0
  77. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/service/pandas/service_class.tmpl +0 -0
  78. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/service/polars/functions.tmpl +0 -0
  79. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/service/polars/service.test.tmpl +0 -0
  80. {pam_python-0.2.8 → pam_python-0.2.10}/pam/templates/service/polars/service_class.tmpl +0 -0
  81. {pam_python-0.2.8 → pam_python-0.2.10}/pam/tester_task.py +0 -0
  82. {pam_python-0.2.8 → pam_python-0.2.10}/pam/utils.py +0 -0
  83. {pam_python-0.2.8 → pam_python-0.2.10}/pam_python.egg-info/dependency_links.txt +0 -0
  84. {pam_python-0.2.8 → pam_python-0.2.10}/pam_python.egg-info/entry_points.txt +0 -0
  85. {pam_python-0.2.8 → pam_python-0.2.10}/pam_python.egg-info/requires.txt +0 -0
  86. {pam_python-0.2.8 → pam_python-0.2.10}/pam_python.egg-info/top_level.txt +0 -0
  87. {pam_python-0.2.8 → pam_python-0.2.10}/pyproject.toml +0 -0
  88. {pam_python-0.2.8 → pam_python-0.2.10}/setup.cfg +0 -0
  89. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_api.py +0 -0
  90. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_cli_templates.py +0 -0
  91. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_data_error_v2.py +0 -0
  92. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_data_timeout_v2.py +0 -0
  93. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_range_transform.py +0 -0
  94. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_request_data_v2.py +0 -0
  95. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_result_batch_uploader.py +0 -0
  96. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_service_dataframe_output.py +0 -0
  97. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_sqlite_download_v2.py +0 -0
  98. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_temp_file_utils.py +0 -0
  99. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_area_report.py +0 -0
  100. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_bar_report.py +0 -0
  101. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_box_plot_report.py +0 -0
  102. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_bubble_report.py +0 -0
  103. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_candlestick_report.py +0 -0
  104. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_donut_report.py +0 -0
  105. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_heatmap_report.py +0 -0
  106. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_line_report.py +0 -0
  107. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_metric_report.py +0 -0
  108. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_pie_report.py +0 -0
  109. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_polar_area_report.py +0 -0
  110. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_radar_report.py +0 -0
  111. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_radial_bar_report.py +0 -0
  112. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_range_area_report.py +0 -0
  113. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_scatter_report.py +0 -0
  114. {pam_python-0.2.8 → pam_python-0.2.10}/tests/test_typed_table_report.py +0 -0
  115. {pam_python-0.2.8 → pam_python-0.2.10}/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.8
3
+ Version: 0.2.10
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.8"
3
+ __version__ = "0.2.10"
@@ -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).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
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
 
@@ -374,11 +374,11 @@ def _parse_time_window(raw_time_window: Any) -> DataInputV2TimeWindow | None:
374
374
  start_inclusive = _required_bool(raw_time_window, "start_inclusive")
375
375
  end_inclusive = _required_bool(raw_time_window, "end_inclusive")
376
376
  try:
377
- start_time = datetime.fromisoformat(start.replace("Z", "+00:00"))
378
- end_time = datetime.fromisoformat(end.replace("Z", "+00:00"))
377
+ start_time = parse_pam_datetime(start)
378
+ end_time = parse_pam_datetime(end)
379
379
  except ValueError as error:
380
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:
381
+ if start_time >= end_time:
382
382
  raise ValueError("V2 manifest time_window order is invalid")
383
383
  if not start_inclusive or end_inclusive:
384
384
  raise ValueError("V2 manifest time_window must be half-open")
@@ -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:
@@ -1,5 +1,13 @@
1
1
  # AGENT
2
2
 
3
+ ## PAM date and time contract
4
+
5
+ PAM timestamps are UTC+0 strings in the exact format `%Y-%m-%d %H:%M:%S`, for
6
+ example `2026-03-31 02:37:44`. The timezone suffix is intentionally omitted.
7
+ Interpret these values as UTC without shifting the clock value. Do not emit or
8
+ expect RFC3339, ISO-8601, `T`, `Z`, or explicit timezone suffixes in internal PAM
9
+ contracts.
10
+
3
11
  This document defines the **data plugin runtime specification** and execution contract so developers and agents can create services and understand runtime behavior inside the CDP environment.
4
12
 
5
13
  This is not a tutorial.
@@ -230,7 +230,8 @@ Initial value types:
230
230
  - `number`
231
231
  - `boolean`
232
232
  - `date` — calendar date, serialized as `YYYY-MM-DD`
233
- - `datetime` — timestamp normalized to epoch milliseconds internally
233
+ - `datetime` — timestamp normalized to epoch milliseconds internally; string
234
+ inputs must use PAM UTC format `YYYY-MM-DD HH:mm:ss`
234
235
 
235
236
  Objects and arrays are not initial field types. A report that requires a complex
236
237
  cell or point must first introduce an explicit framework and CMS contract.
@@ -1127,8 +1128,8 @@ ranges.append_ranges([
1127
1128
  {
1128
1129
  "task": "Phase A",
1129
1130
  "group": "Project 1",
1130
- "start": "2026-08-01T09:00:00+07:00",
1131
- "end": "2026-08-05T18:00:00+07:00",
1131
+ "start": "2026-08-01 09:00:00",
1132
+ "end": "2026-08-05 18:00:00",
1132
1133
  },
1133
1134
  ], partition_key="2026-08")
1134
1135
  ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pam-python
3
- Version: 0.2.8
3
+ Version: 0.2.10
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
@@ -23,7 +23,7 @@ class DataInputV2Test(unittest.TestCase):
23
23
  TempfileUtils.temp_datasource_path = str(self.temp_dir / "data_sources")
24
24
  self.population_path = self._write_file(
25
25
  "population.csv",
26
- b"id,contact_created_at\ncontact-1,2025-01-01T00:00:00Z\n",
26
+ b"id,contact_created_at\ncontact-1,2025-01-01 00:00:00\n",
27
27
  )
28
28
  self.dataset_path = self._write_file(
29
29
  "dataset_001_demographic_part_0001.csv",
@@ -255,8 +255,8 @@ class DataInputV2Test(unittest.TestCase):
255
255
  manifest["datasets"][0]["type"] = "event"
256
256
  manifest["datasets"][0]["event_names"] = ["purchase"]
257
257
  manifest["time_window"] = {
258
- "start": "2025-08-14T07:00:00Z",
259
- "end": "2026-08-14T07:00:00Z",
258
+ "start": "2025-08-14 07:00:00",
259
+ "end": "2026-08-14 07:00:00",
260
260
  "start_inclusive": True,
261
261
  "end_inclusive": False,
262
262
  }
@@ -268,8 +268,8 @@ class DataInputV2Test(unittest.TestCase):
268
268
 
269
269
  self.assertEqual("event_all", data_input.population_mode)
270
270
  self.assertIsNotNone(data_input.time_window)
271
- self.assertEqual("2025-08-14T07:00:00Z", data_input.time_window.start)
272
- self.assertEqual("2026-08-14T07:00:00Z", data_input.time_window.end)
271
+ self.assertEqual("2025-08-14 07:00:00", data_input.time_window.start)
272
+ self.assertEqual("2026-08-14 07:00:00", data_input.time_window.end)
273
273
  self.assertTrue(data_input.time_window.start_inclusive)
274
274
  self.assertFalse(data_input.time_window.end_inclusive)
275
275
 
@@ -299,8 +299,8 @@ class DataInputV2Test(unittest.TestCase):
299
299
  manifest["datasets"][0]["type"] = "event"
300
300
  manifest["datasets"][0]["event_names"] = ["purchase"]
301
301
  manifest["time_window"] = {
302
- "start": "2025-08-14T07:00:00Z",
303
- "end": "2026-08-14T07:00:00Z",
302
+ "start": "2025-08-14 07:00:00",
303
+ "end": "2026-08-14 07:00:00",
304
304
  "start_inclusive": True,
305
305
  "end_inclusive": True,
306
306
  }
@@ -311,6 +311,23 @@ class DataInputV2Test(unittest.TestCase):
311
311
  [str(manifest_path), str(self.population_path), str(self.dataset_path)]
312
312
  )
313
313
 
314
+ def test_parse_rejects_rfc3339_event_time_window(self):
315
+ manifest = self._valid_manifest()
316
+ manifest["datasets"][0]["type"] = "event"
317
+ manifest["datasets"][0]["event_names"] = ["purchase"]
318
+ manifest["time_window"] = {
319
+ "start": "2025-08-14T07:00:00Z",
320
+ "end": "2026-08-14T07:00:00Z",
321
+ "start_inclusive": True,
322
+ "end_inclusive": False,
323
+ }
324
+ manifest_path = self._write_manifest(manifest)
325
+
326
+ with self.assertRaisesRegex(ValueError, "timestamps are invalid"):
327
+ parse_data_input_v2(
328
+ [str(manifest_path), str(self.population_path), str(self.dataset_path)]
329
+ )
330
+
314
331
  def test_parse_rejects_event_population_without_event_dataset(self):
315
332
  manifest = self._valid_manifest()
316
333
  manifest["population"]["mode"] = "event_any"
@@ -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
@@ -136,6 +137,23 @@ class ReportStoreTest(unittest.TestCase):
136
137
  definition_json="{}",
137
138
  )
138
139
 
140
+ def test_metadata_timestamps_use_pam_utc_format(self):
141
+ self.report()
142
+ self.store._append_records(
143
+ "test-report-a",
144
+ [ReportRecord(dimensions={"customer": "contact-1"}, measures={})],
145
+ )
146
+
147
+ timestamps = self.store._connection.execute(
148
+ "SELECT created_at, updated_at FROM report_definitions "
149
+ "UNION ALL SELECT created_at, updated_at FROM report_records"
150
+ ).fetchall()
151
+ pattern = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")
152
+ self.assertTrue(timestamps)
153
+ for created_at, updated_at in timestamps:
154
+ self.assertRegex(created_at, pattern)
155
+ self.assertRegex(updated_at, pattern)
156
+
139
157
  with self.assertRaises(ReportStoreValidationError):
140
158
  self.store._declare_report(
141
159
  key="invalid-range",
@@ -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