pam-python 0.2.19__tar.gz → 0.2.20__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 (120) hide show
  1. {pam_python-0.2.19/pam_python.egg-info → pam_python-0.2.20}/PKG-INFO +1 -1
  2. {pam_python-0.2.19 → pam_python-0.2.20}/pam/__init__.py +1 -1
  3. {pam_python-0.2.19 → pam_python-0.2.20}/pam/models/data_input_v2.py +11 -0
  4. {pam_python-0.2.19 → pam_python-0.2.20}/pam/task_manager.py +26 -28
  5. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/AGENT.md +10 -0
  6. {pam_python-0.2.19 → pam_python-0.2.20/pam_python.egg-info}/PKG-INFO +1 -1
  7. {pam_python-0.2.19 → pam_python-0.2.20}/pam_python.egg-info/SOURCES.txt +1 -0
  8. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_data_input_v2.py +67 -0
  9. pam_python-0.2.20/tests/test_report_delivery.py +34 -0
  10. {pam_python-0.2.19 → pam_python-0.2.20}/LICENSE.txt +0 -0
  11. {pam_python-0.2.19 → pam_python-0.2.20}/README.md +0 -0
  12. {pam_python-0.2.19 → pam_python-0.2.20}/pam/api.py +0 -0
  13. {pam_python-0.2.19 → pam_python-0.2.20}/pam/cli.py +0 -0
  14. {pam_python-0.2.19 → pam_python-0.2.20}/pam/datetime_utils.py +0 -0
  15. {pam_python-0.2.19 → pam_python-0.2.20}/pam/interface_task_manager.py +0 -0
  16. {pam_python-0.2.19 → pam_python-0.2.20}/pam/logger.py +0 -0
  17. {pam_python-0.2.19 → pam_python-0.2.20}/pam/models/__init__.py +0 -0
  18. {pam_python-0.2.19 → pam_python-0.2.20}/pam/models/request_command.py +0 -0
  19. {pam_python-0.2.19 → pam_python-0.2.20}/pam/models/sqlite_download.py +0 -0
  20. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/__init__.py +0 -0
  21. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/area.py +0 -0
  22. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/axis.py +0 -0
  23. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/bar.py +0 -0
  24. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/box_plot.py +0 -0
  25. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/bubble.py +0 -0
  26. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/candlestick.py +0 -0
  27. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/collection.py +0 -0
  28. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/definition.py +0 -0
  29. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/donut.py +0 -0
  30. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/errors.py +0 -0
  31. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/fields.py +0 -0
  32. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/heatmap.py +0 -0
  33. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/line.py +0 -0
  34. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/manager.py +0 -0
  35. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/metric.py +0 -0
  36. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/pie.py +0 -0
  37. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/polar_area.py +0 -0
  38. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/radar.py +0 -0
  39. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/radial_bar.py +0 -0
  40. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/range_area.py +0 -0
  41. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/range_bar.py +0 -0
  42. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/range_transform.py +0 -0
  43. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/record.py +0 -0
  44. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/report.py +0 -0
  45. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/scatter.py +0 -0
  46. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/schema.py +0 -0
  47. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/store.py +0 -0
  48. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/table.py +0 -0
  49. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/temporal.py +0 -0
  50. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/transport.py +0 -0
  51. {pam_python-0.2.19 → pam_python-0.2.20}/pam/reports/treemap.py +0 -0
  52. {pam_python-0.2.19 → pam_python-0.2.20}/pam/request_file_format.py +0 -0
  53. {pam_python-0.2.19 → pam_python-0.2.20}/pam/result_batch_uploader.py +0 -0
  54. {pam_python-0.2.19 → pam_python-0.2.20}/pam/server.py +0 -0
  55. {pam_python-0.2.19 → pam_python-0.2.20}/pam/service.py +0 -0
  56. {pam_python-0.2.19 → pam_python-0.2.20}/pam/sqlite.py +0 -0
  57. {pam_python-0.2.19 → pam_python-0.2.20}/pam/temp_file_utils.py +0 -0
  58. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/buildcmd/pamb +0 -0
  59. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/buildcmd/pamb-base.sh +0 -0
  60. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/REPORTS-th.md +0 -0
  61. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/REPORTS.md +0 -0
  62. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/dockerignore.tmpl +0 -0
  63. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/gitignore.tmpl +0 -0
  64. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/main.tmpl +0 -0
  65. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/pylintrc.tmpl +0 -0
  66. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/run_unit_test.bat +0 -0
  67. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/run_unit_test.ps1 +0 -0
  68. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/init/run_unit_test.sh +0 -0
  69. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/project/pip/Dockerfile +0 -0
  70. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/project/pip/requirements.txt +0 -0
  71. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/project/uv/Dockerfile +0 -0
  72. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/project/uv/pyproject.toml +0 -0
  73. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/project/uv/python-version +0 -0
  74. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/service/common/service.yaml +0 -0
  75. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/service/pandas/functions.tmpl +0 -0
  76. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/service/pandas/service.test.tmpl +0 -0
  77. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/service/pandas/service_class.tmpl +0 -0
  78. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/service/polars/functions.tmpl +0 -0
  79. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/service/polars/service.test.tmpl +0 -0
  80. {pam_python-0.2.19 → pam_python-0.2.20}/pam/templates/service/polars/service_class.tmpl +0 -0
  81. {pam_python-0.2.19 → pam_python-0.2.20}/pam/tester_task.py +0 -0
  82. {pam_python-0.2.19 → pam_python-0.2.20}/pam/utils.py +0 -0
  83. {pam_python-0.2.19 → pam_python-0.2.20}/pam_python.egg-info/dependency_links.txt +0 -0
  84. {pam_python-0.2.19 → pam_python-0.2.20}/pam_python.egg-info/entry_points.txt +0 -0
  85. {pam_python-0.2.19 → pam_python-0.2.20}/pam_python.egg-info/requires.txt +0 -0
  86. {pam_python-0.2.19 → pam_python-0.2.20}/pam_python.egg-info/top_level.txt +0 -0
  87. {pam_python-0.2.19 → pam_python-0.2.20}/pyproject.toml +0 -0
  88. {pam_python-0.2.19 → pam_python-0.2.20}/setup.cfg +0 -0
  89. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_api.py +0 -0
  90. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_cli_templates.py +0 -0
  91. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_data_error_v2.py +0 -0
  92. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_data_timeout_v2.py +0 -0
  93. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_datetime_utils.py +0 -0
  94. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_range_transform.py +0 -0
  95. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_report_store.py +0 -0
  96. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_request_data_v2.py +0 -0
  97. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_result_batch_uploader.py +0 -0
  98. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_service_dataframe_output.py +0 -0
  99. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_sqlite_download_v2.py +0 -0
  100. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_task_manager_upload_token.py +0 -0
  101. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_temp_file_utils.py +0 -0
  102. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_temporal_mode.py +0 -0
  103. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_area_report.py +0 -0
  104. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_bar_report.py +0 -0
  105. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_box_plot_report.py +0 -0
  106. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_bubble_report.py +0 -0
  107. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_candlestick_report.py +0 -0
  108. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_donut_report.py +0 -0
  109. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_heatmap_report.py +0 -0
  110. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_line_report.py +0 -0
  111. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_metric_report.py +0 -0
  112. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_pie_report.py +0 -0
  113. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_polar_area_report.py +0 -0
  114. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_radar_report.py +0 -0
  115. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_radial_bar_report.py +0 -0
  116. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_range_area_report.py +0 -0
  117. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_range_bar_report.py +0 -0
  118. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_scatter_report.py +0 -0
  119. {pam_python-0.2.19 → pam_python-0.2.20}/tests/test_typed_table_report.py +0 -0
  120. {pam_python-0.2.19 → pam_python-0.2.20}/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.19
3
+ Version: 0.2.20
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
@@ -1,3 +1,3 @@
1
1
  """PAM Data Plugin framework."""
2
2
 
3
- __version__ = "0.2.19"
3
+ __version__ = "0.2.20"
@@ -127,6 +127,7 @@ class DataInputV2:
127
127
  datasets: tuple[DataInputV2Dataset, ...]
128
128
  run_complete: bool
129
129
  next: str
130
+ missing_contacts: DataInputV2File | None = None
130
131
 
131
132
  def iter_population_rows(self) -> Iterator[dict[str, str]]:
132
133
  """Stream the population file for this customer-aligned logical page."""
@@ -210,6 +211,15 @@ def parse_data_input_v2(input_files: list[str]) -> DataInputV2:
210
211
  if contact_count > max_contacts_per_batch:
211
212
  raise ValueError("V2 population contact_count exceeds configured batch maximum")
212
213
 
214
+ missing_contacts = None
215
+ if "missing_contacts" in manifest:
216
+ missing_contacts = _parse_and_verify_file(
217
+ manifest["missing_contacts"],
218
+ files_by_name,
219
+ declared_names,
220
+ population_format,
221
+ )
222
+
213
223
  raw_datasets = manifest.get("datasets")
214
224
  if not isinstance(raw_datasets, list) or len(raw_datasets) > V2_MAX_DATASETS:
215
225
  raise ValueError("V2 manifest datasets must be a list of at most five items")
@@ -283,6 +293,7 @@ def parse_data_input_v2(input_files: list[str]) -> DataInputV2:
283
293
  max_contacts_per_batch=max_contacts_per_batch,
284
294
  time_window=time_window,
285
295
  population=population_file,
296
+ missing_contacts=missing_contacts,
286
297
  datasets=tuple(datasets),
287
298
  run_complete=_required_bool(manifest, "run_complete"),
288
299
  next=_required_optional_string(manifest, "next"),
@@ -9,6 +9,7 @@ import math
9
9
  import os
10
10
  import time
11
11
  import threading
12
+ import uuid
12
13
  from typing import Dict, TYPE_CHECKING
13
14
  from pam.utils import log
14
15
  from pam.api import API
@@ -453,34 +454,31 @@ class TaskManager(ITaskManager):
453
454
  return response
454
455
 
455
456
  def service_upload_report(self, service: Service, file_path):
456
- """
457
- Uploads a report file asynchronously and logs the response.
458
- """
459
- endpoint = service.request.response_api
460
-
461
- def handle_report_response(response):
462
- """Logs the response after the report upload completes."""
463
- if response is None:
464
- log(f"Response from report upload to {endpoint}: None")
465
- return
466
- try:
467
- response_data = response.json()
468
- except ValueError:
469
- response_data = response.text
470
- log(f"Response from report upload to {endpoint}: {response_data}")
471
-
472
- def upload_wrapper():
473
- """Wrapper for the report upload to handle response logging."""
474
- response = self.api.http_upload(
475
- endpoint,
476
- file_path,
477
- {"token": service.request.token},
478
- )
479
- handle_report_response(response)
480
-
481
- log(f"Uploading Report to: {endpoint}")
482
- http_thread = threading.Thread(target=upload_wrapper)
483
- http_thread.start()
457
+ """Wait for PAM to enqueue a legacy report; propagate delivery failures."""
458
+ trace_id = uuid.uuid4().hex
459
+ started = time.monotonic()
460
+ log(f"report_delivery_started trace_id={trace_id} service={service.request.service_name} "
461
+ f"report_file={Path(file_path).name}")
462
+ response = self.api.http_upload(
463
+ service.request.response_api,
464
+ file_path,
465
+ {"token": service.request.token, "report_delivery_id": trace_id},
466
+ )
467
+ status = response.status_code if response is not None else None
468
+ if response is None or not 200 <= status < 300:
469
+ log(f"report_delivery_failed trace_id={trace_id} http_status={status}")
470
+ raise RuntimeError(f"Report delivery failed trace_id={trace_id} http_status={status}")
471
+ try:
472
+ body = response.json()
473
+ except ValueError:
474
+ body = {}
475
+ stage = body.get("stage", "accepted_unconfirmed") if isinstance(body, dict) else "accepted_unconfirmed"
476
+ log(f"report_delivery_response trace_id={trace_id} http_status={status} "
477
+ f"stage={stage} elapsed_s={time.monotonic() - started:.3f}")
478
+ # Older PAM deployments only acknowledge receipt. Do not report that as
479
+ # successful publication: the new endpoint explicitly confirms enqueue.
480
+ if not isinstance(body, dict) or body.get("report_delivery_id") != trace_id or stage != "tracker_queued":
481
+ raise RuntimeError(f"Report delivery was not confirmed; update PAM first trace_id={trace_id}")
484
482
 
485
483
  def service_publish_report_pointer_v2(
486
484
  self,
@@ -680,3 +680,13 @@ Prefer streaming/chunked/incremental processing whenever possible.
680
680
  - Treat Polars lazy queries as the default compute path.
681
681
  - Use Pandas only when the selected service template or an integration requires it.
682
682
  - Prefer streaming or incremental durable steps when a query cannot stay in memory.
683
+
684
+ ### Missing contacts in V2 input
685
+
686
+ `data.missing_contacts` is an optional verified `DataInputV2File` with one `id`
687
+ column, using the same CSV or Parquet format as population. Read it with
688
+ `scan_polars()` or `read_pandas()`; `iter_csv_rows()` is CSV-only. Older
689
+ manifests expose `None`, while a present zero-row file is valid. These IDs are
690
+ excluded from population and configured datasets. Their absence does not prove
691
+ deletion; plugin developers decide whether and how to handle them. Read the
692
+ file before page cleanup. Do not treat it as a configured dataset position.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pam-python
3
- Version: 0.2.19
3
+ Version: 0.2.20
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
@@ -89,6 +89,7 @@ tests/test_data_input_v2.py
89
89
  tests/test_data_timeout_v2.py
90
90
  tests/test_datetime_utils.py
91
91
  tests/test_range_transform.py
92
+ tests/test_report_delivery.py
92
93
  tests/test_report_store.py
93
94
  tests/test_request_data_v2.py
94
95
  tests/test_result_batch_uploader.py
@@ -47,6 +47,7 @@ class DataInputV2Test(unittest.TestCase):
47
47
  self.assertEqual("contacts", data_input.population_mode)
48
48
  self.assertEqual(5000, data_input.max_contacts_per_batch)
49
49
  self.assertIsNone(data_input.time_window)
50
+ self.assertIsNone(data_input.missing_contacts)
50
51
  self.assertEqual(str(self.population_path.resolve()), data_input.population.path)
51
52
  self.assertEqual(1, data_input.population.row_count)
52
53
  self.assertEqual(1, len(data_input.datasets))
@@ -56,6 +57,72 @@ class DataInputV2Test(unittest.TestCase):
56
57
  data_input.datasets[0].parts[0].path,
57
58
  )
58
59
 
60
+ def test_missing_contacts_readers_and_zip_contract(self):
61
+ import polars as pl
62
+
63
+ for file_format in ("csv", "parquet"):
64
+ for ids in (["contact-2", "contact-3"], []):
65
+ with self.subTest(format=file_format, ids=ids):
66
+ population = self.temp_dir / f"population.{file_format}"
67
+ missing = self.temp_dir / f"missing_contacts.{file_format}"
68
+ population_frame = pl.DataFrame({"id": ["contact-1"]})
69
+ missing_frame = pl.DataFrame({"id": ids}, schema={"id": pl.String})
70
+ if file_format == "csv":
71
+ population_frame.write_csv(population)
72
+ missing_frame.write_csv(missing)
73
+ else:
74
+ population_frame.write_parquet(population)
75
+ missing_frame.write_parquet(missing)
76
+ manifest = self._valid_manifest()
77
+ manifest["population"]["format"] = file_format
78
+ manifest["population"]["file"] = self._metadata(population, 1)
79
+ manifest["missing_contacts"] = self._metadata(missing, len(ids))
80
+ buffer = BytesIO()
81
+ with zipfile.ZipFile(buffer, "w") as archive:
82
+ archive.writestr("manifest.json", json.dumps(manifest))
83
+ for path in (population, missing, self.dataset_path):
84
+ archive.write(path, path.name)
85
+ buffer.seek(0)
86
+ request, error = self._parse_v2_zip(buffer)
87
+ self.assertEqual("", error)
88
+ data = request.data_input_v2
89
+ self.assertIsNotNone(data)
90
+ self.assertIsNotNone(data.missing_contacts)
91
+ self.assertEqual(file_format, data.missing_contacts.format)
92
+ self.assertEqual(len(ids), data.missing_contacts.row_count)
93
+ self.assertEqual(ids, data.missing_contacts.scan_polars().collect()["id"].to_list())
94
+ self.assertEqual(ids, data.missing_contacts.read_pandas()["id"].tolist())
95
+ if file_format == "csv":
96
+ self.assertEqual(ids, [row["id"] for row in data.missing_contacts.iter_csv_rows()])
97
+ self.assertEqual(1, len(data.datasets))
98
+ missing_path = Path(data.missing_contacts.path)
99
+ self.assertTrue(data.cleanup())
100
+ self.assertFalse(missing_path.exists())
101
+
102
+ def test_missing_contacts_uses_existing_file_validation(self):
103
+ for failure in ("checksum", "size", "format", "missing", "metadata"):
104
+ with self.subTest(failure=failure):
105
+ path = self._write_file("missing_contacts.csv", b"id\ncontact-2\n")
106
+ manifest = self._valid_manifest()
107
+ manifest["missing_contacts"] = self._metadata(path, 1)
108
+ files = [str(self.population_path), str(self.dataset_path), str(path)]
109
+ if failure == "checksum":
110
+ manifest["missing_contacts"]["sha256"] = "0" * 64
111
+ elif failure == "size":
112
+ manifest["missing_contacts"]["size_bytes"] += 1
113
+ elif failure == "format":
114
+ other = path.with_suffix(".parquet")
115
+ other.write_bytes(path.read_bytes())
116
+ manifest["missing_contacts"] = self._metadata(other, 1)
117
+ files[-1] = str(other)
118
+ elif failure == "missing":
119
+ files.pop()
120
+ else:
121
+ manifest["missing_contacts"] = None
122
+ manifest_path = self._write_manifest(manifest)
123
+ with self.assertRaises(ValueError):
124
+ parse_data_input_v2([str(manifest_path), *files])
125
+
59
126
  def test_pandas_readers_follow_csv_metadata(self):
60
127
  manifest_path = self._write_manifest(self._valid_manifest())
61
128
  data_input = parse_data_input_v2(
@@ -0,0 +1,34 @@
1
+ import unittest
2
+ from types import SimpleNamespace
3
+ from unittest.mock import Mock, patch
4
+
5
+ from pam.task_manager import TaskManager
6
+
7
+
8
+ class ReportDeliveryTest(unittest.TestCase):
9
+ def manager_and_service(self):
10
+ manager = TaskManager.__new__(TaskManager)
11
+ manager.api = Mock()
12
+ service = SimpleNamespace(request=SimpleNamespace(
13
+ service_name="rfm", response_api="https://pam/response", token="secret"))
14
+ return manager, service
15
+
16
+ def test_waits_for_confirmed_enqueue(self):
17
+ manager, service = self.manager_and_service()
18
+ def upload(endpoint, path, payload):
19
+ return Mock(status_code=200, json=lambda: {
20
+ "report_delivery_id": payload["report_delivery_id"], "stage": "tracker_queued"})
21
+ manager.api.http_upload.side_effect = upload
22
+ with patch("pam.task_manager.threading.Thread") as thread:
23
+ manager.service_upload_report(service, "/tmp/report.csv")
24
+ thread.assert_not_called()
25
+ self.assertEqual("secret", manager.api.http_upload.call_args.args[2]["token"])
26
+
27
+ def test_failure_and_unconfirmed_ack_raise(self):
28
+ for response in (None, Mock(status_code=500), Mock(status_code=200, json=lambda: {}),
29
+ Mock(status_code=200, json=lambda: {"report_delivery_id": "wrong", "stage": "tracker_queued"})):
30
+ with self.subTest(response=response):
31
+ manager, service = self.manager_and_service()
32
+ manager.api.http_upload.return_value = response
33
+ with self.assertRaises(RuntimeError):
34
+ manager.service_upload_report(service, "/tmp/report.csv")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes