modelwright 0.1.0a2__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.0a2/src/modelwright.egg-info → modelwright-0.1.0a4}/PKG-INFO +2 -2
  2. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/README.md +1 -1
  3. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/pyproject.toml +4 -1
  4. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/__init__.py +27 -1
  5. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/generation.py +141 -32
  6. modelwright-0.1.0a4/src/modelwright/wrappers.py +426 -0
  7. {modelwright-0.1.0a2 → modelwright-0.1.0a4/src/modelwright.egg-info}/PKG-INFO +2 -2
  8. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright.egg-info/SOURCES.txt +4 -1
  9. modelwright-0.1.0a4/tests/test_fable_wrapper_benchmark.py +89 -0
  10. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_generation_contract.py +1 -0
  11. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_import.py +1 -1
  12. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_public_api.py +5 -0
  13. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_python_generation.py +179 -0
  14. modelwright-0.1.0a4/tests/test_wrappers.py +212 -0
  15. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/LICENSE +0 -0
  16. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/setup.cfg +0 -0
  17. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/cli.py +0 -0
  18. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/conversion.py +0 -0
  19. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/evaluation.py +0 -0
  20. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/execution.py +0 -0
  21. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/extraction.py +0 -0
  22. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/formulas.py +0 -0
  23. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/formulas_oracle.py +0 -0
  24. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/graph.py +0 -0
  25. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/oracle_validation.py +0 -0
  26. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/oracles.py +0 -0
  27. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/references.py +0 -0
  28. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright/validation.py +0 -0
  29. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright.egg-info/dependency_links.txt +0 -0
  30. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright.egg-info/entry_points.txt +0 -0
  31. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright.egg-info/requires.txt +0 -0
  32. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/src/modelwright.egg-info/top_level.txt +0 -0
  33. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_cli.py +0 -0
  34. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_conversion_plan.py +0 -0
  35. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_dependency_graph.py +0 -0
  36. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_evaluation_orchestration.py +0 -0
  37. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_extraction_records.py +0 -0
  38. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_formula_expressions.py +0 -0
  39. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_formula_translation.py +0 -0
  40. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_formulas_oracle.py +0 -0
  41. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_generated_execution.py +0 -0
  42. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_materialize_fable_benchmarks.py +0 -0
  43. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_openpyxl_extraction.py +0 -0
  44. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_oracle_backed_validation.py +0 -0
  45. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_oracle_interface.py +0 -0
  46. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_references.py +0 -0
  47. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_scalar_comparison.py +0 -0
  48. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_supported_semantics_fixture.py +0 -0
  49. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_synthetic_fixture.py +0 -0
  50. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_validation.py +0 -0
  51. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_validation_regression.py +0 -0
  52. {modelwright-0.1.0a2 → modelwright-0.1.0a4}/tests/test_validation_report_builder.py +0 -0
  53. {modelwright-0.1.0a2 → 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.0a2
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.0a2`; 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.0a2`; 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.0a2"
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.0a2"
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
  ]
@@ -17,7 +17,10 @@ from modelwright.references import WorkbookReference
17
17
 
18
18
 
19
19
  JsonValue = str | int | float | bool | None | list[Any] | dict[str, Any]
20
+ DEFAULT_INLINE_PROVENANCE_COMMENT_LIMIT = 50_000
21
+ DEFAULT_INLINE_FORMULA_LAMBDA_LIMIT = 50_000
20
22
  DiagnosticSeverity = Literal["info", "warning", "error"]
23
+ FormulaStorage = Literal["lambdas", "expression_source"]
21
24
  GeneratedSymbolKind = Literal["input", "intermediate", "output"]
22
25
 
23
26
 
@@ -89,6 +92,7 @@ class GeneratedModuleContract:
89
92
  output_refs: tuple[str, ...] = field(default_factory=tuple)
90
93
  symbols: tuple[GeneratedSymbol, ...] = field(default_factory=tuple)
91
94
  include_provenance_comments: bool = True
95
+ formula_storage: FormulaStorage = "lambdas"
92
96
 
93
97
  @classmethod
94
98
  def from_dict(cls, data: dict[str, Any]) -> "GeneratedModuleContract":
@@ -100,6 +104,7 @@ class GeneratedModuleContract:
100
104
  output_refs=tuple(data.get("output_refs", [])),
101
105
  symbols=tuple(GeneratedSymbol.from_dict(item) for item in data.get("symbols", [])),
102
106
  include_provenance_comments=data.get("include_provenance_comments", True),
107
+ formula_storage=data.get("formula_storage", "lambdas"),
103
108
  )
104
109
 
105
110
  def to_dict(self) -> dict[str, JsonValue]:
@@ -111,6 +116,7 @@ class GeneratedModuleContract:
111
116
  "output_refs": list(self.output_refs),
112
117
  "symbols": [symbol.to_dict() for symbol in self.symbols],
113
118
  "include_provenance_comments": self.include_provenance_comments,
119
+ "formula_storage": self.formula_storage,
114
120
  }
115
121
 
116
122
 
@@ -186,6 +192,8 @@ def infer_generated_module_contract(
186
192
  module_name: str,
187
193
  input_refs: Sequence[str] = (),
188
194
  progress: Callable[[str], None] | None = None,
195
+ inline_provenance_comment_limit: int | None = DEFAULT_INLINE_PROVENANCE_COMMENT_LIMIT,
196
+ inline_formula_lambda_limit: int | None = DEFAULT_INLINE_FORMULA_LAMBDA_LIMIT,
189
197
  ) -> GeneratedContractInferenceResult:
190
198
  """Infer a generated module contract by walking dependencies for selected outputs."""
191
199
 
@@ -242,7 +250,9 @@ def infer_generated_module_contract(
242
250
  )
243
251
 
244
252
  input_order: list[str] = []
253
+ input_seen: set[str] = set()
245
254
  formula_order: list[str] = []
255
+ formula_seen: set[str] = set()
246
256
  visiting: set[str] = set()
247
257
  visited: set[str] = set()
248
258
  circular_dependency_locations: set[str] = set()
@@ -255,12 +265,11 @@ def infer_generated_module_contract(
255
265
  if isinstance(dependency, str):
256
266
  refs.append(dependency)
257
267
  continue
258
- refs.extend(
259
- expanded_range_dependencies.setdefault(
260
- dependency.normalized,
261
- _expand_range_dependency(dependency),
262
- )
263
- )
268
+ expanded = expanded_range_dependencies.get(dependency.normalized)
269
+ if expanded is None:
270
+ expanded = _expand_range_dependency(dependency)
271
+ expanded_range_dependencies[dependency.normalized] = expanded
272
+ refs.extend(expanded)
264
273
  return tuple(refs)
265
274
 
266
275
  def visit(root_ref: str) -> None:
@@ -273,8 +282,9 @@ def infer_generated_module_contract(
273
282
 
274
283
  if dependencies_processed:
275
284
  visiting.discard(cell_ref)
276
- if cell_ref not in formula_order:
285
+ if cell_ref not in formula_seen:
277
286
  formula_order.append(cell_ref)
287
+ formula_seen.add(cell_ref)
278
288
  visited.add(cell_ref)
279
289
  continue
280
290
 
@@ -303,14 +313,16 @@ def infer_generated_module_contract(
303
313
 
304
314
  cell = cell_by_ref.get(cell_ref)
305
315
  if cell is None:
306
- if cell_ref not in input_order:
316
+ if cell_ref not in input_seen:
307
317
  input_order.append(cell_ref)
318
+ input_seen.add(cell_ref)
308
319
  visited.add(cell_ref)
309
320
  continue
310
321
 
311
322
  if cell_ref in explicit_inputs or cell.formula is None:
312
- if cell_ref not in input_order:
323
+ if cell_ref not in input_seen:
313
324
  input_order.append(cell_ref)
325
+ input_seen.add(cell_ref)
314
326
  visited.add(cell_ref)
315
327
  continue
316
328
 
@@ -368,6 +380,12 @@ def infer_generated_module_contract(
368
380
  input_refs=tuple(input_order),
369
381
  output_refs=selected_outputs,
370
382
  symbols=symbols,
383
+ include_provenance_comments=(
384
+ inline_provenance_comment_limit is None or len(formula_order) <= inline_provenance_comment_limit
385
+ ),
386
+ formula_storage="lambdas"
387
+ if inline_formula_lambda_limit is None or len(formula_order) <= inline_formula_lambda_limit
388
+ else "expression_source",
371
389
  )
372
390
  return GeneratedContractInferenceResult(
373
391
  contract=contract,
@@ -478,6 +496,44 @@ def _render_module(
478
496
  '"""',
479
497
  "",
480
498
  "import fnmatch",
499
+ "from functools import lru_cache",
500
+ "",
501
+ "",
502
+ "class _SfRangeView:",
503
+ " def __init__(self, sheet, min_col, min_row, max_col, max_row, get_value):",
504
+ " self.sheet = sheet",
505
+ " self.min_col = min_col",
506
+ " self.min_row = min_row",
507
+ " self.max_col = max_col",
508
+ " self.max_row = max_row",
509
+ " self._get_value = get_value",
510
+ " self._values = None",
511
+ " self._lazy_values = None",
512
+ " self._value_calls = 0",
513
+ " self._lazy_value_calls = 0",
514
+ "",
515
+ " def _refs(self):",
516
+ " for row in range(self.min_row, self.max_row + 1):",
517
+ " for column in range(self.min_col, self.max_col + 1):",
518
+ " yield f'{self.sheet}!{_sf_column_name(column)}{row}'",
519
+ "",
520
+ " def values(self):",
521
+ " if self._values is not None:",
522
+ " return self._values",
523
+ " values = tuple(self._get_value(ref) for ref in self._refs())",
524
+ " self._value_calls += 1",
525
+ " if self._value_calls > 1:",
526
+ " self._values = values",
527
+ " return values",
528
+ "",
529
+ " def lazy_values(self):",
530
+ " if self._lazy_values is not None:",
531
+ " return self._lazy_values",
532
+ " values = tuple(lambda ref=ref: self._get_value(ref) for ref in self._refs())",
533
+ " self._lazy_value_calls += 1",
534
+ " if self._lazy_value_calls > 1:",
535
+ " self._lazy_values = values",
536
+ " return values",
481
537
  "",
482
538
  "",
483
539
  "def _sf_column_name(index):",
@@ -490,6 +546,9 @@ def _render_module(
490
546
  "",
491
547
  "def _sf_flatten(values):",
492
548
  " for value in values:",
549
+ " if isinstance(value, _SfRangeView):",
550
+ " yield from value.values()",
551
+ " continue",
493
552
  " if isinstance(value, (list, tuple)):",
494
553
  " yield from _sf_flatten(value)",
495
554
  " else:",
@@ -498,6 +557,9 @@ def _render_module(
498
557
  "",
499
558
  "def _sf_flatten_lazy(values):",
500
559
  " for value in values:",
560
+ " if isinstance(value, _SfRangeView):",
561
+ " yield from value.lazy_values()",
562
+ " continue",
501
563
  " if isinstance(value, (list, tuple)):",
502
564
  " yield from _sf_flatten_lazy(value)",
503
565
  " else:",
@@ -525,6 +587,7 @@ def _render_module(
525
587
  " return value",
526
588
  "",
527
589
  "",
590
+ "@lru_cache(maxsize=4096)",
528
591
  "def _sf_numeric_value(value):",
529
592
  " if isinstance(value, bool):",
530
593
  " return None",
@@ -605,17 +668,20 @@ def _render_module(
605
668
  " raise ValueError(f'unsupported criteria operator: {operator}')",
606
669
  "",
607
670
  "",
608
- "def _sf_matches_criteria(value, criteria):",
671
+ "def _sf_criteria_matcher(criteria):",
609
672
  " if isinstance(criteria, str):",
610
673
  " for operator in ('>=', '<=', '<>', '>', '<', '='):",
611
674
  " if criteria.startswith(operator):",
612
- " expected = _sf_coerce_criteria(criteria[len(operator):], value)",
613
- " return _sf_compare_criteria(value, operator, expected)",
675
+ " raw_expected = criteria[len(operator):]",
676
+ " return lambda value: _sf_compare_criteria(value, operator, _sf_coerce_criteria(raw_expected, value))",
614
677
  " if '*' in criteria or '?' in criteria:",
615
- " return fnmatch.fnmatchcase(str(value), criteria)",
616
- " expected = _sf_coerce_criteria(criteria, value)",
617
- " return _sf_compare_criteria(value, '=', expected)",
618
- " return _sf_compare_criteria(value, '=', criteria)",
678
+ " return lambda value: fnmatch.fnmatchcase(str(value), criteria)",
679
+ " return lambda value: _sf_compare_criteria(value, '=', _sf_coerce_criteria(criteria, value))",
680
+ " return lambda value: _sf_compare_criteria(value, '=', criteria)",
681
+ "",
682
+ "",
683
+ "def _sf_matches_criteria(value, criteria):",
684
+ " return _sf_criteria_matcher(criteria)(value)",
619
685
  "",
620
686
  "",
621
687
  "def _sf_lookup_equal(left, right):",
@@ -627,24 +693,26 @@ def _render_module(
627
693
  "def _sf_sumif(criteria_range, criteria, sum_range=None):",
628
694
  " criteria_values = tuple(_sf_flatten((criteria_range,)))",
629
695
  " sum_values = criteria_values if sum_range is None else tuple(_sf_flatten_lazy((sum_range,)))",
696
+ " matcher = _sf_criteria_matcher(criteria)",
630
697
  " total = 0",
631
698
  " for criteria_value, sum_value in zip(criteria_values, sum_values):",
632
- " if _sf_matches_criteria(criteria_value, criteria):",
699
+ " if matcher(criteria_value):",
633
700
  " total += _sf_sum_value(sum_value)",
634
701
  " return total",
635
702
  "",
636
703
  "",
637
704
  "def _sf_countif(criteria_range, criteria):",
638
- " return sum(1 for value in _sf_flatten((criteria_range,)) if _sf_matches_criteria(value, criteria))",
705
+ " matcher = _sf_criteria_matcher(criteria)",
706
+ " return sum(1 for value in _sf_flatten((criteria_range,)) if matcher(value))",
639
707
  "",
640
708
  "",
641
709
  "def _sf_sumifs(sum_range, *criteria_pairs):",
642
710
  " sum_values = tuple(_sf_flatten_lazy((sum_range,)))",
643
711
  " criteria_ranges = [tuple(_sf_flatten((criteria_range,))) for criteria_range, _criteria in criteria_pairs]",
644
- " criteria_values = tuple(criteria for _range, criteria in criteria_pairs)",
712
+ " criteria_matchers = tuple(_sf_criteria_matcher(criteria) for _range, criteria in criteria_pairs)",
645
713
  " total = 0",
646
714
  " for index, sum_value in enumerate(sum_values):",
647
- " if all(_sf_matches_criteria(criteria_range[index], criteria) for criteria_range, criteria in zip(criteria_ranges, criteria_values)):",
715
+ " if all(matcher(criteria_range[index]) for criteria_range, matcher in zip(criteria_ranges, criteria_matchers)):",
648
716
  " total += _sf_sum_value(sum_value)",
649
717
  " return total",
650
718
  "",
@@ -653,11 +721,11 @@ def _render_module(
653
721
  " criteria_ranges = [tuple(_sf_flatten((criteria_range,))) for criteria_range, _criteria in criteria_pairs]",
654
722
  " if not criteria_ranges:",
655
723
  " return 0",
656
- " criteria_values = tuple(criteria for _range, criteria in criteria_pairs)",
724
+ " criteria_matchers = tuple(_sf_criteria_matcher(criteria) for _range, criteria in criteria_pairs)",
657
725
  " return sum(",
658
726
  " 1",
659
727
  " for index in range(len(criteria_ranges[0]))",
660
- " if all(_sf_matches_criteria(criteria_range[index], criteria) for criteria_range, criteria in zip(criteria_ranges, criteria_values))",
728
+ " if all(matcher(criteria_range[index]) for criteria_range, matcher in zip(criteria_ranges, criteria_matchers))",
661
729
  " )",
662
730
  "",
663
731
  "",
@@ -699,6 +767,7 @@ def _render_module(
699
767
  f"def {contract.entrypoint}(inputs=None):",
700
768
  " inputs = {} if inputs is None else dict(inputs)",
701
769
  " _cache = {}",
770
+ " _range_cache = {}",
702
771
  " _stack = []",
703
772
  " _evaluated_count = 0",
704
773
  " _constants = {",
@@ -731,7 +800,7 @@ def _render_module(
731
800
  " raise RuntimeError('circular dependency during generated model execution: ' + ' -> '.join(cycle))",
732
801
  " _stack.append(cell_ref)",
733
802
  " try:",
734
- " value = formula()",
803
+ " value = _evaluate_formula(cell_ref, formula)",
735
804
  " finally:",
736
805
  " _stack.pop()",
737
806
  " _cache[cell_ref] = value",
@@ -750,11 +819,12 @@ def _render_module(
750
819
  " return value",
751
820
  "",
752
821
  " def _range(sheet, min_col, min_row, max_col, max_row):",
753
- " return tuple(",
754
- " lambda ref=f'{sheet}!{_sf_column_name(column)}{row}': _get(ref)",
755
- " for row in range(min_row, max_row + 1)",
756
- " for column in range(min_col, max_col + 1)",
757
- " )",
822
+ " key = (sheet, min_col, min_row, max_col, max_row)",
823
+ " view = _range_cache.get(key)",
824
+ " if view is None:",
825
+ " view = _SfRangeView(sheet, min_col, min_row, max_col, max_row, _get)",
826
+ " _range_cache[key] = view",
827
+ " return view",
758
828
  "",
759
829
  " def _table(sheet, min_col, min_row, max_col, max_row):",
760
830
  " return tuple(",
@@ -762,6 +832,36 @@ def _render_module(
762
832
  " for row in range(min_row, max_row + 1)",
763
833
  " )",
764
834
  "",
835
+ ]
836
+ )
837
+ if contract.formula_storage == "lambdas":
838
+ lines.extend(
839
+ [
840
+ " def _evaluate_formula(_cell_ref, formula):",
841
+ " return formula()",
842
+ "",
843
+ ]
844
+ )
845
+ elif contract.formula_storage == "expression_source":
846
+ lines.extend(
847
+ [
848
+ " _formula_globals = dict(globals())",
849
+ " _formula_globals.update({",
850
+ " '_get': _get,",
851
+ " '_range': _range,",
852
+ " '_table': _table,",
853
+ " })",
854
+ "",
855
+ " def _evaluate_formula(cell_ref, formula):",
856
+ " code = compile(formula, f'<modelwright formula {cell_ref}>', 'eval')",
857
+ " return eval(code, _formula_globals)",
858
+ "",
859
+ ]
860
+ )
861
+ else:
862
+ raise ValueError(f"unsupported formula storage: {contract.formula_storage}")
863
+ lines.extend(
864
+ [
765
865
  " _formulas = {",
766
866
  ]
767
867
  )
@@ -771,7 +871,11 @@ def _render_module(
771
871
  lines.append(f" # {symbol.cell_ref}" + (f": {symbol.raw_formula}" if symbol.raw_formula else ""))
772
872
 
773
873
  expression = expressions[symbol.cell_ref]
774
- lines.append(f" {symbol.cell_ref!r}: lambda: {_render_formula_root(expression.root)},")
874
+ rendered_formula = _render_formula_root(expression.root)
875
+ if contract.formula_storage == "lambdas":
876
+ lines.append(f" {symbol.cell_ref!r}: lambda: {rendered_formula},")
877
+ else:
878
+ lines.append(f" {symbol.cell_ref!r}: {rendered_formula!r},")
775
879
 
776
880
  if index == 1 or index % 10000 == 0 or index == len(formula_symbols):
777
881
  _progress(
@@ -781,12 +885,17 @@ def _render_module(
781
885
  lines.extend(
782
886
  [
783
887
  " }",
784
- " return {",
888
+ " _output_refs = (",
785
889
  ]
786
890
  )
787
891
  for output_ref in contract.output_refs:
788
- lines.append(f" {output_ref!r}: _get({output_ref!r}),")
789
- lines.append(" }")
892
+ lines.append(f" {output_ref!r},")
893
+ lines.extend(
894
+ [
895
+ " )",
896
+ " return {cell_ref: _get(cell_ref) for cell_ref in _output_refs}",
897
+ ]
898
+ )
790
899
  lines.append("")
791
900
  return "\n".join(lines)
792
901