caxton 0.1.0__py3-none-any.whl

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 (131) hide show
  1. caxton/__init__.py +215 -0
  2. caxton/__version__.py +1 -0
  3. caxton/_internal/__init__.py +0 -0
  4. caxton/_internal/aggregation/__init__.py +11 -0
  5. caxton/_internal/aggregation/execution.py +315 -0
  6. caxton/_internal/aggregation/keys.py +80 -0
  7. caxton/_internal/aggregation/matrices.py +308 -0
  8. caxton/_internal/aggregation/models.py +69 -0
  9. caxton/_internal/aggregation/tables.py +233 -0
  10. caxton/_internal/backends/__init__.py +5 -0
  11. caxton/_internal/backends/_common.py +27 -0
  12. caxton/_internal/backends/_xlsx_formats.py +112 -0
  13. caxton/_internal/backends/openpyxl/__init__.py +25 -0
  14. caxton/_internal/backends/openpyxl/conditional_formats.py +42 -0
  15. caxton/_internal/backends/openpyxl/extensions.py +105 -0
  16. caxton/_internal/backends/openpyxl/footers.py +51 -0
  17. caxton/_internal/backends/openpyxl/native_tables.py +65 -0
  18. caxton/_internal/backends/openpyxl/package.py +223 -0
  19. caxton/_internal/backends/openpyxl/renderer.py +98 -0
  20. caxton/_internal/backends/openpyxl/rows.py +111 -0
  21. caxton/_internal/backends/openpyxl/styles.py +118 -0
  22. caxton/_internal/backends/openpyxl/tables.py +32 -0
  23. caxton/_internal/backends/openpyxl/template_renderer.py +108 -0
  24. caxton/_internal/backends/openpyxl/template_workbook.py +479 -0
  25. caxton/_internal/backends/openpyxl/workbook.py +74 -0
  26. caxton/_internal/backends/xlsxwriter/__init__.py +8 -0
  27. caxton/_internal/backends/xlsxwriter/conditional_formats.py +41 -0
  28. caxton/_internal/backends/xlsxwriter/destination.py +81 -0
  29. caxton/_internal/backends/xlsxwriter/drawings.py +118 -0
  30. caxton/_internal/backends/xlsxwriter/execution.py +93 -0
  31. caxton/_internal/backends/xlsxwriter/footers.py +67 -0
  32. caxton/_internal/backends/xlsxwriter/native_tables.py +67 -0
  33. caxton/_internal/backends/xlsxwriter/renderer.py +118 -0
  34. caxton/_internal/backends/xlsxwriter/rows.py +220 -0
  35. caxton/_internal/backends/xlsxwriter/styles.py +121 -0
  36. caxton/_internal/backends/xlsxwriter/tables.py +63 -0
  37. caxton/_internal/backends/xlsxwriter/workbook.py +45 -0
  38. caxton/_internal/block_paths.py +24 -0
  39. caxton/_internal/compiler/__init__.py +5 -0
  40. caxton/_internal/compiler/formula_resolution.py +246 -0
  41. caxton/_internal/compiler/spreadsheet.py +690 -0
  42. caxton/_internal/const.py +144 -0
  43. caxton/_internal/data/__init__.py +9 -0
  44. caxton/_internal/data/accessors.py +72 -0
  45. caxton/_internal/data/sources.py +187 -0
  46. caxton/_internal/formulas.py +140 -0
  47. caxton/_internal/layout/__init__.py +21 -0
  48. caxton/_internal/layout/spreadsheet.py +392 -0
  49. caxton/_internal/normalization/__init__.py +3 -0
  50. caxton/_internal/normalization/coordinates.py +56 -0
  51. caxton/_internal/operations.py +204 -0
  52. caxton/_internal/rendering.py +35 -0
  53. caxton/_internal/requirements.py +252 -0
  54. caxton/_internal/resolver.py +248 -0
  55. caxton/_internal/semantic/__init__.py +3 -0
  56. caxton/_internal/semantic/evaluator.py +413 -0
  57. caxton/_internal/shape.py +14 -0
  58. caxton/_internal/sinks.py +177 -0
  59. caxton/_internal/templates/__init__.py +17 -0
  60. caxton/_internal/templates/xlsx.py +406 -0
  61. caxton/_internal/validation/__init__.py +5 -0
  62. caxton/_internal/validation/expressions.py +179 -0
  63. caxton/_internal/validation/features.py +316 -0
  64. caxton/_internal/validation/formulas.py +303 -0
  65. caxton/_internal/validation/spreadsheet.py +31 -0
  66. caxton/_internal/validation/structure.py +183 -0
  67. caxton/api/__init__.py +155 -0
  68. caxton/api/columns.py +145 -0
  69. caxton/api/operations.py +62 -0
  70. caxton/api/spreadsheet.py +307 -0
  71. caxton/api/templates.py +76 -0
  72. caxton/api/xlsx/__init__.py +19 -0
  73. caxton/core/__init__.py +11 -0
  74. caxton/core/_values.py +211 -0
  75. caxton/core/errors/__init__.py +87 -0
  76. caxton/core/errors/base.py +42 -0
  77. caxton/core/errors/data.py +136 -0
  78. caxton/core/errors/rendering.py +58 -0
  79. caxton/core/errors/validation.py +171 -0
  80. caxton/core/errors/warnings.py +14 -0
  81. caxton/core/formatting/__init__.py +59 -0
  82. caxton/core/formatting/alignment.py +14 -0
  83. caxton/core/formatting/display.py +160 -0
  84. caxton/core/formatting/styles.py +364 -0
  85. caxton/core/ir/__init__.py +51 -0
  86. caxton/core/ir/base.py +22 -0
  87. caxton/core/ir/spreadsheet.py +329 -0
  88. caxton/core/models/__init__.py +140 -0
  89. caxton/core/models/_operators.py +98 -0
  90. caxton/core/models/_validation.py +64 -0
  91. caxton/core/models/columns.py +233 -0
  92. caxton/core/models/common.py +38 -0
  93. caxton/core/models/expressions.py +282 -0
  94. caxton/core/models/formulas.py +302 -0
  95. caxton/core/models/spreadsheet.py +527 -0
  96. caxton/core/models/templates.py +115 -0
  97. caxton/core/protocols/__init__.py +55 -0
  98. caxton/core/protocols/data.py +49 -0
  99. caxton/core/protocols/rendering.py +64 -0
  100. caxton/core/protocols/templates.py +40 -0
  101. caxton/core/rendering.py +201 -0
  102. caxton/core/types/__init__.py +27 -0
  103. caxton/core/types/base.py +37 -0
  104. caxton/core/types/boolean.py +11 -0
  105. caxton/core/types/date.py +11 -0
  106. caxton/core/types/datetime.py +11 -0
  107. caxton/core/types/decimal.py +11 -0
  108. caxton/core/types/duration.py +11 -0
  109. caxton/core/types/integer.py +11 -0
  110. caxton/core/types/link.py +11 -0
  111. caxton/core/types/money.py +22 -0
  112. caxton/core/types/percentage.py +13 -0
  113. caxton/core/types/text.py +11 -0
  114. caxton/core/types/time.py +11 -0
  115. caxton/core/values.py +22 -0
  116. caxton/errors.py +63 -0
  117. caxton/py.typed +0 -0
  118. caxton/testing/__init__.py +102 -0
  119. caxton/testing/_artifact.py +297 -0
  120. caxton/testing/_assertions.py +394 -0
  121. caxton/testing/_diff.py +40 -0
  122. caxton/testing/_errors.py +13 -0
  123. caxton/testing/_layout.py +669 -0
  124. caxton/testing/_snapshots.py +173 -0
  125. caxton/testing/_spec.py +631 -0
  126. caxton/testing/_xlsx.py +186 -0
  127. caxton/testing/strategies.py +160 -0
  128. caxton-0.1.0.dist-info/METADATA +145 -0
  129. caxton-0.1.0.dist-info/RECORD +131 -0
  130. caxton-0.1.0.dist-info/WHEEL +4 -0
  131. caxton-0.1.0.dist-info/licenses/LICENSE +21 -0
caxton/__init__.py ADDED
@@ -0,0 +1,215 @@
1
+ from .api import ( # noqa: WPS347
2
+ AggregateExpr,
3
+ AggregateFunction,
4
+ BlockDirection,
5
+ BorderLine,
6
+ BorderLineStyle,
7
+ Borders,
8
+ CellAlignment,
9
+ ChartKind,
10
+ Column,
11
+ CorporateTheme,
12
+ CustomFormat,
13
+ DateFormat,
14
+ DecimalFormat,
15
+ DocumentTheme,
16
+ Expression,
17
+ FieldRef,
18
+ FillStyle,
19
+ FontStyle,
20
+ Freeze,
21
+ Grouping,
22
+ GroupOrder,
23
+ Matrix,
24
+ MoneyFormat,
25
+ PercentageFormat,
26
+ Style,
27
+ StyleSheet,
28
+ TemplateRepeat,
29
+ TemplateSpecification,
30
+ TimeFormat,
31
+ Total,
32
+ Totals,
33
+ VerticalAlignment,
34
+ absolute,
35
+ boolean,
36
+ chart,
37
+ col,
38
+ custom_format,
39
+ data_source,
40
+ date,
41
+ date_format,
42
+ datetime,
43
+ decimal,
44
+ decimal_format,
45
+ duration,
46
+ field,
47
+ image,
48
+ integer,
49
+ link,
50
+ matrix,
51
+ money,
52
+ money_format,
53
+ path,
54
+ percentage,
55
+ percentage_format,
56
+ ref,
57
+ render,
58
+ repeat,
59
+ sheet,
60
+ sheet_ref,
61
+ spacer,
62
+ spreadsheet,
63
+ stack,
64
+ table,
65
+ table_ref,
66
+ template,
67
+ text,
68
+ time,
69
+ time_format,
70
+ title,
71
+ validate,
72
+ when,
73
+ write,
74
+ xlsx,
75
+ )
76
+ from .core.errors import (
77
+ AggregateEvaluationError,
78
+ AmbiguousTemplateRefError,
79
+ BackendError,
80
+ CaxtonError,
81
+ CaxtonTypeError,
82
+ CaxtonValueError,
83
+ ColumnNotFoundError,
84
+ CyclicColumnError,
85
+ DataEvaluationError,
86
+ DataSourceConsumedError,
87
+ DataSourceError,
88
+ DataSourceIterationError,
89
+ DuplicateColumnError,
90
+ FieldAccessError,
91
+ GroupingError,
92
+ IncompatibleTemplateRefError,
93
+ InvalidOperationError,
94
+ InvalidTemplateRefError,
95
+ MatrixConflictError,
96
+ MissingFieldError,
97
+ MissingTemplateRefError,
98
+ RenderError,
99
+ SchemaError,
100
+ SourceEvaluationError,
101
+ TemplateError,
102
+ TemplateFormatError,
103
+ TemplateRefError,
104
+ UnsupportedDataSourceError,
105
+ UnsupportedFeatureError,
106
+ ValidationError,
107
+ )
108
+ from .core.rendering import ExecutionMode
109
+
110
+ __all__ = (
111
+ "AggregateEvaluationError",
112
+ "AggregateExpr",
113
+ "AggregateFunction",
114
+ "AmbiguousTemplateRefError",
115
+ "BackendError",
116
+ "BlockDirection",
117
+ "BorderLine",
118
+ "BorderLineStyle",
119
+ "Borders",
120
+ "CaxtonError",
121
+ "CaxtonTypeError",
122
+ "CaxtonValueError",
123
+ "CellAlignment",
124
+ "ChartKind",
125
+ "Column",
126
+ "ColumnNotFoundError",
127
+ "CorporateTheme",
128
+ "CustomFormat",
129
+ "CyclicColumnError",
130
+ "DataEvaluationError",
131
+ "DataSourceConsumedError",
132
+ "DataSourceError",
133
+ "DataSourceIterationError",
134
+ "DateFormat",
135
+ "DecimalFormat",
136
+ "DocumentTheme",
137
+ "DuplicateColumnError",
138
+ "ExecutionMode",
139
+ "Expression",
140
+ "FieldAccessError",
141
+ "FieldRef",
142
+ "FillStyle",
143
+ "FontStyle",
144
+ "Freeze",
145
+ "GroupOrder",
146
+ "Grouping",
147
+ "GroupingError",
148
+ "IncompatibleTemplateRefError",
149
+ "InvalidOperationError",
150
+ "InvalidTemplateRefError",
151
+ "Matrix",
152
+ "MatrixConflictError",
153
+ "MissingFieldError",
154
+ "MissingTemplateRefError",
155
+ "MoneyFormat",
156
+ "PercentageFormat",
157
+ "RenderError",
158
+ "SchemaError",
159
+ "SourceEvaluationError",
160
+ "Style",
161
+ "StyleSheet",
162
+ "TemplateError",
163
+ "TemplateFormatError",
164
+ "TemplateRefError",
165
+ "TemplateRepeat",
166
+ "TemplateSpecification",
167
+ "TimeFormat",
168
+ "Total",
169
+ "Totals",
170
+ "UnsupportedDataSourceError",
171
+ "UnsupportedFeatureError",
172
+ "ValidationError",
173
+ "VerticalAlignment",
174
+ "absolute",
175
+ "boolean",
176
+ "chart",
177
+ "col",
178
+ "custom_format",
179
+ "data_source",
180
+ "date",
181
+ "date_format",
182
+ "datetime",
183
+ "decimal",
184
+ "decimal_format",
185
+ "duration",
186
+ "field",
187
+ "image",
188
+ "integer",
189
+ "link",
190
+ "matrix",
191
+ "money",
192
+ "money_format",
193
+ "path",
194
+ "percentage",
195
+ "percentage_format",
196
+ "ref",
197
+ "render",
198
+ "repeat",
199
+ "sheet",
200
+ "sheet_ref",
201
+ "spacer",
202
+ "spreadsheet",
203
+ "stack",
204
+ "table",
205
+ "table_ref",
206
+ "template",
207
+ "text",
208
+ "time",
209
+ "time_format",
210
+ "title",
211
+ "validate",
212
+ "when",
213
+ "write",
214
+ "xlsx",
215
+ )
caxton/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,11 @@
1
+ from .matrices import prepare_matrix
2
+ from .models import PreparedColumn, PreparedTabularData, RelativeMerge
3
+ from .tables import prepare_table
4
+
5
+ __all__ = (
6
+ "PreparedColumn",
7
+ "PreparedTabularData",
8
+ "RelativeMerge",
9
+ "prepare_matrix",
10
+ "prepare_table",
11
+ )
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import warnings
5
+ from collections.abc import Mapping, MutableMapping, Sequence
6
+ from types import MappingProxyType
7
+ from typing import Any
8
+
9
+ from caxton._internal.semantic import SemanticRowEvaluator
10
+ from caxton.core._values import normalize_cell_value
11
+ from caxton.core.errors import AggregateEvaluationError, CaxtonError, PerformanceWarning
12
+ from caxton.core.models import AggregateExpr, Column, Expression
13
+ from caxton.core.protocols import DataSource
14
+ from caxton.core.values import CellValue
15
+
16
+ _BUFFERED_ROW_WARNING_THRESHOLD = 1_000_000
17
+
18
+
19
+ @dataclasses.dataclass(frozen=True, slots=True)
20
+ class InputRow:
21
+ """Retained values needed after the original source row is released."""
22
+
23
+ index: int
24
+ values: Mapping[str, CellValue]
25
+ expressions: Mapping[Expression, object]
26
+
27
+ def __post_init__(self) -> None:
28
+ object.__setattr__(
29
+ self,
30
+ "expressions",
31
+ MappingProxyType(dict(self.expressions)),
32
+ )
33
+
34
+
35
+ def read_rows(
36
+ source: DataSource[Any],
37
+ columns: Sequence[Column],
38
+ evaluator: SemanticRowEvaluator,
39
+ *,
40
+ aggregates: Sequence[AggregateExpr] = (),
41
+ path: str = "table",
42
+ ) -> tuple[InputRow, ...]:
43
+ """Read and evaluate retained row state in exactly one source pass.
44
+
45
+ Returns:
46
+ Buffered semantic and aggregate-input values.
47
+ """
48
+ rows: list[InputRow] = []
49
+ column_catalog = {column.id: column for column in columns}
50
+ for index, raw in evaluator.iter_source_rows(source):
51
+ rows.append(
52
+ evaluate_input_row(
53
+ source,
54
+ raw,
55
+ index,
56
+ columns,
57
+ aggregates,
58
+ evaluator,
59
+ column_catalog=column_catalog,
60
+ ),
61
+ )
62
+ output = tuple(rows)
63
+ warn_if_large_buffer(len(output), path=path, reason="grouping or aggregation")
64
+ return output
65
+
66
+
67
+ def evaluate_input_row( # noqa: WPS211
68
+ source: DataSource[Any],
69
+ raw: object,
70
+ index: int,
71
+ columns: Sequence[Column],
72
+ aggregates: Sequence[AggregateExpr],
73
+ evaluator: SemanticRowEvaluator,
74
+ *,
75
+ column_catalog: Mapping[str, Column],
76
+ ) -> InputRow:
77
+ """Evaluate all retained state before releasing an original row.
78
+
79
+ Returns:
80
+ A buffered input row without the original source object.
81
+ """
82
+ semantic = evaluator.evaluate_row(
83
+ source,
84
+ raw,
85
+ columns,
86
+ row_index=index,
87
+ column_catalog=column_catalog,
88
+ )
89
+ expressions = _evaluate_aggregate_expressions(
90
+ aggregates,
91
+ source=source,
92
+ raw=raw,
93
+ index=index,
94
+ columns=column_catalog,
95
+ values=semantic.values,
96
+ evaluator=evaluator,
97
+ )
98
+ return InputRow(
99
+ index=index,
100
+ values=semantic.values,
101
+ expressions=expressions,
102
+ )
103
+
104
+
105
+ def _evaluate_aggregate_expressions( # noqa: WPS211
106
+ aggregates: Sequence[AggregateExpr],
107
+ *,
108
+ source: DataSource[Any],
109
+ raw: object,
110
+ index: int,
111
+ columns: Mapping[str, Column],
112
+ values: Mapping[str, CellValue],
113
+ evaluator: SemanticRowEvaluator,
114
+ ) -> Mapping[Expression, object]:
115
+ filters = _unique_expressions(
116
+ tuple(aggregate.where for aggregate in aggregates),
117
+ )
118
+ output = _evaluate_expression_mapping(
119
+ filters,
120
+ source=source,
121
+ raw=raw,
122
+ index=index,
123
+ columns=columns,
124
+ values=values,
125
+ evaluator=evaluator,
126
+ )
127
+ inputs = _unique_expressions(
128
+ tuple(
129
+ expression
130
+ for aggregate in aggregates
131
+ if aggregate.where is None or bool(output[aggregate.where])
132
+ for expression in aggregate.expressions
133
+ ),
134
+ )
135
+ output.update(
136
+ _evaluate_expression_mapping(
137
+ inputs,
138
+ source=source,
139
+ raw=raw,
140
+ index=index,
141
+ columns=columns,
142
+ values=values,
143
+ evaluator=evaluator,
144
+ ),
145
+ )
146
+ return output
147
+
148
+
149
+ def _evaluate_expression_mapping( # noqa: WPS211
150
+ expressions: Sequence[Expression],
151
+ *,
152
+ source: DataSource[Any],
153
+ raw: object,
154
+ index: int,
155
+ columns: Mapping[str, Column],
156
+ values: Mapping[str, CellValue],
157
+ evaluator: SemanticRowEvaluator,
158
+ ) -> dict[Expression, object]:
159
+ results = evaluator.evaluate_expressions(
160
+ source,
161
+ raw,
162
+ columns,
163
+ expressions,
164
+ row_index=index,
165
+ values=values,
166
+ )
167
+ return dict(zip(expressions, results, strict=True))
168
+
169
+
170
+ def _unique_expressions(
171
+ expressions: Sequence[Expression | None],
172
+ ) -> tuple[Expression, ...]:
173
+ output: dict[Expression, None] = {}
174
+ for expression in expressions:
175
+ if expression is not None:
176
+ output.setdefault(expression, None)
177
+ return tuple(output)
178
+
179
+
180
+ def warn_if_large_buffer(row_count: int, *, path: str, reason: str) -> None:
181
+ """Warn once when a shape-dependent block retains a very large source."""
182
+ if row_count <= _BUFFERED_ROW_WARNING_THRESHOLD:
183
+ return
184
+ warnings.warn(
185
+ f"{path} buffered {row_count:,} rows for {reason}",
186
+ PerformanceWarning,
187
+ stacklevel=3,
188
+ )
189
+
190
+
191
+ def execute_aggregate(
192
+ expression: AggregateExpr,
193
+ rows: Sequence[InputRow],
194
+ *,
195
+ path: str,
196
+ filter_cache: MutableMapping[
197
+ Expression | None,
198
+ tuple[InputRow, ...],
199
+ ]
200
+ | None = None,
201
+ ) -> CellValue:
202
+ """Execute one aggregate from captured values.
203
+
204
+ Returns:
205
+ The normalized aggregate result.
206
+ """
207
+ included = _included_rows(
208
+ expression.where,
209
+ rows,
210
+ filter_cache=filter_cache,
211
+ )
212
+ if not included and expression.has_default:
213
+ return normalize_cell_value(expression.default)
214
+ inputs = tuple(
215
+ tuple(row.expressions[item] for row in included)
216
+ for item in expression.expressions
217
+ )
218
+ function_name = getattr(
219
+ expression.function,
220
+ "__name__",
221
+ type(expression.function).__name__,
222
+ )
223
+ result = _call_aggregate(
224
+ expression,
225
+ inputs,
226
+ function_name=function_name,
227
+ scope_size=len(included),
228
+ path=path,
229
+ )
230
+ return _normalize_aggregate_result(
231
+ result,
232
+ function_name=function_name,
233
+ scope_size=len(included),
234
+ path=path,
235
+ )
236
+
237
+
238
+ def _included_rows(
239
+ where: Expression | None,
240
+ rows: Sequence[InputRow],
241
+ *,
242
+ filter_cache: MutableMapping[
243
+ Expression | None,
244
+ tuple[InputRow, ...],
245
+ ]
246
+ | None,
247
+ ) -> tuple[InputRow, ...]:
248
+ if filter_cache is not None and where in filter_cache:
249
+ return filter_cache[where]
250
+ included = (
251
+ tuple(rows)
252
+ if where is None
253
+ else tuple(row for row in rows if bool(row.expressions[where]))
254
+ )
255
+ if filter_cache is not None:
256
+ filter_cache[where] = included
257
+ return included
258
+
259
+
260
+ def _call_aggregate(
261
+ expression: AggregateExpr,
262
+ inputs: Sequence[Sequence[object]],
263
+ *,
264
+ function_name: str,
265
+ scope_size: int,
266
+ path: str,
267
+ ) -> object:
268
+ try:
269
+ return expression.function(*inputs)
270
+ except CaxtonError:
271
+ raise
272
+ except Exception as error:
273
+ message = f"Aggregate {function_name!r} failed"
274
+ raise AggregateEvaluationError(
275
+ message,
276
+ path=path,
277
+ context={
278
+ "exception_type": type(error).__name__,
279
+ "function": function_name,
280
+ "phase": "callable",
281
+ "scope_size": scope_size,
282
+ },
283
+ ) from error
284
+
285
+
286
+ def _normalize_aggregate_result(
287
+ result: object,
288
+ *,
289
+ function_name: str,
290
+ scope_size: int,
291
+ path: str,
292
+ ) -> CellValue:
293
+ try:
294
+ return normalize_cell_value(result)
295
+ except Exception as error:
296
+ message = f"Aggregate {function_name!r} returned an unsupported result"
297
+ raise AggregateEvaluationError(
298
+ message,
299
+ path=path,
300
+ context={
301
+ "exception_type": type(error).__name__,
302
+ "function": function_name,
303
+ "phase": "result_normalization",
304
+ "scope_size": scope_size,
305
+ },
306
+ ) from error
307
+
308
+
309
+ __all__ = (
310
+ "InputRow",
311
+ "evaluate_input_row",
312
+ "execute_aggregate",
313
+ "read_rows",
314
+ "warn_if_large_buffer",
315
+ )
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import decimal
4
+ from collections.abc import Callable, Sequence
5
+ from typing import Any, TypeAlias, TypeVar, cast
6
+
7
+ from caxton.core.errors import GroupingError
8
+ from caxton.core.models import Column, GroupOrder
9
+ from caxton.core.values import CellValue
10
+
11
+ DimensionToken: TypeAlias = tuple[str, object]
12
+ GroupKey: TypeAlias = tuple[CellValue, ...]
13
+ TokenKey: TypeAlias = tuple[DimensionToken, ...]
14
+ _Item = TypeVar("_Item")
15
+
16
+
17
+ def dimension_token(value: object) -> DimensionToken:
18
+ """Return the stable, type-sensitive identity of one dimension value."""
19
+ type_name = f"{type(value).__module__}.{type(value).__qualname__}"
20
+ if isinstance(value, decimal.Decimal):
21
+ return type_name, value.as_tuple()
22
+ if isinstance(value, float):
23
+ if not value:
24
+ return type_name, value.hex().lstrip("-")
25
+ return type_name, value.hex()
26
+ return type_name, value
27
+
28
+
29
+ def order_group_values(
30
+ items: Sequence[_Item],
31
+ column: Column,
32
+ value: Callable[[_Item], CellValue],
33
+ *,
34
+ path: str,
35
+ ) -> list[_Item]:
36
+ """Order dimension items with grouping's nulls-last semantics.
37
+
38
+ Returns:
39
+ A new list in the declared group order.
40
+
41
+ Raises:
42
+ GroupingError: If sorted values cannot be compared.
43
+ """
44
+ grouping = column.grouping
45
+ if grouping is None or grouping.order is GroupOrder.FIRST_SEEN:
46
+ return list(items)
47
+ non_null = [item for item in items if value(item) is not None]
48
+ nulls = [item for item in items if value(item) is None]
49
+ try:
50
+ ordered = sorted(
51
+ non_null,
52
+ key=lambda item: cast("Any", value(item)),
53
+ reverse=grouping.order is GroupOrder.DESCENDING,
54
+ )
55
+ except TypeError as error:
56
+ message = f"Group column {column.id!r} contains incomparable values"
57
+ raise GroupingError(
58
+ message,
59
+ path=f'{path}.column["{column.id}"].grouping',
60
+ context={
61
+ "column": column.id,
62
+ "order": grouping.order.value,
63
+ "value_types": sorted({type(value(item)).__name__ for item in items}),
64
+ },
65
+ ) from error
66
+ return [*ordered, *nulls]
67
+
68
+
69
+ def key_token(key: GroupKey) -> TokenKey:
70
+ """Return the strict identity of one compound dimension key."""
71
+ return tuple(dimension_token(value) for value in key)
72
+
73
+
74
+ __all__ = (
75
+ "GroupKey",
76
+ "TokenKey",
77
+ "dimension_token",
78
+ "key_token",
79
+ "order_group_values",
80
+ )