pam-python 0.2.2__tar.gz → 0.2.5__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 (109) hide show
  1. {pam_python-0.2.2 → pam_python-0.2.5}/PKG-INFO +16 -13
  2. {pam_python-0.2.2 → pam_python-0.2.5}/README.md +15 -12
  3. {pam_python-0.2.2 → pam_python-0.2.5}/pam/__init__.py +1 -1
  4. {pam_python-0.2.2 → pam_python-0.2.5}/pam/cli.py +5 -2
  5. pam_python-0.2.5/pam/result_batch_uploader.py +412 -0
  6. {pam_python-0.2.2 → pam_python-0.2.5}/pam/task_manager.py +18 -19
  7. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/init/AGENT.md +89 -22
  8. pam_python-0.2.5/pam/templates/init/gitignore.tmpl +65 -0
  9. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/service/pandas/service.test.tmpl +4 -0
  10. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/service/pandas/service_class.tmpl +18 -5
  11. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/service/polars/service.test.tmpl +6 -2
  12. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/service/polars/service_class.tmpl +25 -6
  13. {pam_python-0.2.2 → pam_python-0.2.5}/pam/tester_task.py +2 -4
  14. {pam_python-0.2.2 → pam_python-0.2.5}/pam_python.egg-info/PKG-INFO +16 -13
  15. {pam_python-0.2.2 → pam_python-0.2.5}/pyproject.toml +1 -1
  16. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_cli_templates.py +52 -2
  17. pam_python-0.2.5/tests/test_result_batch_uploader.py +265 -0
  18. pam_python-0.2.2/pam/result_batch_uploader.py +0 -227
  19. pam_python-0.2.2/pam/templates/init/gitignore.tmpl +0 -178
  20. pam_python-0.2.2/tests/test_result_batch_uploader.py +0 -137
  21. {pam_python-0.2.2 → pam_python-0.2.5}/LICENSE.txt +0 -0
  22. {pam_python-0.2.2 → pam_python-0.2.5}/pam/api.py +0 -0
  23. {pam_python-0.2.2 → pam_python-0.2.5}/pam/interface_task_manager.py +0 -0
  24. {pam_python-0.2.2 → pam_python-0.2.5}/pam/logger.py +0 -0
  25. {pam_python-0.2.2 → pam_python-0.2.5}/pam/models/__init__.py +0 -0
  26. {pam_python-0.2.2 → pam_python-0.2.5}/pam/models/request_command.py +0 -0
  27. {pam_python-0.2.2 → pam_python-0.2.5}/pam/models/sqlite_download.py +0 -0
  28. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/__init__.py +0 -0
  29. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/area.py +0 -0
  30. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/axis.py +0 -0
  31. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/bar.py +0 -0
  32. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/box_plot.py +0 -0
  33. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/bubble.py +0 -0
  34. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/candlestick.py +0 -0
  35. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/collection.py +0 -0
  36. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/definition.py +0 -0
  37. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/donut.py +0 -0
  38. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/errors.py +0 -0
  39. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/fields.py +0 -0
  40. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/heatmap.py +0 -0
  41. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/line.py +0 -0
  42. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/manager.py +0 -0
  43. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/metric.py +0 -0
  44. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/pie.py +0 -0
  45. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/polar_area.py +0 -0
  46. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/radar.py +0 -0
  47. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/radial_bar.py +0 -0
  48. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/range_area.py +0 -0
  49. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/range_bar.py +0 -0
  50. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/range_transform.py +0 -0
  51. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/record.py +0 -0
  52. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/report.py +0 -0
  53. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/scatter.py +0 -0
  54. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/schema.py +0 -0
  55. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/store.py +0 -0
  56. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/table.py +0 -0
  57. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/transport.py +0 -0
  58. {pam_python-0.2.2 → pam_python-0.2.5}/pam/reports/treemap.py +0 -0
  59. {pam_python-0.2.2 → pam_python-0.2.5}/pam/request_file_format.py +0 -0
  60. {pam_python-0.2.2 → pam_python-0.2.5}/pam/server.py +0 -0
  61. {pam_python-0.2.2 → pam_python-0.2.5}/pam/service.py +0 -0
  62. {pam_python-0.2.2 → pam_python-0.2.5}/pam/sqlite.py +0 -0
  63. {pam_python-0.2.2 → pam_python-0.2.5}/pam/temp_file_utils.py +0 -0
  64. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/buildcmd/pamb +0 -0
  65. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/buildcmd/pamb-base.sh +0 -0
  66. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/init/REPORTS.md +0 -0
  67. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/init/dockerignore.tmpl +0 -0
  68. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/init/main.tmpl +0 -0
  69. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/init/pylintrc.tmpl +0 -0
  70. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/init/run_unit_test.bat +0 -0
  71. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/init/run_unit_test.ps1 +0 -0
  72. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/init/run_unit_test.sh +0 -0
  73. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/project/pip/Dockerfile +0 -0
  74. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/project/pip/requirements.txt +0 -0
  75. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/project/uv/Dockerfile +0 -0
  76. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/project/uv/pyproject.toml +0 -0
  77. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/project/uv/python-version +0 -0
  78. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/service/common/service.yaml +0 -0
  79. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/service/pandas/functions.tmpl +0 -0
  80. {pam_python-0.2.2 → pam_python-0.2.5}/pam/templates/service/polars/functions.tmpl +0 -0
  81. {pam_python-0.2.2 → pam_python-0.2.5}/pam/utils.py +0 -0
  82. {pam_python-0.2.2 → pam_python-0.2.5}/pam_python.egg-info/SOURCES.txt +0 -0
  83. {pam_python-0.2.2 → pam_python-0.2.5}/pam_python.egg-info/dependency_links.txt +0 -0
  84. {pam_python-0.2.2 → pam_python-0.2.5}/pam_python.egg-info/entry_points.txt +0 -0
  85. {pam_python-0.2.2 → pam_python-0.2.5}/pam_python.egg-info/requires.txt +0 -0
  86. {pam_python-0.2.2 → pam_python-0.2.5}/pam_python.egg-info/top_level.txt +0 -0
  87. {pam_python-0.2.2 → pam_python-0.2.5}/setup.cfg +0 -0
  88. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_range_transform.py +0 -0
  89. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_report_store.py +0 -0
  90. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_service_dataframe_output.py +0 -0
  91. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_sqlite_download_v2.py +0 -0
  92. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_area_report.py +0 -0
  93. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_bar_report.py +0 -0
  94. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_box_plot_report.py +0 -0
  95. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_bubble_report.py +0 -0
  96. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_candlestick_report.py +0 -0
  97. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_donut_report.py +0 -0
  98. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_heatmap_report.py +0 -0
  99. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_line_report.py +0 -0
  100. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_metric_report.py +0 -0
  101. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_pie_report.py +0 -0
  102. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_polar_area_report.py +0 -0
  103. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_radar_report.py +0 -0
  104. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_radial_bar_report.py +0 -0
  105. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_range_area_report.py +0 -0
  106. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_range_bar_report.py +0 -0
  107. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_scatter_report.py +0 -0
  108. {pam_python-0.2.2 → pam_python-0.2.5}/tests/test_typed_table_report.py +0 -0
  109. {pam_python-0.2.2 → pam_python-0.2.5}/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.2
3
+ Version: 0.2.5
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
@@ -122,13 +122,13 @@ The runtime calls your service in two main phases.
122
122
  2. `on_data_input`
123
123
 
124
124
  - Called when CDP sends input files
125
- - `req.input_files` contains ordered Parquet files
125
+ - `req.input_files` is the ordered positional data-source contract configured in PAM
126
126
  - `req.file_format` is the typed format sent by PAM
127
127
  - Should also return quickly (use a thread if needed)
128
128
 
129
129
  When your service is done:
130
130
 
131
- - Upload ordinary CDP result rows through `ResultBatchUploader` or `_upload_result(...)`
131
+ - Upload ordinary CDP result rows through `ResultBatchUploader`
132
132
  - Publish managed reports through `self.reports`; do not build report JSON manually
133
133
  - Call `self._exit()` to signal completion
134
134
 
@@ -151,11 +151,11 @@ Notes:
151
151
  ---
152
152
 
153
153
  **Uploading Results in Batches**
154
- Polars templates pass `LazyFrame` results directly to `_upload_result(...)`, which
155
- streams them to the CSV upload boundary. Do not collect or convert to Pandas first.
156
-
157
- For eager Polars or Pandas DataFrames, use the batch uploader to handle chunking
158
- and flushing automatically.
154
+ Polars `LazyFrame`/`DataFrame` and Pandas `DataFrame` results all pass through
155
+ `ResultBatchUploader`. It streams lazy results in bounded chunks, buffers incomplete
156
+ batches across calls, writes complete batches as CSV, and uploads them through PAM.
157
+ The framework does not prescribe page-local/global computation or intermediate
158
+ storage; submit rows when your business result is ready.
159
159
 
160
160
  Recommended usage:
161
161
 
@@ -165,6 +165,7 @@ from pam.result_batch_uploader import ResultBatchUploader
165
165
  batch_uploader = ResultBatchUploader(self, batch_size=50000)
166
166
  batch_uploader.upload(df, name="main")
167
167
  batch_uploader.flush()
168
+ batch_uploader.wait_for_uploader()
168
169
  status = batch_uploader.get_status()
169
170
  ```
170
171
 
@@ -173,9 +174,12 @@ Notes:
173
174
  - `request.runtime_parameters["batch_size"]` overrides the constructor default when valid.
174
175
  - `name` separates result streams and enforces one stable column schema per stream.
175
176
  - `options` may be passed to `upload(...)` and are forwarded to `_upload_result`.
176
- - Complete batches upload immediately; `flush()` sends the remaining rows.
177
- - `get_status()` returns buffered rows, uploaded rows, and uploaded batch counts per stream.
178
- - `ResultBatchUploader` intentionally rejects Polars `LazyFrame`; upload it directly.
177
+ - Uploads run in a bounded background queue so processing can continue while PAM receives CSV batches.
178
+ - `flush()` closes input and queues the final remainder; it does not wait for network completion.
179
+ - Always call `wait_for_uploader()` after `flush()` and before `_exit()`.
180
+ - Failed uploads retry with exponential backoff, then log/record the failed batch and continue.
181
+ - `get_status()` includes buffered, uploaded, failed, and retried counts per stream.
182
+ - Polars `LazyFrame` is supported without collecting the complete result at once.
179
183
 
180
184
  ---
181
185
 
@@ -304,6 +308,5 @@ After `pam init` and one service:
304
308
  - Implement your logic in `functions.py`.
305
309
  - Wire it into `on_start` and `on_data_input` in your service class.
306
310
  - Use the temp utilities to write intermediate files.
307
- - Keep Polars transformations lazy and upload the resulting `LazyFrame` directly.
308
- - Use `ResultBatchUploader` only for eager Polars/Pandas frames that need row batching.
311
+ - Keep Polars transformations lazy and submit final results through `ResultBatchUploader`.
309
312
  - Read `REPORTS.md` before implementing a managed report.
@@ -102,13 +102,13 @@ The runtime calls your service in two main phases.
102
102
  2. `on_data_input`
103
103
 
104
104
  - Called when CDP sends input files
105
- - `req.input_files` contains ordered Parquet files
105
+ - `req.input_files` is the ordered positional data-source contract configured in PAM
106
106
  - `req.file_format` is the typed format sent by PAM
107
107
  - Should also return quickly (use a thread if needed)
108
108
 
109
109
  When your service is done:
110
110
 
111
- - Upload ordinary CDP result rows through `ResultBatchUploader` or `_upload_result(...)`
111
+ - Upload ordinary CDP result rows through `ResultBatchUploader`
112
112
  - Publish managed reports through `self.reports`; do not build report JSON manually
113
113
  - Call `self._exit()` to signal completion
114
114
 
@@ -131,11 +131,11 @@ Notes:
131
131
  ---
132
132
 
133
133
  **Uploading Results in Batches**
134
- Polars templates pass `LazyFrame` results directly to `_upload_result(...)`, which
135
- streams them to the CSV upload boundary. Do not collect or convert to Pandas first.
136
-
137
- For eager Polars or Pandas DataFrames, use the batch uploader to handle chunking
138
- and flushing automatically.
134
+ Polars `LazyFrame`/`DataFrame` and Pandas `DataFrame` results all pass through
135
+ `ResultBatchUploader`. It streams lazy results in bounded chunks, buffers incomplete
136
+ batches across calls, writes complete batches as CSV, and uploads them through PAM.
137
+ The framework does not prescribe page-local/global computation or intermediate
138
+ storage; submit rows when your business result is ready.
139
139
 
140
140
  Recommended usage:
141
141
 
@@ -145,6 +145,7 @@ from pam.result_batch_uploader import ResultBatchUploader
145
145
  batch_uploader = ResultBatchUploader(self, batch_size=50000)
146
146
  batch_uploader.upload(df, name="main")
147
147
  batch_uploader.flush()
148
+ batch_uploader.wait_for_uploader()
148
149
  status = batch_uploader.get_status()
149
150
  ```
150
151
 
@@ -153,9 +154,12 @@ Notes:
153
154
  - `request.runtime_parameters["batch_size"]` overrides the constructor default when valid.
154
155
  - `name` separates result streams and enforces one stable column schema per stream.
155
156
  - `options` may be passed to `upload(...)` and are forwarded to `_upload_result`.
156
- - Complete batches upload immediately; `flush()` sends the remaining rows.
157
- - `get_status()` returns buffered rows, uploaded rows, and uploaded batch counts per stream.
158
- - `ResultBatchUploader` intentionally rejects Polars `LazyFrame`; upload it directly.
157
+ - Uploads run in a bounded background queue so processing can continue while PAM receives CSV batches.
158
+ - `flush()` closes input and queues the final remainder; it does not wait for network completion.
159
+ - Always call `wait_for_uploader()` after `flush()` and before `_exit()`.
160
+ - Failed uploads retry with exponential backoff, then log/record the failed batch and continue.
161
+ - `get_status()` includes buffered, uploaded, failed, and retried counts per stream.
162
+ - Polars `LazyFrame` is supported without collecting the complete result at once.
159
163
 
160
164
  ---
161
165
 
@@ -284,6 +288,5 @@ After `pam init` and one service:
284
288
  - Implement your logic in `functions.py`.
285
289
  - Wire it into `on_start` and `on_data_input` in your service class.
286
290
  - Use the temp utilities to write intermediate files.
287
- - Keep Polars transformations lazy and upload the resulting `LazyFrame` directly.
288
- - Use `ResultBatchUploader` only for eager Polars/Pandas frames that need row batching.
291
+ - Keep Polars transformations lazy and submit final results through `ResultBatchUploader`.
289
292
  - Read `REPORTS.md` before implementing a managed report.
@@ -1,3 +1,3 @@
1
1
  """PAM Data Plugin framework."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.5"
@@ -55,7 +55,7 @@ SERVICE_TEMPLATES = {
55
55
  }
56
56
 
57
57
  ENGINE_DEPENDENCIES = {
58
- "polars": ["polars>=1.0.0"],
58
+ "polars": ["polars>=1.43.0"],
59
59
  "pandas": ["pandas>=2.2.3", "pyarrow>=19.0.1"],
60
60
  }
61
61
 
@@ -255,9 +255,12 @@ def init_project(package_manager: str | None = None):
255
255
  **PACKAGE_MANAGER_TEMPLATES[package_manager],
256
256
  }
257
257
  if preinitialized_uv:
258
+ preserved_uv_files = {".python-version", "pyproject.toml"}
259
+ if (destination / ".gitignore").exists():
260
+ preserved_uv_files.add(".gitignore")
258
261
  templates = {
259
262
  src: dest for src, dest in templates.items()
260
- if dest not in {".gitignore", ".python-version", "pyproject.toml"}
263
+ if dest not in preserved_uv_files
261
264
  }
262
265
 
263
266
  with tempfile.TemporaryDirectory(prefix=".pam-init-", dir=destination) as temp_dir:
@@ -0,0 +1,412 @@
1
+ """Asynchronous, bounded result batching for PAM CSV uploads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import queue
7
+ import threading
8
+ import time
9
+ from typing import Any, Dict, Optional, Tuple
10
+
11
+ from pam.utils import log
12
+
13
+
14
+ DEFAULT_BATCH_SIZE = 50000
15
+ DEFAULT_QUEUE_SIZE = 4
16
+ DEFAULT_MAX_RETRIES = 3
17
+ DEFAULT_RETRY_DELAY_SECONDS = 2.0
18
+ DEFAULT_RETRY_MAX_DELAY_SECONDS = 30.0
19
+ _FLUSH = object()
20
+
21
+
22
+ @dataclass
23
+ class _ResultInput:
24
+ dataframe: Any
25
+ name: str
26
+ options: Optional[dict]
27
+
28
+
29
+ @dataclass
30
+ class _ResultStream:
31
+ engine: str
32
+ columns: Tuple[object, ...]
33
+ options: Optional[dict]
34
+ buffer: Any
35
+ uploaded_rows: int = 0
36
+ uploaded_batches: int = 0
37
+ failed_rows: int = 0
38
+ failed_batches: int = 0
39
+ retried_uploads: int = 0
40
+
41
+
42
+ class ResultBatchUploader:
43
+ """Queue result frames and upload bounded CSV batches in one worker.
44
+
45
+ Plugin computation and intermediate storage are outside this class. Calls to
46
+ ``upload`` submit final result frames without waiting for network I/O. A
47
+ bounded queue applies backpressure when producers outrun the uploader.
48
+
49
+ ``flush`` closes input and places a FIFO completion marker. The worker first
50
+ consumes every earlier result, uploads all complete batches, then uploads all
51
+ remaining rows. ``wait_for_uploader`` is the completion barrier that must
52
+ return before a service exits.
53
+ """
54
+
55
+ def __init__(self, service, batch_size: int = DEFAULT_BATCH_SIZE):
56
+ self._service = service
57
+ self.batch_size = self._resolve_positive_int("batch_size", batch_size)
58
+ self.queue_size = self._resolve_positive_int(
59
+ "upload_queue_size", DEFAULT_QUEUE_SIZE
60
+ )
61
+ self.max_retries = self._resolve_non_negative_int(
62
+ "upload_max_retries", DEFAULT_MAX_RETRIES
63
+ )
64
+ self.retry_delay_seconds = self._resolve_non_negative_float(
65
+ "upload_retry_delay_seconds", DEFAULT_RETRY_DELAY_SECONDS
66
+ )
67
+ self.retry_max_delay_seconds = self._resolve_non_negative_float(
68
+ "upload_retry_max_delay_seconds", DEFAULT_RETRY_MAX_DELAY_SECONDS
69
+ )
70
+
71
+ self._streams: Dict[str, _ResultStream] = {}
72
+ self._queue: queue.Queue = queue.Queue(maxsize=self.queue_size)
73
+ self._submission_lock = threading.Lock()
74
+ self._state_lock = threading.RLock()
75
+ self._completion = threading.Event()
76
+ self._accepting = True
77
+ self._flush_requested = False
78
+ self._fatal_error: Optional[BaseException] = None
79
+ self._state = "open"
80
+
81
+ self._worker = threading.Thread(
82
+ target=self._run_worker,
83
+ name=f"{self.__class__.__name__}-worker",
84
+ daemon=True,
85
+ )
86
+ self._worker.start()
87
+
88
+ def upload(
89
+ self,
90
+ dataframe: Any,
91
+ name: str = "default",
92
+ options: Optional[dict] = None,
93
+ ) -> None:
94
+ """Submit a final result without waiting for CSV/network upload."""
95
+ stream_name = self._normalize_name(name)
96
+ normalized_options = self._normalize_options(options)
97
+ submitted = self._clone_submitted_dataframe(dataframe)
98
+
99
+ with self._submission_lock:
100
+ with self._state_lock:
101
+ if not self._accepting:
102
+ raise RuntimeError("ResultBatchUploader no longer accepts results")
103
+ if self._fatal_error is not None:
104
+ raise RuntimeError("ResultBatchUploader worker failed") from self._fatal_error
105
+ self._queue.put(_ResultInput(submitted, stream_name, normalized_options))
106
+
107
+ def flush(self) -> None:
108
+ """Close input and enqueue one terminal flush marker without blocking."""
109
+ with self._submission_lock:
110
+ with self._state_lock:
111
+ if self._flush_requested:
112
+ return
113
+ self._accepting = False
114
+ self._flush_requested = True
115
+ self._state = "flush_requested"
116
+ self._queue.put(_FLUSH)
117
+
118
+ def wait_for_uploader(self) -> None:
119
+ """Wait for all queued/retried uploads and the final remainder."""
120
+ with self._state_lock:
121
+ if not self._flush_requested:
122
+ raise RuntimeError("flush() must be called before wait_for_uploader()")
123
+ self._completion.wait()
124
+ with self._state_lock:
125
+ if self._fatal_error is not None:
126
+ raise RuntimeError("ResultBatchUploader worker failed") from self._fatal_error
127
+
128
+ def get_status(self) -> Dict[str, dict]:
129
+ with self._state_lock:
130
+ return {
131
+ name: {
132
+ "buffered_rows": len(stream.buffer),
133
+ "uploaded_rows": stream.uploaded_rows,
134
+ "uploaded_batches": stream.uploaded_batches,
135
+ "failed_rows": stream.failed_rows,
136
+ "failed_batches": stream.failed_batches,
137
+ "retried_uploads": stream.retried_uploads,
138
+ }
139
+ for name, stream in self._streams.items()
140
+ }
141
+
142
+ @property
143
+ def state(self) -> str:
144
+ with self._state_lock:
145
+ return self._state
146
+
147
+ def _run_worker(self) -> None:
148
+ try:
149
+ while True:
150
+ item = self._queue.get()
151
+ try:
152
+ if item is _FLUSH:
153
+ if self._fatal_error is None:
154
+ self._flush_all_streams()
155
+ with self._state_lock:
156
+ self._state = (
157
+ "failed" if self._fatal_error is not None else "completed"
158
+ )
159
+ return
160
+ if self._fatal_error is None:
161
+ self._consume_input(item)
162
+ except BaseException as exc: # keep worker alive until the flush barrier
163
+ self._record_fatal_error(exc)
164
+ finally:
165
+ self._queue.task_done()
166
+ finally:
167
+ with self._state_lock:
168
+ self._accepting = False
169
+ if self._state not in {"completed", "failed"}:
170
+ self._state = "failed"
171
+ self._completion.set()
172
+
173
+ def _consume_input(self, item: _ResultInput) -> None:
174
+ dataframe = item.dataframe
175
+ if self._is_polars_lazyframe(dataframe):
176
+ for batch in dataframe.collect_batches(
177
+ chunk_size=self.batch_size,
178
+ maintain_order=True,
179
+ engine="streaming",
180
+ ):
181
+ self._consume_eager(batch, item.name, item.options)
182
+ return
183
+ self._consume_eager(dataframe, item.name, item.options)
184
+
185
+ def _consume_eager(
186
+ self,
187
+ dataframe: Any,
188
+ name: str,
189
+ options: Optional[dict],
190
+ ) -> None:
191
+ engine, normalized = self._normalize_eager_dataframe(dataframe)
192
+ batches = []
193
+ with self._state_lock:
194
+ stream = self._get_or_create_stream(name, engine, normalized, options)
195
+ if len(normalized) == 0:
196
+ return
197
+
198
+ remaining = normalized
199
+ if len(stream.buffer) > 0:
200
+ rows_needed = self.batch_size - len(stream.buffer)
201
+ stream.buffer = self._concat(
202
+ engine,
203
+ stream.buffer,
204
+ self._slice(engine, remaining, 0, rows_needed),
205
+ )
206
+ remaining = self._slice(
207
+ engine,
208
+ remaining,
209
+ rows_needed,
210
+ len(remaining) - rows_needed,
211
+ )
212
+ if len(stream.buffer) == self.batch_size:
213
+ batches.append(self._clone(engine, stream.buffer))
214
+ stream.buffer = self._empty_frame(engine, stream.buffer)
215
+
216
+ while len(remaining) >= self.batch_size:
217
+ batches.append(self._slice(engine, remaining, 0, self.batch_size))
218
+ remaining = self._slice(
219
+ engine,
220
+ remaining,
221
+ self.batch_size,
222
+ len(remaining) - self.batch_size,
223
+ )
224
+
225
+ if len(remaining) > 0:
226
+ stream.buffer = self._clone(engine, remaining)
227
+
228
+ for batch in batches:
229
+ self._upload_batch(name, stream, batch)
230
+
231
+ def _flush_all_streams(self) -> None:
232
+ pending = []
233
+ with self._state_lock:
234
+ for name, stream in self._streams.items():
235
+ if len(stream.buffer) == 0:
236
+ continue
237
+ pending.append((name, stream, self._clone(stream.engine, stream.buffer)))
238
+ stream.buffer = self._empty_frame(stream.engine, stream.buffer)
239
+ for name, stream, batch in pending:
240
+ self._upload_batch(name, stream, batch)
241
+
242
+ def _upload_batch(self, name: str, stream: _ResultStream, dataframe: Any) -> None:
243
+ attempts = self.max_retries + 1
244
+ for attempt in range(1, attempts + 1):
245
+ try:
246
+ self._service._upload_result(
247
+ self._clone(stream.engine, dataframe),
248
+ stream.options,
249
+ )
250
+ with self._state_lock:
251
+ stream.uploaded_rows += len(dataframe)
252
+ stream.uploaded_batches += 1
253
+ return
254
+ except Exception as exc: # network/upload failures are retryable
255
+ if attempt < attempts:
256
+ with self._state_lock:
257
+ stream.retried_uploads += 1
258
+ delay = min(
259
+ self.retry_delay_seconds * (2 ** (attempt - 1)),
260
+ self.retry_max_delay_seconds,
261
+ )
262
+ log(
263
+ f"Result upload retry stream={name} rows={len(dataframe)} "
264
+ f"attempt={attempt + 1}/{attempts} delay={delay}s error={exc}",
265
+ level="WARNING",
266
+ )
267
+ if delay > 0:
268
+ time.sleep(delay)
269
+ continue
270
+
271
+ with self._state_lock:
272
+ stream.failed_rows += len(dataframe)
273
+ stream.failed_batches += 1
274
+ log(
275
+ f"Result upload skipped after retries stream={name} "
276
+ f"rows={len(dataframe)} attempts={attempts} error={exc}",
277
+ level="ERROR",
278
+ )
279
+
280
+ def _record_fatal_error(self, exc: BaseException) -> None:
281
+ with self._state_lock:
282
+ if self._fatal_error is None:
283
+ self._fatal_error = exc
284
+ self._accepting = False
285
+ log(f"Result uploader worker failed: {exc}", level="ERROR")
286
+
287
+ def _get_or_create_stream(
288
+ self,
289
+ name: str,
290
+ engine: str,
291
+ dataframe: Any,
292
+ options: Optional[dict],
293
+ ) -> _ResultStream:
294
+ columns = tuple(dataframe.columns)
295
+ stream = self._streams.get(name)
296
+ if stream is None:
297
+ stream = _ResultStream(
298
+ engine=engine,
299
+ columns=columns,
300
+ options=options,
301
+ buffer=self._empty_frame(engine, dataframe),
302
+ )
303
+ self._streams[name] = stream
304
+ return stream
305
+ if stream.engine != engine:
306
+ raise ValueError(f"Result stream '{name}' dataframe engine changed")
307
+ if stream.columns != columns:
308
+ raise ValueError(
309
+ f"Result stream '{name}' columns changed from "
310
+ f"{list(stream.columns)!r} to {list(columns)!r}"
311
+ )
312
+ if stream.options != options:
313
+ raise ValueError(f"Result stream '{name}' upload options changed")
314
+ return stream
315
+
316
+ def _runtime_parameters(self) -> dict:
317
+ return getattr(self._service.request, "runtime_parameters", {})
318
+
319
+ def _resolve_positive_int(self, key: str, fallback: int) -> int:
320
+ try:
321
+ value = int(self._runtime_parameters().get(key, fallback))
322
+ except (TypeError, ValueError):
323
+ return fallback
324
+ return value if value > 0 else fallback
325
+
326
+ def _resolve_non_negative_int(self, key: str, fallback: int) -> int:
327
+ try:
328
+ value = int(self._runtime_parameters().get(key, fallback))
329
+ except (TypeError, ValueError):
330
+ return fallback
331
+ return value if value >= 0 else fallback
332
+
333
+ def _resolve_non_negative_float(self, key: str, fallback: float) -> float:
334
+ try:
335
+ value = float(self._runtime_parameters().get(key, fallback))
336
+ except (TypeError, ValueError):
337
+ return fallback
338
+ return value if value >= 0 else fallback
339
+
340
+ @classmethod
341
+ def _clone_submitted_dataframe(cls, dataframe: Any):
342
+ if cls._is_polars_lazyframe(dataframe):
343
+ return dataframe.clone()
344
+ _, normalized = cls._normalize_eager_dataframe(dataframe)
345
+ return normalized
346
+
347
+ @staticmethod
348
+ def _normalize_eager_dataframe(dataframe: Any) -> tuple[str, Any]:
349
+ module_name = type(dataframe).__module__.split(".", 1)[0]
350
+ class_name = type(dataframe).__name__
351
+ if module_name == "polars" and class_name == "DataFrame":
352
+ return "polars", dataframe.clone()
353
+ if module_name == "pandas" and class_name == "DataFrame":
354
+ return "pandas", dataframe.reset_index(drop=True).copy()
355
+ raise TypeError(
356
+ "dataframe must be a Polars LazyFrame, Polars DataFrame, "
357
+ "or Pandas DataFrame"
358
+ )
359
+
360
+ @staticmethod
361
+ def _is_polars_lazyframe(dataframe: Any) -> bool:
362
+ return (
363
+ type(dataframe).__module__.split(".", 1)[0] == "polars"
364
+ and type(dataframe).__name__ == "LazyFrame"
365
+ )
366
+
367
+ @staticmethod
368
+ def _clone(engine: str, dataframe: Any):
369
+ if engine == "polars":
370
+ return dataframe.clone()
371
+ return dataframe.reset_index(drop=True).copy()
372
+
373
+ @staticmethod
374
+ def _empty_frame(engine: str, dataframe: Any):
375
+ if engine == "polars":
376
+ return dataframe.head(0)
377
+ return dataframe.iloc[0:0].copy()
378
+
379
+ @staticmethod
380
+ def _slice(engine: str, dataframe: Any, offset: int, length: int):
381
+ length = max(0, length)
382
+ if engine == "polars":
383
+ return dataframe.slice(offset, length)
384
+ return dataframe.iloc[offset:offset + length].reset_index(drop=True)
385
+
386
+ @staticmethod
387
+ def _concat(engine: str, left: Any, right: Any):
388
+ if len(left) == 0:
389
+ return ResultBatchUploader._clone(engine, right)
390
+ if len(right) == 0:
391
+ return ResultBatchUploader._clone(engine, left)
392
+ if engine == "polars":
393
+ import polars as pl
394
+
395
+ return pl.concat([left, right], how="vertical")
396
+ import pandas as pd
397
+
398
+ return pd.concat([left, right], ignore_index=True)
399
+
400
+ @staticmethod
401
+ def _normalize_name(name: str) -> str:
402
+ if not isinstance(name, str) or not name.strip():
403
+ raise ValueError("name must be a non-empty string")
404
+ return name.strip()
405
+
406
+ @staticmethod
407
+ def _normalize_options(options: Optional[dict]) -> Optional[dict]:
408
+ if options is None:
409
+ return None
410
+ if not isinstance(options, dict):
411
+ raise ValueError("options must be a dictionary or None")
412
+ return dict(options)
@@ -305,7 +305,10 @@ class TaskManager(ITaskManager):
305
305
 
306
306
  def service_upload_result(self, service: Service, file_path, options=None):
307
307
  """
308
- Uploads a result file asynchronously and logs the response.
308
+ Uploads one result file synchronously.
309
+
310
+ ResultBatchUploader owns background concurrency and invokes this method
311
+ from its worker, so failures must propagate for retry handling.
309
312
  """
310
313
  endpoint = service.request.response_api
311
314
  payload = None
@@ -318,25 +321,21 @@ class TaskManager(ITaskManager):
318
321
  is_priority = options.get("is_priority")
319
322
  payload["is_priority"] = str(is_priority).lower() if isinstance(is_priority, bool) else is_priority
320
323
 
321
- def handle_upload_response(response):
322
- """Logs the response after the upload completes."""
323
- if response is None:
324
- log(f"Response from upload to {endpoint}: None")
325
- return
326
- try:
327
- response_data = response.json()
328
- except ValueError:
329
- response_data = response.text
330
- log(f"Response from upload to {endpoint}: {response_data}")
331
-
332
- def upload_wrapper():
333
- """Wrapper for the upload to handle response logging."""
334
- response = self.api.http_upload(endpoint, file_path, payload)
335
- handle_upload_response(response)
336
-
337
324
  log(f"Uploading Result to: {endpoint}")
338
- http_thread = threading.Thread(target=upload_wrapper)
339
- http_thread.start()
325
+ response = self.api.http_upload(endpoint, file_path, payload)
326
+ if response is None:
327
+ raise RuntimeError(f"No response from result upload to {endpoint}")
328
+ if not response.ok:
329
+ raise RuntimeError(
330
+ f"Result upload failed status={response.status_code} "
331
+ f"endpoint={endpoint}"
332
+ )
333
+ try:
334
+ response_data = response.json()
335
+ except ValueError:
336
+ response_data = response.text
337
+ log(f"Response from upload to {endpoint}: {response_data}")
338
+ return response
340
339
 
341
340
  def service_upload_report(self, service: Service, file_path):
342
341
  """