pam-python 0.2.14__tar.gz → 0.2.15__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 (118) hide show
  1. {pam_python-0.2.14/pam_python.egg-info → pam_python-0.2.15}/PKG-INFO +1 -1
  2. {pam_python-0.2.14 → pam_python-0.2.15}/pam/__init__.py +1 -1
  3. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/axis.py +28 -2
  4. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/bar.py +38 -2
  5. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/box_plot.py +19 -2
  6. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/heatmap.py +34 -2
  7. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/range_bar.py +37 -1
  8. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/store.py +15 -2
  9. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/REPORTS-th.md +10 -0
  10. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/REPORTS.md +18 -1
  11. {pam_python-0.2.14 → pam_python-0.2.15/pam_python.egg-info}/PKG-INFO +1 -1
  12. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_bar_report.py +32 -0
  13. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_box_plot_report.py +21 -0
  14. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_heatmap_report.py +20 -0
  15. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_range_bar_report.py +21 -0
  16. {pam_python-0.2.14 → pam_python-0.2.15}/LICENSE.txt +0 -0
  17. {pam_python-0.2.14 → pam_python-0.2.15}/README.md +0 -0
  18. {pam_python-0.2.14 → pam_python-0.2.15}/pam/api.py +0 -0
  19. {pam_python-0.2.14 → pam_python-0.2.15}/pam/cli.py +0 -0
  20. {pam_python-0.2.14 → pam_python-0.2.15}/pam/datetime_utils.py +0 -0
  21. {pam_python-0.2.14 → pam_python-0.2.15}/pam/interface_task_manager.py +0 -0
  22. {pam_python-0.2.14 → pam_python-0.2.15}/pam/logger.py +0 -0
  23. {pam_python-0.2.14 → pam_python-0.2.15}/pam/models/__init__.py +0 -0
  24. {pam_python-0.2.14 → pam_python-0.2.15}/pam/models/data_input_v2.py +0 -0
  25. {pam_python-0.2.14 → pam_python-0.2.15}/pam/models/request_command.py +0 -0
  26. {pam_python-0.2.14 → pam_python-0.2.15}/pam/models/sqlite_download.py +0 -0
  27. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/__init__.py +0 -0
  28. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/area.py +0 -0
  29. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/bubble.py +0 -0
  30. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/candlestick.py +0 -0
  31. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/collection.py +0 -0
  32. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/definition.py +0 -0
  33. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/donut.py +0 -0
  34. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/errors.py +0 -0
  35. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/fields.py +0 -0
  36. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/line.py +0 -0
  37. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/manager.py +0 -0
  38. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/metric.py +0 -0
  39. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/pie.py +0 -0
  40. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/polar_area.py +0 -0
  41. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/radar.py +0 -0
  42. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/radial_bar.py +0 -0
  43. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/range_area.py +0 -0
  44. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/range_transform.py +0 -0
  45. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/record.py +0 -0
  46. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/report.py +0 -0
  47. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/scatter.py +0 -0
  48. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/schema.py +0 -0
  49. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/table.py +0 -0
  50. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/temporal.py +0 -0
  51. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/transport.py +0 -0
  52. {pam_python-0.2.14 → pam_python-0.2.15}/pam/reports/treemap.py +0 -0
  53. {pam_python-0.2.14 → pam_python-0.2.15}/pam/request_file_format.py +0 -0
  54. {pam_python-0.2.14 → pam_python-0.2.15}/pam/result_batch_uploader.py +0 -0
  55. {pam_python-0.2.14 → pam_python-0.2.15}/pam/server.py +0 -0
  56. {pam_python-0.2.14 → pam_python-0.2.15}/pam/service.py +0 -0
  57. {pam_python-0.2.14 → pam_python-0.2.15}/pam/sqlite.py +0 -0
  58. {pam_python-0.2.14 → pam_python-0.2.15}/pam/task_manager.py +0 -0
  59. {pam_python-0.2.14 → pam_python-0.2.15}/pam/temp_file_utils.py +0 -0
  60. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/buildcmd/pamb +0 -0
  61. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/buildcmd/pamb-base.sh +0 -0
  62. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/AGENT.md +0 -0
  63. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/dockerignore.tmpl +0 -0
  64. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/gitignore.tmpl +0 -0
  65. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/main.tmpl +0 -0
  66. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/pylintrc.tmpl +0 -0
  67. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/run_unit_test.bat +0 -0
  68. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/run_unit_test.ps1 +0 -0
  69. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/init/run_unit_test.sh +0 -0
  70. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/project/pip/Dockerfile +0 -0
  71. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/project/pip/requirements.txt +0 -0
  72. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/project/uv/Dockerfile +0 -0
  73. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/project/uv/pyproject.toml +0 -0
  74. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/project/uv/python-version +0 -0
  75. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/service/common/service.yaml +0 -0
  76. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/service/pandas/functions.tmpl +0 -0
  77. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/service/pandas/service.test.tmpl +0 -0
  78. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/service/pandas/service_class.tmpl +0 -0
  79. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/service/polars/functions.tmpl +0 -0
  80. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/service/polars/service.test.tmpl +0 -0
  81. {pam_python-0.2.14 → pam_python-0.2.15}/pam/templates/service/polars/service_class.tmpl +0 -0
  82. {pam_python-0.2.14 → pam_python-0.2.15}/pam/tester_task.py +0 -0
  83. {pam_python-0.2.14 → pam_python-0.2.15}/pam/utils.py +0 -0
  84. {pam_python-0.2.14 → pam_python-0.2.15}/pam_python.egg-info/SOURCES.txt +0 -0
  85. {pam_python-0.2.14 → pam_python-0.2.15}/pam_python.egg-info/dependency_links.txt +0 -0
  86. {pam_python-0.2.14 → pam_python-0.2.15}/pam_python.egg-info/entry_points.txt +0 -0
  87. {pam_python-0.2.14 → pam_python-0.2.15}/pam_python.egg-info/requires.txt +0 -0
  88. {pam_python-0.2.14 → pam_python-0.2.15}/pam_python.egg-info/top_level.txt +0 -0
  89. {pam_python-0.2.14 → pam_python-0.2.15}/pyproject.toml +0 -0
  90. {pam_python-0.2.14 → pam_python-0.2.15}/setup.cfg +0 -0
  91. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_api.py +0 -0
  92. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_cli_templates.py +0 -0
  93. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_data_error_v2.py +0 -0
  94. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_data_input_v2.py +0 -0
  95. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_data_timeout_v2.py +0 -0
  96. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_datetime_utils.py +0 -0
  97. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_range_transform.py +0 -0
  98. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_report_store.py +0 -0
  99. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_request_data_v2.py +0 -0
  100. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_result_batch_uploader.py +0 -0
  101. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_service_dataframe_output.py +0 -0
  102. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_sqlite_download_v2.py +0 -0
  103. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_temp_file_utils.py +0 -0
  104. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_temporal_mode.py +0 -0
  105. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_area_report.py +0 -0
  106. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_bubble_report.py +0 -0
  107. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_candlestick_report.py +0 -0
  108. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_donut_report.py +0 -0
  109. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_line_report.py +0 -0
  110. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_metric_report.py +0 -0
  111. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_pie_report.py +0 -0
  112. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_polar_area_report.py +0 -0
  113. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_radar_report.py +0 -0
  114. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_radial_bar_report.py +0 -0
  115. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_range_area_report.py +0 -0
  116. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_scatter_report.py +0 -0
  117. {pam_python-0.2.14 → pam_python-0.2.15}/tests/test_typed_table_report.py +0 -0
  118. {pam_python-0.2.14 → pam_python-0.2.15}/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.14
3
+ Version: 0.2.15
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.14"
3
+ __version__ = "0.2.15"
@@ -1,6 +1,6 @@
1
- """Shared declarations for partition-axis reports."""
1
+ """Shared declarations for reports with ordered axes."""
2
2
 
3
- from typing import Iterable, Optional, Tuple
3
+ from typing import Any, Iterable, Optional, Tuple
4
4
 
5
5
  from pam.reports.errors import ReportStoreValidationError
6
6
  from pam.reports.fields import Dimension, Measure
@@ -9,6 +9,32 @@ from pam.reports.fields import Dimension, Measure
9
9
  NUMERIC_TYPES = {"integer", "number"}
10
10
 
11
11
 
12
+ def normalize_value_order(
13
+ report_name: str,
14
+ option_name: str,
15
+ dimension: Dimension,
16
+ values: Optional[Iterable[Any]],
17
+ ) -> Optional[Tuple[Any, ...]]:
18
+ """Normalize an optional complete order for a categorical dimension."""
19
+ if values is None:
20
+ return None
21
+ if isinstance(values, (str, bytes)):
22
+ raise ReportStoreValidationError(
23
+ f"{report_name} {option_name} must be an iterable of values"
24
+ )
25
+ try:
26
+ normalized = tuple(dimension.normalize_value(value) for value in values)
27
+ except TypeError as exc:
28
+ raise ReportStoreValidationError(
29
+ f"{report_name} {option_name} must be an iterable of values"
30
+ ) from exc
31
+ if not normalized or len(normalized) != len(set(normalized)):
32
+ raise ReportStoreValidationError(
33
+ f"{report_name} {option_name} must be non-empty and unique"
34
+ )
35
+ return normalized
36
+
37
+
12
38
  def normalize_partition_series_declaration(
13
39
  report_name: str,
14
40
  values: Iterable[Measure],
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any, Iterable, Mapping, Optional, Sequence
4
4
 
5
- from pam.reports.axis import NUMERIC_TYPES
5
+ from pam.reports.axis import NUMERIC_TYPES, normalize_value_order
6
6
  from pam.reports.collection import TypedCollectionReport
7
7
  from pam.reports.definition import ReportDefinition
8
8
  from pam.reports.errors import ReportStoreValidationError
@@ -29,21 +29,36 @@ class BarChartReport(TypedCollectionReport):
29
29
  series: Optional[Dimension],
30
30
  orientation: str,
31
31
  stacked: bool,
32
+ category_order: Optional[Sequence[Any]],
33
+ series_order: Optional[Sequence[Any]],
32
34
  ):
33
35
  fields = (category,) + ((series,) if series is not None else ()) + tuple(values)
34
36
  identity_fields = (category,) + ((series,) if series is not None else ())
37
+ def validate_order(item: Mapping[str, Any], _index: int) -> None:
38
+ if category_order is not None and item[category.name] not in category_order:
39
+ raise ReportStoreValidationError(
40
+ f"Bar Chart category value '{item[category.name]}' is not declared in category_order"
41
+ )
42
+ if series_order is not None and item[series.name] not in series_order:
43
+ raise ReportStoreValidationError(
44
+ f"Bar Chart series value '{item[series.name]}' is not declared in series_order"
45
+ )
46
+
35
47
  super().__init__(
36
48
  report=report,
37
49
  granularity=granularity,
38
50
  fields=fields,
39
51
  identity_fields=identity_fields,
40
52
  item_name="bar",
53
+ item_validator=validate_order,
41
54
  )
42
55
  self.category = category
43
56
  self.values = tuple(values)
44
57
  self.series = series
45
58
  self.orientation = orientation
46
59
  self.stacked = stacked
60
+ self.category_order = category_order
61
+ self.series_order = series_order
47
62
 
48
63
  @classmethod
49
64
  def declare(
@@ -58,6 +73,8 @@ class BarChartReport(TypedCollectionReport):
58
73
  stacked: bool = False,
59
74
  range_transform: Optional[TimeRangeTransform] = None,
60
75
  temporal_mode: Optional[TemporalMode] = None,
76
+ category_order: Optional[Iterable[Any]] = None,
77
+ series_order: Optional[Iterable[Any]] = None,
61
78
  ):
62
79
  if not isinstance(category, Dimension):
63
80
  raise ReportStoreValidationError(
@@ -107,6 +124,18 @@ class BarChartReport(TypedCollectionReport):
107
124
  )
108
125
  if not isinstance(stacked, bool):
109
126
  raise ReportStoreValidationError("Bar Chart stacked must be boolean")
127
+ normalized_category_order = normalize_value_order(
128
+ "Bar Chart", "category_order", category, category_order
129
+ )
130
+ if series_order is not None and series is None:
131
+ raise ReportStoreValidationError(
132
+ "Bar Chart series_order requires a series Dimension"
133
+ )
134
+ normalized_series_order = (
135
+ normalize_value_order("Bar Chart", "series_order", series, series_order)
136
+ if series is not None
137
+ else None
138
+ )
110
139
 
111
140
  fields = (category,) + ((series,) if series is not None else ()) + normalized_values
112
141
  normalized_range_transform = normalize_range_transform(
@@ -119,6 +148,11 @@ class BarChartReport(TypedCollectionReport):
119
148
  "Bar Chart",
120
149
  range_transform,
121
150
  )
151
+ options = {"orientation": orientation, "stacked": stacked}
152
+ if normalized_category_order is not None:
153
+ options["category_order"] = list(normalized_category_order)
154
+ if normalized_series_order is not None:
155
+ options["series_order"] = list(normalized_series_order)
122
156
  definition = ReportDefinition.create(
123
157
  report_type="bar",
124
158
  contract_version=BAR_CONTRACT_VERSION,
@@ -128,7 +162,7 @@ class BarChartReport(TypedCollectionReport):
128
162
  "series": series.name if series is not None else None,
129
163
  "values": [value.name for value in normalized_values],
130
164
  },
131
- options={"orientation": orientation, "stacked": stacked},
165
+ options=options,
132
166
  range_transform=normalized_range_transform,
133
167
  temporal_mode=normalized_temporal_mode,
134
168
  )
@@ -147,6 +181,8 @@ class BarChartReport(TypedCollectionReport):
147
181
  series,
148
182
  orientation,
149
183
  stacked,
184
+ normalized_category_order,
185
+ normalized_series_order,
150
186
  )
151
187
 
152
188
  def append_bars(
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any, Iterable, Mapping, Optional
4
4
 
5
- from pam.reports.axis import NUMERIC_TYPES
5
+ from pam.reports.axis import NUMERIC_TYPES, normalize_value_order
6
6
  from pam.reports.collection import TypedCollectionReport
7
7
  from pam.reports.definition import ReportDefinition
8
8
  from pam.reports.errors import ReportStoreValidationError
@@ -27,6 +27,7 @@ class BoxPlotReport(TypedCollectionReport):
27
27
  median: Measure,
28
28
  q3: Measure,
29
29
  maximum: Measure,
30
+ category_order: Optional[Iterable[Any]],
30
31
  ):
31
32
  values = (minimum, q1, median, q3, maximum)
32
33
  fields = (category,) + values
@@ -37,6 +38,10 @@ class BoxPlotReport(TypedCollectionReport):
37
38
  raise ReportStoreValidationError(
38
39
  f"Box Plot distribution {index} must satisfy minimum <= q1 <= median <= q3 <= maximum"
39
40
  )
41
+ if category_order is not None and item[category.name] not in category_order:
42
+ raise ReportStoreValidationError(
43
+ f"Box Plot category value '{item[category.name]}' is not declared in category_order"
44
+ )
40
45
 
41
46
  super().__init__(
42
47
  report=report,
@@ -52,6 +57,7 @@ class BoxPlotReport(TypedCollectionReport):
52
57
  self.median = median
53
58
  self.q3 = q3
54
59
  self.maximum = maximum
60
+ self.category_order = category_order
55
61
 
56
62
  @classmethod
57
63
  def declare(
@@ -65,6 +71,7 @@ class BoxPlotReport(TypedCollectionReport):
65
71
  median: Measure,
66
72
  q3: Measure,
67
73
  maximum: Measure,
74
+ category_order: Optional[Iterable[Any]] = None,
68
75
  ):
69
76
  if not isinstance(category, Dimension) or not category.required or category.nullable:
70
77
  raise ReportStoreValidationError(
@@ -85,7 +92,13 @@ class BoxPlotReport(TypedCollectionReport):
85
92
  names = [field.name for field in fields]
86
93
  if len(names) != len(set(names)):
87
94
  raise ReportStoreValidationError("Box Plot fields must use different names")
95
+ normalized_category_order = normalize_value_order(
96
+ "Box Plot", "category_order", category, category_order
97
+ )
88
98
 
99
+ options = {}
100
+ if normalized_category_order is not None:
101
+ options["category_order"] = list(normalized_category_order)
89
102
  definition = ReportDefinition.create(
90
103
  report_type="box_plot",
91
104
  contract_version=BOX_PLOT_CONTRACT_VERSION,
@@ -98,11 +111,15 @@ class BoxPlotReport(TypedCollectionReport):
98
111
  "q3": q3.name,
99
112
  "maximum": maximum.name,
100
113
  },
114
+ options=options,
101
115
  )
102
116
  report = store._declare_typed_report(
103
117
  key, BOX_PLOT_RENDERER, BOX_PLOT_SCHEMA, granularity, definition.to_json()
104
118
  )
105
- return cls(report, granularity, category, minimum, q1, median, q3, maximum)
119
+ return cls(
120
+ report, granularity, category, minimum, q1, median, q3, maximum,
121
+ normalized_category_order,
122
+ )
106
123
 
107
124
  def append_distributions(
108
125
  self,
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Any, Iterable, Mapping, Optional
4
4
 
5
- from pam.reports.axis import NUMERIC_TYPES
5
+ from pam.reports.axis import NUMERIC_TYPES, normalize_value_order
6
6
  from pam.reports.collection import TypedCollectionReport
7
7
  from pam.reports.definition import ReportDefinition
8
8
  from pam.reports.errors import ReportStoreValidationError
@@ -22,17 +22,32 @@ class HeatmapReport(TypedCollectionReport):
22
22
  x: Dimension,
23
23
  y: Dimension,
24
24
  value: Measure,
25
+ x_order: Optional[Iterable[Any]],
26
+ y_order: Optional[Iterable[Any]],
25
27
  ):
28
+ def validate_order(item: Mapping[str, Any], _index: int) -> None:
29
+ if x_order is not None and item[x.name] not in x_order:
30
+ raise ReportStoreValidationError(
31
+ f"Heatmap x value '{item[x.name]}' is not declared in x_order"
32
+ )
33
+ if y_order is not None and item[y.name] not in y_order:
34
+ raise ReportStoreValidationError(
35
+ f"Heatmap y value '{item[y.name]}' is not declared in y_order"
36
+ )
37
+
26
38
  super().__init__(
27
39
  report=report,
28
40
  granularity=granularity,
29
41
  fields=(x, y, value),
30
42
  identity_fields=(x, y),
31
43
  item_name="cell",
44
+ item_validator=validate_order,
32
45
  )
33
46
  self.x = x
34
47
  self.y = y
35
48
  self.value = value
49
+ self.x_order = x_order
50
+ self.y_order = y_order
36
51
 
37
52
  @classmethod
38
53
  def declare(
@@ -43,6 +58,8 @@ class HeatmapReport(TypedCollectionReport):
43
58
  x: Dimension,
44
59
  y: Dimension,
45
60
  value: Measure,
61
+ x_order: Optional[Iterable[Any]] = None,
62
+ y_order: Optional[Iterable[Any]] = None,
46
63
  ):
47
64
  for name, field in (("x", x), ("y", y)):
48
65
  if not isinstance(field, Dimension):
@@ -61,17 +78,32 @@ class HeatmapReport(TypedCollectionReport):
61
78
  raise ReportStoreValidationError("Heatmap value must be required and non-nullable")
62
79
  if len({x.name, y.name, value.name}) != 3:
63
80
  raise ReportStoreValidationError("Heatmap fields must use different names")
81
+ normalized_x_order = normalize_value_order(
82
+ "Heatmap", "x_order", x, x_order
83
+ )
84
+ normalized_y_order = normalize_value_order(
85
+ "Heatmap", "y_order", y, y_order
86
+ )
64
87
 
88
+ options = {}
89
+ if normalized_x_order is not None:
90
+ options["x_order"] = list(normalized_x_order)
91
+ if normalized_y_order is not None:
92
+ options["y_order"] = list(normalized_y_order)
65
93
  definition = ReportDefinition.create(
66
94
  report_type="heatmap",
67
95
  contract_version=HEATMAP_CONTRACT_VERSION,
68
96
  fields=(x, y, value),
69
97
  binding={"x": x.name, "y": y.name, "value": value.name},
98
+ options=options,
70
99
  )
71
100
  report = store._declare_typed_report(
72
101
  key, HEATMAP_RENDERER, HEATMAP_SCHEMA, granularity, definition.to_json()
73
102
  )
74
- return cls(report, granularity, x, y, value)
103
+ return cls(
104
+ report, granularity, x, y, value,
105
+ normalized_x_order, normalized_y_order,
106
+ )
75
107
 
76
108
  def append_cells(
77
109
  self,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from typing import Any, Iterable, Mapping, Optional
4
4
 
5
+ from pam.reports.axis import normalize_value_order
5
6
  from pam.reports.collection import TypedCollectionReport
6
7
  from pam.reports.definition import ReportDefinition
7
8
  from pam.reports.errors import ReportStoreValidationError
@@ -25,6 +26,8 @@ class RangeBarChartReport(TypedCollectionReport):
25
26
  start: Measure,
26
27
  end: Measure,
27
28
  series: Optional[Dimension],
29
+ category_order: Optional[Iterable[Any]],
30
+ series_order: Optional[Iterable[Any]],
28
31
  ):
29
32
  fields = (category,) + ((series,) if series is not None else ()) + (start, end)
30
33
  identity_fields = (
@@ -36,6 +39,14 @@ class RangeBarChartReport(TypedCollectionReport):
36
39
  raise ReportStoreValidationError(
37
40
  f"Range Bar range {index} field '{end.name}' must be greater than or equal to '{start.name}'"
38
41
  )
42
+ if category_order is not None and item[category.name] not in category_order:
43
+ raise ReportStoreValidationError(
44
+ f"Range Bar category value '{item[category.name]}' is not declared in category_order"
45
+ )
46
+ if series_order is not None and item[series.name] not in series_order:
47
+ raise ReportStoreValidationError(
48
+ f"Range Bar series value '{item[series.name]}' is not declared in series_order"
49
+ )
39
50
 
40
51
  super().__init__(
41
52
  report=report,
@@ -49,6 +60,8 @@ class RangeBarChartReport(TypedCollectionReport):
49
60
  self.start = start
50
61
  self.end = end
51
62
  self.series = series
63
+ self.category_order = category_order
64
+ self.series_order = series_order
52
65
 
53
66
  @classmethod
54
67
  def declare(
@@ -60,6 +73,8 @@ class RangeBarChartReport(TypedCollectionReport):
60
73
  start: Measure,
61
74
  end: Measure,
62
75
  series: Optional[Dimension] = None,
76
+ category_order: Optional[Iterable[Any]] = None,
77
+ series_order: Optional[Iterable[Any]] = None,
63
78
  ):
64
79
  if not isinstance(category, Dimension) or not category.required or category.nullable:
65
80
  raise ReportStoreValidationError(
@@ -89,7 +104,24 @@ class RangeBarChartReport(TypedCollectionReport):
89
104
  names = [field.name for field in fields]
90
105
  if len(names) != len(set(names)):
91
106
  raise ReportStoreValidationError("Range Bar fields must use different names")
107
+ normalized_category_order = normalize_value_order(
108
+ "Range Bar", "category_order", category, category_order
109
+ )
110
+ if series_order is not None and series is None:
111
+ raise ReportStoreValidationError(
112
+ "Range Bar series_order requires a series Dimension"
113
+ )
114
+ normalized_series_order = (
115
+ normalize_value_order("Range Bar", "series_order", series, series_order)
116
+ if series is not None
117
+ else None
118
+ )
92
119
 
120
+ options = {}
121
+ if normalized_category_order is not None:
122
+ options["category_order"] = list(normalized_category_order)
123
+ if normalized_series_order is not None:
124
+ options["series_order"] = list(normalized_series_order)
93
125
  definition = ReportDefinition.create(
94
126
  report_type="range_bar",
95
127
  contract_version=RANGE_BAR_CONTRACT_VERSION,
@@ -100,11 +132,15 @@ class RangeBarChartReport(TypedCollectionReport):
100
132
  "end": end.name,
101
133
  "series": series.name if series is not None else None,
102
134
  },
135
+ options=options,
103
136
  )
104
137
  report = store._declare_typed_report(
105
138
  key, RANGE_BAR_RENDERER, RANGE_BAR_SCHEMA, granularity, definition.to_json()
106
139
  )
107
- return cls(report, granularity, category, start, end, series)
140
+ return cls(
141
+ report, granularity, category, start, end, series,
142
+ normalized_category_order, normalized_series_order,
143
+ )
108
144
 
109
145
  def append_ranges(
110
146
  self,
@@ -221,6 +221,8 @@ class ReportStore:
221
221
  stacked: bool = False,
222
222
  range_transform: Optional[TimeRangeTransform] = None,
223
223
  temporal_mode: Optional[TemporalMode] = None,
224
+ category_order: Optional[Iterable[Any]] = None,
225
+ series_order: Optional[Iterable[Any]] = None,
224
226
  ) -> BarChartReport:
225
227
  """Declare a typed Bar/Column Chart report."""
226
228
  return BarChartReport.declare(
@@ -234,6 +236,8 @@ class ReportStore:
234
236
  stacked,
235
237
  range_transform,
236
238
  temporal_mode,
239
+ category_order,
240
+ series_order,
237
241
  )
238
242
 
239
243
  def pie_chart(
@@ -355,6 +359,7 @@ class ReportStore:
355
359
  median: Measure,
356
360
  q3: Measure,
357
361
  maximum: Measure,
362
+ category_order: Optional[Iterable[Any]] = None,
358
363
  ) -> BoxPlotReport:
359
364
  """Declare a typed Box Plot report."""
360
365
  return BoxPlotReport.declare(
@@ -367,6 +372,7 @@ class ReportStore:
367
372
  median,
368
373
  q3,
369
374
  maximum,
375
+ category_order,
370
376
  )
371
377
 
372
378
  def heatmap(
@@ -376,9 +382,13 @@ class ReportStore:
376
382
  x: Dimension,
377
383
  y: Dimension,
378
384
  value: Measure,
385
+ x_order: Optional[Iterable[Any]] = None,
386
+ y_order: Optional[Iterable[Any]] = None,
379
387
  ) -> HeatmapReport:
380
388
  """Declare a typed Heatmap report."""
381
- return HeatmapReport.declare(self, key, granularity, x, y, value)
389
+ return HeatmapReport.declare(
390
+ self, key, granularity, x, y, value, x_order, y_order
391
+ )
382
392
 
383
393
  def radar_chart(
384
394
  self,
@@ -402,10 +412,13 @@ class ReportStore:
402
412
  start: Measure,
403
413
  end: Measure,
404
414
  series: Optional[Dimension] = None,
415
+ category_order: Optional[Iterable[Any]] = None,
416
+ series_order: Optional[Iterable[Any]] = None,
405
417
  ) -> RangeBarChartReport:
406
418
  """Declare a typed Range Bar Chart report."""
407
419
  return RangeBarChartReport.declare(
408
- self, key, granularity, category, start, end, series
420
+ self, key, granularity, category, start, end, series,
421
+ category_order, series_order,
409
422
  )
410
423
 
411
424
  def range_area_chart(
@@ -496,12 +496,16 @@ bars = store.bar_chart(
496
496
  series=Dimension("branch", label="สาขา", value_type="string"),
497
497
  orientation="vertical",
498
498
  stacked=False,
499
+ category_order=["Online", "Store"],
500
+ series_order=["Bangkok", "Chiang Mai"],
499
501
  )
500
502
  ```
501
503
 
502
504
  ต้องมี category Dimension หนึ่งตัวและ numeric Measure อย่างน้อยหนึ่งตัว `series`
503
505
  ต้องเป็น Dimension, `orientation` เป็น `vertical` หรือ `horizontal` และห้ามมี
504
506
  category/series identity ซ้ำ Partition เป็น metadata ไม่ถูกเติมหน้าชื่อ category
507
+ กำหนดลำดับเองได้ด้วย `category_order` และ `series_order` โดยรายการที่ระบุถือเป็น
508
+ รายการค่าที่อนุญาตทั้งหมด หากไม่กำหนด CMS จะเรียง label แบบ ascending ตามเดิม
505
509
 
506
510
  ---
507
511
 
@@ -598,9 +602,13 @@ heatmap = store.heatmap(
598
602
  x=Dimension("hour", label="ชั่วโมง", value_type="integer"),
599
603
  y=Dimension("weekday", label="วัน", value_type="string"),
600
604
  value=Measure("events", label="เหตุการณ์", value_type="integer"),
605
+ x_order=[9, 10],
606
+ y_order=["Monday", "Tuesday"],
601
607
  )
602
608
  ```
603
609
 
610
+ `x_order` และ `y_order` ใช้กำหนดลำดับแกน โดยค่าที่เขียนต้องอยู่ในรายการที่ประกาศ
611
+
604
612
  ---
605
613
 
606
614
  # 16. Candlestick Chart Report
@@ -617,6 +625,7 @@ open, high, low, close โดยต้องเป็นไปตาม `low <=
617
625
  ต้องมี category Dimension หนึ่งตัวและ numeric Measure ห้าตัว: minimum, q1, median,
618
626
  q3, maximum โดยต้องเป็น `minimum <= q1 <= median <= q3 <= maximum` ห้าม category
619
627
  ซ้ำ และแต่ละ partition แสดง Box Plot แยกกัน ไม่ merge distribution ข้าม partition
628
+ กำหนดลำดับ category ได้ด้วย `category_order=[...]` และค่าที่เขียนต้องอยู่ในรายการ
620
629
 
621
630
  ---
622
631
 
@@ -640,6 +649,7 @@ category ซ้ำ `NONE` คือ current snapshot หากใช้ fixed ra
640
649
 
641
650
  ต้องมี category, start และ end โดย start/end ต้องเป็นชนิด numeric หรือ datetime
642
651
  ชนิดเดียวกัน และ `end >= start` identity ของ category/series/range ห้ามซ้ำ
652
+ กำหนดลำดับได้ด้วย `category_order` และ `series_order`; ค่าที่เขียนต้องอยู่ในรายการ
643
653
 
644
654
  ---
645
655
 
@@ -813,6 +813,7 @@ bars = store.bar_chart(
813
813
  ],
814
814
  orientation="vertical",
815
815
  stacked=False,
816
+ category_order=["Online", "Store"],
816
817
  range_transform=TimeRangeTransform(
817
818
  default_bucket="MONTH",
818
819
  allowed_buckets=["MONTH", "YEAR", "ALL"],
@@ -847,6 +848,8 @@ bars = store.bar_chart(
847
848
  Measure("amount", label="Amount", value_type="number", unit="THB"),
848
849
  ],
849
850
  stacked=True,
851
+ category_order=["Online", "Store"],
852
+ series_order=["Bangkok", "Chiang Mai"],
850
853
  )
851
854
 
852
855
  bars.delete_partition("2026-08")
@@ -864,6 +867,9 @@ bars.append_bars([
864
867
  - `orientation` is `vertical` or `horizontal`.
865
868
  - The optional series field must be a dimension.
866
869
  - Duplicate category/series combinations are rejected.
870
+ - Optional `category_order` and `series_order` define the complete categorical
871
+ display order. Values written to an ordered dimension must appear in its declaration.
872
+ Without these options, CMS retains its default ascending label order.
867
873
  - `NONE` creates a current snapshot and cannot declare `range_transform`.
868
874
  - `DAY` and `MONTH` store facts under their business partition. When
869
875
  `range_transform` is declared, CMS groups records by the selected time bucket plus
@@ -1077,6 +1083,8 @@ heatmap = store.heatmap(
1077
1083
  x=Dimension("hour", label="Hour", value_type="integer"),
1078
1084
  y=Dimension("weekday", label="Weekday", value_type="string"),
1079
1085
  value=Measure("count", label="Count", value_type="integer"),
1086
+ x_order=[9, 10],
1087
+ y_order=["Monday", "Tuesday"],
1080
1088
  )
1081
1089
 
1082
1090
  heatmap.delete_partition("2026-08")
@@ -1095,6 +1103,8 @@ heatmap.append_cells([
1095
1103
  - Each selected partition renders as an independent grid, so CMS never combines
1096
1104
  equal x/y cells using an invented reducer. `NONE` stores one current snapshot.
1097
1105
  - Heatmap does not declare `range_transform`.
1106
+ - Optional `x_order` and `y_order` define the complete categorical display order
1107
+ for each axis; undeclared coordinate values are rejected.
1098
1108
 
1099
1109
  ---
1100
1110
 
@@ -1148,12 +1158,13 @@ box_plot = store.box_plot(
1148
1158
  median=Measure("median", label="Median", value_type="number"),
1149
1159
  q3=Measure("q3", label="Q3", value_type="number"),
1150
1160
  maximum=Measure("maximum", label="Maximum", value_type="number"),
1161
+ category_order=["VIP", "General"],
1151
1162
  )
1152
1163
 
1153
1164
  box_plot.delete_partition("2026-08")
1154
1165
 
1155
1166
  box_plot.append_distributions([
1156
- {"segment": "A", "minimum": 5, "q1": 10, "median": 15, "q3": 20, "maximum": 30},
1167
+ {"segment": "VIP", "minimum": 5, "q1": 10, "median": 15, "q3": 20, "maximum": 30},
1157
1168
  ], partition_key="2026-08")
1158
1169
  ```
1159
1170
 
@@ -1162,6 +1173,8 @@ box_plot.append_distributions([
1162
1173
  - Exactly one category dimension and five numeric measures are required.
1163
1174
  - Values must satisfy `minimum <= q1 <= median <= q3 <= maximum`.
1164
1175
  - Duplicate categories are rejected.
1176
+ - Optional `category_order` defines the complete categorical display order;
1177
+ undeclared categories are rejected.
1165
1178
  - Each partition renders as an independent Box Plot; distributions are not merged
1166
1179
  across partitions by an invented statistical reducer.
1167
1180
 
@@ -1256,6 +1269,8 @@ ranges = store.range_bar_chart(
1256
1269
  start=Measure("start", label="Start", value_type="datetime"),
1257
1270
  end=Measure("end", label="End", value_type="datetime"),
1258
1271
  series=Dimension("group", label="Group", value_type="string"),
1272
+ category_order=["Phase B", "Phase A"],
1273
+ series_order=["Project 2", "Project 1"],
1259
1274
  )
1260
1275
 
1261
1276
  ranges.delete_partition("2026-08")
@@ -1276,6 +1291,8 @@ ranges.append_ranges([
1276
1291
  - Start and end use the same numeric or datetime value type.
1277
1292
  - `end >= start`.
1278
1293
  - Duplicate category/series/range identities are rejected.
1294
+ - Optional `category_order` and `series_order` define the complete categorical
1295
+ display order; undeclared values are rejected.
1279
1296
 
1280
1297
  ---
1281
1298
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pam-python
3
- Version: 0.2.14
3
+ Version: 0.2.15
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
@@ -139,6 +139,38 @@ class TypedBarReportTest(unittest.TestCase):
139
139
  self.bar(orientation="diagonal")
140
140
  with self.assertRaisesRegex(ReportStoreValidationError, "stacked"):
141
141
  self.bar(stacked="yes")
142
+
143
+ def test_explicit_category_and_series_order(self):
144
+ bar = self.store.bar_chart(
145
+ key="ordered-bar",
146
+ granularity="NONE",
147
+ category=Dimension("status", label="Status", value_type="string"),
148
+ series=Dimension("region", label="Region", value_type="string"),
149
+ values=[Measure("count", label="Count", value_type="integer")],
150
+ category_order=["Critical", "Warning", "Healthy"],
151
+ series_order=["North", "South"],
152
+ )
153
+ definition = json.loads(
154
+ self.store._connection.execute(
155
+ "SELECT definition_json FROM report_definitions WHERE report_key = ?",
156
+ ("ordered-bar",),
157
+ ).fetchone()[0]
158
+ )
159
+ self.assertEqual(
160
+ ["Critical", "Warning", "Healthy"],
161
+ definition["options"]["category_order"],
162
+ )
163
+ self.assertEqual(["North", "South"], definition["options"]["series_order"])
164
+ bar.append_bars([{"status": "Critical", "region": "North", "count": 1}])
165
+ with self.assertRaisesRegex(ReportStoreValidationError, "category_order"):
166
+ bar.append_bars([{"status": "Unknown", "region": "North", "count": 1}])
167
+ with self.assertRaisesRegex(ReportStoreValidationError, "non-empty and unique"):
168
+ self.store.bar_chart(
169
+ "duplicate-order", "NONE",
170
+ Dimension("status", label="Status", value_type="string"),
171
+ [Measure("count", label="Count", value_type="integer")],
172
+ category_order=["A", "A"],
173
+ )
142
174
  with self.assertRaisesRegex(ReportStoreValidationError, "integer or number"):
143
175
  self.store.bar_chart(
144
176
  "invalid-value",
@@ -67,6 +67,27 @@ class TypedBoxPlotReportTest(unittest.TestCase):
67
67
  "box-plot-report", generate_box_plot_contract_fixture(fixture)["report_key"]
68
68
  )
69
69
 
70
+ def test_explicit_category_order(self):
71
+ report = self.store.box_plot(
72
+ "ordered", "NONE",
73
+ Dimension("segment", label="Segment", value_type="string"),
74
+ Measure("minimum", label="Minimum", value_type="number"),
75
+ Measure("q1", label="Q1", value_type="number"),
76
+ Measure("median", label="Median", value_type="number"),
77
+ Measure("q3", label="Q3", value_type="number"),
78
+ Measure("maximum", label="Maximum", value_type="number"),
79
+ category_order=["VIP", "General"],
80
+ )
81
+ report.append_distributions([{
82
+ "segment": "VIP", "minimum": 1, "q1": 2, "median": 3,
83
+ "q3": 4, "maximum": 5,
84
+ }])
85
+ with self.assertRaisesRegex(ReportStoreValidationError, "category_order"):
86
+ report.append_distributions([{
87
+ "segment": "Unknown", "minimum": 1, "q1": 2, "median": 3,
88
+ "q3": 4, "maximum": 5,
89
+ }])
90
+
70
91
 
71
92
  if __name__ == "__main__":
72
93
  unittest.main()
@@ -52,5 +52,25 @@ class TypedHeatmapReportTest(unittest.TestCase):
52
52
  fixture = Path(self.temp.name) / "heatmap.sqlite"
53
53
  self.assertEqual("heatmap-report", generate_heatmap_contract_fixture(fixture)["report_key"])
54
54
 
55
+ def test_explicit_axis_orders(self):
56
+ heatmap = self.store.heatmap(
57
+ "ordered", "NONE",
58
+ Dimension("hour", label="Hour", value_type="integer"),
59
+ Dimension("weekday", label="Weekday", value_type="string"),
60
+ Measure("count", label="Count", value_type="integer"),
61
+ x_order=[9, 8],
62
+ y_order=["Friday", "Monday"],
63
+ )
64
+ heatmap.append_cells([{"hour": 9, "weekday": "Friday", "count": 1}])
65
+ definition = json.loads(
66
+ self.store._connection.execute(
67
+ "SELECT definition_json FROM report_definitions WHERE report_key = 'ordered'"
68
+ ).fetchone()[0]
69
+ )
70
+ self.assertEqual([9, 8], definition["options"]["x_order"])
71
+ self.assertEqual(["Friday", "Monday"], definition["options"]["y_order"])
72
+ with self.assertRaisesRegex(ReportStoreValidationError, "y_order"):
73
+ heatmap.append_cells([{"hour": 9, "weekday": "Sunday", "count": 1}])
74
+
55
75
 
56
76
  if __name__ == "__main__": unittest.main()
@@ -102,6 +102,27 @@ class TypedRangeBarReportTest(unittest.TestCase):
102
102
  Measure("end", label="End", value_type="number"),
103
103
  )
104
104
 
105
+ def test_explicit_category_and_series_order(self):
106
+ report = self.store.range_bar_chart(
107
+ "ordered", "NONE",
108
+ Dimension("task", label="Task", value_type="string"),
109
+ Measure("start", label="Start", value_type="integer"),
110
+ Measure("end", label="End", value_type="integer"),
111
+ series=Dimension("group", label="Group", value_type="string"),
112
+ category_order=["Deploy", "Build"],
113
+ series_order=["P2", "P1"],
114
+ )
115
+ report.append_ranges([{"task": "Deploy", "group": "P2", "start": 1, "end": 2}])
116
+ definition = json.loads(
117
+ self.store._connection.execute(
118
+ "SELECT definition_json FROM report_definitions WHERE report_key = 'ordered'"
119
+ ).fetchone()[0]
120
+ )
121
+ self.assertEqual(["Deploy", "Build"], definition["options"]["category_order"])
122
+ self.assertEqual(["P2", "P1"], definition["options"]["series_order"])
123
+ with self.assertRaisesRegex(ReportStoreValidationError, "series_order"):
124
+ report.append_ranges([{"task": "Deploy", "group": "P3", "start": 1, "end": 2}])
125
+
105
126
 
106
127
  if __name__ == "__main__":
107
128
  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
File without changes
File without changes