pam-python 0.2.1__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pam/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """PAM Data Plugin framework."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.3"
pam/cli.py CHANGED
@@ -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,6 +1,6 @@
1
- pam/__init__.py,sha256=HkDyjbFKSTG9cYUIHwpisICJbo99Cl8uvvTA7cva-wg,56
1
+ pam/__init__.py,sha256=_my-Ddw7odQ1s6OaAir2Dk7rSmUS9FIHUgcNaBBy6mw,56
2
2
  pam/api.py,sha256=4t64ey_Jc4gC3SvfI-mtedEGzkX2cPTt0cdZgP_huLk,7231
3
- pam/cli.py,sha256=1jzS7a4Tz9SSIDraSdIQ88AhpAKMMMvtjiRmdQT4umU,11381
3
+ pam/cli.py,sha256=GbTyHD2Gc2WLTY54bZ9rtofjVc7bV2JjqXThR9JCQf4,14130
4
4
  pam/interface_task_manager.py,sha256=moKUjSpAeNF9C2dluDFPAdU4IyvwXckB0RK-jINTLm4,1665
5
5
  pam/logger.py,sha256=JYPfIpaQZy6QrfVKrzEMrxOgTzWIEhoegvIZgHTGWoc,3021
6
6
  pam/request_file_format.py,sha256=fSRlJagbvVGqdIEqhINSeqBNUTNQdm8GL5JfqgX9bxU,317
@@ -48,7 +48,7 @@ pam/reports/transport.py,sha256=t3oEVptS9msnNafia1G8vmTUCdhxvV5L2-0AY-8SKFk,2062
48
48
  pam/reports/treemap.py,sha256=W_VwxbiRMSvkNpY2z1HfbZtGKWXNr6FdbVdI0grVesw,4251
49
49
  pam/templates/buildcmd/pamb,sha256=58GCN59ysvHGNhPvTD3nkF4aLg-kM1WfWmH7CL3eQVo,530
50
50
  pam/templates/buildcmd/pamb-base.sh,sha256=Q0DkbeHtPx33PIjc68EeI7ZIwBOLlwcSmPhpxALXxVY,518
51
- pam/templates/init/AGENT.md,sha256=XoguXdHm7qw4ssHIL7yP3ZVhmiPosOc0Js_WCLbw_3o,14822
51
+ pam/templates/init/AGENT.md,sha256=PWZ-qp1bfyU5D0H5hnFLR6LHnv4efNLIglAGhsAfb1A,16105
52
52
  pam/templates/init/REPORTS.md,sha256=rg9oi0_xDYr0BZBluJh0vPF8lSx7mCRFpDCpUgNl68A,42851
53
53
  pam/templates/init/dockerignore.tmpl,sha256=Ht1eCgQLqalEwpUjih0FQavzhYJbT2jH-FP4btM3960,52
54
54
  pam/templates/init/gitignore.tmpl,sha256=U59ynPRu_Kw6evqQzOOTAhWKdIXbf1uBOF2V8223tU8,3510
@@ -65,13 +65,13 @@ pam/templates/project/uv/python-version,sha256=e1X45ntWI8S-8_ppEojalDfXnTq6FW3kj
65
65
  pam/templates/service/common/service.yaml,sha256=ceyFswXjTh07vB4hrJEu7pj3mjlq3NPD4GPYTmvGwmE,44
66
66
  pam/templates/service/pandas/functions.tmpl,sha256=ziHgPaK7i8ESY-TxPMuKbqAFILjzdgn1p5FdPf3pYuw,360
67
67
  pam/templates/service/pandas/service.test.tmpl,sha256=PybHu_c5BMhUmvkc9ycQH7vJbPWt7isJAH2r-ElQ9dE,3760
68
- pam/templates/service/pandas/service_class.tmpl,sha256=LGgIYjfdsBwppOqP-FfgEsF1kxeSwUnYwK935gqC7GQ,2138
68
+ pam/templates/service/pandas/service_class.tmpl,sha256=629kX2qJAmNzj5MrGdg6FLqgijEML8D3Ddo7k27LzbM,2614
69
69
  pam/templates/service/polars/functions.tmpl,sha256=nMKFC5GqIm2Q1dSg2zDOscSwQaOpzS2jzqjQV7xhPRk,382
70
70
  pam/templates/service/polars/service.test.tmpl,sha256=58x_MJYwp7BvDtuSsETAt9Q6v5KwOI8MaEdjNciAQDk,3746
71
- pam/templates/service/polars/service_class.tmpl,sha256=8FDTFb6I5OHFqM0KPJtYDFAHeL3kl2WbwtbHAjwTG4Y,1830
72
- pam_python-0.2.1.dist-info/licenses/LICENSE.txt,sha256=yeHD2-gzrkkET2ZkMe22WyUGpDjlwTrqA4POb0FoZi4,1138
73
- pam_python-0.2.1.dist-info/METADATA,sha256=fPvsI78skyXPDA5yh_a2dZopS9xLmYa7TY2HftO9a4s,8730
74
- pam_python-0.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
75
- pam_python-0.2.1.dist-info/entry_points.txt,sha256=xqYpsIqvNlO0j8elZc02bDwR-12dLK9FU2B1ACsLYxw,37
76
- pam_python-0.2.1.dist-info/top_level.txt,sha256=0EOjbyc3hQyzjhn6iyMgsEseqA66Xz0p27iBN7G7W1w,4
77
- pam_python-0.2.1.dist-info/RECORD,,
71
+ pam/templates/service/polars/service_class.tmpl,sha256=tWfEv6dBeL0wm41VfL2YI5JA6EUF08rH_nnsmk25A84,2306
72
+ pam_python-0.2.3.dist-info/licenses/LICENSE.txt,sha256=yeHD2-gzrkkET2ZkMe22WyUGpDjlwTrqA4POb0FoZi4,1138
73
+ pam_python-0.2.3.dist-info/METADATA,sha256=Ce02r8gxDTde0fNWk6WGCIwxBz6538-pJBTcy8HR0Do,8703
74
+ pam_python-0.2.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
75
+ pam_python-0.2.3.dist-info/entry_points.txt,sha256=xqYpsIqvNlO0j8elZc02bDwR-12dLK9FU2B1ACsLYxw,37
76
+ pam_python-0.2.3.dist-info/top_level.txt,sha256=0EOjbyc3hQyzjhn6iyMgsEseqA66Xz0p27iBN7G7W1w,4
77
+ pam_python-0.2.3.dist-info/RECORD,,