pam-python 0.2.16__tar.gz → 0.2.18__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.16/pam_python.egg-info → pam_python-0.2.18}/PKG-INFO +2 -1
  2. {pam_python-0.2.16 → pam_python-0.2.18}/pam/__init__.py +1 -1
  3. {pam_python-0.2.16 → pam_python-0.2.18}/pam/api.py +9 -4
  4. {pam_python-0.2.16 → pam_python-0.2.18}/pam/models/data_input_v2.py +19 -6
  5. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/AGENT.md +5 -0
  6. {pam_python-0.2.16 → pam_python-0.2.18/pam_python.egg-info}/PKG-INFO +2 -1
  7. {pam_python-0.2.16 → pam_python-0.2.18}/pam_python.egg-info/requires.txt +1 -0
  8. {pam_python-0.2.16 → pam_python-0.2.18}/pyproject.toml +1 -0
  9. pam_python-0.2.18/tests/test_api.py +102 -0
  10. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_data_input_v2.py +24 -0
  11. pam_python-0.2.16/tests/test_api.py +0 -44
  12. {pam_python-0.2.16 → pam_python-0.2.18}/LICENSE.txt +0 -0
  13. {pam_python-0.2.16 → pam_python-0.2.18}/README.md +0 -0
  14. {pam_python-0.2.16 → pam_python-0.2.18}/pam/cli.py +0 -0
  15. {pam_python-0.2.16 → pam_python-0.2.18}/pam/datetime_utils.py +0 -0
  16. {pam_python-0.2.16 → pam_python-0.2.18}/pam/interface_task_manager.py +0 -0
  17. {pam_python-0.2.16 → pam_python-0.2.18}/pam/logger.py +0 -0
  18. {pam_python-0.2.16 → pam_python-0.2.18}/pam/models/__init__.py +0 -0
  19. {pam_python-0.2.16 → pam_python-0.2.18}/pam/models/request_command.py +0 -0
  20. {pam_python-0.2.16 → pam_python-0.2.18}/pam/models/sqlite_download.py +0 -0
  21. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/__init__.py +0 -0
  22. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/area.py +0 -0
  23. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/axis.py +0 -0
  24. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/bar.py +0 -0
  25. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/box_plot.py +0 -0
  26. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/bubble.py +0 -0
  27. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/candlestick.py +0 -0
  28. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/collection.py +0 -0
  29. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/definition.py +0 -0
  30. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/donut.py +0 -0
  31. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/errors.py +0 -0
  32. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/fields.py +0 -0
  33. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/heatmap.py +0 -0
  34. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/line.py +0 -0
  35. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/manager.py +0 -0
  36. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/metric.py +0 -0
  37. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/pie.py +0 -0
  38. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/polar_area.py +0 -0
  39. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/radar.py +0 -0
  40. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/radial_bar.py +0 -0
  41. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/range_area.py +0 -0
  42. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/range_bar.py +0 -0
  43. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/range_transform.py +0 -0
  44. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/record.py +0 -0
  45. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/report.py +0 -0
  46. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/scatter.py +0 -0
  47. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/schema.py +0 -0
  48. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/store.py +0 -0
  49. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/table.py +0 -0
  50. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/temporal.py +0 -0
  51. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/transport.py +0 -0
  52. {pam_python-0.2.16 → pam_python-0.2.18}/pam/reports/treemap.py +0 -0
  53. {pam_python-0.2.16 → pam_python-0.2.18}/pam/request_file_format.py +0 -0
  54. {pam_python-0.2.16 → pam_python-0.2.18}/pam/result_batch_uploader.py +0 -0
  55. {pam_python-0.2.16 → pam_python-0.2.18}/pam/server.py +0 -0
  56. {pam_python-0.2.16 → pam_python-0.2.18}/pam/service.py +0 -0
  57. {pam_python-0.2.16 → pam_python-0.2.18}/pam/sqlite.py +0 -0
  58. {pam_python-0.2.16 → pam_python-0.2.18}/pam/task_manager.py +0 -0
  59. {pam_python-0.2.16 → pam_python-0.2.18}/pam/temp_file_utils.py +0 -0
  60. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/buildcmd/pamb +0 -0
  61. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/buildcmd/pamb-base.sh +0 -0
  62. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/REPORTS-th.md +0 -0
  63. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/REPORTS.md +0 -0
  64. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/dockerignore.tmpl +0 -0
  65. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/gitignore.tmpl +0 -0
  66. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/main.tmpl +0 -0
  67. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/pylintrc.tmpl +0 -0
  68. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/run_unit_test.bat +0 -0
  69. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/run_unit_test.ps1 +0 -0
  70. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/init/run_unit_test.sh +0 -0
  71. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/project/pip/Dockerfile +0 -0
  72. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/project/pip/requirements.txt +0 -0
  73. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/project/uv/Dockerfile +0 -0
  74. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/project/uv/pyproject.toml +0 -0
  75. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/project/uv/python-version +0 -0
  76. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/service/common/service.yaml +0 -0
  77. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/service/pandas/functions.tmpl +0 -0
  78. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/service/pandas/service.test.tmpl +0 -0
  79. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/service/pandas/service_class.tmpl +0 -0
  80. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/service/polars/functions.tmpl +0 -0
  81. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/service/polars/service.test.tmpl +0 -0
  82. {pam_python-0.2.16 → pam_python-0.2.18}/pam/templates/service/polars/service_class.tmpl +0 -0
  83. {pam_python-0.2.16 → pam_python-0.2.18}/pam/tester_task.py +0 -0
  84. {pam_python-0.2.16 → pam_python-0.2.18}/pam/utils.py +0 -0
  85. {pam_python-0.2.16 → pam_python-0.2.18}/pam_python.egg-info/SOURCES.txt +0 -0
  86. {pam_python-0.2.16 → pam_python-0.2.18}/pam_python.egg-info/dependency_links.txt +0 -0
  87. {pam_python-0.2.16 → pam_python-0.2.18}/pam_python.egg-info/entry_points.txt +0 -0
  88. {pam_python-0.2.16 → pam_python-0.2.18}/pam_python.egg-info/top_level.txt +0 -0
  89. {pam_python-0.2.16 → pam_python-0.2.18}/setup.cfg +0 -0
  90. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_cli_templates.py +0 -0
  91. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_data_error_v2.py +0 -0
  92. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_data_timeout_v2.py +0 -0
  93. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_datetime_utils.py +0 -0
  94. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_range_transform.py +0 -0
  95. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_report_store.py +0 -0
  96. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_request_data_v2.py +0 -0
  97. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_result_batch_uploader.py +0 -0
  98. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_service_dataframe_output.py +0 -0
  99. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_sqlite_download_v2.py +0 -0
  100. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_task_manager_upload_token.py +0 -0
  101. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_temp_file_utils.py +0 -0
  102. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_temporal_mode.py +0 -0
  103. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_area_report.py +0 -0
  104. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_bar_report.py +0 -0
  105. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_box_plot_report.py +0 -0
  106. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_bubble_report.py +0 -0
  107. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_candlestick_report.py +0 -0
  108. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_donut_report.py +0 -0
  109. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_heatmap_report.py +0 -0
  110. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_line_report.py +0 -0
  111. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_metric_report.py +0 -0
  112. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_pie_report.py +0 -0
  113. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_polar_area_report.py +0 -0
  114. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_radar_report.py +0 -0
  115. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_radial_bar_report.py +0 -0
  116. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_range_area_report.py +0 -0
  117. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_range_bar_report.py +0 -0
  118. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_scatter_report.py +0 -0
  119. {pam_python-0.2.16 → pam_python-0.2.18}/tests/test_typed_table_report.py +0 -0
  120. {pam_python-0.2.16 → pam_python-0.2.18}/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.16
3
+ Version: 0.2.18
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
@@ -15,6 +15,7 @@ Description-Content-Type: text/markdown
15
15
  License-File: LICENSE.txt
16
16
  Requires-Dist: Flask>=3.0.2
17
17
  Requires-Dist: requests>=2.32.3
18
+ Requires-Dist: requests-toolbelt>=1.0.0
18
19
  Requires-Dist: PyYAML>=6.0.2
19
20
  Dynamic: license-file
20
21
 
@@ -1,3 +1,3 @@
1
1
  """PAM Data Plugin framework."""
2
2
 
3
- __version__ = "0.2.16"
3
+ __version__ = "0.2.18"
@@ -2,6 +2,7 @@ from pathlib import Path
2
2
  import os
3
3
  import sqlite3
4
4
  import requests
5
+ from requests_toolbelt.multipart.encoder import MultipartEncoder
5
6
  from pam.models.sqlite_download import SQLiteDownloadResult, SQLiteDownloadStatus
6
7
  from pam.utils import log
7
8
 
@@ -36,7 +37,7 @@ class API:
36
37
 
37
38
  def http_upload(self, url: str, file_path: str, payload: dict = None) -> requests.Response | None:
38
39
  """
39
- Uploads a file to the specified URL with optional form data (payload).
40
+ Streams a file as multipart form data without buffering the whole file.
40
41
 
41
42
  :param url: The URL to upload the file to.
42
43
  :param file_path: The path to the file to be uploaded.
@@ -49,11 +50,15 @@ class API:
49
50
 
50
51
  try:
51
52
  with open(file_path, 'rb') as file:
52
- files = {'file': file}
53
+ fields = dict(payload or {})
54
+ fields['file'] = (Path(file_path).name, file)
55
+ # requests' files= buffers the entire multipart body. This encoder
56
+ # reads the open file incrementally and provides Content-Length.
57
+ body = MultipartEncoder(fields=fields)
53
58
  response = self.session.post(
54
59
  url,
55
- files=files,
56
- data=payload or {}, # optional form data
60
+ data=body,
61
+ headers={'Content-Type': body.content_type},
57
62
  timeout=300
58
63
  )
59
64
  response.raise_for_status()
@@ -71,21 +71,34 @@ class DataInputV2Dataset:
71
71
  for part in self.parts:
72
72
  yield from part.iter_csv_rows()
73
73
 
74
- def scan_polars(self) -> Any:
75
- """Return one lazy frame across all ordered CSV or Parquet parts."""
74
+ def scan_polars(self, empty_schema: dict[str, Any] | None = None) -> Any:
75
+ """Return one lazy frame across all ordered CSV or Parquet parts.
76
+
77
+ PAM represents a valid dataset with no rows as ``row_count=0`` and no
78
+ physical parts. In that case the plugin may provide its domain schema
79
+ so downstream computation can handle the empty input normally.
80
+ """
76
81
  pl = _import_polars()
77
82
  if not self.parts:
78
- return pl.LazyFrame(schema={"id": pl.String})
83
+ schema = {"id": pl.String} if empty_schema is None else empty_schema
84
+ return pl.LazyFrame(schema=schema)
79
85
  scans = [part.scan_polars() for part in self.parts]
80
86
  if len(scans) == 1:
81
87
  return scans[0]
82
88
  return pl.concat(scans, how="diagonal_relaxed")
83
89
 
84
- def read_pandas(self) -> Any:
85
- """Materialize and concatenate all verified parts using their metadata."""
90
+ def read_pandas(self, empty_dtypes: dict[str, Any] | None = None) -> Any:
91
+ """Materialize and concatenate all verified parts using their metadata.
92
+
93
+ ``empty_dtypes`` is used only for a declared empty dataset, allowing
94
+ the plugin to retain its domain columns without fabricating data rows.
95
+ """
86
96
  pd = _import_pandas()
87
97
  if not self.parts:
88
- return pd.DataFrame({"id": pd.Series(dtype="string")})
98
+ dtypes = {"id": "string"} if empty_dtypes is None else empty_dtypes
99
+ return pd.DataFrame(
100
+ {name: pd.Series(dtype=dtype) for name, dtype in dtypes.items()}
101
+ )
89
102
  frames = [part.read_pandas() for part in self.parts]
90
103
  if len(frames) == 1:
91
104
  return frames[0]
@@ -234,6 +234,11 @@ result = calculate(population, profile_frame, purchase_frame).collect()
234
234
 
235
235
  `scan_polars()` returns one lazy frame across ordered CSV or Parquet parts and
236
236
  uses verified manifest format metadata. CSV-only code may use `iter_rows()`.
237
+ `row_count=0` with `parts=[]` is a valid empty dataset. The plugin decides the
238
+ business result for the page population; it must not treat the absence of event
239
+ rows as a transport error. If computation requires domain columns on an empty
240
+ frame, call `scan_polars(empty_schema=...)` or
241
+ `read_pandas(empty_dtypes=...)` with the plugin-owned schema.
237
242
  The external customer key is `id`. The population file is authoritative: in
238
243
  `contacts` mode, contacts with no event rows remain visible there. Use the
239
244
  opaque `data.next` cursor for the next `_request_data_v2(...)` call and stop when
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pam-python
3
- Version: 0.2.16
3
+ Version: 0.2.18
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
@@ -15,6 +15,7 @@ Description-Content-Type: text/markdown
15
15
  License-File: LICENSE.txt
16
16
  Requires-Dist: Flask>=3.0.2
17
17
  Requires-Dist: requests>=2.32.3
18
+ Requires-Dist: requests-toolbelt>=1.0.0
18
19
  Requires-Dist: PyYAML>=6.0.2
19
20
  Dynamic: license-file
20
21
 
@@ -1,3 +1,4 @@
1
1
  Flask>=3.0.2
2
2
  requests>=2.32.3
3
+ requests-toolbelt>=1.0.0
3
4
  PyYAML>=6.0.2
@@ -22,6 +22,7 @@ classifiers = [
22
22
  dependencies = [
23
23
  "Flask>=3.0.2",
24
24
  "requests>=2.32.3",
25
+ "requests-toolbelt>=1.0.0",
25
26
  "PyYAML>=6.0.2",
26
27
  ]
27
28
 
@@ -0,0 +1,102 @@
1
+ import unittest
2
+ import tempfile
3
+ from pathlib import Path
4
+ from email.parser import BytesParser
5
+ from email.policy import default
6
+ from unittest import mock
7
+
8
+ import requests
9
+
10
+ from pam.api import API
11
+
12
+
13
+ class APITest(unittest.TestCase):
14
+ def test_http_upload_streams_file_and_preserves_multipart_fields(self):
15
+ api = API()
16
+ content = b'SQLite format 3\x00' * 65536
17
+ payload = {'token': 'test-token', 'file_name': 'state', 'is_shared': 'false'}
18
+ response = requests.Response()
19
+ response.status_code = 200
20
+ with tempfile.TemporaryDirectory() as directory:
21
+ path = Path(directory) / 'state.sqlite'
22
+ path.write_bytes(content)
23
+ real_open = open
24
+ opened = []
25
+
26
+ def guarded_open(*args, **kwargs):
27
+ file = real_open(*args, **kwargs)
28
+ opened.append(file)
29
+ original_read = file.read
30
+
31
+ def bounded_read(size=-1):
32
+ self.assertGreaterEqual(size, 0)
33
+ self.assertLessEqual(size, 16384)
34
+ return original_read(size)
35
+
36
+ file.read = bounded_read
37
+ return file
38
+
39
+ def post(url, **kwargs):
40
+ self.assertNotIn('files', kwargs)
41
+ prepared = requests.Request('POST', url, **{
42
+ key: kwargs[key] for key in ('data', 'headers')
43
+ }).prepare()
44
+ self.assertIs(prepared.body, kwargs['data'])
45
+ chunks = []
46
+ while chunk := prepared.body.read(16384):
47
+ chunks.append(chunk)
48
+ body = b''.join(chunks)
49
+ self.assertEqual(int(prepared.headers['Content-Length']), len(body))
50
+ message = BytesParser(policy=default).parsebytes(
51
+ ('Content-Type: ' + prepared.headers['Content-Type'] + '\r\n\r\n').encode() + body
52
+ )
53
+ parts = {part.get_param('name', header='content-disposition'): part
54
+ for part in message.iter_parts()}
55
+ for key, value in payload.items():
56
+ self.assertEqual(parts[key].get_payload(decode=True).decode(), value)
57
+ self.assertEqual(parts['file'].get_filename(), path.name)
58
+ self.assertEqual(parts['file'].get_payload(decode=True), content)
59
+ self.assertEqual(kwargs['timeout'], 300)
60
+ return response
61
+
62
+ api.session.post = mock.Mock(side_effect=post)
63
+ with mock.patch('pam.api.open', side_effect=guarded_open):
64
+ self.assertIs(api.http_upload('http://pam/sqlite_upload', str(path), payload), response)
65
+ self.assertTrue(opened[0].closed)
66
+ self.assertNotIn('file', payload)
67
+
68
+ def test_http_post_logs_error_response_body(self):
69
+ api = API()
70
+ response = requests.Response()
71
+ response.status_code = 404
72
+ response.url = "http://pam/api/dataadapter/adapter-1/log"
73
+ response._content = (
74
+ b'{"code":"NOT_FOUND","error_code":"ADAPTER_LOG_NOT_FOUND",'
75
+ b'"message":"Adapter execution log was not found"}'
76
+ )
77
+ api.session.post = mock.Mock(return_value=response)
78
+
79
+ with mock.patch("pam.api.log") as log_mock:
80
+ result = api.http_post(response.url, {"token": "secret"})
81
+
82
+ self.assertIsNone(result)
83
+ logged_message = log_mock.call_args.args[0]
84
+ self.assertIn("404 Client Error", logged_message)
85
+ self.assertIn('"error_code":"ADAPTER_LOG_NOT_FOUND"', logged_message)
86
+ self.assertNotIn("secret", logged_message)
87
+
88
+ def test_http_post_without_response_does_not_add_response_detail(self):
89
+ api = API()
90
+ api.session.post = mock.Mock(side_effect=requests.ConnectionError("offline"))
91
+
92
+ with mock.patch("pam.api.log") as log_mock:
93
+ result = api.http_post("http://pam/log", {})
94
+
95
+ self.assertIsNone(result)
96
+ logged_message = log_mock.call_args.args[0]
97
+ self.assertIn("offline", logged_message)
98
+ self.assertNotIn("Response:", logged_message)
99
+
100
+
101
+ if __name__ == "__main__":
102
+ unittest.main()
@@ -78,6 +78,30 @@ class DataInputV2Test(unittest.TestCase):
78
78
  self.assertEqual(0, data_input.datasets[0].row_count)
79
79
  self.assertEqual((), data_input.datasets[0].parts)
80
80
 
81
+ def test_empty_dataset_readers_use_plugin_owned_schema(self):
82
+ import polars as pl
83
+
84
+ manifest = self._valid_manifest()
85
+ manifest["datasets"][0]["row_count"] = 0
86
+ manifest["datasets"][0]["parts"] = []
87
+ self.dataset_path.unlink()
88
+ manifest_path = self._write_manifest(manifest)
89
+ dataset = parse_data_input_v2(
90
+ [str(manifest_path), str(self.population_path)]
91
+ ).datasets[0]
92
+
93
+ polars_frame = dataset.scan_polars(
94
+ empty_schema={"id": pl.String, "netamount": pl.Float64}
95
+ ).collect()
96
+ self.assertEqual(0, polars_frame.height)
97
+ self.assertEqual(["id", "netamount"], polars_frame.columns)
98
+
99
+ pandas_frame = dataset.read_pandas(
100
+ empty_dtypes={"id": "string", "netamount": "float64"}
101
+ )
102
+ self.assertTrue(pandas_frame.empty)
103
+ self.assertEqual(["id", "netamount"], pandas_frame.columns.tolist())
104
+
81
105
  def test_streams_population_and_ordered_dataset_parts(self):
82
106
  second_part_path = self._write_file(
83
107
  "dataset_001_demographic_part_0002.csv",
@@ -1,44 +0,0 @@
1
- import unittest
2
- from unittest import mock
3
-
4
- import requests
5
-
6
- from pam.api import API
7
-
8
-
9
- class APITest(unittest.TestCase):
10
- def test_http_post_logs_error_response_body(self):
11
- api = API()
12
- response = requests.Response()
13
- response.status_code = 404
14
- response.url = "http://pam/api/dataadapter/adapter-1/log"
15
- response._content = (
16
- b'{"code":"NOT_FOUND","error_code":"ADAPTER_LOG_NOT_FOUND",'
17
- b'"message":"Adapter execution log was not found"}'
18
- )
19
- api.session.post = mock.Mock(return_value=response)
20
-
21
- with mock.patch("pam.api.log") as log_mock:
22
- result = api.http_post(response.url, {"token": "secret"})
23
-
24
- self.assertIsNone(result)
25
- logged_message = log_mock.call_args.args[0]
26
- self.assertIn("404 Client Error", logged_message)
27
- self.assertIn('"error_code":"ADAPTER_LOG_NOT_FOUND"', logged_message)
28
- self.assertNotIn("secret", logged_message)
29
-
30
- def test_http_post_without_response_does_not_add_response_detail(self):
31
- api = API()
32
- api.session.post = mock.Mock(side_effect=requests.ConnectionError("offline"))
33
-
34
- with mock.patch("pam.api.log") as log_mock:
35
- result = api.http_post("http://pam/log", {})
36
-
37
- self.assertIsNone(result)
38
- logged_message = log_mock.call_args.args[0]
39
- self.assertIn("offline", logged_message)
40
- self.assertNotIn("Response:", logged_message)
41
-
42
-
43
- if __name__ == "__main__":
44
- unittest.main()
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