pam-python 0.2.10__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.10/pam_python.egg-info → pam_python-0.2.11}/PKG-INFO +1 -1
  2. {pam_python-0.2.10 → pam_python-0.2.11}/pam/__init__.py +1 -1
  3. {pam_python-0.2.10 → pam_python-0.2.11}/pam/interface_task_manager.py +1 -0
  4. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/manager.py +30 -2
  5. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/store.py +20 -2
  6. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/transport.py +1 -0
  7. {pam_python-0.2.10 → pam_python-0.2.11}/pam/task_manager.py +2 -1
  8. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/init/AGENT.md +107 -9
  9. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/init/REPORTS.md +15 -3
  10. {pam_python-0.2.10 → pam_python-0.2.11}/pam/tester_task.py +22 -1
  11. {pam_python-0.2.10 → pam_python-0.2.11/pam_python.egg-info}/PKG-INFO +1 -1
  12. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_cli_templates.py +17 -0
  13. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_report_store.py +40 -6
  14. {pam_python-0.2.10 → pam_python-0.2.11}/LICENSE.txt +0 -0
  15. {pam_python-0.2.10 → pam_python-0.2.11}/README.md +0 -0
  16. {pam_python-0.2.10 → pam_python-0.2.11}/pam/api.py +0 -0
  17. {pam_python-0.2.10 → pam_python-0.2.11}/pam/cli.py +0 -0
  18. {pam_python-0.2.10 → pam_python-0.2.11}/pam/datetime_utils.py +0 -0
  19. {pam_python-0.2.10 → pam_python-0.2.11}/pam/logger.py +0 -0
  20. {pam_python-0.2.10 → pam_python-0.2.11}/pam/models/__init__.py +0 -0
  21. {pam_python-0.2.10 → pam_python-0.2.11}/pam/models/data_input_v2.py +0 -0
  22. {pam_python-0.2.10 → pam_python-0.2.11}/pam/models/request_command.py +0 -0
  23. {pam_python-0.2.10 → pam_python-0.2.11}/pam/models/sqlite_download.py +0 -0
  24. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/__init__.py +0 -0
  25. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/area.py +0 -0
  26. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/axis.py +0 -0
  27. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/bar.py +0 -0
  28. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/box_plot.py +0 -0
  29. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/bubble.py +0 -0
  30. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/candlestick.py +0 -0
  31. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/collection.py +0 -0
  32. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/definition.py +0 -0
  33. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/donut.py +0 -0
  34. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/errors.py +0 -0
  35. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/fields.py +0 -0
  36. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/heatmap.py +0 -0
  37. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/line.py +0 -0
  38. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/metric.py +0 -0
  39. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/pie.py +0 -0
  40. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/polar_area.py +0 -0
  41. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/radar.py +0 -0
  42. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/radial_bar.py +0 -0
  43. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/range_area.py +0 -0
  44. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/range_bar.py +0 -0
  45. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/range_transform.py +0 -0
  46. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/record.py +0 -0
  47. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/report.py +0 -0
  48. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/scatter.py +0 -0
  49. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/schema.py +0 -0
  50. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/table.py +0 -0
  51. {pam_python-0.2.10 → pam_python-0.2.11}/pam/reports/treemap.py +0 -0
  52. {pam_python-0.2.10 → pam_python-0.2.11}/pam/request_file_format.py +0 -0
  53. {pam_python-0.2.10 → pam_python-0.2.11}/pam/result_batch_uploader.py +0 -0
  54. {pam_python-0.2.10 → pam_python-0.2.11}/pam/server.py +0 -0
  55. {pam_python-0.2.10 → pam_python-0.2.11}/pam/service.py +0 -0
  56. {pam_python-0.2.10 → pam_python-0.2.11}/pam/sqlite.py +0 -0
  57. {pam_python-0.2.10 → pam_python-0.2.11}/pam/temp_file_utils.py +0 -0
  58. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/buildcmd/pamb +0 -0
  59. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/buildcmd/pamb-base.sh +0 -0
  60. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/init/dockerignore.tmpl +0 -0
  61. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/init/gitignore.tmpl +0 -0
  62. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/init/main.tmpl +0 -0
  63. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/init/pylintrc.tmpl +0 -0
  64. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/init/run_unit_test.bat +0 -0
  65. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/init/run_unit_test.ps1 +0 -0
  66. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/init/run_unit_test.sh +0 -0
  67. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/project/pip/Dockerfile +0 -0
  68. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/project/pip/requirements.txt +0 -0
  69. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/project/uv/Dockerfile +0 -0
  70. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/project/uv/pyproject.toml +0 -0
  71. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/project/uv/python-version +0 -0
  72. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/service/common/service.yaml +0 -0
  73. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/service/pandas/functions.tmpl +0 -0
  74. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/service/pandas/service.test.tmpl +0 -0
  75. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/service/pandas/service_class.tmpl +0 -0
  76. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/service/polars/functions.tmpl +0 -0
  77. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/service/polars/service.test.tmpl +0 -0
  78. {pam_python-0.2.10 → pam_python-0.2.11}/pam/templates/service/polars/service_class.tmpl +0 -0
  79. {pam_python-0.2.10 → pam_python-0.2.11}/pam/utils.py +0 -0
  80. {pam_python-0.2.10 → pam_python-0.2.11}/pam_python.egg-info/SOURCES.txt +0 -0
  81. {pam_python-0.2.10 → pam_python-0.2.11}/pam_python.egg-info/dependency_links.txt +0 -0
  82. {pam_python-0.2.10 → pam_python-0.2.11}/pam_python.egg-info/entry_points.txt +0 -0
  83. {pam_python-0.2.10 → pam_python-0.2.11}/pam_python.egg-info/requires.txt +0 -0
  84. {pam_python-0.2.10 → pam_python-0.2.11}/pam_python.egg-info/top_level.txt +0 -0
  85. {pam_python-0.2.10 → pam_python-0.2.11}/pyproject.toml +0 -0
  86. {pam_python-0.2.10 → pam_python-0.2.11}/setup.cfg +0 -0
  87. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_api.py +0 -0
  88. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_data_error_v2.py +0 -0
  89. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_data_input_v2.py +0 -0
  90. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_data_timeout_v2.py +0 -0
  91. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_datetime_utils.py +0 -0
  92. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_range_transform.py +0 -0
  93. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_request_data_v2.py +0 -0
  94. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_result_batch_uploader.py +0 -0
  95. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_service_dataframe_output.py +0 -0
  96. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_sqlite_download_v2.py +0 -0
  97. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_temp_file_utils.py +0 -0
  98. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_area_report.py +0 -0
  99. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_bar_report.py +0 -0
  100. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_box_plot_report.py +0 -0
  101. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_bubble_report.py +0 -0
  102. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_candlestick_report.py +0 -0
  103. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_donut_report.py +0 -0
  104. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_heatmap_report.py +0 -0
  105. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_line_report.py +0 -0
  106. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_metric_report.py +0 -0
  107. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_pie_report.py +0 -0
  108. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_polar_area_report.py +0 -0
  109. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_radar_report.py +0 -0
  110. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_radial_bar_report.py +0 -0
  111. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_range_area_report.py +0 -0
  112. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_range_bar_report.py +0 -0
  113. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_scatter_report.py +0 -0
  114. {pam_python-0.2.10 → pam_python-0.2.11}/tests/test_typed_table_report.py +0 -0
  115. {pam_python-0.2.10 → 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.10
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.10"
3
+ __version__ = "0.2.11"
@@ -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
@@ -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():
@@ -60,12 +60,14 @@ class ReportStore:
60
60
  path: Path,
61
61
  file_name: str,
62
62
  shared: bool,
63
+ tracker_name: str = "",
63
64
  transport=None,
64
65
  cleanup_on_close: bool = False,
65
66
  ):
66
67
  self.path = Path(path)
67
68
  self.file_name = file_name
68
69
  self.shared = shared
70
+ self.tracker_name = tracker_name
69
71
  self._transport = transport
70
72
  self._cleanup_on_close = cleanup_on_close
71
73
  self._lock = threading.RLock()
@@ -87,6 +89,7 @@ class ReportStore:
87
89
  path: Path,
88
90
  file_name: str,
89
91
  shared: bool,
92
+ tracker_name: str = "",
90
93
  transport=None,
91
94
  cleanup_on_close: bool = False,
92
95
  ):
@@ -97,7 +100,14 @@ class ReportStore:
97
100
  except OSError as exc:
98
101
  raise ReportStoreLocalIOError(str(exc)) from exc
99
102
 
100
- 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
+ )
101
111
  try:
102
112
  with store._connection:
103
113
  store._connection.executescript(CREATE_SCHEMA_SQL)
@@ -116,10 +126,18 @@ class ReportStore:
116
126
  path: Path,
117
127
  file_name: str,
118
128
  shared: bool,
129
+ tracker_name: str = "",
119
130
  transport=None,
120
131
  cleanup_on_close: bool = False,
121
132
  ):
122
- 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
+ )
123
141
  try:
124
142
  store._migrate_compatible_schema()
125
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
 
@@ -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.10
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
@@ -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())
@@ -16,6 +16,7 @@ from pam.reports import (
16
16
  ReportDefinitionConflictError,
17
17
  ReportStore,
18
18
  ReportStoreDownloadError,
19
+ ReportStoreLocalIOError,
19
20
  ReportStoreManager,
20
21
  ReportStoreValidationError,
21
22
  )
@@ -45,7 +46,9 @@ class FakeTransport:
45
46
  return result
46
47
 
47
48
  def publish(self, store, changed_report_keys):
48
- 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
+ )
49
52
 
50
53
 
51
54
  def make_service():
@@ -70,8 +73,14 @@ class FakeTaskManager:
70
73
  self.uploads.append((file_name, shared, sqlite_file))
71
74
  return file_name
72
75
 
73
- def service_publish_report_pointer_v2(self, service, report_key, pointer):
74
- 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))
75
84
  return True
76
85
 
77
86
 
@@ -277,7 +286,7 @@ class ReportStoreTest(unittest.TestCase):
277
286
  self.store.publish()
278
287
 
279
288
  self.assertEqual([
280
- ("reports.sqlite", ("test-report-a", "test-report-b"))
289
+ ("reports.sqlite", "", ("test-report-a", "test-report-b"))
281
290
  ], self.transport.publish_calls)
282
291
 
283
292
  def test_generated_consumer_contract_uses_public_framework_api(self):
@@ -338,9 +347,28 @@ class ReportStoreManagerTest(unittest.TestCase):
338
347
  store = manager.create("reports.sqlite")
339
348
 
340
349
  self.assertTrue(store.path.is_file())
350
+ self.assertEqual("test-service", store.tracker_name)
341
351
  self.assertEqual([], transport.download_calls)
342
352
  manager.close()
343
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
+
344
372
  def test_discard_local_removes_only_open_local_store_and_allows_recreate(self):
345
373
  transport = FakeTransport()
346
374
  manager = ReportStoreManager(self.service, transport=transport)
@@ -447,6 +475,7 @@ class ReportStoreTransportTest(unittest.TestCase):
447
475
  store = SimpleNamespace(
448
476
  file_name="reports.sqlite",
449
477
  shared=False,
478
+ tracker_name="Customer Report Group",
450
479
  path=Path("/tmp/reports.sqlite"),
451
480
  )
452
481
 
@@ -454,10 +483,11 @@ class ReportStoreTransportTest(unittest.TestCase):
454
483
 
455
484
  self.assertEqual(1, len(service.task_manager.uploads))
456
485
  self.assertEqual(2, len(service.task_manager.pointers))
457
- report_key, pointer = service.task_manager.pointers[0]
486
+ report_key, pointer, tracker_name = service.task_manager.pointers[0]
458
487
  self.assertEqual("test-report-a", report_key)
459
488
  self.assertEqual("REPORT_STORE_V2", pointer["protocol"])
460
489
  self.assertEqual("adapter-1", pointer["namespace"])
490
+ self.assertEqual("Customer Report Group", tracker_name)
461
491
 
462
492
  def test_task_manager_wraps_pointer_in_existing_report_csv_format(self):
463
493
  with tempfile.TemporaryDirectory() as temp_dir:
@@ -495,7 +525,10 @@ class ReportStoreTransportTest(unittest.TestCase):
495
525
 
496
526
  try:
497
527
  published = manager.service_publish_report_pointer_v2(
498
- service, "test-report-a", pointer
528
+ service,
529
+ "test-report-a",
530
+ pointer,
531
+ tracker_name="Customer Report Group",
499
532
  )
500
533
  finally:
501
534
  TempfileUtils.temp_datasource_path = previous_temp_path
@@ -503,6 +536,7 @@ class ReportStoreTransportTest(unittest.TestCase):
503
536
  self.assertTrue(published)
504
537
  self.assertEqual(request.response_api, captured["endpoint"])
505
538
  self.assertEqual("adapter-1", captured["row"]["customer"])
539
+ self.assertEqual("Customer Report Group", captured["row"]["_tracker_name"])
506
540
  self.assertEqual(
507
541
  pointer,
508
542
  json.loads(captured["row"]["data_test-report-a_no_index"]),
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