pam-python 0.2.1__tar.gz → 0.2.3__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 (106) hide show
  1. {pam_python-0.2.1/pam_python.egg-info → pam_python-0.2.3}/PKG-INFO +18 -22
  2. {pam_python-0.2.1 → pam_python-0.2.3}/README.md +17 -21
  3. {pam_python-0.2.1 → pam_python-0.2.3}/pam/__init__.py +1 -1
  4. {pam_python-0.2.1 → pam_python-0.2.3}/pam/cli.py +79 -4
  5. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/init/AGENT.md +37 -14
  6. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/service/pandas/service_class.tmpl +17 -5
  7. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/service/polars/service_class.tmpl +17 -5
  8. {pam_python-0.2.1 → pam_python-0.2.3/pam_python.egg-info}/PKG-INFO +18 -22
  9. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_cli_templates.py +59 -0
  10. {pam_python-0.2.1 → pam_python-0.2.3}/LICENSE.txt +0 -0
  11. {pam_python-0.2.1 → pam_python-0.2.3}/pam/api.py +0 -0
  12. {pam_python-0.2.1 → pam_python-0.2.3}/pam/interface_task_manager.py +0 -0
  13. {pam_python-0.2.1 → pam_python-0.2.3}/pam/logger.py +0 -0
  14. {pam_python-0.2.1 → pam_python-0.2.3}/pam/models/__init__.py +0 -0
  15. {pam_python-0.2.1 → pam_python-0.2.3}/pam/models/request_command.py +0 -0
  16. {pam_python-0.2.1 → pam_python-0.2.3}/pam/models/sqlite_download.py +0 -0
  17. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/__init__.py +0 -0
  18. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/area.py +0 -0
  19. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/axis.py +0 -0
  20. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/bar.py +0 -0
  21. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/box_plot.py +0 -0
  22. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/bubble.py +0 -0
  23. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/candlestick.py +0 -0
  24. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/collection.py +0 -0
  25. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/definition.py +0 -0
  26. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/donut.py +0 -0
  27. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/errors.py +0 -0
  28. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/fields.py +0 -0
  29. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/heatmap.py +0 -0
  30. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/line.py +0 -0
  31. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/manager.py +0 -0
  32. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/metric.py +0 -0
  33. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/pie.py +0 -0
  34. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/polar_area.py +0 -0
  35. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/radar.py +0 -0
  36. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/radial_bar.py +0 -0
  37. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/range_area.py +0 -0
  38. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/range_bar.py +0 -0
  39. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/range_transform.py +0 -0
  40. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/record.py +0 -0
  41. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/report.py +0 -0
  42. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/scatter.py +0 -0
  43. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/schema.py +0 -0
  44. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/store.py +0 -0
  45. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/table.py +0 -0
  46. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/transport.py +0 -0
  47. {pam_python-0.2.1 → pam_python-0.2.3}/pam/reports/treemap.py +0 -0
  48. {pam_python-0.2.1 → pam_python-0.2.3}/pam/request_file_format.py +0 -0
  49. {pam_python-0.2.1 → pam_python-0.2.3}/pam/result_batch_uploader.py +0 -0
  50. {pam_python-0.2.1 → pam_python-0.2.3}/pam/server.py +0 -0
  51. {pam_python-0.2.1 → pam_python-0.2.3}/pam/service.py +0 -0
  52. {pam_python-0.2.1 → pam_python-0.2.3}/pam/sqlite.py +0 -0
  53. {pam_python-0.2.1 → pam_python-0.2.3}/pam/task_manager.py +0 -0
  54. {pam_python-0.2.1 → pam_python-0.2.3}/pam/temp_file_utils.py +0 -0
  55. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/buildcmd/pamb +0 -0
  56. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/buildcmd/pamb-base.sh +0 -0
  57. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/init/REPORTS.md +0 -0
  58. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/init/dockerignore.tmpl +0 -0
  59. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/init/gitignore.tmpl +0 -0
  60. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/init/main.tmpl +0 -0
  61. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/init/pylintrc.tmpl +0 -0
  62. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/init/run_unit_test.bat +0 -0
  63. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/init/run_unit_test.ps1 +0 -0
  64. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/init/run_unit_test.sh +0 -0
  65. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/project/pip/Dockerfile +0 -0
  66. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/project/pip/requirements.txt +0 -0
  67. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/project/uv/Dockerfile +0 -0
  68. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/project/uv/pyproject.toml +0 -0
  69. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/project/uv/python-version +0 -0
  70. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/service/common/service.yaml +0 -0
  71. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/service/pandas/functions.tmpl +0 -0
  72. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/service/pandas/service.test.tmpl +0 -0
  73. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/service/polars/functions.tmpl +0 -0
  74. {pam_python-0.2.1 → pam_python-0.2.3}/pam/templates/service/polars/service.test.tmpl +0 -0
  75. {pam_python-0.2.1 → pam_python-0.2.3}/pam/tester_task.py +0 -0
  76. {pam_python-0.2.1 → pam_python-0.2.3}/pam/utils.py +0 -0
  77. {pam_python-0.2.1 → pam_python-0.2.3}/pam_python.egg-info/SOURCES.txt +0 -0
  78. {pam_python-0.2.1 → pam_python-0.2.3}/pam_python.egg-info/dependency_links.txt +0 -0
  79. {pam_python-0.2.1 → pam_python-0.2.3}/pam_python.egg-info/entry_points.txt +0 -0
  80. {pam_python-0.2.1 → pam_python-0.2.3}/pam_python.egg-info/requires.txt +0 -0
  81. {pam_python-0.2.1 → pam_python-0.2.3}/pam_python.egg-info/top_level.txt +0 -0
  82. {pam_python-0.2.1 → pam_python-0.2.3}/pyproject.toml +0 -0
  83. {pam_python-0.2.1 → pam_python-0.2.3}/setup.cfg +0 -0
  84. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_range_transform.py +0 -0
  85. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_report_store.py +0 -0
  86. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_result_batch_uploader.py +0 -0
  87. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_service_dataframe_output.py +0 -0
  88. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_sqlite_download_v2.py +0 -0
  89. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_area_report.py +0 -0
  90. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_bar_report.py +0 -0
  91. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_box_plot_report.py +0 -0
  92. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_bubble_report.py +0 -0
  93. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_candlestick_report.py +0 -0
  94. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_donut_report.py +0 -0
  95. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_heatmap_report.py +0 -0
  96. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_line_report.py +0 -0
  97. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_metric_report.py +0 -0
  98. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_pie_report.py +0 -0
  99. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_polar_area_report.py +0 -0
  100. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_radar_report.py +0 -0
  101. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_radial_bar_report.py +0 -0
  102. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_range_area_report.py +0 -0
  103. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_range_bar_report.py +0 -0
  104. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_scatter_report.py +0 -0
  105. {pam_python-0.2.1 → pam_python-0.2.3}/tests/test_typed_table_report.py +0 -0
  106. {pam_python-0.2.1 → pam_python-0.2.3}/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.1
3
+ Version: 0.2.3
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
@@ -58,46 +58,42 @@ This README is a practical, step-by-step guide you can follow to create and run
58
58
 
59
59
  ---
60
60
 
61
- **Install**
62
- Recommended installation:
61
+ **Create a uv project**
62
+
63
+ Install PAM inside each project instead of installing the CLI globally:
63
64
 
64
65
  ```bash
65
66
  mkdir my_data_plugin
66
67
  cd my_data_plugin
67
- uv tool install pam-python
68
+ uv init --python 3.12
69
+ uv add pam-python
70
+ uv run pam init
68
71
  ```
69
72
 
70
- Pip remains supported:
73
+ `pam init` recognizes a fresh uv project, keeps its uv configuration, and adds
74
+ the PAM scaffold. It refuses projects whose generated source files have already
75
+ been edited.
76
+
77
+ Pip remains supported for new projects that do not use uv:
71
78
 
72
79
  ```bash
80
+ mkdir my_data_plugin
81
+ cd my_data_plugin
73
82
  python3 -m venv venv
74
83
  source venv/bin/activate
75
84
  python -m pip install pam-python
76
- ```
77
-
78
- ---
79
-
80
- **Initialize a Project**
81
- This creates a runnable project with templates, including `AGENT.md` for runtime
82
- rules and `REPORTS.md` for the managed-report public API and copyable examples.
83
-
84
- ```bash
85
85
  pam init
86
86
  ```
87
87
 
88
- `pam init` asks you to select `uv` (default) or `pip`. A uv project contains
89
- `pyproject.toml`, `.python-version`, and `uv.lock`; a pip project contains
90
- `requirements.txt`. The two templates are independent and are never mixed.
91
-
92
- Initialization only supports a new project directory and will not migrate or
93
- overwrite an existing project.
88
+ When run in an empty directory, `pam init` still asks whether to create a uv or
89
+ pip project. PAM never migrates an existing application.
94
90
 
95
91
  ---
96
92
 
97
93
  **Create a Service**
98
94
 
99
95
  ```bash
100
- pam new service rfm_segment
96
+ uv run pam new service rfm_segment
101
97
  ```
102
98
 
103
99
  The generator asks you to select Polars (default) or Pandas. Polars services use
@@ -126,7 +122,7 @@ The runtime calls your service in two main phases.
126
122
  2. `on_data_input`
127
123
 
128
124
  - Called when CDP sends input files
129
- - `req.input_files` contains ordered Parquet files
125
+ - `req.input_files` is the ordered positional data-source contract configured in PAM
130
126
  - `req.file_format` is the typed format sent by PAM
131
127
  - Should also return quickly (use a thread if needed)
132
128
 
@@ -38,46 +38,42 @@ This README is a practical, step-by-step guide you can follow to create and run
38
38
 
39
39
  ---
40
40
 
41
- **Install**
42
- Recommended installation:
41
+ **Create a uv project**
42
+
43
+ Install PAM inside each project instead of installing the CLI globally:
43
44
 
44
45
  ```bash
45
46
  mkdir my_data_plugin
46
47
  cd my_data_plugin
47
- uv tool install pam-python
48
+ uv init --python 3.12
49
+ uv add pam-python
50
+ uv run pam init
48
51
  ```
49
52
 
50
- Pip remains supported:
53
+ `pam init` recognizes a fresh uv project, keeps its uv configuration, and adds
54
+ the PAM scaffold. It refuses projects whose generated source files have already
55
+ been edited.
56
+
57
+ Pip remains supported for new projects that do not use uv:
51
58
 
52
59
  ```bash
60
+ mkdir my_data_plugin
61
+ cd my_data_plugin
53
62
  python3 -m venv venv
54
63
  source venv/bin/activate
55
64
  python -m pip install pam-python
56
- ```
57
-
58
- ---
59
-
60
- **Initialize a Project**
61
- This creates a runnable project with templates, including `AGENT.md` for runtime
62
- rules and `REPORTS.md` for the managed-report public API and copyable examples.
63
-
64
- ```bash
65
65
  pam init
66
66
  ```
67
67
 
68
- `pam init` asks you to select `uv` (default) or `pip`. A uv project contains
69
- `pyproject.toml`, `.python-version`, and `uv.lock`; a pip project contains
70
- `requirements.txt`. The two templates are independent and are never mixed.
71
-
72
- Initialization only supports a new project directory and will not migrate or
73
- overwrite an existing project.
68
+ When run in an empty directory, `pam init` still asks whether to create a uv or
69
+ pip project. PAM never migrates an existing application.
74
70
 
75
71
  ---
76
72
 
77
73
  **Create a Service**
78
74
 
79
75
  ```bash
80
- pam new service rfm_segment
76
+ uv run pam new service rfm_segment
81
77
  ```
82
78
 
83
79
  The generator asks you to select Polars (default) or Pandas. Polars services use
@@ -106,7 +102,7 @@ The runtime calls your service in two main phases.
106
102
  2. `on_data_input`
107
103
 
108
104
  - Called when CDP sends input files
109
- - `req.input_files` contains ordered Parquet files
105
+ - `req.input_files` is the ordered positional data-source contract configured in PAM
110
106
  - `req.file_format` is the typed format sent by PAM
111
107
  - Should also return quickly (use a thread if needed)
112
108
 
@@ -1,3 +1,3 @@
1
1
  """PAM Data Plugin framework."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.3"
@@ -8,6 +8,7 @@ import shutil
8
8
  import subprocess
9
9
  import sys
10
10
  import tempfile
11
+ import tomllib
11
12
 
12
13
 
13
14
  PROJECT_MARKER = ".pam-project"
@@ -159,6 +160,67 @@ def _assert_new_project(directory: Path):
159
160
  )
160
161
 
161
162
 
163
+ def _requirement_name(requirement: str) -> str:
164
+ return re.split(r"[<>=!~;\s\[]", requirement, maxsplit=1)[0].lower()
165
+
166
+
167
+ def _assert_fresh_uv_project(directory: Path):
168
+ pyproject_path = directory / "pyproject.toml"
169
+ try:
170
+ project = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))["project"]
171
+ except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError) as exc:
172
+ raise FileExistsError(f"Invalid fresh uv project: {exc}") from exc
173
+
174
+ dependencies = project.get("dependencies", [])
175
+ if not isinstance(dependencies, list) or not any(
176
+ isinstance(item, str) and _requirement_name(item) == "pam-python"
177
+ for item in dependencies
178
+ ):
179
+ raise FileExistsError(
180
+ "Run `uv add pam-python` before `uv run pam init`"
181
+ )
182
+
183
+ python_version = directory / ".python-version"
184
+ if not python_version.is_file() or not python_version.read_text().strip().startswith("3.12"):
185
+ raise FileExistsError(
186
+ "A fresh uv project must pin Python 3.12; run `uv init --python 3.12`"
187
+ )
188
+
189
+ allowed = {
190
+ ".git",
191
+ ".gitignore",
192
+ ".python-version",
193
+ ".venv",
194
+ "README.md",
195
+ "main.py",
196
+ "pyproject.toml",
197
+ "uv.lock",
198
+ }
199
+ unexpected = sorted(path.name for path in directory.iterdir() if path.name not in allowed)
200
+ if unexpected:
201
+ raise FileExistsError(
202
+ "pam init only accepts a fresh uv project; existing files: "
203
+ + ", ".join(unexpected)
204
+ )
205
+
206
+ readme = directory / "README.md"
207
+ if readme.is_file() and readme.read_text(encoding="utf-8").strip():
208
+ raise FileExistsError("pam init will not overwrite a non-empty README.md")
209
+
210
+ main_file = directory / "main.py"
211
+ if main_file.is_file():
212
+ source = main_file.read_text(encoding="utf-8")
213
+ uv_placeholder = re.fullmatch(
214
+ r'def main\(\):\n'
215
+ r' print\("Hello from [a-z0-9-]+!"\)\n\n\n'
216
+ r'if __name__ == "__main__":\n'
217
+ r' main\(\)\n?',
218
+ source,
219
+ )
220
+ if uv_placeholder is None:
221
+ raise FileExistsError("pam init will not overwrite an edited main.py")
222
+
223
+
162
224
  def _run_uv(args: list[str], cwd: Path):
163
225
  uv = shutil.which("uv")
164
226
  if uv is None:
@@ -167,12 +229,20 @@ def _run_uv(args: list[str], cwd: Path):
167
229
 
168
230
 
169
231
  def init_project(package_manager: str | None = None):
170
- package_manager = package_manager or _select_package_manager()
232
+ destination = Path.cwd()
233
+ preinitialized_uv = (destination / "pyproject.toml").is_file()
234
+ if preinitialized_uv:
235
+ if package_manager not in (None, "uv"):
236
+ raise FileExistsError("A uv project cannot be initialized as a pip project")
237
+ _assert_fresh_uv_project(destination)
238
+ package_manager = "uv"
239
+ else:
240
+ package_manager = package_manager or _select_package_manager()
241
+ _assert_new_project(destination)
242
+
171
243
  if package_manager not in PACKAGE_MANAGER_TEMPLATES:
172
244
  raise ValueError(f"Unsupported package manager: {package_manager}")
173
245
 
174
- destination = Path.cwd()
175
- _assert_new_project(destination)
176
246
  if package_manager == "uv" and shutil.which("uv") is None:
177
247
  raise RuntimeError("uv is required for this project but was not found on PATH")
178
248
 
@@ -184,6 +254,11 @@ def init_project(package_manager: str | None = None):
184
254
  **COMMON_PROJECT_TEMPLATES,
185
255
  **PACKAGE_MANAGER_TEMPLATES[package_manager],
186
256
  }
257
+ if preinitialized_uv:
258
+ templates = {
259
+ src: dest for src, dest in templates.items()
260
+ if dest not in {".gitignore", ".python-version", "pyproject.toml"}
261
+ }
187
262
 
188
263
  with tempfile.TemporaryDirectory(prefix=".pam-init-", dir=destination) as temp_dir:
189
264
  staging = Path(temp_dir)
@@ -202,7 +277,7 @@ def init_project(package_manager: str | None = None):
202
277
  encoding="utf-8",
203
278
  )
204
279
 
205
- if package_manager == "uv":
280
+ if package_manager == "uv" and not preinitialized_uv:
206
281
  _run_uv(["lock"], staging)
207
282
 
208
283
  for path in staging.iterdir():
@@ -117,15 +117,17 @@ input data. CDP returns up to 5 Parquet files (each file is a separate event).
117
117
 
118
118
  - File order is guaranteed per agreement, so you can index by position.
119
119
  - Column names and record counts are defined by CDP configuration.
120
+ - `input_files` is not a list of interchangeable files. Each position represents
121
+ a different configured data source and must be bound to a meaningful domain name.
122
+ - Never process `input_files` with `for file_path in req.input_files`.
120
123
 
121
124
  Example file order usage:
122
125
 
123
126
  ```python
124
- input_files = req.input_files
125
- # Index 0: purchase events
126
- # Index 1: use points events
127
- purchase_path = input_files[0]
128
- use_points_path = input_files[1]
127
+ if len(req.input_files) != 2:
128
+ raise ValueError("Expected purchase_event and point_received_event inputs")
129
+
130
+ purchase_event_file, point_received_event_file = req.input_files
129
131
  ```
130
132
 
131
133
  Column meaning
@@ -163,20 +165,30 @@ def __run_process_data_in_thread(self, req: RequestCommand):
163
165
  if req.file_format is not RequestFileFormat.PARQUET:
164
166
  raise ValueError(f"Expected parquet, received {req.file_format.value}")
165
167
 
166
- result = transform(pl.scan_parquet(req.input_files[0]))
168
+ if len(req.input_files) != 2:
169
+ raise ValueError("Expected purchase_event and point_received_event inputs")
170
+
171
+ purchase_event_file, point_received_event_file = req.input_files
172
+ result = transform(
173
+ pl.scan_parquet(purchase_event_file),
174
+ pl.scan_parquet(point_received_event_file),
175
+ )
167
176
  self._upload_result(result)
168
177
 
169
178
  if not req.is_end:
170
- self._request_data(req.next, file_format=RequestFileFormat.PARQUET)
179
+ self._request_data(
180
+ page=req.next,
181
+ file_format=RequestFileFormat.PARQUET,
182
+ )
171
183
  else:
172
184
  self._exit()
173
185
  ```
174
186
 
175
187
  Why a thread: to end the HTTP request quickly and avoid timeouts.
176
188
 
177
- `req.input_files` is the ordered list of Parquet paths. `req.file_format` is the
178
- typed metadata sent by PAM and is the source of truth; validate file extensions as
179
- an additional consistency check.
189
+ `req.input_files` is the ordered positional contract of Parquet paths.
190
+ `req.file_format` is the typed metadata sent by PAM and is the source of truth;
191
+ validate file extensions as an additional consistency check.
180
192
 
181
193
  ---
182
194
 
@@ -258,21 +270,32 @@ CDP paging is **customer-based**, not row-based.
258
270
 
259
271
  When requesting data page by page:
260
272
 
261
- - CDP collects customers in batches of approximately **10,000 customers per page**.
262
- - All events belonging to those customers are merged into **a single Parquet file**.
263
- - The number of rows is **unknown** and depends on how many events each customer has.
273
+ - PAM first selects a cohort of approximately **10,000 unique customers per page**.
274
+ - PAM then queries **every configured dataset** with that same customer-ID cohort.
275
+ - Dataset 1 through dataset N in one callback therefore belong to the same group
276
+ of customers, while retaining their configured positional meanings.
277
+ - Each dataset has its own Parquet file and may have a different row count because
278
+ event density differs and some customers may have no rows in a dataset.
279
+ - A customer's relevant data is kept within one logical page instead of being
280
+ split across customer pages.
281
+ - `req.next` advances the customer-cohort cursor, not an event-row offset.
264
282
 
265
283
  This means:
266
284
 
267
285
  - Each page represents a logical customer group, not a fixed number of records.
268
286
  - A page may contain a small or very large number of rows depending on behavior density.
287
+ - Files at different input positions must be joined or compared by the agreed
288
+ customer key; row positions across files do not correspond.
269
289
 
270
290
  ### Execution Implications
271
291
 
272
292
  Because paging is based on customers:
273
293
 
274
294
  - In many cases, a service can process **one page (≈10,000 customers)** independently.
275
- - If the logic allows, you may process and upload results immediately because the dataset is self-contained.
295
+ - If the logic allows, you may process and upload results immediately because all
296
+ configured datasets for that customer cohort arrive together.
297
+ - Do not create row-offset pagination or carry partial state because one dataset
298
+ happens to have more event rows than another.
276
299
 
277
300
  However, upload behavior must balance two constraints:
278
301
 
@@ -13,6 +13,7 @@ from #MODULE_NAME#.functions import load_input_file, transform
13
13
 
14
14
  class #CLASS_NAME#(Service):
15
15
  INPUT_FILE_FORMAT = RequestFileFormat.PARQUET
16
+ EXPECTED_DATASET_COUNT = 1
16
17
 
17
18
  def __init__(self, task_manager, req):
18
19
  super().__init__(task_manager, req)
@@ -40,11 +41,22 @@ class #CLASS_NAME#(Service):
40
41
  f"received {req.file_format.value}"
41
42
  )
42
43
 
43
- for file_path in req.input_files:
44
- if not file_path.lower().endswith(".parquet"):
45
- raise ValueError(f"Expected a .parquet input file: {file_path}")
46
- result = transform(load_input_file(file_path))
47
- self.batch_uploader.upload(result, name="main")
44
+ if len(req.input_files) != self.EXPECTED_DATASET_COUNT:
45
+ raise ValueError(
46
+ f"Expected {self.EXPECTED_DATASET_COUNT} configured dataset, "
47
+ f"received {len(req.input_files)}"
48
+ )
49
+
50
+ # Bind every position to the data source name configured in PAM.
51
+ # Add more named positions here when the service contract has more datasets.
52
+ (input_dataset_file,) = req.input_files
53
+ if not input_dataset_file.lower().endswith(".parquet"):
54
+ raise ValueError(
55
+ f"Expected a .parquet input file: {input_dataset_file}"
56
+ )
57
+
58
+ result = transform(load_input_file(input_dataset_file))
59
+ self.batch_uploader.upload(result, name="main")
48
60
 
49
61
  if not req.is_end:
50
62
  self._request_data(
@@ -12,6 +12,7 @@ from #MODULE_NAME#.functions import load_input_file, transform
12
12
 
13
13
  class #CLASS_NAME#(Service):
14
14
  INPUT_FILE_FORMAT = RequestFileFormat.PARQUET
15
+ EXPECTED_DATASET_COUNT = 1
15
16
 
16
17
  def on_start(self):
17
18
  log("on_start")
@@ -35,11 +36,22 @@ class #CLASS_NAME#(Service):
35
36
  f"received {req.file_format.value}"
36
37
  )
37
38
 
38
- for file_path in req.input_files:
39
- if not file_path.lower().endswith(".parquet"):
40
- raise ValueError(f"Expected a .parquet input file: {file_path}")
41
- result = transform(load_input_file(file_path))
42
- self._upload_result(result)
39
+ if len(req.input_files) != self.EXPECTED_DATASET_COUNT:
40
+ raise ValueError(
41
+ f"Expected {self.EXPECTED_DATASET_COUNT} configured dataset, "
42
+ f"received {len(req.input_files)}"
43
+ )
44
+
45
+ # Bind every position to the data source name configured in PAM.
46
+ # Add more named positions here when the service contract has more datasets.
47
+ (input_dataset_file,) = req.input_files
48
+ if not input_dataset_file.lower().endswith(".parquet"):
49
+ raise ValueError(
50
+ f"Expected a .parquet input file: {input_dataset_file}"
51
+ )
52
+
53
+ result = transform(load_input_file(input_dataset_file))
54
+ self._upload_result(result)
43
55
 
44
56
  if not req.is_end:
45
57
  self._request_data(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pam-python
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -58,46 +58,42 @@ This README is a practical, step-by-step guide you can follow to create and run
58
58
 
59
59
  ---
60
60
 
61
- **Install**
62
- Recommended installation:
61
+ **Create a uv project**
62
+
63
+ Install PAM inside each project instead of installing the CLI globally:
63
64
 
64
65
  ```bash
65
66
  mkdir my_data_plugin
66
67
  cd my_data_plugin
67
- uv tool install pam-python
68
+ uv init --python 3.12
69
+ uv add pam-python
70
+ uv run pam init
68
71
  ```
69
72
 
70
- Pip remains supported:
73
+ `pam init` recognizes a fresh uv project, keeps its uv configuration, and adds
74
+ the PAM scaffold. It refuses projects whose generated source files have already
75
+ been edited.
76
+
77
+ Pip remains supported for new projects that do not use uv:
71
78
 
72
79
  ```bash
80
+ mkdir my_data_plugin
81
+ cd my_data_plugin
73
82
  python3 -m venv venv
74
83
  source venv/bin/activate
75
84
  python -m pip install pam-python
76
- ```
77
-
78
- ---
79
-
80
- **Initialize a Project**
81
- This creates a runnable project with templates, including `AGENT.md` for runtime
82
- rules and `REPORTS.md` for the managed-report public API and copyable examples.
83
-
84
- ```bash
85
85
  pam init
86
86
  ```
87
87
 
88
- `pam init` asks you to select `uv` (default) or `pip`. A uv project contains
89
- `pyproject.toml`, `.python-version`, and `uv.lock`; a pip project contains
90
- `requirements.txt`. The two templates are independent and are never mixed.
91
-
92
- Initialization only supports a new project directory and will not migrate or
93
- overwrite an existing project.
88
+ When run in an empty directory, `pam init` still asks whether to create a uv or
89
+ pip project. PAM never migrates an existing application.
94
90
 
95
91
  ---
96
92
 
97
93
  **Create a Service**
98
94
 
99
95
  ```bash
100
- pam new service rfm_segment
96
+ uv run pam new service rfm_segment
101
97
  ```
102
98
 
103
99
  The generator asks you to select Polars (default) or Pandas. Polars services use
@@ -126,7 +122,7 @@ The runtime calls your service in two main phases.
126
122
  2. `on_data_input`
127
123
 
128
124
  - Called when CDP sends input files
129
- - `req.input_files` contains ordered Parquet files
125
+ - `req.input_files` is the ordered positional data-source contract configured in PAM
130
126
  - `req.file_format` is the typed format sent by PAM
131
127
  - Should also return quickly (use a thread if needed)
132
128
 
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import os
2
3
  from pathlib import Path
3
4
  import sys
@@ -42,6 +43,10 @@ class CLITemplateTest(unittest.TestCase):
42
43
  self.assertIn("pyarrow>=19.0.1", (root / "requirements.txt").read_text())
43
44
  self.assertFalse((root / "pyproject.toml").exists())
44
45
  self.assertTrue((root / "REPORTS.md").is_file())
46
+ agent_guide = (root / "AGENT.md").read_text()
47
+ self.assertIn("Customer-based Pagination Contract", agent_guide)
48
+ self.assertIn("same customer-ID cohort", agent_guide)
49
+ self.assertIn("customer-cohort cursor", agent_guide)
45
50
  self.assertTrue((root / "run_unit_test.sh").is_file())
46
51
  self.assertTrue(os.access(root / "run_unit_test.sh", os.X_OK))
47
52
  self.assertTrue((root / "run_unit_test.ps1").is_file())
@@ -61,6 +66,9 @@ class CLITemplateTest(unittest.TestCase):
61
66
  ):
62
67
  service_source = service_path.read_text()
63
68
  self.assertIn("page=req.next", service_source)
69
+ self.assertIn("EXPECTED_DATASET_COUNT = 1", service_source)
70
+ self.assertIn("(input_dataset_file,) = req.input_files", service_source)
71
+ self.assertNotIn("for file_path in req.input_files", service_source)
64
72
  self.assertEqual(
65
73
  2,
66
74
  service_source.count("file_format=self.INPUT_FILE_FORMAT"),
@@ -114,6 +122,57 @@ class CLITemplateTest(unittest.TestCase):
114
122
  calls,
115
123
  )
116
124
 
125
+ def test_init_adopts_a_fresh_uv_project_without_rewriting_uv_files(self):
126
+ calls = []
127
+ original_pyproject = """[project]
128
+ name = "customer-plugin"
129
+ version = "0.1.0"
130
+ requires-python = ">=3.12"
131
+ dependencies = ["pam-python>=0.2.1"]
132
+ """
133
+ uv_main = '''def main():
134
+ print("Hello from customer-plugin!")
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
139
+ '''
140
+
141
+ with tempfile.TemporaryDirectory() as temp_dir, WorkingDirectory(temp_dir):
142
+ root = Path(temp_dir)
143
+ (root / "pyproject.toml").write_text(original_pyproject)
144
+ (root / ".python-version").write_text("3.12\n")
145
+ (root / "uv.lock").write_text("version = 1\n")
146
+ (root / ".gitignore").write_text(".venv\n")
147
+ (root / "README.md").touch()
148
+ (root / "main.py").write_text(uv_main)
149
+
150
+ with mock.patch.object(cli.shutil, "which", return_value="/usr/bin/uv"), mock.patch.object(
151
+ cli, "_run_uv", side_effect=lambda args, cwd: calls.append(list(args))
152
+ ):
153
+ cli.init_project()
154
+
155
+ self.assertEqual(original_pyproject, (root / "pyproject.toml").read_text())
156
+ self.assertEqual("3.12\n", (root / ".python-version").read_text())
157
+ self.assertEqual("version = 1\n", (root / "uv.lock").read_text())
158
+ self.assertEqual(".venv\n", (root / ".gitignore").read_text())
159
+ self.assertIn("plugin_server = Server", (root / "main.py").read_text())
160
+ self.assertEqual("uv", json.loads((root / ".pam-project").read_text())["package_manager"])
161
+ self.assertEqual([], calls)
162
+
163
+ def test_init_rejects_an_edited_uv_project(self):
164
+ with tempfile.TemporaryDirectory() as temp_dir, WorkingDirectory(temp_dir):
165
+ root = Path(temp_dir)
166
+ (root / "pyproject.toml").write_text(
167
+ '[project]\nname = "plugin"\nversion = "0.1.0"\n'
168
+ 'dependencies = ["pam-python"]\n'
169
+ )
170
+ (root / ".python-version").write_text("3.12\n")
171
+ (root / "main.py").write_text("print('real code')\n")
172
+
173
+ with self.assertRaisesRegex(FileExistsError, "edited main.py"):
174
+ cli.init_project()
175
+
117
176
  def test_init_rejects_an_existing_project_without_overwriting(self):
118
177
  with tempfile.TemporaryDirectory() as temp_dir, WorkingDirectory(temp_dir):
119
178
  original = Path("main.py")
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