pam-python 0.2.17__tar.gz → 0.2.19__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.17/pam_python.egg-info → pam_python-0.2.19}/PKG-INFO +2 -1
  2. {pam_python-0.2.17 → pam_python-0.2.19}/pam/__init__.py +1 -1
  3. {pam_python-0.2.17 → pam_python-0.2.19}/pam/api.py +9 -4
  4. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/store.py +5 -2
  5. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/table.py +54 -2
  6. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/REPORTS-th.md +3 -0
  7. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/REPORTS.md +7 -1
  8. {pam_python-0.2.17 → pam_python-0.2.19/pam_python.egg-info}/PKG-INFO +2 -1
  9. {pam_python-0.2.17 → pam_python-0.2.19}/pam_python.egg-info/requires.txt +1 -0
  10. {pam_python-0.2.17 → pam_python-0.2.19}/pyproject.toml +1 -0
  11. pam_python-0.2.19/tests/test_api.py +102 -0
  12. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_table_report.py +39 -0
  13. pam_python-0.2.17/tests/test_api.py +0 -44
  14. {pam_python-0.2.17 → pam_python-0.2.19}/LICENSE.txt +0 -0
  15. {pam_python-0.2.17 → pam_python-0.2.19}/README.md +0 -0
  16. {pam_python-0.2.17 → pam_python-0.2.19}/pam/cli.py +0 -0
  17. {pam_python-0.2.17 → pam_python-0.2.19}/pam/datetime_utils.py +0 -0
  18. {pam_python-0.2.17 → pam_python-0.2.19}/pam/interface_task_manager.py +0 -0
  19. {pam_python-0.2.17 → pam_python-0.2.19}/pam/logger.py +0 -0
  20. {pam_python-0.2.17 → pam_python-0.2.19}/pam/models/__init__.py +0 -0
  21. {pam_python-0.2.17 → pam_python-0.2.19}/pam/models/data_input_v2.py +0 -0
  22. {pam_python-0.2.17 → pam_python-0.2.19}/pam/models/request_command.py +0 -0
  23. {pam_python-0.2.17 → pam_python-0.2.19}/pam/models/sqlite_download.py +0 -0
  24. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/__init__.py +0 -0
  25. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/area.py +0 -0
  26. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/axis.py +0 -0
  27. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/bar.py +0 -0
  28. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/box_plot.py +0 -0
  29. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/bubble.py +0 -0
  30. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/candlestick.py +0 -0
  31. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/collection.py +0 -0
  32. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/definition.py +0 -0
  33. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/donut.py +0 -0
  34. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/errors.py +0 -0
  35. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/fields.py +0 -0
  36. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/heatmap.py +0 -0
  37. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/line.py +0 -0
  38. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/manager.py +0 -0
  39. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/metric.py +0 -0
  40. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/pie.py +0 -0
  41. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/polar_area.py +0 -0
  42. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/radar.py +0 -0
  43. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/radial_bar.py +0 -0
  44. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/range_area.py +0 -0
  45. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/range_bar.py +0 -0
  46. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/range_transform.py +0 -0
  47. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/record.py +0 -0
  48. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/report.py +0 -0
  49. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/scatter.py +0 -0
  50. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/schema.py +0 -0
  51. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/temporal.py +0 -0
  52. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/transport.py +0 -0
  53. {pam_python-0.2.17 → pam_python-0.2.19}/pam/reports/treemap.py +0 -0
  54. {pam_python-0.2.17 → pam_python-0.2.19}/pam/request_file_format.py +0 -0
  55. {pam_python-0.2.17 → pam_python-0.2.19}/pam/result_batch_uploader.py +0 -0
  56. {pam_python-0.2.17 → pam_python-0.2.19}/pam/server.py +0 -0
  57. {pam_python-0.2.17 → pam_python-0.2.19}/pam/service.py +0 -0
  58. {pam_python-0.2.17 → pam_python-0.2.19}/pam/sqlite.py +0 -0
  59. {pam_python-0.2.17 → pam_python-0.2.19}/pam/task_manager.py +0 -0
  60. {pam_python-0.2.17 → pam_python-0.2.19}/pam/temp_file_utils.py +0 -0
  61. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/buildcmd/pamb +0 -0
  62. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/buildcmd/pamb-base.sh +0 -0
  63. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/AGENT.md +0 -0
  64. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/dockerignore.tmpl +0 -0
  65. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/gitignore.tmpl +0 -0
  66. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/main.tmpl +0 -0
  67. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/pylintrc.tmpl +0 -0
  68. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/run_unit_test.bat +0 -0
  69. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/run_unit_test.ps1 +0 -0
  70. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/init/run_unit_test.sh +0 -0
  71. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/project/pip/Dockerfile +0 -0
  72. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/project/pip/requirements.txt +0 -0
  73. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/project/uv/Dockerfile +0 -0
  74. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/project/uv/pyproject.toml +0 -0
  75. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/project/uv/python-version +0 -0
  76. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/service/common/service.yaml +0 -0
  77. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/service/pandas/functions.tmpl +0 -0
  78. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/service/pandas/service.test.tmpl +0 -0
  79. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/service/pandas/service_class.tmpl +0 -0
  80. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/service/polars/functions.tmpl +0 -0
  81. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/service/polars/service.test.tmpl +0 -0
  82. {pam_python-0.2.17 → pam_python-0.2.19}/pam/templates/service/polars/service_class.tmpl +0 -0
  83. {pam_python-0.2.17 → pam_python-0.2.19}/pam/tester_task.py +0 -0
  84. {pam_python-0.2.17 → pam_python-0.2.19}/pam/utils.py +0 -0
  85. {pam_python-0.2.17 → pam_python-0.2.19}/pam_python.egg-info/SOURCES.txt +0 -0
  86. {pam_python-0.2.17 → pam_python-0.2.19}/pam_python.egg-info/dependency_links.txt +0 -0
  87. {pam_python-0.2.17 → pam_python-0.2.19}/pam_python.egg-info/entry_points.txt +0 -0
  88. {pam_python-0.2.17 → pam_python-0.2.19}/pam_python.egg-info/top_level.txt +0 -0
  89. {pam_python-0.2.17 → pam_python-0.2.19}/setup.cfg +0 -0
  90. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_cli_templates.py +0 -0
  91. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_data_error_v2.py +0 -0
  92. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_data_input_v2.py +0 -0
  93. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_data_timeout_v2.py +0 -0
  94. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_datetime_utils.py +0 -0
  95. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_range_transform.py +0 -0
  96. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_report_store.py +0 -0
  97. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_request_data_v2.py +0 -0
  98. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_result_batch_uploader.py +0 -0
  99. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_service_dataframe_output.py +0 -0
  100. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_sqlite_download_v2.py +0 -0
  101. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_task_manager_upload_token.py +0 -0
  102. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_temp_file_utils.py +0 -0
  103. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_temporal_mode.py +0 -0
  104. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_area_report.py +0 -0
  105. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_bar_report.py +0 -0
  106. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_box_plot_report.py +0 -0
  107. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_bubble_report.py +0 -0
  108. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_candlestick_report.py +0 -0
  109. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_donut_report.py +0 -0
  110. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_heatmap_report.py +0 -0
  111. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_line_report.py +0 -0
  112. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_metric_report.py +0 -0
  113. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_pie_report.py +0 -0
  114. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_polar_area_report.py +0 -0
  115. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_radar_report.py +0 -0
  116. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_radial_bar_report.py +0 -0
  117. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_range_area_report.py +0 -0
  118. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_range_bar_report.py +0 -0
  119. {pam_python-0.2.17 → pam_python-0.2.19}/tests/test_typed_scatter_report.py +0 -0
  120. {pam_python-0.2.17 → pam_python-0.2.19}/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.17
3
+ Version: 0.2.19
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.17"
3
+ __version__ = "0.2.19"
@@ -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()
@@ -6,7 +6,7 @@ import sqlite3
6
6
  import threading
7
7
  from datetime import datetime, timezone
8
8
  from pathlib import Path
9
- from typing import Any, Iterable, List, Optional, Set
9
+ from typing import Any, Iterable, List, Mapping, Optional, Set
10
10
 
11
11
  from pam.datetime_utils import format_pam_datetime
12
12
  from pam.reports.errors import (
@@ -157,9 +157,12 @@ class ReportStore:
157
157
  granularity: str,
158
158
  columns: Iterable[Field],
159
159
  temporal_mode: Optional[TemporalMode] = None,
160
+ row_order: Optional[Mapping[str, Iterable[Any]]] = None,
160
161
  ) -> TableReport:
161
162
  """Declare a typed Table report."""
162
- return TableReport.declare(self, key, granularity, columns, temporal_mode)
163
+ return TableReport.declare(
164
+ self, key, granularity, columns, temporal_mode, row_order
165
+ )
163
166
 
164
167
  def metric(
165
168
  self,
@@ -1,7 +1,8 @@
1
1
  """Typed Table report public API."""
2
2
 
3
- from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
3
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
4
4
 
5
+ from pam.reports.axis import normalize_value_order
5
6
  from pam.reports.definition import ReportDefinition
6
7
  from pam.reports.errors import ReportStoreValidationError
7
8
  from pam.reports.fields import Dimension, Field, Measure
@@ -22,6 +23,7 @@ class TableReport:
22
23
  report,
23
24
  granularity: str,
24
25
  columns: Sequence[Field],
26
+ row_order: Mapping[str, Tuple[Any, ...]],
25
27
  ):
26
28
  self._report = report
27
29
  self.key = report.key
@@ -33,6 +35,7 @@ class TableReport:
33
35
  self._measures = tuple(
34
36
  column for column in self.columns if isinstance(column, Measure)
35
37
  )
38
+ self.row_order = dict(row_order)
36
39
 
37
40
  @classmethod
38
41
  def declare(
@@ -42,6 +45,7 @@ class TableReport:
42
45
  granularity: str,
43
46
  columns: Iterable[Field],
44
47
  temporal_mode: Optional[TemporalMode] = None,
48
+ row_order: Optional[Mapping[str, Iterable[Any]]] = None,
45
49
  ):
46
50
  if isinstance(columns, (str, bytes)):
47
51
  raise ReportStoreValidationError("columns must contain field declarations")
@@ -71,12 +75,21 @@ class TableReport:
71
75
  normalized_temporal_mode = normalize_temporal_mode(
72
76
  temporal_mode, granularity, measures, "Table"
73
77
  )
78
+ normalized_row_order = cls._normalize_row_order(
79
+ normalized_columns, row_order
80
+ )
74
81
 
75
82
  definition = ReportDefinition.create(
76
83
  report_type="table",
77
84
  contract_version=TABLE_CONTRACT_VERSION,
78
85
  fields=normalized_columns,
79
86
  binding={"columns": [column.name for column in normalized_columns]},
87
+ options={
88
+ "row_order": {
89
+ name: list(values)
90
+ for name, values in normalized_row_order.items()
91
+ }
92
+ } if normalized_row_order else None,
80
93
  temporal_mode=normalized_temporal_mode,
81
94
  )
82
95
  report = store._declare_typed_report(
@@ -86,7 +99,40 @@ class TableReport:
86
99
  granularity=granularity,
87
100
  definition_json=definition.to_json(),
88
101
  )
89
- return cls(report, granularity, normalized_columns)
102
+ return cls(report, granularity, normalized_columns, normalized_row_order)
103
+
104
+ @staticmethod
105
+ def _normalize_row_order(
106
+ columns: Sequence[Field],
107
+ row_order: Optional[Mapping[str, Iterable[Any]]],
108
+ ) -> Dict[str, Tuple[Any, ...]]:
109
+ if row_order is None:
110
+ return {}
111
+ if not isinstance(row_order, Mapping) or not row_order:
112
+ raise ReportStoreValidationError(
113
+ "Table row_order must be a non-empty mapping of dimension names to values"
114
+ )
115
+ dimensions = {
116
+ column.name: column
117
+ for column in columns
118
+ if isinstance(column, Dimension)
119
+ }
120
+ unknown = set(row_order) - set(dimensions)
121
+ if unknown:
122
+ raise ReportStoreValidationError(
123
+ f"Table row_order contains unknown dimension fields: {sorted(unknown)}"
124
+ )
125
+ normalized = {}
126
+ for name, values in row_order.items():
127
+ order = normalize_value_order(
128
+ "Table", f"row_order['{name}']", dimensions[name], values
129
+ )
130
+ if order is None:
131
+ raise ReportStoreValidationError(
132
+ f"Table row_order['{name}'] must be non-empty"
133
+ )
134
+ normalized[name] = order
135
+ return normalized
90
136
 
91
137
  def append_rows(
92
138
  self,
@@ -241,4 +287,10 @@ class TableReport:
241
287
  raise ReportStoreValidationError(
242
288
  f"Report '{self.key}' row {index} must contain a dimension value"
243
289
  )
290
+ for name, values in self.row_order.items():
291
+ if dimensions.get(name) not in values:
292
+ raise ReportStoreValidationError(
293
+ f"Table row {index} value '{dimensions.get(name)}' is not declared "
294
+ f"in row_order['{name}']"
295
+ )
244
296
  return normalized
@@ -399,6 +399,7 @@ table = store.table(
399
399
  Dimension("branch", label="สาขา", value_type="string"),
400
400
  Measure("members", label="สมาชิก", value_type="integer", unit="คน"),
401
401
  ],
402
+ row_order={"branch": ["กรุงเทพฯ", "เชียงใหม่", "รวม"]},
402
403
  )
403
404
 
404
405
  table.append_rows(
@@ -413,6 +414,8 @@ table.append_rows(
413
414
  - Required column ต้องมีครบและค่าต้องตรงชนิด
414
415
  - Row ใน Report เดียวกันห้ามมี generated identity ซ้ำ
415
416
  - CMS ใช้ลำดับ column จาก definition ไม่ใช่ row แรก
417
+ - `row_order` กำหนดลำดับค่าของ Dimension ได้ โดยค่าที่เขียนต้องอยู่ในรายการทั้งหมด
418
+ เช่น วาง `รวม` เป็นตัวสุดท้ายเพื่อให้อยู่ล่างสุดเสมอ
416
419
  - Partition เป็น metadata สำหรับ range/order ไม่ถูกเพิ่มเป็น Day/Month column พิเศษ
417
420
  - `remove_rows` รับเฉพาะ Dimension identity; Measure ไม่ใช่ identity
418
421
 
@@ -558,11 +558,17 @@ table = store.table(
558
558
  unit="THB",
559
559
  ),
560
560
  ],
561
+ row_order={
562
+ "branch": ["Bangkok", "Chiang Mai", "Total"],
563
+ },
561
564
  )
562
565
  ```
563
566
 
564
567
  Column order is the declaration order. At least one dimension is required. By
565
- default, all dimension values form the stable row identity.
568
+ default, all dimension values form the stable row identity. Optional `row_order`
569
+ maps Dimension names to their complete display order. Every written value for an
570
+ ordered Dimension must appear in that list; place values such as `Total` last when
571
+ they must remain at the bottom of the table.
566
572
 
567
573
  ## Writing rows
568
574
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pam-python
3
- Version: 0.2.17
3
+ Version: 0.2.19
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()
@@ -105,6 +105,45 @@ class TypedTableReportTest(unittest.TestCase):
105
105
  with self.assertRaises(ReportStoreValidationError):
106
106
  table.delete_partition("2026-01")
107
107
 
108
+ def test_explicit_row_order(self):
109
+ table = self.store.table(
110
+ key="ordered-table",
111
+ granularity="NONE",
112
+ columns=[
113
+ Dimension("segment", label="Segment", value_type="string"),
114
+ Measure("customers", label="Customers", value_type="integer"),
115
+ ],
116
+ row_order={"segment": ["New", "VIP", "Total"]},
117
+ )
118
+ definition = json.loads(
119
+ self.store._connection.execute(
120
+ "SELECT definition_json FROM report_definitions WHERE report_key = ?",
121
+ ("ordered-table",),
122
+ ).fetchone()[0]
123
+ )
124
+ self.assertEqual(
125
+ {"segment": ["New", "VIP", "Total"]},
126
+ definition["options"]["row_order"],
127
+ )
128
+ table.append_rows([
129
+ {"segment": "VIP", "customers": 2},
130
+ {"segment": "Total", "customers": 3},
131
+ ])
132
+ with self.assertRaisesRegex(ReportStoreValidationError, "row_order"):
133
+ table.append_rows([{"segment": "Unknown", "customers": 1}])
134
+
135
+ def test_row_order_rejects_unknown_dimension(self):
136
+ with self.assertRaisesRegex(ReportStoreValidationError, "unknown dimension"):
137
+ self.store.table(
138
+ key="invalid-order",
139
+ granularity="NONE",
140
+ columns=[
141
+ Dimension("segment", label="Segment", value_type="string"),
142
+ Measure("customers", label="Customers", value_type="integer"),
143
+ ],
144
+ row_order={"customers": [1, 2]},
145
+ )
146
+
108
147
  def test_public_fixture_generation(self):
109
148
  fixture = Path(self.temp_dir.name) / "table.sqlite"
110
149
  pointer = generate_table_contract_fixture(fixture)
@@ -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