daitum-test 0.5.2__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.
@@ -0,0 +1,107 @@
1
+ # Copyright 2026 Daitum
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ daitum_test - Test Daitum models from Python
17
+ ============================================
18
+
19
+ Load a model, feed it data, run it, and make plain assertions about the results — tables,
20
+ named values, and diagnostics — locally, without the platform. Use it to unit-test a formula
21
+ against crafted inputs, check an optimisation result, assert an output table has no error
22
+ cells, or compare several scenarios in one run.
23
+
24
+ .. code-block:: python
25
+
26
+ from daitum_test import Evaluator, Model, Run
27
+
28
+ model = Model.from_archive("MyModel.zip")
29
+ evaluator = Evaluator()
30
+
31
+ result = evaluator.evaluate(model, runs=[
32
+ Run(scenario="Initial", id="high-demand", mode="OPTIMISER",
33
+ tables={"Items": "fixtures/items_high_demand.csv"},
34
+ named_values={"Budget": 50_000}),
35
+ ])
36
+
37
+ run = result["high-demand"]
38
+ assert run.errors == []
39
+ assert run.named_value("TotalCost") <= 50_000
40
+ assert run.table("Output").row_count >= 1
41
+ assert run.table("Output").error_cells() == []
42
+
43
+ Load a model with :meth:`Model.from_archive`, :meth:`Model.from_directory`, or
44
+ :meth:`Model.from_definition` (daitum-core builders work directly). Feed data as CSV files or
45
+ inline rows — cells are typed from the model's own field definitions. A pytest plugin provides
46
+ a ready-made ``evaluator`` fixture.
47
+
48
+ Install with ``pip install daitum-test`` (no runtime dependencies beyond the standard
49
+ library). Evaluation runs against the Daitum model engine, which is obtained separately and
50
+ located via the ``EVALUATOR_JAR`` environment variable, with a Java 25+ runtime available; see
51
+ the README. Each evaluation loads the whole model once, so batch a module's runs into a single
52
+ ``evaluate`` call (e.g. from a module-scoped fixture) rather than calling per test.
53
+ """
54
+
55
+ from daitum_test.data import ERROR, INDEX_KEY, REF_KEY
56
+ from daitum_test.errors import (
57
+ ContractError,
58
+ DaitumTestError,
59
+ DataConversionError,
60
+ EvaluatorCrashError,
61
+ EvaluatorNotFoundError,
62
+ ModelInspectionError,
63
+ UnknownFieldError,
64
+ UnknownNamedValueError,
65
+ UnknownRunError,
66
+ UnknownTableError,
67
+ )
68
+ from daitum_test.evaluator import Evaluator
69
+ from daitum_test.model import Model
70
+ from daitum_test.result import (
71
+ Diagnostic,
72
+ ErrorCell,
73
+ EvaluationResult,
74
+ RunResult,
75
+ TableResult,
76
+ ValidationReport,
77
+ is_error_cell,
78
+ )
79
+ from daitum_test.run import Options, Run, TableLike
80
+
81
+ __all__ = [
82
+ "ERROR",
83
+ "INDEX_KEY",
84
+ "REF_KEY",
85
+ "ContractError",
86
+ "DaitumTestError",
87
+ "DataConversionError",
88
+ "Diagnostic",
89
+ "ErrorCell",
90
+ "EvaluationResult",
91
+ "Evaluator",
92
+ "EvaluatorCrashError",
93
+ "EvaluatorNotFoundError",
94
+ "Model",
95
+ "ModelInspectionError",
96
+ "Options",
97
+ "Run",
98
+ "RunResult",
99
+ "TableLike",
100
+ "TableResult",
101
+ "UnknownFieldError",
102
+ "UnknownNamedValueError",
103
+ "UnknownRunError",
104
+ "UnknownTableError",
105
+ "ValidationReport",
106
+ "is_error_cell",
107
+ ]
daitum_test/data.py ADDED
@@ -0,0 +1,253 @@
1
+ # Copyright 2026 Daitum
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Conversion of Python data into the evaluator's JSON encodings.
16
+
17
+ Encodings in the request contract:
18
+
19
+ - **Table rows** are plain JSON values (``"#ERROR"`` marks an error cell; a missing field
20
+ is null).
21
+ - **Named values** are typed objects: ``{"@type": "DECIMAL", "value": 0.5}``.
22
+ - **Object references** (``OBJECT`` / ``OBJECT_ARRAY`` fields) are the **row index** of the
23
+ referenced row within the referenced table, as supplied in the same request (0-based). The
24
+ harness resolves a natural key (the referenced table's key-column value) to that index for
25
+ you.
26
+ - **Maps** (``*_MAP`` fields) are objects keyed by the referenced row index as a string:
27
+ ``{"0": true, "1": false}``. The harness resolves natural keys here too.
28
+
29
+ CSV files are converted using the model's declared field types — never by guessing from the
30
+ cell text (``"0123"`` silently becoming ``123`` would corrupt STRING data).
31
+ """
32
+
33
+ import csv
34
+ import datetime
35
+ from decimal import Decimal
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from daitum_test.errors import DataConversionError
40
+
41
+ ERROR = "#ERROR"
42
+ """Sentinel for an error cell in input rows."""
43
+
44
+ #: Escape hatch: wrap a cell as ``{"@ref": key}`` to force resolution by natural key (no
45
+ #: row-index fallback) — use when a key is an integer and might be mistaken for a row index.
46
+ REF_KEY = "@ref"
47
+
48
+ #: Escape hatch: wrap a cell as ``{"@index": n}`` to pass an explicit 0-based row index
49
+ #: without any key lookup.
50
+ INDEX_KEY = "@index"
51
+
52
+ # Python type → evaluator DataType, used when the model's declared type is unknown.
53
+ # bool must be checked before int (bool subclasses int).
54
+ _PYTHON_TYPE_NAMES = (
55
+ (bool, "BOOLEAN"),
56
+ (int, "INTEGER"),
57
+ (float, "DECIMAL"),
58
+ (Decimal, "DECIMAL"),
59
+ (str, "STRING"),
60
+ (datetime.datetime, "DATETIME"), # before date (datetime subclasses date)
61
+ (datetime.date, "DATE"),
62
+ (datetime.time, "TIME"),
63
+ )
64
+
65
+
66
+ def convert_cell(value: Any) -> Any:
67
+ """Convert one Python value to the plain JSON cell encoding."""
68
+ if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
69
+ return value.isoformat()
70
+ if isinstance(value, Decimal):
71
+ return float(value)
72
+ if value is None or isinstance(value, (bool, int, float, str)):
73
+ return value
74
+ if isinstance(value, (list, tuple)):
75
+ return [convert_cell(v) for v in value]
76
+ raise DataConversionError(f"Cannot encode {value!r} ({type(value).__name__}) as a table cell.")
77
+
78
+
79
+ class ReferenceResolver:
80
+ """Resolves natural keys of referenced tables to the 0-based row indices the evaluator
81
+ expects for OBJECT / MAP fields.
82
+
83
+ A row's index is its position in the referenced table **as supplied in the same request**
84
+ (an override's rows if the table is overridden, else the model's base scenario rows). The
85
+ resolver is built from exactly those rows, so the indices it emits match what the evaluator
86
+ assigns when it loads them.
87
+
88
+ A bare value is treated as a natural key and looked up. If the key is not found **and** it
89
+ is an integer, it falls back to being used as a row index directly — so integer-keyed
90
+ tables resolve by key, while a hand-supplied row index still works where there is no
91
+ ambiguity. Use ``{"@ref": k}`` to force a key lookup (no fallback) or ``{"@index": n}`` to
92
+ force a raw row index.
93
+ """
94
+
95
+ def __init__(self, key_indexes: dict[str, dict[Any, int]]):
96
+ self._key_indexes = key_indexes
97
+
98
+ def _lookup(self, table: str, key: Any, *, allow_index_fallback: bool) -> int:
99
+ if isinstance(key, bool): # bool is an int subclass; never a key or index
100
+ raise DataConversionError(f"A boolean is not a valid reference into {table!r}.")
101
+ index = self._key_indexes.get(table, {})
102
+ if key in index:
103
+ return index[key]
104
+ if allow_index_fallback and isinstance(key, int):
105
+ return key # no row has this key, but a bare int is a usable row index
106
+ known = ", ".join(repr(k) for k in list(index)[:10])
107
+ raise DataConversionError(
108
+ f"Cannot resolve reference {key!r} into table {table!r}: no row with that key. "
109
+ f"Known keys: {known or '(the table has no rows in this run)'}. Provide the "
110
+ f"referenced table's rows in the same run, or pass {{'@index': n}} for a row index."
111
+ )
112
+
113
+ def resolve(self, table: str, value: Any) -> int:
114
+ """Row index for a cell value: a bare key (with int-index fallback), ``{"@ref": k}``
115
+ (key lookup only), or ``{"@index": n}`` (raw row index)."""
116
+ if isinstance(value, dict) and set(value) == {INDEX_KEY}:
117
+ n = value[INDEX_KEY]
118
+ if not isinstance(n, int) or isinstance(n, bool):
119
+ raise DataConversionError(f"{INDEX_KEY} requires an integer row index, got {n!r}.")
120
+ return n
121
+ if isinstance(value, dict) and set(value) == {REF_KEY}:
122
+ return self._lookup(table, value[REF_KEY], allow_index_fallback=False)
123
+ return self._lookup(table, value, allow_index_fallback=True)
124
+
125
+
126
+ def encode_object_cell(
127
+ value: Any, table: str, resolver: ReferenceResolver, *, is_array: bool
128
+ ) -> Any:
129
+ """Encode an OBJECT / OBJECT_ARRAY cell to row index/indices (None passes through)."""
130
+ if value is None or value == ERROR:
131
+ return value
132
+ if is_array:
133
+ if not isinstance(value, (list, tuple)):
134
+ raise DataConversionError(
135
+ f"{table!r} object-array field expects a list of keys, got {value!r}."
136
+ )
137
+ return [resolver.resolve(table, key) for key in value]
138
+ return resolver.resolve(table, value)
139
+
140
+
141
+ def encode_map_cell(value: Any, table: str, resolver: ReferenceResolver) -> Any:
142
+ """Encode a map cell ``{key: v}`` to ``{"<row index>": v}`` (None passes through).
143
+
144
+ Map keys are natural keys of the referenced table (integer keys resolve by key too).
145
+ """
146
+ if value is None or value == ERROR:
147
+ return value
148
+ if not isinstance(value, dict):
149
+ raise DataConversionError(
150
+ f"{table!r} map field expects a dict of key→value, got {value!r}."
151
+ )
152
+ return {str(resolver.resolve(table, key)): convert_cell(v) for key, v in value.items()}
153
+
154
+
155
+ def _convert_csv_cell(table: str, field: str, data_type: str, text: str) -> Any:
156
+ if text == ERROR:
157
+ return ERROR
158
+ try:
159
+ if data_type == "INTEGER":
160
+ return int(text)
161
+ if data_type == "DECIMAL":
162
+ return float(text)
163
+ if data_type == "BOOLEAN":
164
+ lowered = text.lower()
165
+ if lowered not in ("true", "false"):
166
+ raise ValueError(f"expected 'true' or 'false', got {text!r}")
167
+ return lowered == "true"
168
+ if data_type in ("STRING", "DATE", "DATETIME", "TIME"):
169
+ return text # dates are ISO-8601 strings; the evaluator validates them
170
+ if data_type == "OBJECT":
171
+ # A single reference: the raw natural key, resolved to a row index later.
172
+ return text
173
+ except ValueError as exc:
174
+ raise DataConversionError(
175
+ f"{table}[{field}]: cannot convert {text!r} to {data_type}: {exc}"
176
+ ) from exc
177
+ raise DataConversionError(
178
+ f"{table}[{field}] has type {data_type}, which is not supported from CSV "
179
+ "(object-array and map fields use commas ambiguously); pass inline rows for this "
180
+ "table instead."
181
+ )
182
+
183
+
184
+ def csv_rows(path: Path, field_types: dict[str, str], table: str = "") -> list[dict[str, Any]]:
185
+ """Read a CSV file into typed JSON rows using the model's declared field types.
186
+
187
+ An empty cell omits the field from the row (null per the contract); an unknown header
188
+ fails immediately, before any JVM is paid for.
189
+ """
190
+ table = table or path.stem
191
+ # utf-8-sig transparently strips a BOM, common in Excel-exported CSVs.
192
+ with open(path, newline="", encoding="utf-8-sig") as handle:
193
+ reader = csv.DictReader(handle)
194
+ headers = reader.fieldnames or []
195
+ unknown = [header for header in headers if header not in field_types]
196
+ if unknown:
197
+ raise DataConversionError(
198
+ f"{path}: unknown field(s) {unknown} for table {table!r}. "
199
+ f"Model fields: {', '.join(sorted(field_types))}"
200
+ )
201
+ rows = []
202
+ for row in reader:
203
+ converted = {
204
+ field: _convert_csv_cell(table, field, field_types[field], text)
205
+ for field, text in row.items()
206
+ if text is not None and text != ""
207
+ }
208
+ rows.append(converted)
209
+ return rows
210
+
211
+
212
+ def _infer_type(value: Any) -> str:
213
+ for python_type, name in _PYTHON_TYPE_NAMES:
214
+ if isinstance(value, python_type):
215
+ return name
216
+ raise DataConversionError(
217
+ f"Cannot encode {value!r} ({type(value).__name__}) as a named value; pass a dict "
218
+ 'with an explicit "@type" instead.'
219
+ )
220
+
221
+
222
+ def encode_named_value(value: Any, declared_type: str | None = None) -> dict[str, Any]:
223
+ """Encode a Python value as the evaluator's ``{"@type": ..., "value": ...}`` object.
224
+
225
+ The model's declared type wins when known: the Python value is coerced to it (e.g. ``40``
226
+ for a DECIMAL named value becomes ``40.0``). A dict already carrying ``"@type"`` passes
227
+ through verbatim as an escape hatch for types this module does not model.
228
+ """
229
+ if isinstance(value, dict):
230
+ if "@type" in value:
231
+ return value
232
+ raise DataConversionError(
233
+ f'A dict named value must carry "@type"; got keys {sorted(value)}.'
234
+ )
235
+
236
+ if isinstance(value, (list, tuple)):
237
+ if not value:
238
+ if declared_type is None:
239
+ raise DataConversionError(
240
+ "Cannot infer the element type of an empty list; the model's declared "
241
+ "type is required."
242
+ )
243
+ element_type = declared_type
244
+ else:
245
+ element_type = declared_type or f"{_infer_type(value[0])}_ARRAY"
246
+ return {"@type": element_type, "value": [convert_cell(v) for v in value]}
247
+
248
+ type_name = declared_type or _infer_type(value)
249
+ converted = convert_cell(value)
250
+ # Coerce numerics to the declared type so 40 for a DECIMAL becomes 40.0.
251
+ if type_name == "DECIMAL" and isinstance(converted, int) and not isinstance(converted, bool):
252
+ converted = float(converted)
253
+ return {"@type": type_name, "value": converted}
daitum_test/errors.py ADDED
@@ -0,0 +1,59 @@
1
+ # Copyright 2026 Daitum
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Exception hierarchy for daitum-test."""
16
+
17
+
18
+ class DaitumTestError(Exception):
19
+ """Base class for all daitum-test errors."""
20
+
21
+
22
+ class EvaluatorNotFoundError(DaitumTestError):
23
+ """The evaluator jar or a usable Java runtime could not be located."""
24
+
25
+
26
+ class ContractError(DaitumTestError):
27
+ """The evaluator rejected the request (exit 2), or it was invalid pre-flight.
28
+
29
+ This signals a bug in daitum-test or bad caller input — never a model error.
30
+ Model errors are returned on the :class:`~daitum_test.result.EvaluationResult`.
31
+ """
32
+
33
+
34
+ class EvaluatorCrashError(DaitumTestError):
35
+ """The evaluator crashed unexpectedly (exit 3 or an unrecognised exit code)."""
36
+
37
+
38
+ class ModelInspectionError(DaitumTestError):
39
+ """The model definition could not be read, so typed conversion is unavailable."""
40
+
41
+
42
+ class DataConversionError(DaitumTestError):
43
+ """Input data could not be converted to the evaluator's JSON encoding."""
44
+
45
+
46
+ class UnknownRunError(DaitumTestError):
47
+ """A run id was not present in the evaluation response."""
48
+
49
+
50
+ class UnknownTableError(DaitumTestError):
51
+ """A table id was not present in a run's results."""
52
+
53
+
54
+ class UnknownFieldError(DaitumTestError):
55
+ """A field id was not present in a table's results."""
56
+
57
+
58
+ class UnknownNamedValueError(DaitumTestError):
59
+ """A named value was not present in a run's results."""