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.
Files changed (103) hide show
  1. {pam_python-0.1.44/pam_python.egg-info → pam_python-0.2.0}/PKG-INFO +68 -14
  2. {pam_python-0.1.44 → pam_python-0.2.0}/README.md +66 -10
  3. pam_python-0.2.0/pam/__init__.py +3 -0
  4. pam_python-0.2.0/pam/api.py +188 -0
  5. {pam_python-0.1.44 → pam_python-0.2.0}/pam/cli.py +40 -22
  6. {pam_python-0.1.44 → pam_python-0.2.0}/pam/interface_task_manager.py +33 -2
  7. {pam_python-0.1.44 → pam_python-0.2.0}/pam/models/request_command.py +11 -2
  8. pam_python-0.2.0/pam/models/sqlite_download.py +26 -0
  9. pam_python-0.2.0/pam/reports/__init__.py +73 -0
  10. pam_python-0.2.0/pam/reports/area.py +97 -0
  11. pam_python-0.2.0/pam/reports/axis.py +50 -0
  12. pam_python-0.2.0/pam/reports/bar.py +154 -0
  13. pam_python-0.2.0/pam/reports/box_plot.py +119 -0
  14. pam_python-0.2.0/pam/reports/bubble.py +113 -0
  15. pam_python-0.2.0/pam/reports/candlestick.py +132 -0
  16. pam_python-0.2.0/pam/reports/collection.py +198 -0
  17. pam_python-0.2.0/pam/reports/definition.py +82 -0
  18. pam_python-0.2.0/pam/reports/donut.py +111 -0
  19. pam_python-0.2.0/pam/reports/errors.py +33 -0
  20. pam_python-0.2.0/pam/reports/fields.py +144 -0
  21. pam_python-0.2.0/pam/reports/heatmap.py +88 -0
  22. pam_python-0.2.0/pam/reports/line.py +90 -0
  23. pam_python-0.2.0/pam/reports/manager.py +148 -0
  24. pam_python-0.2.0/pam/reports/metric.py +139 -0
  25. pam_python-0.2.0/pam/reports/pie.py +111 -0
  26. pam_python-0.2.0/pam/reports/polar_area.py +112 -0
  27. pam_python-0.2.0/pam/reports/radar.py +115 -0
  28. pam_python-0.2.0/pam/reports/radial_bar.py +135 -0
  29. pam_python-0.2.0/pam/reports/range_area.py +121 -0
  30. pam_python-0.2.0/pam/reports/range_bar.py +121 -0
  31. pam_python-0.2.0/pam/reports/range_transform.py +183 -0
  32. pam_python-0.2.0/pam/reports/record.py +138 -0
  33. pam_python-0.2.0/pam/reports/report.py +35 -0
  34. pam_python-0.2.0/pam/reports/scatter.py +121 -0
  35. pam_python-0.2.0/pam/reports/schema.py +47 -0
  36. pam_python-0.2.0/pam/reports/store.py +694 -0
  37. pam_python-0.2.0/pam/reports/table.py +234 -0
  38. pam_python-0.2.0/pam/reports/transport.py +57 -0
  39. pam_python-0.2.0/pam/reports/treemap.py +121 -0
  40. pam_python-0.2.0/pam/result_batch_uploader.py +179 -0
  41. {pam_python-0.1.44 → pam_python-0.2.0}/pam/service.py +10 -2
  42. {pam_python-0.1.44 → pam_python-0.2.0}/pam/task_manager.py +82 -3
  43. {pam_python-0.1.44 → pam_python-0.2.0}/pam/temp_file_utils.py +27 -5
  44. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/AGENT.md +24 -6
  45. pam_python-0.2.0/pam/templates/init/REPORTS.md +1395 -0
  46. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/main.tmpl +3 -3
  47. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/requirements.txt +0 -1
  48. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/service/functions.tmpl +1 -17
  49. pam_python-0.2.0/pam/templates/service/service.test.tmpl +137 -0
  50. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/service/service_class.tmpl +14 -10
  51. {pam_python-0.1.44 → pam_python-0.2.0}/pam/tester_task.py +54 -3
  52. {pam_python-0.1.44 → pam_python-0.2.0}/pam/utils.py +1 -1
  53. {pam_python-0.1.44 → pam_python-0.2.0/pam_python.egg-info}/PKG-INFO +68 -14
  54. pam_python-0.2.0/pam_python.egg-info/SOURCES.txt +97 -0
  55. {pam_python-0.1.44 → pam_python-0.2.0}/pam_python.egg-info/requires.txt +0 -1
  56. {pam_python-0.1.44 → pam_python-0.2.0}/setup.py +4 -5
  57. pam_python-0.2.0/tests/test_cli_templates.py +78 -0
  58. pam_python-0.2.0/tests/test_range_transform.py +172 -0
  59. pam_python-0.2.0/tests/test_report_store.py +496 -0
  60. pam_python-0.2.0/tests/test_result_batch_uploader.py +111 -0
  61. pam_python-0.2.0/tests/test_sqlite_download_v2.py +175 -0
  62. pam_python-0.2.0/tests/test_typed_area_report.py +101 -0
  63. pam_python-0.2.0/tests/test_typed_bar_report.py +172 -0
  64. pam_python-0.2.0/tests/test_typed_box_plot_report.py +72 -0
  65. pam_python-0.2.0/tests/test_typed_bubble_report.py +61 -0
  66. pam_python-0.2.0/tests/test_typed_candlestick_report.py +77 -0
  67. pam_python-0.2.0/tests/test_typed_donut_report.py +140 -0
  68. pam_python-0.2.0/tests/test_typed_heatmap_report.py +56 -0
  69. pam_python-0.2.0/tests/test_typed_line_report.py +128 -0
  70. pam_python-0.2.0/tests/test_typed_metric_report.py +106 -0
  71. pam_python-0.2.0/tests/test_typed_pie_report.py +149 -0
  72. pam_python-0.2.0/tests/test_typed_polar_area_report.py +139 -0
  73. pam_python-0.2.0/tests/test_typed_radar_report.py +22 -0
  74. pam_python-0.2.0/tests/test_typed_radial_bar_report.py +77 -0
  75. pam_python-0.2.0/tests/test_typed_range_area_report.py +75 -0
  76. pam_python-0.2.0/tests/test_typed_range_bar_report.py +97 -0
  77. pam_python-0.2.0/tests/test_typed_scatter_report.py +131 -0
  78. pam_python-0.2.0/tests/test_typed_table_report.py +116 -0
  79. pam_python-0.2.0/tests/test_typed_treemap_report.py +19 -0
  80. pam_python-0.1.44/pam/api.py +0 -90
  81. pam_python-0.1.44/pam/models/__init__.py +0 -0
  82. pam_python-0.1.44/pam/templates/service/service.test.tmpl +0 -116
  83. pam_python-0.1.44/pam_python.egg-info/SOURCES.txt +0 -40
  84. {pam_python-0.1.44 → pam_python-0.2.0}/LICENSE.txt +0 -0
  85. {pam_python-0.1.44 → pam_python-0.2.0}/pam/logger.py +0 -0
  86. {pam_python-0.1.44/pam → pam_python-0.2.0/pam/models}/__init__.py +0 -0
  87. {pam_python-0.1.44 → pam_python-0.2.0}/pam/request_file_format.py +0 -0
  88. {pam_python-0.1.44 → pam_python-0.2.0}/pam/server.py +0 -0
  89. {pam_python-0.1.44 → pam_python-0.2.0}/pam/sqlite.py +0 -0
  90. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/buildcmd/pamb +0 -0
  91. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/buildcmd/pamb-base.sh +0 -0
  92. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/docker/Dockerfile +0 -0
  93. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/dockerignore.tmpl +0 -0
  94. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/gitignore.tmpl +0 -0
  95. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/pylintrc.tmpl +0 -0
  96. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/run_unit_test.bat +0 -0
  97. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/run_unit_test.ps1 +0 -0
  98. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/init/run_unit_test.sh +0 -0
  99. {pam_python-0.1.44 → pam_python-0.2.0}/pam/templates/service/service.yaml +0 -0
  100. {pam_python-0.1.44 → pam_python-0.2.0}/pam_python.egg-info/dependency_links.txt +0 -0
  101. {pam_python-0.1.44 → pam_python-0.2.0}/pam_python.egg-info/entry_points.txt +0 -0
  102. {pam_python-0.1.44 → pam_python-0.2.0}/pam_python.egg-info/top_level.txt +0 -0
  103. {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.1.44
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.8
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.8+ recommended
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 (including `AGENT.md`).
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
- - Call `self._upload_result(...)` or `self._upload_report(...)`
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, "data-name")
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
- - `name` separates different result streams (A/B) to avoid schema conflicts.
184
- - `flush()` uploads any remaining rows that are below the batch size.
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 unit tests for a service:
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
- pam test rfm_segment
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
- If you write custom tests, place them in the service folder and name them `test_<service>.py`.
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 `_upload_result` to return output to CDP.
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.8+ recommended
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 (including `AGENT.md`).
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
- - Call `self._upload_result(...)` or `self._upload_report(...)`
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, "data-name")
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
- - `name` separates different result streams (A/B) to avoid schema conflicts.
145
- - `flush()` uploads any remaining rows that are below the batch size.
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 unit tests for a service:
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
- pam test rfm_segment
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
- If you write custom tests, place them in the service folder and name them `test_<service>.py`.
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 `_upload_result` to return output to CDP.
275
+ - Use `ResultBatchUploader` to return ordinary output to CDP.
276
+ - Read `REPORTS.md` before implementing a managed report.
@@ -0,0 +1,3 @@
1
+ """PAM Data Plugin framework."""
2
+
3
+ __version__ = "0.2.0"
@@ -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
- def main():
9
- args = sys.argv[1:]
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'\W+', input_string)
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.capitalize() for word in words if 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): ").strip().lower()
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("service/service_class.tmpl",
87
- os.path.join(name, to_pascal_case(name)+"Svc.py"))
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
- replace_template_content(name, to_pascal_case(
95
- name)+"Svc", to_pascal_case(name)+"Svc.py")
96
- replace_template_content(name, to_pascal_case(name)+"Svc", "service.yaml")
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, to_pascal_case(name)+"Svc", f"test_{name}.py")
108
+ name, class_name, f"test_{name}.py"
109
+ )
99
110
 
100
111
  print(f"Service {name} created.")
101
- print(f"Run `pam test {name}` to run tests for the service.")
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 servive run\n`pam new service <service_name>`\n\n")
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
- setup_py = os.path.join(os.path.dirname(os.path.dirname(__file__)), "setup.py")
171
- if os.path.exists(setup_py):
172
- with open(setup_py, "r", encoding="utf-8") as file:
173
- content = file.read()
174
- match = re.search(r'version="([^"]+)"', content)
175
- if match:
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()