pam-python 0.1.44__tar.gz → 0.2.0__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.
- {pam_python-0.1.44/pam_python.egg-info → pam_python-0.2.0}/PKG-INFO +68 -14
- {pam_python-0.1.44 → pam_python-0.2.0}/README.md +66 -10
- pam_python-0.2.0/pam/__init__.py +3 -0
- pam_python-0.2.0/pam/api.py +188 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/cli.py +40 -22
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/interface_task_manager.py +33 -2
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/models/request_command.py +11 -2
- pam_python-0.2.0/pam/models/sqlite_download.py +26 -0
- pam_python-0.2.0/pam/reports/__init__.py +73 -0
- pam_python-0.2.0/pam/reports/area.py +97 -0
- pam_python-0.2.0/pam/reports/axis.py +50 -0
- pam_python-0.2.0/pam/reports/bar.py +154 -0
- pam_python-0.2.0/pam/reports/box_plot.py +119 -0
- pam_python-0.2.0/pam/reports/bubble.py +113 -0
- pam_python-0.2.0/pam/reports/candlestick.py +132 -0
- pam_python-0.2.0/pam/reports/collection.py +198 -0
- pam_python-0.2.0/pam/reports/definition.py +82 -0
- pam_python-0.2.0/pam/reports/donut.py +111 -0
- pam_python-0.2.0/pam/reports/errors.py +33 -0
- pam_python-0.2.0/pam/reports/fields.py +144 -0
- pam_python-0.2.0/pam/reports/heatmap.py +88 -0
- pam_python-0.2.0/pam/reports/line.py +90 -0
- pam_python-0.2.0/pam/reports/manager.py +148 -0
- pam_python-0.2.0/pam/reports/metric.py +139 -0
- pam_python-0.2.0/pam/reports/pie.py +111 -0
- pam_python-0.2.0/pam/reports/polar_area.py +112 -0
- pam_python-0.2.0/pam/reports/radar.py +115 -0
- pam_python-0.2.0/pam/reports/radial_bar.py +135 -0
- pam_python-0.2.0/pam/reports/range_area.py +121 -0
- pam_python-0.2.0/pam/reports/range_bar.py +121 -0
- pam_python-0.2.0/pam/reports/range_transform.py +183 -0
- pam_python-0.2.0/pam/reports/record.py +138 -0
- pam_python-0.2.0/pam/reports/report.py +35 -0
- pam_python-0.2.0/pam/reports/scatter.py +121 -0
- pam_python-0.2.0/pam/reports/schema.py +47 -0
- pam_python-0.2.0/pam/reports/store.py +694 -0
- pam_python-0.2.0/pam/reports/table.py +234 -0
- pam_python-0.2.0/pam/reports/transport.py +57 -0
- pam_python-0.2.0/pam/reports/treemap.py +121 -0
- pam_python-0.2.0/pam/result_batch_uploader.py +179 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/service.py +10 -2
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/task_manager.py +82 -3
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/temp_file_utils.py +27 -5
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/AGENT.md +24 -6
- pam_python-0.2.0/pam/templates/init/REPORTS.md +1395 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/main.tmpl +3 -3
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/requirements.txt +0 -1
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/service/functions.tmpl +1 -17
- pam_python-0.2.0/pam/templates/service/service.test.tmpl +137 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/service/service_class.tmpl +14 -10
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/tester_task.py +54 -3
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/utils.py +1 -1
- {pam_python-0.1.44 → pam_python-0.2.0/pam_python.egg-info}/PKG-INFO +68 -14
- pam_python-0.2.0/pam_python.egg-info/SOURCES.txt +97 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam_python.egg-info/requires.txt +0 -1
- {pam_python-0.1.44 → pam_python-0.2.0}/setup.py +4 -5
- pam_python-0.2.0/tests/test_cli_templates.py +78 -0
- pam_python-0.2.0/tests/test_range_transform.py +172 -0
- pam_python-0.2.0/tests/test_report_store.py +496 -0
- pam_python-0.2.0/tests/test_result_batch_uploader.py +111 -0
- pam_python-0.2.0/tests/test_sqlite_download_v2.py +175 -0
- pam_python-0.2.0/tests/test_typed_area_report.py +101 -0
- pam_python-0.2.0/tests/test_typed_bar_report.py +172 -0
- pam_python-0.2.0/tests/test_typed_box_plot_report.py +72 -0
- pam_python-0.2.0/tests/test_typed_bubble_report.py +61 -0
- pam_python-0.2.0/tests/test_typed_candlestick_report.py +77 -0
- pam_python-0.2.0/tests/test_typed_donut_report.py +140 -0
- pam_python-0.2.0/tests/test_typed_heatmap_report.py +56 -0
- pam_python-0.2.0/tests/test_typed_line_report.py +128 -0
- pam_python-0.2.0/tests/test_typed_metric_report.py +106 -0
- pam_python-0.2.0/tests/test_typed_pie_report.py +149 -0
- pam_python-0.2.0/tests/test_typed_polar_area_report.py +139 -0
- pam_python-0.2.0/tests/test_typed_radar_report.py +22 -0
- pam_python-0.2.0/tests/test_typed_radial_bar_report.py +77 -0
- pam_python-0.2.0/tests/test_typed_range_area_report.py +75 -0
- pam_python-0.2.0/tests/test_typed_range_bar_report.py +97 -0
- pam_python-0.2.0/tests/test_typed_scatter_report.py +131 -0
- pam_python-0.2.0/tests/test_typed_table_report.py +116 -0
- pam_python-0.2.0/tests/test_typed_treemap_report.py +19 -0
- pam_python-0.1.44/pam/api.py +0 -90
- pam_python-0.1.44/pam/models/__init__.py +0 -0
- pam_python-0.1.44/pam/templates/service/service.test.tmpl +0 -116
- pam_python-0.1.44/pam_python.egg-info/SOURCES.txt +0 -40
- {pam_python-0.1.44 → pam_python-0.2.0}/LICENSE.txt +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/logger.py +0 -0
- {pam_python-0.1.44/pam → pam_python-0.2.0/pam/models}/__init__.py +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/request_file_format.py +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/server.py +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/sqlite.py +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/buildcmd/pamb +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/buildcmd/pamb-base.sh +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/docker/Dockerfile +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/dockerignore.tmpl +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/gitignore.tmpl +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/pylintrc.tmpl +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/run_unit_test.bat +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/run_unit_test.ps1 +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/run_unit_test.sh +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/service/service.yaml +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam_python.egg-info/dependency_links.txt +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam_python.egg-info/entry_points.txt +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/pam_python.egg-info/top_level.txt +0 -0
- {pam_python-0.1.44 → pam_python-0.2.0}/setup.cfg +0 -0
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pam-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Pam Python Library
|
|
5
5
|
Home-page: https://github.com/heart/pam-python
|
|
6
6
|
Author: Narongrit Kanhanoi
|
|
7
7
|
Author-email: narongrit@pams.ai
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
10
9
|
Classifier: Programming Language :: Python :: 3.9
|
|
11
10
|
Classifier: License :: Other/Proprietary License
|
|
12
11
|
Classifier: Operating System :: OS Independent
|
|
13
12
|
Classifier: Intended Audience :: Developers
|
|
14
13
|
Classifier: Intended Audience :: Customer Service
|
|
15
14
|
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
-
Requires-Python: >=3.
|
|
15
|
+
Requires-Python: >=3.9
|
|
17
16
|
Description-Content-Type: text/markdown
|
|
18
17
|
License-File: LICENSE.txt
|
|
19
18
|
Requires-Dist: setuptools>=70.0.0
|
|
20
19
|
Requires-Dist: Flask>=3.0.2
|
|
21
20
|
Requires-Dist: aiohttp>=3.11.11
|
|
22
21
|
Requires-Dist: pandas>=2.2.3
|
|
23
|
-
Requires-Dist: Faker>=33.1.0
|
|
24
22
|
Requires-Dist: dask>=2024.12.1
|
|
25
23
|
Requires-Dist: dask-expr>=1.1.21
|
|
26
24
|
Requires-Dist: requests>=2.32.3
|
|
@@ -43,11 +41,15 @@ This repository provides the `pam` CLI and runtime framework to build Data Plugi
|
|
|
43
41
|
|
|
44
42
|
This README is a practical, step-by-step guide you can follow to create and run a real service.
|
|
45
43
|
|
|
44
|
+
Current release target: **0.2.0**
|
|
45
|
+
|
|
46
46
|
**What you get**
|
|
47
47
|
|
|
48
48
|
- CLI to initialize a project and scaffold services
|
|
49
49
|
- Service lifecycle contract (start, data input, upload, exit)
|
|
50
50
|
- Temp file and SQLite helpers
|
|
51
|
+
- Managed Report Store V2 with typed Table, Metric, and ApexCharts-compatible builders
|
|
52
|
+
- Named `ResultBatchUploader` streams for bounded DataFrame uploads
|
|
51
53
|
- A monitoring loop for service timeouts and periodic cleanup
|
|
52
54
|
|
|
53
55
|
---
|
|
@@ -70,7 +72,7 @@ This README is a practical, step-by-step guide you can follow to create and run
|
|
|
70
72
|
|
|
71
73
|
**Prerequisites**
|
|
72
74
|
|
|
73
|
-
- Python 3.
|
|
75
|
+
- Python 3.9+
|
|
74
76
|
- `pip` and a working virtual environment
|
|
75
77
|
|
|
76
78
|
---
|
|
@@ -94,7 +96,8 @@ pip install pam-python
|
|
|
94
96
|
---
|
|
95
97
|
|
|
96
98
|
**Initialize a Project**
|
|
97
|
-
This creates a runnable project with templates
|
|
99
|
+
This creates a runnable project with templates, including `AGENT.md` for runtime
|
|
100
|
+
rules and `REPORTS.md` for the managed-report public API and copyable examples.
|
|
98
101
|
|
|
99
102
|
```bash
|
|
100
103
|
pam init
|
|
@@ -143,7 +146,8 @@ The runtime calls your service in two main phases.
|
|
|
143
146
|
|
|
144
147
|
When your service is done:
|
|
145
148
|
|
|
146
|
-
-
|
|
149
|
+
- Upload ordinary CDP result rows through `ResultBatchUploader` or `_upload_result(...)`
|
|
150
|
+
- Publish managed reports through `self.reports`; do not build report JSON manually
|
|
147
151
|
- Call `self._exit()` to signal completion
|
|
148
152
|
|
|
149
153
|
---
|
|
@@ -173,15 +177,47 @@ Recommended usage:
|
|
|
173
177
|
from pam.result_batch_uploader import ResultBatchUploader
|
|
174
178
|
|
|
175
179
|
batch_uploader = ResultBatchUploader(self, batch_size=50000)
|
|
176
|
-
batch_uploader.upload(df,
|
|
180
|
+
batch_uploader.upload(df, name="main")
|
|
177
181
|
batch_uploader.flush()
|
|
178
182
|
status = batch_uploader.get_status()
|
|
179
183
|
```
|
|
180
184
|
|
|
181
185
|
Notes:
|
|
182
186
|
|
|
183
|
-
- `
|
|
184
|
-
- `
|
|
187
|
+
- `request.runtime_parameters["batch_size"]` overrides the constructor default when valid.
|
|
188
|
+
- `name` separates result streams and enforces one stable column schema per stream.
|
|
189
|
+
- `options` may be passed to `upload(...)` and are forwarded to `_upload_result`.
|
|
190
|
+
- Complete batches upload immediately; `flush()` sends the remaining rows.
|
|
191
|
+
- `get_status()` returns buffered rows, uploaded rows, and uploaded batch counts per stream.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
**Managed Reports (Report Store V2)**
|
|
196
|
+
|
|
197
|
+
Managed reports are static facts stored in framework-owned SQLite. Plugin code
|
|
198
|
+
selects a typed builder, writes source-derived `DAY`, `MONTH`, or `NONE` facts, and
|
|
199
|
+
publishes only after the full calculation succeeds.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
fresh = self.request.get_runtime_bool("fresh", default=False)
|
|
203
|
+
store = (
|
|
204
|
+
self.reports.create("reports.sqlite")
|
|
205
|
+
if fresh
|
|
206
|
+
else self.reports.open("reports.sqlite")
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Choose the builder agreed in the customer requirement.
|
|
210
|
+
# See generated REPORTS.md for every supported declaration and write API.
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Important rules:
|
|
214
|
+
|
|
215
|
+
- Use only typed builders documented in `REPORTS.md`; there is no raw-report API.
|
|
216
|
+
- `open()` resumes the remote file or creates it only after an authoritative 404.
|
|
217
|
+
- Network/5xx download failures never create a replacement store.
|
|
218
|
+
- Use `create()` only for a human-requested fresh restart.
|
|
219
|
+
- Delete a partition, write its recalculated facts, then call `store.publish()` once.
|
|
220
|
+
- Low-level `_request_sqlite`/`_upload_sqlite` remain for custom plugin state, not reports.
|
|
185
221
|
|
|
186
222
|
---
|
|
187
223
|
|
|
@@ -202,13 +238,29 @@ export SERVER_PORT=8000
|
|
|
202
238
|
---
|
|
203
239
|
|
|
204
240
|
**Testing a Service**
|
|
205
|
-
Run
|
|
241
|
+
Run tests through the generated script for your current shell. This keeps the
|
|
242
|
+
active terminal's Python environment and project context instead of launching
|
|
243
|
+
test discovery through the `pam` CLI.
|
|
244
|
+
|
|
245
|
+
macOS/Linux:
|
|
206
246
|
|
|
207
247
|
```bash
|
|
208
|
-
|
|
248
|
+
./run_unit_test.sh rfm_segment
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Windows PowerShell:
|
|
252
|
+
|
|
253
|
+
```powershell
|
|
254
|
+
.\run_unit_test.ps1 rfm_segment
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Windows Command Prompt:
|
|
258
|
+
|
|
259
|
+
```bat
|
|
260
|
+
run_unit_test.bat rfm_segment
|
|
209
261
|
```
|
|
210
262
|
|
|
211
|
-
|
|
263
|
+
Place custom tests in the service folder and name them `test_<service>.py`.
|
|
212
264
|
|
|
213
265
|
---
|
|
214
266
|
|
|
@@ -231,6 +283,7 @@ After `pam init` and one service:
|
|
|
231
283
|
.
|
|
232
284
|
├── main.py
|
|
233
285
|
├── AGENT.md
|
|
286
|
+
├── REPORTS.md
|
|
234
287
|
├── Dockerfile
|
|
235
288
|
├── rfm_segment/
|
|
236
289
|
│ ├── RfmSegmentSvc.py
|
|
@@ -256,4 +309,5 @@ After `pam init` and one service:
|
|
|
256
309
|
- Implement your logic in `functions.py`.
|
|
257
310
|
- Wire it into `on_start` and `on_data_input` in your service class.
|
|
258
311
|
- Use the temp utilities to write intermediate files.
|
|
259
|
-
- Use `
|
|
312
|
+
- Use `ResultBatchUploader` to return ordinary output to CDP.
|
|
313
|
+
- Read `REPORTS.md` before implementing a managed report.
|
|
@@ -4,11 +4,15 @@ This repository provides the `pam` CLI and runtime framework to build Data Plugi
|
|
|
4
4
|
|
|
5
5
|
This README is a practical, step-by-step guide you can follow to create and run a real service.
|
|
6
6
|
|
|
7
|
+
Current release target: **0.2.0**
|
|
8
|
+
|
|
7
9
|
**What you get**
|
|
8
10
|
|
|
9
11
|
- CLI to initialize a project and scaffold services
|
|
10
12
|
- Service lifecycle contract (start, data input, upload, exit)
|
|
11
13
|
- Temp file and SQLite helpers
|
|
14
|
+
- Managed Report Store V2 with typed Table, Metric, and ApexCharts-compatible builders
|
|
15
|
+
- Named `ResultBatchUploader` streams for bounded DataFrame uploads
|
|
12
16
|
- A monitoring loop for service timeouts and periodic cleanup
|
|
13
17
|
|
|
14
18
|
---
|
|
@@ -31,7 +35,7 @@ This README is a practical, step-by-step guide you can follow to create and run
|
|
|
31
35
|
|
|
32
36
|
**Prerequisites**
|
|
33
37
|
|
|
34
|
-
- Python 3.
|
|
38
|
+
- Python 3.9+
|
|
35
39
|
- `pip` and a working virtual environment
|
|
36
40
|
|
|
37
41
|
---
|
|
@@ -55,7 +59,8 @@ pip install pam-python
|
|
|
55
59
|
---
|
|
56
60
|
|
|
57
61
|
**Initialize a Project**
|
|
58
|
-
This creates a runnable project with templates
|
|
62
|
+
This creates a runnable project with templates, including `AGENT.md` for runtime
|
|
63
|
+
rules and `REPORTS.md` for the managed-report public API and copyable examples.
|
|
59
64
|
|
|
60
65
|
```bash
|
|
61
66
|
pam init
|
|
@@ -104,7 +109,8 @@ The runtime calls your service in two main phases.
|
|
|
104
109
|
|
|
105
110
|
When your service is done:
|
|
106
111
|
|
|
107
|
-
-
|
|
112
|
+
- Upload ordinary CDP result rows through `ResultBatchUploader` or `_upload_result(...)`
|
|
113
|
+
- Publish managed reports through `self.reports`; do not build report JSON manually
|
|
108
114
|
- Call `self._exit()` to signal completion
|
|
109
115
|
|
|
110
116
|
---
|
|
@@ -134,15 +140,47 @@ Recommended usage:
|
|
|
134
140
|
from pam.result_batch_uploader import ResultBatchUploader
|
|
135
141
|
|
|
136
142
|
batch_uploader = ResultBatchUploader(self, batch_size=50000)
|
|
137
|
-
batch_uploader.upload(df,
|
|
143
|
+
batch_uploader.upload(df, name="main")
|
|
138
144
|
batch_uploader.flush()
|
|
139
145
|
status = batch_uploader.get_status()
|
|
140
146
|
```
|
|
141
147
|
|
|
142
148
|
Notes:
|
|
143
149
|
|
|
144
|
-
- `
|
|
145
|
-
- `
|
|
150
|
+
- `request.runtime_parameters["batch_size"]` overrides the constructor default when valid.
|
|
151
|
+
- `name` separates result streams and enforces one stable column schema per stream.
|
|
152
|
+
- `options` may be passed to `upload(...)` and are forwarded to `_upload_result`.
|
|
153
|
+
- Complete batches upload immediately; `flush()` sends the remaining rows.
|
|
154
|
+
- `get_status()` returns buffered rows, uploaded rows, and uploaded batch counts per stream.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
**Managed Reports (Report Store V2)**
|
|
159
|
+
|
|
160
|
+
Managed reports are static facts stored in framework-owned SQLite. Plugin code
|
|
161
|
+
selects a typed builder, writes source-derived `DAY`, `MONTH`, or `NONE` facts, and
|
|
162
|
+
publishes only after the full calculation succeeds.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
fresh = self.request.get_runtime_bool("fresh", default=False)
|
|
166
|
+
store = (
|
|
167
|
+
self.reports.create("reports.sqlite")
|
|
168
|
+
if fresh
|
|
169
|
+
else self.reports.open("reports.sqlite")
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Choose the builder agreed in the customer requirement.
|
|
173
|
+
# See generated REPORTS.md for every supported declaration and write API.
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Important rules:
|
|
177
|
+
|
|
178
|
+
- Use only typed builders documented in `REPORTS.md`; there is no raw-report API.
|
|
179
|
+
- `open()` resumes the remote file or creates it only after an authoritative 404.
|
|
180
|
+
- Network/5xx download failures never create a replacement store.
|
|
181
|
+
- Use `create()` only for a human-requested fresh restart.
|
|
182
|
+
- Delete a partition, write its recalculated facts, then call `store.publish()` once.
|
|
183
|
+
- Low-level `_request_sqlite`/`_upload_sqlite` remain for custom plugin state, not reports.
|
|
146
184
|
|
|
147
185
|
---
|
|
148
186
|
|
|
@@ -163,13 +201,29 @@ export SERVER_PORT=8000
|
|
|
163
201
|
---
|
|
164
202
|
|
|
165
203
|
**Testing a Service**
|
|
166
|
-
Run
|
|
204
|
+
Run tests through the generated script for your current shell. This keeps the
|
|
205
|
+
active terminal's Python environment and project context instead of launching
|
|
206
|
+
test discovery through the `pam` CLI.
|
|
207
|
+
|
|
208
|
+
macOS/Linux:
|
|
167
209
|
|
|
168
210
|
```bash
|
|
169
|
-
|
|
211
|
+
./run_unit_test.sh rfm_segment
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Windows PowerShell:
|
|
215
|
+
|
|
216
|
+
```powershell
|
|
217
|
+
.\run_unit_test.ps1 rfm_segment
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Windows Command Prompt:
|
|
221
|
+
|
|
222
|
+
```bat
|
|
223
|
+
run_unit_test.bat rfm_segment
|
|
170
224
|
```
|
|
171
225
|
|
|
172
|
-
|
|
226
|
+
Place custom tests in the service folder and name them `test_<service>.py`.
|
|
173
227
|
|
|
174
228
|
---
|
|
175
229
|
|
|
@@ -192,6 +246,7 @@ After `pam init` and one service:
|
|
|
192
246
|
.
|
|
193
247
|
├── main.py
|
|
194
248
|
├── AGENT.md
|
|
249
|
+
├── REPORTS.md
|
|
195
250
|
├── Dockerfile
|
|
196
251
|
├── rfm_segment/
|
|
197
252
|
│ ├── RfmSegmentSvc.py
|
|
@@ -217,4 +272,5 @@ After `pam init` and one service:
|
|
|
217
272
|
- Implement your logic in `functions.py`.
|
|
218
273
|
- Wire it into `on_start` and `on_data_input` in your service class.
|
|
219
274
|
- Use the temp utilities to write intermediate files.
|
|
220
|
-
- Use `
|
|
275
|
+
- Use `ResultBatchUploader` to return ordinary output to CDP.
|
|
276
|
+
- Read `REPORTS.md` before implementing a managed report.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
import sqlite3
|
|
4
|
+
import requests
|
|
5
|
+
from pam.models.sqlite_download import SQLiteDownloadResult, SQLiteDownloadStatus
|
|
6
|
+
from pam.utils import log
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class API:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.session = requests.Session() # Use a session for connection reuse
|
|
12
|
+
|
|
13
|
+
def http_post(self, url: str, data: dict) -> requests.Response | None:
|
|
14
|
+
"""
|
|
15
|
+
Sends an HTTP POST request to the specified URL with the given data as JSON.
|
|
16
|
+
|
|
17
|
+
:param url: The URL to send the POST request to.
|
|
18
|
+
:param data: A dictionary to be used as the JSON body of the POST request.
|
|
19
|
+
:return: The response from the server, or None if an error occurred.
|
|
20
|
+
"""
|
|
21
|
+
headers = {'Content-Type': 'application/json'}
|
|
22
|
+
try:
|
|
23
|
+
response = self.session.post(
|
|
24
|
+
url, json=data, timeout=30, headers=headers
|
|
25
|
+
)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
return response
|
|
28
|
+
except requests.RequestException as e:
|
|
29
|
+
log(f"HTTP POST request failed. URL: {url}, Error: {e}")
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
def http_upload(self, url: str, file_path: str, payload: dict = None) -> requests.Response | None:
|
|
33
|
+
"""
|
|
34
|
+
Uploads a file to the specified URL with optional form data (payload).
|
|
35
|
+
|
|
36
|
+
:param url: The URL to upload the file to.
|
|
37
|
+
:param file_path: The path to the file to be uploaded.
|
|
38
|
+
:param payload: Optional dictionary of form fields to include in the request.
|
|
39
|
+
:return: The response from the server, or None if an error occurred.
|
|
40
|
+
"""
|
|
41
|
+
if not Path(file_path).is_file():
|
|
42
|
+
log(f"File does not exist: {file_path}")
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
with open(file_path, 'rb') as file:
|
|
47
|
+
files = {'file': file}
|
|
48
|
+
response = self.session.post(
|
|
49
|
+
url,
|
|
50
|
+
files=files,
|
|
51
|
+
data=payload or {}, # optional form data
|
|
52
|
+
timeout=300
|
|
53
|
+
)
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
return response
|
|
56
|
+
except requests.RequestException as e:
|
|
57
|
+
log(f"File upload failed. URL: {url}, File: {file_path}, Error: {e}")
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def download_sqlite_from_post(self, url: str, data: dict, output_path: str) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Sends a POST request with JSON data and expects a SQLite file in response.
|
|
63
|
+
Saves the response content as a .sqlite file.
|
|
64
|
+
|
|
65
|
+
:param url: The URL to send the POST request to.
|
|
66
|
+
:param data: Dictionary to send as JSON in the body.
|
|
67
|
+
:param output_path: File path to save the .sqlite file.
|
|
68
|
+
:return: True if successful, False otherwise.
|
|
69
|
+
"""
|
|
70
|
+
headers = {'Content-Type': 'application/json'}
|
|
71
|
+
try:
|
|
72
|
+
response = self.session.post(
|
|
73
|
+
url, json=data, timeout=60, headers=headers, stream=True
|
|
74
|
+
)
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
|
|
77
|
+
with open(output_path, "wb") as f:
|
|
78
|
+
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
|
79
|
+
if chunk:
|
|
80
|
+
f.write(chunk)
|
|
81
|
+
|
|
82
|
+
log(f"SQLite file saved to {output_path}")
|
|
83
|
+
return True
|
|
84
|
+
except requests.RequestException as e:
|
|
85
|
+
log(f"Failed to download SQLite. URL: {url}, Error: {e}")
|
|
86
|
+
return False
|
|
87
|
+
except IOError as e:
|
|
88
|
+
log(f"Failed to save SQLite file. Path: {output_path}, Error: {e}")
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def download_sqlite_v2_from_post(
|
|
92
|
+
self, url: str, data: dict, output_path: str
|
|
93
|
+
) -> SQLiteDownloadResult:
|
|
94
|
+
"""Download and validate managed report state without hiding failures."""
|
|
95
|
+
headers = {'Content-Type': 'application/json'}
|
|
96
|
+
target_path = Path(output_path)
|
|
97
|
+
partial_path = Path(f"{output_path}.part")
|
|
98
|
+
|
|
99
|
+
self.__remove_partial_file(partial_path)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
response = self.session.post(
|
|
103
|
+
url, json=data, timeout=60, headers=headers, stream=True
|
|
104
|
+
)
|
|
105
|
+
except requests.RequestException as exc:
|
|
106
|
+
return SQLiteDownloadResult(
|
|
107
|
+
status=SQLiteDownloadStatus.FAILED,
|
|
108
|
+
error_code="NETWORK_ERROR",
|
|
109
|
+
message=str(exc),
|
|
110
|
+
retryable=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if response.status_code == 404:
|
|
114
|
+
response.close()
|
|
115
|
+
return SQLiteDownloadResult(
|
|
116
|
+
status=SQLiteDownloadStatus.NOT_FOUND,
|
|
117
|
+
http_status=response.status_code,
|
|
118
|
+
error_code="NOT_FOUND",
|
|
119
|
+
retryable=False,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
123
|
+
retryable = response.status_code >= 500 or response.status_code in (408, 429)
|
|
124
|
+
response.close()
|
|
125
|
+
return SQLiteDownloadResult(
|
|
126
|
+
status=SQLiteDownloadStatus.FAILED,
|
|
127
|
+
http_status=response.status_code,
|
|
128
|
+
error_code="HTTP_ERROR",
|
|
129
|
+
message=f"SQLite download failed with HTTP {response.status_code}",
|
|
130
|
+
retryable=retryable,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
with partial_path.open("wb") as output_file:
|
|
136
|
+
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
|
137
|
+
if chunk:
|
|
138
|
+
output_file.write(chunk)
|
|
139
|
+
|
|
140
|
+
self.__validate_sqlite(partial_path)
|
|
141
|
+
os.replace(str(partial_path), str(target_path))
|
|
142
|
+
except requests.RequestException as exc:
|
|
143
|
+
self.__remove_partial_file(partial_path)
|
|
144
|
+
response.close()
|
|
145
|
+
return SQLiteDownloadResult(
|
|
146
|
+
status=SQLiteDownloadStatus.FAILED,
|
|
147
|
+
http_status=response.status_code,
|
|
148
|
+
error_code="NETWORK_ERROR",
|
|
149
|
+
message=str(exc),
|
|
150
|
+
retryable=True,
|
|
151
|
+
)
|
|
152
|
+
except (OSError, sqlite3.Error) as exc:
|
|
153
|
+
self.__remove_partial_file(partial_path)
|
|
154
|
+
response.close()
|
|
155
|
+
return SQLiteDownloadResult(
|
|
156
|
+
status=SQLiteDownloadStatus.FAILED,
|
|
157
|
+
http_status=response.status_code,
|
|
158
|
+
error_code="INVALID_SQLITE_OR_LOCAL_IO",
|
|
159
|
+
message=str(exc),
|
|
160
|
+
retryable=False,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
response.close()
|
|
164
|
+
log(f"Report Store V2 SQLite file saved to {target_path}")
|
|
165
|
+
return SQLiteDownloadResult(
|
|
166
|
+
status=SQLiteDownloadStatus.DOWNLOADED,
|
|
167
|
+
path=target_path,
|
|
168
|
+
http_status=response.status_code,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def __validate_sqlite(path: Path) -> None:
|
|
173
|
+
sqlite_uri = f"file:{path.resolve()}?mode=ro"
|
|
174
|
+
with sqlite3.connect(sqlite_uri, uri=True) as connection:
|
|
175
|
+
result = connection.execute("PRAGMA quick_check").fetchone()
|
|
176
|
+
if result is None or result[0] != "ok":
|
|
177
|
+
raise sqlite3.DatabaseError("SQLite quick_check failed")
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def __remove_partial_file(path: Path) -> None:
|
|
181
|
+
try:
|
|
182
|
+
path.unlink()
|
|
183
|
+
except FileNotFoundError:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def close(self):
|
|
187
|
+
"""Close the session."""
|
|
188
|
+
self.session.close()
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import sys
|
|
3
2
|
import re
|
|
4
3
|
import shutil
|
|
4
|
+
import sys
|
|
5
5
|
import importlib.resources as resources
|
|
6
6
|
from importlib import metadata
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
|
|
9
|
+
def main(argv=None):
|
|
10
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
10
11
|
if len(args) == 0:
|
|
11
12
|
print("Usage: pam <command> [args]")
|
|
12
13
|
return
|
|
@@ -40,13 +41,14 @@ def to_pascal_case(input_string: str) -> str:
|
|
|
40
41
|
:return: The string in PascalCase.
|
|
41
42
|
"""
|
|
42
43
|
# Split the string into words using non-alphanumeric characters as delimiters
|
|
43
|
-
words = re.split(r'
|
|
44
|
+
words = re.split(r'[^A-Za-z0-9]+', input_string)
|
|
44
45
|
|
|
45
46
|
# Capitalize each word and join them
|
|
46
|
-
pascal_case = ''.join(word.
|
|
47
|
+
pascal_case = ''.join(word[:1].upper() + word[1:] for word in words if word)
|
|
47
48
|
|
|
48
49
|
return pascal_case
|
|
49
50
|
|
|
51
|
+
|
|
50
52
|
def cpy(src, dest):
|
|
51
53
|
template_dir = resources.files("pam") / "templates"
|
|
52
54
|
src_file = os.path.join(template_dir, src)
|
|
@@ -59,6 +61,7 @@ def read_template_text(src: str) -> str:
|
|
|
59
61
|
with open(src_file, "r", encoding="utf-8") as file:
|
|
60
62
|
return file.read()
|
|
61
63
|
|
|
64
|
+
|
|
62
65
|
def replace_template_content(service_name, class_name, file_name):
|
|
63
66
|
file_path = os.path.join(service_name, file_name)
|
|
64
67
|
with open(file_path, 'r+', encoding='utf-8') as file:
|
|
@@ -71,9 +74,15 @@ def replace_template_content(service_name, class_name, file_name):
|
|
|
71
74
|
|
|
72
75
|
|
|
73
76
|
def create_service(name):
|
|
77
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name):
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"service_name must start with a letter and contain only letters, "
|
|
80
|
+
"numbers, and underscores"
|
|
81
|
+
)
|
|
74
82
|
if os.path.exists(name):
|
|
75
83
|
response = input(
|
|
76
|
-
f"Service {name} already exists. Do you want to overwrite it? (y/N): "
|
|
84
|
+
f"Service {name} already exists. Do you want to overwrite it? (y/N): "
|
|
85
|
+
).strip().lower()
|
|
77
86
|
if response == 'y':
|
|
78
87
|
shutil.rmtree(name)
|
|
79
88
|
else:
|
|
@@ -83,22 +92,27 @@ def create_service(name):
|
|
|
83
92
|
os.mkdir(name)
|
|
84
93
|
open(os.path.join(name, "__init__.py"), 'a', encoding='utf-8').close()
|
|
85
94
|
|
|
86
|
-
cpy(
|
|
87
|
-
|
|
95
|
+
cpy(
|
|
96
|
+
"service/service_class.tmpl",
|
|
97
|
+
os.path.join(name, to_pascal_case(name) + "Svc.py"),
|
|
98
|
+
)
|
|
88
99
|
|
|
89
100
|
cpy("service/service.yaml", os.path.join(name, "service.yaml"))
|
|
90
101
|
cpy("service/functions.tmpl", os.path.join(name, "functions.py"))
|
|
91
102
|
cpy("service/service.test.tmpl", os.path.join(name, f"test_{name}.py"))
|
|
92
|
-
|
|
93
103
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
replace_template_content(name,
|
|
104
|
+
class_name = to_pascal_case(name) + "Svc"
|
|
105
|
+
replace_template_content(name, class_name, class_name + ".py")
|
|
106
|
+
replace_template_content(name, class_name, "service.yaml")
|
|
97
107
|
replace_template_content(
|
|
98
|
-
name,
|
|
108
|
+
name, class_name, f"test_{name}.py"
|
|
109
|
+
)
|
|
99
110
|
|
|
100
111
|
print(f"Service {name} created.")
|
|
101
|
-
print(
|
|
112
|
+
print(
|
|
113
|
+
f"Run the generated run_unit_test script for your shell with `{name}` "
|
|
114
|
+
"to test the service."
|
|
115
|
+
)
|
|
102
116
|
|
|
103
117
|
|
|
104
118
|
def init_project():
|
|
@@ -113,6 +127,7 @@ def init_project():
|
|
|
113
127
|
cpy("init/run_unit_test.bat", "run_unit_test.bat")
|
|
114
128
|
cpy("init/run_unit_test.ps1", "run_unit_test.ps1")
|
|
115
129
|
cpy("init/AGENT.md", "AGENT.md")
|
|
130
|
+
cpy("init/REPORTS.md", "REPORTS.md")
|
|
116
131
|
|
|
117
132
|
requirements_path = "requirements.txt"
|
|
118
133
|
has_requirements = os.path.exists(requirements_path)
|
|
@@ -147,7 +162,7 @@ def init_project():
|
|
|
147
162
|
open("__init__.py", 'a', encoding='utf-8').close()
|
|
148
163
|
|
|
149
164
|
print("--- Welcome to PAM ---\n")
|
|
150
|
-
print("To create a new
|
|
165
|
+
print("To create a new service run\n`pam new service <service_name>`\n\n")
|
|
151
166
|
|
|
152
167
|
|
|
153
168
|
def _parse_requirement_name(line: str) -> str:
|
|
@@ -167,13 +182,12 @@ def _parse_requirement_name(line: str) -> str:
|
|
|
167
182
|
|
|
168
183
|
|
|
169
184
|
def _get_installed_pam_version() -> str:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return match.group(1)
|
|
185
|
+
try:
|
|
186
|
+
from pam import __version__
|
|
187
|
+
|
|
188
|
+
return __version__
|
|
189
|
+
except ImportError:
|
|
190
|
+
pass
|
|
177
191
|
|
|
178
192
|
try:
|
|
179
193
|
return metadata.version("pam-python")
|
|
@@ -206,3 +220,7 @@ def _merge_requirements(existing_lines, frozen_lines):
|
|
|
206
220
|
merged.append(line)
|
|
207
221
|
existing_keys.add(key)
|
|
208
222
|
return merged
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
main()
|