modelwright 0.1.0a3__tar.gz → 0.1.0a4__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 (53) hide show
  1. {modelwright-0.1.0a3/src/modelwright.egg-info → modelwright-0.1.0a4}/PKG-INFO +2 -2
  2. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/README.md +1 -1
  3. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/pyproject.toml +4 -1
  4. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/__init__.py +27 -1
  5. modelwright-0.1.0a4/src/modelwright/wrappers.py +426 -0
  6. {modelwright-0.1.0a3 → modelwright-0.1.0a4/src/modelwright.egg-info}/PKG-INFO +2 -2
  7. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/SOURCES.txt +4 -1
  8. modelwright-0.1.0a4/tests/test_fable_wrapper_benchmark.py +89 -0
  9. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_import.py +1 -1
  10. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_public_api.py +5 -0
  11. modelwright-0.1.0a4/tests/test_wrappers.py +212 -0
  12. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/LICENSE +0 -0
  13. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/setup.cfg +0 -0
  14. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/cli.py +0 -0
  15. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/conversion.py +0 -0
  16. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/evaluation.py +0 -0
  17. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/execution.py +0 -0
  18. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/extraction.py +0 -0
  19. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/formulas.py +0 -0
  20. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/formulas_oracle.py +0 -0
  21. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/generation.py +0 -0
  22. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/graph.py +0 -0
  23. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/oracle_validation.py +0 -0
  24. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/oracles.py +0 -0
  25. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/references.py +0 -0
  26. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/validation.py +0 -0
  27. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/dependency_links.txt +0 -0
  28. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/entry_points.txt +0 -0
  29. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/requires.txt +0 -0
  30. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/top_level.txt +0 -0
  31. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_cli.py +0 -0
  32. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_conversion_plan.py +0 -0
  33. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_dependency_graph.py +0 -0
  34. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_evaluation_orchestration.py +0 -0
  35. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_extraction_records.py +0 -0
  36. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_formula_expressions.py +0 -0
  37. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_formula_translation.py +0 -0
  38. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_formulas_oracle.py +0 -0
  39. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_generated_execution.py +0 -0
  40. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_generation_contract.py +0 -0
  41. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_materialize_fable_benchmarks.py +0 -0
  42. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_openpyxl_extraction.py +0 -0
  43. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_oracle_backed_validation.py +0 -0
  44. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_oracle_interface.py +0 -0
  45. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_python_generation.py +0 -0
  46. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_references.py +0 -0
  47. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_scalar_comparison.py +0 -0
  48. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_supported_semantics_fixture.py +0 -0
  49. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_synthetic_fixture.py +0 -0
  50. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_validation.py +0 -0
  51. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_validation_regression.py +0 -0
  52. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_validation_report_builder.py +0 -0
  53. {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_validation_scenario.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modelwright
3
- Version: 0.1.0a3
3
+ Version: 0.1.0a4
4
4
  Summary: Tools for converting spreadsheet workbooks into transparent Python models.
5
5
  Author: UBC FRESH Lab
6
6
  License-Expression: MIT
@@ -135,7 +135,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
135
135
  scripts/bootstrap_dev_env.sh --benchmarks
136
136
  ```
137
137
 
138
- `modelwright` is pre-release. The current alpha line is `0.1.0a3`; alpha releases must not be described as full-workbook conversion guarantees.
138
+ `modelwright` is pre-release. The current alpha line is `0.1.0a4`; alpha releases must not be described as full-workbook conversion guarantees.
139
139
 
140
140
  Check release artifacts locally:
141
141
 
@@ -84,7 +84,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
84
84
  scripts/bootstrap_dev_env.sh --benchmarks
85
85
  ```
86
86
 
87
- `modelwright` is pre-release. The current alpha line is `0.1.0a3`; alpha releases must not be described as full-workbook conversion guarantees.
87
+ `modelwright` is pre-release. The current alpha line is `0.1.0a4`; alpha releases must not be described as full-workbook conversion guarantees.
88
88
 
89
89
  Check release artifacts locally:
90
90
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "modelwright"
7
- version = "0.1.0a3"
7
+ version = "0.1.0a4"
8
8
  description = "Tools for converting spreadsheet workbooks into transparent Python models."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -82,6 +82,9 @@ where = ["src"]
82
82
 
83
83
  [tool.pytest.ini_options]
84
84
  testpaths = ["tests"]
85
+ markers = [
86
+ "benchmark: tests that require local benchmark artifacts and are skipped unless explicitly enabled"
87
+ ]
85
88
 
86
89
  [tool.ruff]
87
90
  line-length = 120
@@ -80,11 +80,27 @@ from modelwright.validation import (
80
80
  compare_scalar_output,
81
81
  load_validation_scenario,
82
82
  )
83
+ from modelwright.wrappers import (
84
+ CellRef,
85
+ CellView,
86
+ ModelFacade,
87
+ ReportRef,
88
+ Scenario,
89
+ TableRef,
90
+ TableView,
91
+ WrapperDeclarationError,
92
+ WrapperExecutionError,
93
+ cell,
94
+ report,
95
+ table,
96
+ )
83
97
 
84
- __version__ = "0.1.0a3"
98
+ __version__ = "0.1.0a4"
85
99
 
86
100
  __all__ = [
87
101
  "CellRecord",
102
+ "CellRef",
103
+ "CellView",
88
104
  "ComparisonResult",
89
105
  "ComparisonRules",
90
106
  "ConversionPlan",
@@ -109,6 +125,7 @@ __all__ = [
109
125
  "GenerationDiagnostic",
110
126
  "GenerationResult",
111
127
  "MISSING_VALUE",
128
+ "ModelFacade",
112
129
  "NamedRangeRecord",
113
130
  "OracleConfig",
114
131
  "OracleDiagnostic",
@@ -117,10 +134,14 @@ __all__ = [
117
134
  "PlanRecommendation",
118
135
  "PrivacyReview",
119
136
  "ResidualBlocker",
137
+ "ReportRef",
138
+ "Scenario",
120
139
  "ScenarioInput",
121
140
  "ScenarioOutput",
122
141
  "SheetRecord",
142
+ "TableRef",
123
143
  "TableRecord",
144
+ "TableView",
124
145
  "ValidationReport",
125
146
  "ValidationEvaluationResult",
126
147
  "ValidationScenario",
@@ -129,12 +150,15 @@ __all__ = [
129
150
  "WorkbookReference",
130
151
  "WorkbookRecord",
131
152
  "WorkflowStatus",
153
+ "WrapperDeclarationError",
154
+ "WrapperExecutionError",
132
155
  "__version__",
133
156
  "build_dependency_graph",
134
157
  "build_conversion_plan",
135
158
  "build_formula_reference_index",
136
159
  "build_oracle_validation_report",
137
160
  "build_validation_report",
161
+ "cell",
138
162
  "compare_scalar_output",
139
163
  "execute_generated_model",
140
164
  "evaluate_generated_model",
@@ -144,5 +168,7 @@ __all__ = [
144
168
  "load_validation_scenario",
145
169
  "normalize_cell_reference",
146
170
  "normalize_reference",
171
+ "report",
172
+ "table",
147
173
  "translate_formula_cell",
148
174
  ]
@@ -0,0 +1,426 @@
1
+ """Lightweight facades for generated Modelwright models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from types import ModuleType
8
+ from typing import Any, Literal
9
+
10
+ from openpyxl.utils import get_column_letter
11
+ from openpyxl.utils.cell import column_index_from_string, range_boundaries
12
+
13
+ from modelwright.references import normalize_cell_reference, normalize_reference
14
+
15
+
16
+ CellRole = Literal["input", "output", "intermediate", "metadata"]
17
+ GeneratedCalculate = Callable[[dict[str, object] | None], dict[str, object]]
18
+
19
+
20
+ class WrapperDeclarationError(ValueError):
21
+ """Raised when wrapper declarations are malformed or inconsistent."""
22
+
23
+
24
+ class WrapperExecutionError(RuntimeError):
25
+ """Raised when a generated model cannot be executed through a facade."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CellRef:
30
+ """Declared facade metadata for one generated-model cell reference."""
31
+
32
+ cell_ref: str
33
+ name: str
34
+ label: str | None = None
35
+ role: CellRole = "metadata"
36
+ unit: str | None = None
37
+ description: str | None = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class TableRef:
42
+ """Declared rectangular workbook-like table facade."""
43
+
44
+ name: str
45
+ sheet: str
46
+ range_ref: str
47
+ cell_refs: tuple[tuple[str, ...], ...]
48
+ row_labels: tuple[str, ...]
49
+ column_labels: tuple[str, ...]
50
+ role: CellRole = "output"
51
+ label: str | None = None
52
+ description: str | None = None
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ReportRef:
57
+ """Declared named output bundle."""
58
+
59
+ name: str
60
+ cells: tuple[str, ...] = ()
61
+ tables: tuple[str, ...] = ()
62
+ label: str | None = None
63
+ description: str | None = None
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class CellView:
68
+ """Inspection payload for one cell in a facade scenario."""
69
+
70
+ cell_ref: str
71
+ name: str | None = None
72
+ label: str | None = None
73
+ role: CellRole | None = None
74
+ unit: str | None = None
75
+ description: str | None = None
76
+ value: object = None
77
+ has_value: bool = False
78
+
79
+ def to_dict(self) -> dict[str, object]:
80
+ return {
81
+ "cell_ref": self.cell_ref,
82
+ "name": self.name,
83
+ "label": self.label,
84
+ "role": self.role,
85
+ "unit": self.unit,
86
+ "description": self.description,
87
+ "value": self.value,
88
+ "has_value": self.has_value,
89
+ }
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class TableView:
94
+ """Calculated rectangular table payload."""
95
+
96
+ name: str
97
+ sheet: str
98
+ range_ref: str
99
+ rows: tuple[str, ...]
100
+ columns: tuple[str, ...]
101
+ cell_refs: tuple[tuple[str, ...], ...]
102
+ values: tuple[tuple[object, ...], ...]
103
+ label: str | None = None
104
+ description: str | None = None
105
+
106
+ def to_dict(self) -> dict[str, object]:
107
+ return {
108
+ "name": self.name,
109
+ "sheet": self.sheet,
110
+ "range_ref": self.range_ref,
111
+ "rows": list(self.rows),
112
+ "columns": list(self.columns),
113
+ "cell_refs": [list(row) for row in self.cell_refs],
114
+ "values": [list(row) for row in self.values],
115
+ "label": self.label,
116
+ "description": self.description,
117
+ }
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class Scenario:
122
+ """Immutable input override set for a facade calculation."""
123
+
124
+ name: str = "default"
125
+ _inputs: tuple[tuple[str, object], ...] = field(default_factory=tuple)
126
+
127
+ @classmethod
128
+ def from_inputs(cls, name: str = "default", inputs: Mapping[str, object] | None = None) -> "Scenario":
129
+ if inputs is None:
130
+ return cls(name=name)
131
+ normalized = {normalize_full_cell_ref(cell_ref): value for cell_ref, value in inputs.items()}
132
+ return cls(name=name, _inputs=tuple(normalized.items()))
133
+
134
+ @property
135
+ def inputs(self) -> dict[str, object]:
136
+ return dict(self._inputs)
137
+
138
+ def with_input(self, cell_ref: str, value: object) -> "Scenario":
139
+ inputs = self.inputs
140
+ inputs[normalize_full_cell_ref(cell_ref)] = value
141
+ return Scenario.from_inputs(name=self.name, inputs=inputs)
142
+
143
+
144
+ def cell(
145
+ cell_ref: str,
146
+ *,
147
+ name: str | None = None,
148
+ label: str | None = None,
149
+ role: CellRole = "metadata",
150
+ unit: str | None = None,
151
+ description: str | None = None,
152
+ ) -> CellRef:
153
+ """Declare one facade cell."""
154
+
155
+ normalized = normalize_full_cell_ref(cell_ref)
156
+ return CellRef(
157
+ cell_ref=normalized,
158
+ name=name or normalized,
159
+ label=label,
160
+ role=role,
161
+ unit=unit,
162
+ description=description,
163
+ )
164
+
165
+
166
+ def table(
167
+ name: str,
168
+ *,
169
+ sheet: str | None = None,
170
+ range_ref: str,
171
+ row_labels: tuple[str, ...] | list[str] | None = None,
172
+ column_labels: tuple[str, ...] | list[str] | None = None,
173
+ role: CellRole = "output",
174
+ label: str | None = None,
175
+ description: str | None = None,
176
+ ) -> TableRef:
177
+ """Declare one rectangular facade table."""
178
+
179
+ table_sheet, coordinate = normalize_table_range(sheet=sheet, range_ref=range_ref)
180
+ min_col, min_row, max_col, max_row = range_boundaries(coordinate)
181
+ row_count = max_row - min_row + 1
182
+ column_count = max_col - min_col + 1
183
+
184
+ rows = tuple(row_labels) if row_labels is not None else tuple(str(row) for row in range(min_row, max_row + 1))
185
+ columns = (
186
+ tuple(column_labels)
187
+ if column_labels is not None
188
+ else tuple(get_column_letter(column) for column in range(min_col, max_col + 1))
189
+ )
190
+ if len(rows) != row_count:
191
+ raise WrapperDeclarationError(
192
+ f"table {name!r} declares {len(rows)} row labels for {row_count} table rows"
193
+ )
194
+ if len(columns) != column_count:
195
+ raise WrapperDeclarationError(
196
+ f"table {name!r} declares {len(columns)} column labels for {column_count} table columns"
197
+ )
198
+
199
+ cell_refs = tuple(
200
+ tuple(f"{table_sheet}!{get_column_letter(column)}{row}" for column in range(min_col, max_col + 1))
201
+ for row in range(min_row, max_row + 1)
202
+ )
203
+ normalized_range = f"{get_column_letter(min_col)}{min_row}:{get_column_letter(max_col)}{max_row}"
204
+ return TableRef(
205
+ name=name,
206
+ sheet=table_sheet,
207
+ range_ref=normalized_range,
208
+ cell_refs=cell_refs,
209
+ row_labels=rows,
210
+ column_labels=columns,
211
+ role=role,
212
+ label=label,
213
+ description=description,
214
+ )
215
+
216
+
217
+ def report(
218
+ name: str,
219
+ *,
220
+ cells: tuple[str, ...] | list[str] = (),
221
+ tables: tuple[str, ...] | list[str] = (),
222
+ label: str | None = None,
223
+ description: str | None = None,
224
+ ) -> ReportRef:
225
+ """Declare one structured report selection."""
226
+
227
+ return ReportRef(
228
+ name=name,
229
+ cells=tuple(cells),
230
+ tables=tuple(tables),
231
+ label=label,
232
+ description=description,
233
+ )
234
+
235
+
236
+ class ModelFacade:
237
+ """Analyst-facing facade around a generated Modelwright model."""
238
+
239
+ def __init__(
240
+ self,
241
+ generated_model: ModuleType | GeneratedCalculate | object,
242
+ *,
243
+ cells: tuple[CellRef, ...] | list[CellRef] = (),
244
+ tables: tuple[TableRef, ...] | list[TableRef] = (),
245
+ reports: tuple[ReportRef, ...] | list[ReportRef] = (),
246
+ ) -> None:
247
+ self._calculate = generated_calculate(generated_model)
248
+ self._cells = keyed_declarations(cells, kind="cell")
249
+ self._cells_by_ref = {declaration.cell_ref: declaration for declaration in self._cells.values()}
250
+ self._tables = keyed_declarations(tables, kind="table")
251
+ self._reports = keyed_declarations(reports, kind="report")
252
+ self._last_scenario: Scenario | None = None
253
+ self._last_inputs: dict[str, object] = {}
254
+ self._last_values: dict[str, object] | None = None
255
+ self._validate_reports()
256
+
257
+ @property
258
+ def cells(self) -> dict[str, CellRef]:
259
+ return dict(self._cells)
260
+
261
+ @property
262
+ def tables(self) -> dict[str, TableRef]:
263
+ return dict(self._tables)
264
+
265
+ @property
266
+ def reports(self) -> dict[str, ReportRef]:
267
+ return dict(self._reports)
268
+
269
+ def inputs(self) -> dict[str, CellRef]:
270
+ return {
271
+ name: declaration
272
+ for name, declaration in self._cells.items()
273
+ if declaration.role == "input"
274
+ }
275
+
276
+ def outputs(self) -> dict[str, CellRef]:
277
+ return {
278
+ name: declaration
279
+ for name, declaration in self._cells.items()
280
+ if declaration.role == "output"
281
+ }
282
+
283
+ def scenario(self, name: str = "default", inputs: Mapping[str, object] | None = None) -> Scenario:
284
+ return Scenario.from_inputs(name=name, inputs=inputs)
285
+
286
+ def calculate(self, scenario: Scenario | None = None) -> dict[str, object]:
287
+ scenario = scenario or Scenario()
288
+ try:
289
+ values = self._calculate(scenario.inputs)
290
+ except Exception as error: # noqa: BLE001
291
+ raise WrapperExecutionError(f"generated model calculation failed: {error!r}") from error
292
+ if not isinstance(values, dict):
293
+ raise WrapperExecutionError("generated model calculate() did not return a dictionary")
294
+ self._last_scenario = scenario
295
+ self._last_inputs = scenario.inputs
296
+ self._last_values = dict(values)
297
+ return dict(values)
298
+
299
+ def inspect(self, cell_ref: str, scenario: Scenario | None = None) -> CellView:
300
+ normalized = normalize_full_cell_ref(cell_ref)
301
+ values = self._values_for(scenario)
302
+ declaration = self._cells_by_ref.get(normalized)
303
+ has_value = normalized in values
304
+ return CellView(
305
+ cell_ref=normalized,
306
+ name=declaration.name if declaration else None,
307
+ label=declaration.label if declaration else None,
308
+ role=declaration.role if declaration else None,
309
+ unit=declaration.unit if declaration else None,
310
+ description=declaration.description if declaration else None,
311
+ value=values.get(normalized),
312
+ has_value=has_value,
313
+ )
314
+
315
+ def table(self, name: str, scenario: Scenario | None = None) -> TableView:
316
+ declaration = self._tables.get(name)
317
+ if declaration is None:
318
+ raise WrapperDeclarationError(f"unknown table declaration {name!r}")
319
+ values = self._values_for(scenario)
320
+ table_values = tuple(
321
+ tuple(values.get(cell_ref) for cell_ref in row)
322
+ for row in declaration.cell_refs
323
+ )
324
+ return TableView(
325
+ name=declaration.name,
326
+ sheet=declaration.sheet,
327
+ range_ref=declaration.range_ref,
328
+ rows=declaration.row_labels,
329
+ columns=declaration.column_labels,
330
+ cell_refs=declaration.cell_refs,
331
+ values=table_values,
332
+ label=declaration.label,
333
+ description=declaration.description,
334
+ )
335
+
336
+ def report(self, name: str, scenario: Scenario | None = None) -> dict[str, object]:
337
+ declaration = self._reports.get(name)
338
+ if declaration is None:
339
+ raise WrapperDeclarationError(f"unknown report declaration {name!r}")
340
+ cell_payloads = {}
341
+ for cell_name in declaration.cells:
342
+ cell_declaration = self._cells.get(cell_name)
343
+ if cell_declaration is None:
344
+ raise WrapperDeclarationError(f"report {name!r} references unknown cell {cell_name!r}")
345
+ cell_payloads[cell_name] = self.inspect(cell_declaration.cell_ref, scenario=scenario).to_dict()
346
+ table_payloads = {
347
+ table_name: self.table(table_name, scenario=scenario).to_dict()
348
+ for table_name in declaration.tables
349
+ }
350
+ return {
351
+ "name": declaration.name,
352
+ "label": declaration.label,
353
+ "description": declaration.description,
354
+ "cells": cell_payloads,
355
+ "tables": table_payloads,
356
+ }
357
+
358
+ def _values_for(self, scenario: Scenario | None) -> dict[str, object]:
359
+ if scenario is None and self._last_values is not None:
360
+ return {**self._last_inputs, **self._last_values}
361
+ active_scenario = scenario or Scenario()
362
+ values = self.calculate(active_scenario)
363
+ return {**active_scenario.inputs, **values}
364
+
365
+ def _validate_reports(self) -> None:
366
+ for report_declaration in self._reports.values():
367
+ for cell_name in report_declaration.cells:
368
+ if cell_name not in self._cells:
369
+ raise WrapperDeclarationError(
370
+ f"report {report_declaration.name!r} references unknown cell {cell_name!r}"
371
+ )
372
+ for table_name in report_declaration.tables:
373
+ if table_name not in self._tables:
374
+ raise WrapperDeclarationError(
375
+ f"report {report_declaration.name!r} references unknown table {table_name!r}"
376
+ )
377
+
378
+
379
+ def generated_calculate(generated_model: ModuleType | GeneratedCalculate | object) -> GeneratedCalculate:
380
+ if callable(generated_model):
381
+ return generated_model
382
+ calculate = getattr(generated_model, "calculate", None)
383
+ if callable(calculate):
384
+ return calculate
385
+ raise WrapperDeclarationError("generated model must be callable or expose a callable calculate()")
386
+
387
+
388
+ def keyed_declarations(declarations: tuple[Any, ...] | list[Any], *, kind: str) -> dict[str, Any]:
389
+ keyed: dict[str, Any] = {}
390
+ for declaration in declarations:
391
+ name = getattr(declaration, "name", None)
392
+ if not isinstance(name, str) or not name:
393
+ raise WrapperDeclarationError(f"{kind} declaration is missing a non-empty name")
394
+ if name in keyed:
395
+ raise WrapperDeclarationError(f"duplicate {kind} declaration {name!r}")
396
+ keyed[name] = declaration
397
+ return keyed
398
+
399
+
400
+ def normalize_full_cell_ref(cell_ref: str) -> str:
401
+ normalized = normalize_cell_reference(cell_ref)
402
+ if normalized.kind != "cell" or normalized.sheet is None:
403
+ raise WrapperDeclarationError(f"expected a full cell reference like 'Sheet!A1', got {cell_ref!r}")
404
+ return normalized.normalized
405
+
406
+
407
+ def normalize_table_range(*, sheet: str | None, range_ref: str) -> tuple[str, str]:
408
+ if sheet is not None:
409
+ reference = normalize_reference(f"{sheet}!{range_ref}")
410
+ else:
411
+ reference = normalize_reference(range_ref)
412
+ if reference.kind != "range" or reference.sheet is None or reference.start_cell is None or reference.end_cell is None:
413
+ raise WrapperDeclarationError(
414
+ f"expected a rectangular range with a sheet, got sheet={sheet!r} range_ref={range_ref!r}"
415
+ )
416
+ start_col, start_row = split_cell(reference.start_cell)
417
+ end_col, end_row = split_cell(reference.end_cell)
418
+ return reference.sheet, f"{start_col}{start_row}:{end_col}{end_row}"
419
+
420
+
421
+ def split_cell(cell_ref: str) -> tuple[str, int]:
422
+ column = "".join(character for character in cell_ref if character.isalpha())
423
+ row = "".join(character for character in cell_ref if character.isdigit())
424
+ if not column or not row:
425
+ raise WrapperDeclarationError(f"invalid cell coordinate {cell_ref!r}")
426
+ return get_column_letter(column_index_from_string(column)), int(row)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modelwright
3
- Version: 0.1.0a3
3
+ Version: 0.1.0a4
4
4
  Summary: Tools for converting spreadsheet workbooks into transparent Python models.
5
5
  Author: UBC FRESH Lab
6
6
  License-Expression: MIT
@@ -135,7 +135,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
135
135
  scripts/bootstrap_dev_env.sh --benchmarks
136
136
  ```
137
137
 
138
- `modelwright` is pre-release. The current alpha line is `0.1.0a3`; alpha releases must not be described as full-workbook conversion guarantees.
138
+ `modelwright` is pre-release. The current alpha line is `0.1.0a4`; alpha releases must not be described as full-workbook conversion guarantees.
139
139
 
140
140
  Check release artifacts locally:
141
141
 
@@ -15,6 +15,7 @@ src/modelwright/oracle_validation.py
15
15
  src/modelwright/oracles.py
16
16
  src/modelwright/references.py
17
17
  src/modelwright/validation.py
18
+ src/modelwright/wrappers.py
18
19
  src/modelwright.egg-info/PKG-INFO
19
20
  src/modelwright.egg-info/SOURCES.txt
20
21
  src/modelwright.egg-info/dependency_links.txt
@@ -26,6 +27,7 @@ tests/test_conversion_plan.py
26
27
  tests/test_dependency_graph.py
27
28
  tests/test_evaluation_orchestration.py
28
29
  tests/test_extraction_records.py
30
+ tests/test_fable_wrapper_benchmark.py
29
31
  tests/test_formula_expressions.py
30
32
  tests/test_formula_translation.py
31
33
  tests/test_formulas_oracle.py
@@ -45,4 +47,5 @@ tests/test_synthetic_fixture.py
45
47
  tests/test_validation.py
46
48
  tests/test_validation_regression.py
47
49
  tests/test_validation_report_builder.py
48
- tests/test_validation_scenario.py
50
+ tests/test_validation_scenario.py
51
+ tests/test_wrappers.py
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from types import ModuleType
9
+
10
+ import pytest
11
+
12
+ from modelwright.wrappers import ModelFacade, cell, report, table
13
+
14
+
15
+ FABLE_ARTIFACT_DIR = Path("tmp/p26-fable-full-validation")
16
+ FABLE_MODEL_PATH = FABLE_ARTIFACT_DIR / "generated_fable_2020_model.py"
17
+ FABLE_SUMMARY_PATH = FABLE_ARTIFACT_DIR / "summary.json"
18
+ FABLE_SCENARIO_OUTPUTS = {
19
+ "SCENARIOS selection!D20": 2.146115426018433,
20
+ "SCENARIOS selection!D21": 1.8982220554032356,
21
+ "SCENARIOS selection!D22": 1.462761288724012,
22
+ }
23
+
24
+
25
+ def load_module(path: Path) -> ModuleType:
26
+ spec = importlib.util.spec_from_file_location("generated_fable_2020_model_for_wrapper_test", path)
27
+ assert spec is not None
28
+ assert spec.loader is not None
29
+ module = importlib.util.module_from_spec(spec)
30
+ sys.modules[spec.name] = module
31
+ spec.loader.exec_module(module)
32
+ return module
33
+
34
+
35
+ @pytest.mark.benchmark
36
+ def test_model_facade_wraps_2020_fable_benchmark_model_outputs() -> None:
37
+ if os.environ.get("MODELWRIGHT_RUN_FABLE_BENCHMARKS") != "1":
38
+ pytest.skip("set MODELWRIGHT_RUN_FABLE_BENCHMARKS=1 to run local FABLE benchmark facade tests")
39
+ if not FABLE_MODEL_PATH.exists() or not FABLE_SUMMARY_PATH.exists():
40
+ pytest.skip("materialize local P26 FABLE validation artifacts before running this benchmark test")
41
+
42
+ summary = json.loads(FABLE_SUMMARY_PATH.read_text())
43
+ assert summary["status"] == "pass"
44
+ assert summary["validated_output_universe"]["matches"] == 281741
45
+ assert summary["validated_output_universe"]["mismatches"] == 0
46
+
47
+ module = load_module(FABLE_MODEL_PATH)
48
+ facade = ModelFacade(
49
+ module,
50
+ cells=[
51
+ cell("SCENARIOS selection!D20", name="scenario_metric_1", role="output"),
52
+ cell("SCENARIOS selection!D21", name="scenario_metric_2", role="output"),
53
+ cell("SCENARIOS selection!D22", name="scenario_metric_3", role="output"),
54
+ ],
55
+ tables=[
56
+ table(
57
+ "scenario_selection_slice",
58
+ sheet="SCENARIOS selection",
59
+ range_ref="D20:D22",
60
+ row_labels=["d20", "d21", "d22"],
61
+ column_labels=["value"],
62
+ )
63
+ ],
64
+ reports=[
65
+ report(
66
+ "scenario_selection",
67
+ cells=["scenario_metric_1", "scenario_metric_2", "scenario_metric_3"],
68
+ tables=["scenario_selection_slice"],
69
+ )
70
+ ],
71
+ )
72
+
73
+ values = facade.calculate()
74
+
75
+ for cell_ref, expected in FABLE_SCENARIO_OUTPUTS.items():
76
+ assert values[cell_ref] == pytest.approx(expected)
77
+ assert facade.inspect(cell_ref).value == pytest.approx(expected)
78
+ table_values = facade.table("scenario_selection_slice").to_dict()["values"]
79
+ assert table_values[0][0] == pytest.approx(FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D20"])
80
+ assert table_values[1][0] == pytest.approx(FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D21"])
81
+ assert table_values[2][0] == pytest.approx(FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D22"])
82
+
83
+ report_payload = facade.report("scenario_selection")
84
+ assert report_payload["cells"]["scenario_metric_1"]["value"] == pytest.approx(
85
+ FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D20"]
86
+ )
87
+ assert report_payload["tables"]["scenario_selection_slice"]["values"][2][0] == pytest.approx(
88
+ FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D22"]
89
+ )
@@ -2,4 +2,4 @@ import modelwright
2
2
 
3
3
 
4
4
  def test_package_imports() -> None:
5
- assert modelwright.__version__ == "0.1.0a3"
5
+ assert modelwright.__version__ == "0.1.0a4"
@@ -16,6 +16,11 @@ def test_root_facade_exports_primary_entrypoints() -> None:
16
16
  assert "ValidationEvaluationResult" in modelwright.__all__
17
17
  assert "infer_generated_module_contract" in modelwright.__all__
18
18
  assert "GeneratedContractInferenceResult" in modelwright.__all__
19
+ assert "ModelFacade" in modelwright.__all__
20
+ assert "Scenario" in modelwright.__all__
21
+ assert "cell" in modelwright.__all__
22
+ assert "table" in modelwright.__all__
23
+ assert "report" in modelwright.__all__
19
24
 
20
25
 
21
26
  def test_root_facade_does_not_export_internal_helpers() -> None:
@@ -0,0 +1,212 @@
1
+ import importlib.util
2
+ import sys
3
+ from pathlib import Path
4
+ from types import ModuleType
5
+
6
+ import pytest
7
+
8
+ from modelwright.extraction import extract_workbook
9
+ from modelwright.formulas import translate_formula_cell
10
+ from modelwright.generation import generate_python_module, infer_generated_module_contract
11
+ from modelwright.graph import build_dependency_graph
12
+ from modelwright.wrappers import (
13
+ ModelFacade,
14
+ Scenario,
15
+ WrapperDeclarationError,
16
+ WrapperExecutionError,
17
+ cell,
18
+ report,
19
+ table,
20
+ )
21
+ from tests.fixtures.synthetic_model.build_workbook import build_workbook
22
+
23
+
24
+ def generated_model(inputs=None):
25
+ inputs = inputs or {}
26
+ base = inputs.get("Inputs!B2", 100)
27
+ growth = inputs.get("Inputs!B3", 0.1)
28
+ return {
29
+ "Summary!B2": base * (1 + growth),
30
+ "Summary!C2": base * 2,
31
+ "Summary!B3": "ok",
32
+ "Summary!C3": base + 5,
33
+ }
34
+
35
+
36
+ def load_module(path: Path) -> ModuleType:
37
+ spec = importlib.util.spec_from_file_location("wrapped_generated_synthetic_model", path)
38
+ assert spec is not None
39
+ assert spec.loader is not None
40
+ module = importlib.util.module_from_spec(spec)
41
+ sys.modules[spec.name] = module
42
+ spec.loader.exec_module(module)
43
+ return module
44
+
45
+
46
+ def build_generated_synthetic_model(tmp_path: Path) -> ModuleType:
47
+ workbook = extract_workbook(build_workbook(tmp_path / "synthetic_model.xlsx"))
48
+ graph = build_dependency_graph(workbook)
49
+ formula_cells = {cell.cell_ref: cell for cell in workbook.cells if cell.formula is not None}
50
+ expressions = {
51
+ cell_ref: translate_formula_cell(cell, graph)
52
+ for cell_ref, cell in formula_cells.items()
53
+ }
54
+ inference = infer_generated_module_contract(
55
+ workbook=workbook,
56
+ graph=graph,
57
+ expressions=expressions,
58
+ output_refs=("Summary!B2", "Summary!B3"),
59
+ module_name="synthetic_model",
60
+ )
61
+ output_path = tmp_path / "generated_synthetic_model.py"
62
+
63
+ generation = generate_python_module(
64
+ contract=inference.contract,
65
+ expressions=inference.expressions,
66
+ constants=inference.constants,
67
+ output_path=output_path,
68
+ )
69
+
70
+ assert generation.generated is True
71
+ return load_module(output_path)
72
+
73
+
74
+ def test_model_facade_wraps_generated_model_with_scenario_tables_and_reports() -> None:
75
+ facade = ModelFacade(
76
+ generated_model,
77
+ cells=[
78
+ cell("Inputs!B2", name="base", label="Base volume", role="input", unit="t"),
79
+ cell("Inputs!B3", name="growth", label="Growth rate", role="input", unit="fraction"),
80
+ cell("Summary!B2", name="projected", label="Projected volume", role="output", unit="t"),
81
+ ],
82
+ tables=[
83
+ table(
84
+ "summary_grid",
85
+ sheet="Summary",
86
+ range_ref="B2:C3",
87
+ row_labels=["volume", "status"],
88
+ column_labels=["primary", "secondary"],
89
+ )
90
+ ],
91
+ reports=[report("summary", cells=["base", "projected"], tables=["summary_grid"])],
92
+ )
93
+
94
+ scenario = facade.scenario(inputs={"Inputs!B2": 50}).with_input("Inputs!B3", 0.2)
95
+
96
+ assert facade.inputs()["base"].cell_ref == "Inputs!B2"
97
+ assert facade.outputs()["projected"].cell_ref == "Summary!B2"
98
+ assert facade.calculate(scenario) == {
99
+ "Summary!B2": 60.0,
100
+ "Summary!C2": 100,
101
+ "Summary!B3": "ok",
102
+ "Summary!C3": 55,
103
+ }
104
+
105
+ input_view = facade.inspect("Inputs!B2", scenario)
106
+ assert input_view.to_dict() == {
107
+ "cell_ref": "Inputs!B2",
108
+ "name": "base",
109
+ "label": "Base volume",
110
+ "role": "input",
111
+ "unit": "t",
112
+ "description": None,
113
+ "value": 50,
114
+ "has_value": True,
115
+ }
116
+
117
+ table_view = facade.table("summary_grid", scenario)
118
+ assert table_view.to_dict() == {
119
+ "name": "summary_grid",
120
+ "sheet": "Summary",
121
+ "range_ref": "B2:C3",
122
+ "rows": ["volume", "status"],
123
+ "columns": ["primary", "secondary"],
124
+ "cell_refs": [["Summary!B2", "Summary!C2"], ["Summary!B3", "Summary!C3"]],
125
+ "values": [[60.0, 100], ["ok", 55]],
126
+ "label": None,
127
+ "description": None,
128
+ }
129
+
130
+ report_payload = facade.report("summary", scenario)
131
+ assert report_payload["cells"]["base"]["value"] == 50
132
+ assert report_payload["cells"]["projected"]["value"] == 60.0
133
+ assert report_payload["tables"]["summary_grid"]["values"] == [[60.0, 100], ["ok", 55]]
134
+
135
+
136
+ def test_model_facade_preserves_real_generated_synthetic_model_semantics(tmp_path: Path) -> None:
137
+ module = build_generated_synthetic_model(tmp_path)
138
+ facade = ModelFacade(
139
+ module,
140
+ cells=[
141
+ cell("Inputs!B2", name="base", label="Base volume", role="input"),
142
+ cell("Inputs!B3", name="growth", label="Growth rate", role="input"),
143
+ cell("Inputs!B4", name="harvest_share", label="Harvest share", role="input"),
144
+ cell("Summary!B2", name="harvest", label="Rounded harvest", role="output"),
145
+ cell("Summary!B3", name="status", label="Status", role="output"),
146
+ ],
147
+ tables=[
148
+ table(
149
+ "summary",
150
+ sheet="Summary",
151
+ range_ref="B2:B3",
152
+ row_labels=["harvest", "status"],
153
+ column_labels=["value"],
154
+ )
155
+ ],
156
+ reports=[report("default", cells=["base", "harvest", "status"], tables=["summary"])],
157
+ )
158
+
159
+ assert facade.calculate() == module.calculate()
160
+
161
+ scenario = facade.scenario(name="low-volume", inputs={"Inputs!B2": 10})
162
+ generated_values = module.calculate(scenario.inputs)
163
+
164
+ assert facade.calculate(scenario) == generated_values
165
+ assert generated_values == {"Summary!B2": 7.02, "Summary!B3": "low"}
166
+ assert facade.inspect("Summary!B2", scenario).value == generated_values["Summary!B2"]
167
+ assert facade.table("summary", scenario).to_dict()["values"] == [[7.02], ["low"]]
168
+
169
+ report_payload = facade.report("default", scenario)
170
+ assert report_payload["cells"]["base"]["value"] == 10
171
+ assert report_payload["cells"]["harvest"]["value"] == generated_values["Summary!B2"]
172
+ assert report_payload["tables"]["summary"]["values"] == [[7.02], ["low"]]
173
+
174
+
175
+ def test_scenario_is_copy_on_write_and_normalizes_input_refs() -> None:
176
+ original = Scenario.from_inputs(inputs={"Inputs!B2": 10})
177
+ updated = original.with_input("Inputs!B3", 0.25)
178
+
179
+ assert original.inputs == {"Inputs!B2": 10}
180
+ assert updated.inputs == {"Inputs!B2": 10, "Inputs!B3": 0.25}
181
+
182
+
183
+ def test_table_declaration_validates_label_shape() -> None:
184
+ with pytest.raises(WrapperDeclarationError, match="row labels"):
185
+ table("bad_rows", sheet="Summary", range_ref="A1:A2", row_labels=["only one"])
186
+
187
+ with pytest.raises(WrapperDeclarationError, match="column labels"):
188
+ table("bad_columns", sheet="Summary", range_ref="A1:B1", column_labels=["only one"])
189
+
190
+
191
+ def test_facade_validates_duplicate_names_and_report_references() -> None:
192
+ with pytest.raises(WrapperDeclarationError, match="duplicate cell declaration"):
193
+ ModelFacade(
194
+ generated_model,
195
+ cells=[
196
+ cell("Inputs!B2", name="base"),
197
+ cell("Inputs!B3", name="base"),
198
+ ],
199
+ )
200
+
201
+ with pytest.raises(WrapperDeclarationError, match="unknown table"):
202
+ ModelFacade(generated_model, reports=[report("bad", tables=["missing"])])
203
+
204
+
205
+ def test_facade_wraps_generated_model_execution_errors() -> None:
206
+ def broken_model(inputs=None):
207
+ raise RuntimeError("boom")
208
+
209
+ facade = ModelFacade(broken_model)
210
+
211
+ with pytest.raises(WrapperExecutionError, match="calculation failed"):
212
+ facade.calculate()
File without changes
File without changes