excel-model 0.1.0__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.
@@ -0,0 +1,28 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ *.egg
8
+
9
+ # pixi
10
+ .pixi/
11
+
12
+ # IDE
13
+ .vscode/
14
+ .idea/
15
+ *.swp
16
+
17
+ # OS
18
+ .DS_Store
19
+ Thumbs.db
20
+
21
+ # Testing
22
+ .pytest_cache/
23
+ .hypothesis/
24
+ .coverage
25
+ htmlcov/
26
+
27
+ # Docs
28
+ site/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthias Christenson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: excel-model
3
+ Version: 0.1.0
4
+ Summary: YAML-driven Excel financial model generator
5
+ Project-URL: Documentation, https://neuralsignal.github.io/excel-model/
6
+ Project-URL: Repository, https://github.com/neuralsignal/excel-model
7
+ Author: Matthias Christenson
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: dcf,excel,financial-model,openpyxl,p&l,scenario,yaml
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Office/Business :: Financial
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: click<9,>=8.0
20
+ Requires-Dist: dacite<2,>=1.8
21
+ Requires-Dist: openpyxl<4,>=3.1
22
+ Requires-Dist: polars<2,>=1.0
23
+ Requires-Dist: pyyaml<7,>=6.0
24
+ Requires-Dist: strictyaml<2,>=1.3
25
+ Provides-Extra: dev
26
+ Requires-Dist: hypothesis<7,>=6.0; extra == 'dev'
27
+ Requires-Dist: pytest-cov<6,>=5.0; extra == 'dev'
28
+ Requires-Dist: pytest<9,>=8.0; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # excel-model
32
+
33
+ YAML-driven Excel financial model generator.
34
+
35
+ Build professional financial models (P&L, DCF, Budget vs Actuals, Scenario Analysis) from declarative YAML specs. Generates `.xlsx` workbooks with named ranges, styled sheets, and Excel formulas using openpyxl.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install excel-model
41
+ ```
42
+
43
+ Or for development:
44
+
45
+ ```bash
46
+ pixi install
47
+ ```
48
+
49
+ ## Quick Start
50
+
51
+ ### CLI
52
+
53
+ ```bash
54
+ # Build a P&L model
55
+ excel-model build --spec model.yaml --output model.xlsx --mode batch
56
+
57
+ # Validate a spec
58
+ excel-model validate --spec model.yaml
59
+
60
+ # Describe what a spec would produce
61
+ excel-model describe --spec model.yaml --format text
62
+ ```
63
+
64
+ ### Python API
65
+
66
+ ```python
67
+ from excel_model.spec_loader import load_spec
68
+ from excel_model.validator import validate_spec
69
+ from excel_model.excel_writer import build_workbook
70
+ from excel_model.config import load_style
71
+
72
+ spec = load_spec("model.yaml")
73
+ errors = validate_spec(spec)
74
+ assert not errors
75
+
76
+ style = load_style(None) # uses bundled defaults
77
+ build_workbook(spec=spec, inputs=None, output_path="model.xlsx", style=style)
78
+ ```
79
+
80
+ ## Model Types
81
+
82
+ | Type | Description |
83
+ |------|-------------|
84
+ | `p_and_l` | Profit & Loss statement |
85
+ | `dcf` | Discounted Cash Flow valuation |
86
+ | `budget_vs_actuals` | Budget vs Actuals comparison |
87
+ | `scenario` | Multi-scenario analysis (Base/Bull/Bear) |
88
+ | `comparison` | Cross-entity comparison |
89
+
90
+ ## Formula Types
91
+
92
+ 21 built-in formula types including `growth_projected`, `pct_of_revenue`, `sum_of_rows`, `subtraction`, `ratio`, `discounted_pv`, `terminal_value`, `npv_sum`, `variance`, `variance_pct`, `constant`, `custom`, and more.
93
+
94
+ ## Configuration
95
+
96
+ Style config controls Excel formatting (colors, fonts, number formats). A bundled default is included; override with `--style`:
97
+
98
+ ```yaml
99
+ header_fill_hex: "1F3864"
100
+ header_font_color: "FFFFFF"
101
+ font_name: "Calibri"
102
+ font_size: 10
103
+ number_format_currency: '#,##0'
104
+ number_format_percent: '0.0%'
105
+ ```
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,79 @@
1
+ # excel-model
2
+
3
+ YAML-driven Excel financial model generator.
4
+
5
+ Build professional financial models (P&L, DCF, Budget vs Actuals, Scenario Analysis) from declarative YAML specs. Generates `.xlsx` workbooks with named ranges, styled sheets, and Excel formulas using openpyxl.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install excel-model
11
+ ```
12
+
13
+ Or for development:
14
+
15
+ ```bash
16
+ pixi install
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### CLI
22
+
23
+ ```bash
24
+ # Build a P&L model
25
+ excel-model build --spec model.yaml --output model.xlsx --mode batch
26
+
27
+ # Validate a spec
28
+ excel-model validate --spec model.yaml
29
+
30
+ # Describe what a spec would produce
31
+ excel-model describe --spec model.yaml --format text
32
+ ```
33
+
34
+ ### Python API
35
+
36
+ ```python
37
+ from excel_model.spec_loader import load_spec
38
+ from excel_model.validator import validate_spec
39
+ from excel_model.excel_writer import build_workbook
40
+ from excel_model.config import load_style
41
+
42
+ spec = load_spec("model.yaml")
43
+ errors = validate_spec(spec)
44
+ assert not errors
45
+
46
+ style = load_style(None) # uses bundled defaults
47
+ build_workbook(spec=spec, inputs=None, output_path="model.xlsx", style=style)
48
+ ```
49
+
50
+ ## Model Types
51
+
52
+ | Type | Description |
53
+ |------|-------------|
54
+ | `p_and_l` | Profit & Loss statement |
55
+ | `dcf` | Discounted Cash Flow valuation |
56
+ | `budget_vs_actuals` | Budget vs Actuals comparison |
57
+ | `scenario` | Multi-scenario analysis (Base/Bull/Bear) |
58
+ | `comparison` | Cross-entity comparison |
59
+
60
+ ## Formula Types
61
+
62
+ 21 built-in formula types including `growth_projected`, `pct_of_revenue`, `sum_of_rows`, `subtraction`, `ratio`, `discounted_pv`, `terminal_value`, `npv_sum`, `variance`, `variance_pct`, `constant`, `custom`, and more.
63
+
64
+ ## Configuration
65
+
66
+ Style config controls Excel formatting (colors, fonts, number formats). A bundled default is included; override with `--style`:
67
+
68
+ ```yaml
69
+ header_fill_hex: "1F3864"
70
+ header_font_color: "FFFFFF"
71
+ font_name: "Calibri"
72
+ font_size: 10
73
+ number_format_currency: '#,##0'
74
+ number_format_percent: '0.0%'
75
+ ```
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1 @@
1
+ """excel_model — Excel financial model builder."""
@@ -0,0 +1,261 @@
1
+ """CLI entry point for excel-model."""
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from excel_model.config import load_style
9
+ from excel_model.exceptions import StyleConfigError
10
+
11
+
12
+ @click.group()
13
+ def main() -> None:
14
+ """YAML-driven Excel financial model generator."""
15
+
16
+
17
+ @main.command()
18
+ @click.option("--spec", required=True, type=click.Path(exists=True), help="Path to model spec YAML")
19
+ @click.option("--output", required=True, type=click.Path(), help="Path for output .xlsx file")
20
+ @click.option("--style", required=False, type=click.Path(exists=True), help="Path to style config YAML (uses bundled defaults if omitted)")
21
+ @click.option("--data", required=False, type=click.Path(exists=True), help="Path to input data file")
22
+ @click.option("--mode", required=True, type=click.Choice(["batch", "interactive"]), help="batch = JSON to stdout; interactive = verbose narrative")
23
+ def build(spec: str, output: str, style: str | None, data: str | None, mode: str) -> None:
24
+ """Build an Excel financial model from a YAML spec."""
25
+ def emit_error(message: str) -> None:
26
+ if mode == "batch":
27
+ click.echo(json.dumps({"status": "error", "message": message}))
28
+ else:
29
+ click.echo(f"ERROR: {message}", err=True)
30
+ sys.exit(1)
31
+
32
+ def emit_info(message: str) -> None:
33
+ if mode == "interactive":
34
+ click.echo(message)
35
+
36
+ # Load spec
37
+ emit_info(f"Loading model spec: {spec}")
38
+ try:
39
+ from excel_model.spec_loader import load_spec
40
+ loaded_spec = load_spec(spec)
41
+ except (FileNotFoundError, ValueError, KeyError) as e:
42
+ emit_error(f"Failed to load spec: {e}")
43
+ return # unreachable, but keeps type checker happy
44
+
45
+ # Validate spec
46
+ emit_info("Validating model spec...")
47
+ from excel_model.validator import validate_spec
48
+ errors = validate_spec(loaded_spec)
49
+ if errors:
50
+ emit_error("Spec validation failed:\n" + "\n".join(f" - {e}" for e in errors))
51
+ emit_info(f" Model type: {loaded_spec.model_type}")
52
+ emit_info(f" Title: {loaded_spec.title}")
53
+ emit_info(f" Currency: {loaded_spec.currency}")
54
+ emit_info(f" Periods: {loaded_spec.n_history_periods} history + {loaded_spec.n_periods} projection ({loaded_spec.granularity})")
55
+ emit_info(f" Assumptions: {len(loaded_spec.assumptions)}")
56
+ emit_info(f" Line items: {len(loaded_spec.line_items)}")
57
+
58
+ # Load style
59
+ emit_info(f"Loading style config: {style or '(bundled defaults)'}")
60
+ try:
61
+ loaded_style = load_style(style)
62
+ except StyleConfigError as e:
63
+ emit_error(f"Failed to load style config: {e}")
64
+ return
65
+
66
+ # Load input data (optional)
67
+ inputs = None
68
+ if data:
69
+ emit_info(f"Loading input data: {data}")
70
+ try:
71
+ from excel_model.loader import load
72
+ value_cols = list(loaded_spec.inputs.value_cols.values())
73
+ inputs = load(
74
+ source_path=data,
75
+ period_col=loaded_spec.inputs.period_col,
76
+ value_cols=value_cols,
77
+ sheet=loaded_spec.inputs.sheet,
78
+ )
79
+ emit_info(f" Loaded {len(inputs.df)} rows")
80
+
81
+ from excel_model.validator import validate_inputs_against_spec
82
+ input_errors = validate_inputs_against_spec(loaded_spec, inputs)
83
+ if input_errors:
84
+ emit_error("Input data validation failed:\n" + "\n".join(f" - {e}" for e in input_errors))
85
+ except (FileNotFoundError, ValueError) as e:
86
+ emit_error(f"Failed to load input data: {e}")
87
+
88
+ # Build workbook
89
+ emit_info("Building workbook...")
90
+ try:
91
+ from excel_model.excel_writer import build_workbook
92
+ build_workbook(spec=loaded_spec, inputs=inputs, output_path=output, style=loaded_style)
93
+ except Exception as e:
94
+ emit_error(f"Failed to build workbook: {e}")
95
+
96
+ output_path = str(Path(output).resolve())
97
+ emit_info(f"Workbook saved to: {output_path}")
98
+
99
+ if mode == "batch":
100
+ click.echo(json.dumps({"status": "ok", "output": output_path}))
101
+
102
+
103
+ @main.command()
104
+ @click.option("--spec", required=True, type=click.Path(exists=True), help="Path to model spec YAML")
105
+ @click.option("--data", required=False, type=click.Path(exists=True), help="Optional input data file to validate column mapping")
106
+ def validate(spec: str, data: str | None) -> None:
107
+ """Validate a model spec YAML file."""
108
+ # Load spec
109
+ try:
110
+ from excel_model.spec_loader import load_spec
111
+ loaded_spec = load_spec(spec)
112
+ except (FileNotFoundError, ValueError, KeyError) as e:
113
+ click.echo(f"ERROR: {e}")
114
+ sys.exit(1)
115
+
116
+ # Validate spec
117
+ from excel_model.validator import validate_spec
118
+ errors = validate_spec(loaded_spec)
119
+
120
+ # Optionally validate input data columns
121
+ if data:
122
+ try:
123
+ from excel_model.loader import load
124
+ value_cols = list(loaded_spec.inputs.value_cols.values())
125
+ inputs = load(
126
+ source_path=data,
127
+ period_col=loaded_spec.inputs.period_col,
128
+ value_cols=value_cols,
129
+ sheet=loaded_spec.inputs.sheet,
130
+ )
131
+ from excel_model.validator import validate_inputs_against_spec
132
+ input_errors = validate_inputs_against_spec(loaded_spec, inputs)
133
+ errors.extend(input_errors)
134
+ except (FileNotFoundError, ValueError) as e:
135
+ errors.append(f"Input data: {e}")
136
+
137
+ if errors:
138
+ for err in errors:
139
+ click.echo(err)
140
+ sys.exit(1)
141
+ else:
142
+ click.echo("OK")
143
+
144
+
145
+ @main.command()
146
+ @click.option("--spec", required=True, type=click.Path(exists=True), help="Path to model spec YAML")
147
+ @click.option("--format", "output_format", required=True, type=click.Choice(["text", "json"]), help="Output format")
148
+ def describe(spec: str, output_format: str) -> None:
149
+ """Dry-run description of what build would produce."""
150
+ # Load spec
151
+ try:
152
+ from excel_model.spec_loader import load_spec
153
+ loaded_spec = load_spec(spec)
154
+ except (FileNotFoundError, ValueError, KeyError) as e:
155
+ click.echo(f"ERROR: Failed to load spec: {e}", err=True)
156
+ sys.exit(1)
157
+
158
+ # Validate spec
159
+ from excel_model.validator import validate_spec
160
+ errors = validate_spec(loaded_spec)
161
+
162
+ # Build description
163
+ from excel_model.time_engine import generate_periods
164
+ try:
165
+ periods = generate_periods(
166
+ start_period=loaded_spec.start_period,
167
+ n_periods=loaded_spec.n_periods,
168
+ n_history=loaded_spec.n_history_periods,
169
+ granularity=loaded_spec.granularity,
170
+ )
171
+ except ValueError:
172
+ periods = []
173
+
174
+ # Group assumptions by group
175
+ assumption_groups: dict[str, list] = {}
176
+ for a in loaded_spec.assumptions:
177
+ assumption_groups.setdefault(a.group, []).append(a)
178
+
179
+ # Group line items by section
180
+ sections: dict[str, list] = {}
181
+ for li in loaded_spec.line_items:
182
+ sections.setdefault(li.section, []).append(li)
183
+
184
+ description = {
185
+ "model_type": loaded_spec.model_type,
186
+ "title": loaded_spec.title,
187
+ "currency": loaded_spec.currency,
188
+ "granularity": loaded_spec.granularity,
189
+ "start_period": loaded_spec.start_period,
190
+ "n_history_periods": loaded_spec.n_history_periods,
191
+ "n_periods": loaded_spec.n_periods,
192
+ "total_periods": len(periods),
193
+ "period_labels": [p.label for p in periods],
194
+ "metadata": {
195
+ "preparer": loaded_spec.metadata.preparer,
196
+ "date": loaded_spec.metadata.date,
197
+ "version": loaded_spec.metadata.version,
198
+ },
199
+ "assumptions_count": len(loaded_spec.assumptions),
200
+ "assumption_groups": {
201
+ group: [{"name": a.name, "value": a.value, "format": a.format} for a in assumptions]
202
+ for group, assumptions in assumption_groups.items()
203
+ },
204
+ "line_items_count": len(loaded_spec.line_items),
205
+ "sections": {
206
+ section: [{"key": li.key, "label": li.label, "formula_type": li.formula_type} for li in items]
207
+ for section, items in sections.items()
208
+ },
209
+ "scenarios": [{"name": s.name, "label": s.label} for s in loaded_spec.scenarios],
210
+ "column_groups": [{"key": cg.key, "label": cg.label} for cg in loaded_spec.column_groups],
211
+ "sheets_to_create": ["Assumptions", "Inputs", "Model"],
212
+ "validation_errors": errors,
213
+ "inputs": {
214
+ "source": loaded_spec.inputs.source,
215
+ "period_col": loaded_spec.inputs.period_col,
216
+ "value_cols": dict(loaded_spec.inputs.value_cols),
217
+ },
218
+ }
219
+
220
+ if output_format == "json":
221
+ click.echo(json.dumps(description, indent=2))
222
+ else:
223
+ click.echo(f"Model: {loaded_spec.title}")
224
+ click.echo(f"Type: {loaded_spec.model_type}")
225
+ click.echo(f"Currency: {loaded_spec.currency}")
226
+ click.echo()
227
+ click.echo(f"Periods ({loaded_spec.granularity}):")
228
+ if loaded_spec.n_history_periods > 0:
229
+ hist = [p.label for p in periods if p.is_history]
230
+ click.echo(f" History ({loaded_spec.n_history_periods}): {', '.join(hist)}")
231
+ proj = [p.label for p in periods if not p.is_history]
232
+ click.echo(f" Projection ({loaded_spec.n_periods}): {', '.join(proj)}")
233
+ click.echo()
234
+ click.echo(f"Assumptions ({len(loaded_spec.assumptions)}):")
235
+ for group, assumptions in assumption_groups.items():
236
+ click.echo(f" [{group}]")
237
+ for a in assumptions:
238
+ click.echo(f" {a.name}: {a.value} ({a.format})")
239
+ click.echo()
240
+ click.echo(f"Line Items ({len(loaded_spec.line_items)}):")
241
+ for section, items in sections.items():
242
+ if section:
243
+ click.echo(f" [{section}]")
244
+ for li in items:
245
+ marker = " [subtotal]" if li.is_subtotal else (" [total]" if li.is_total else "")
246
+ click.echo(f" {li.label.strip()}: {li.formula_type}{marker}")
247
+ if loaded_spec.scenarios:
248
+ click.echo()
249
+ click.echo(f"Scenarios ({len(loaded_spec.scenarios)}):")
250
+ for s in loaded_spec.scenarios:
251
+ overrides = ", ".join(f"{k}={v}" for k, v in s.assumption_overrides.items())
252
+ override_str = f" (overrides: {overrides})" if overrides else ""
253
+ click.echo(f" {s.label}{override_str}")
254
+ if errors:
255
+ click.echo()
256
+ click.echo(f"Validation errors ({len(errors)}):")
257
+ for e in errors:
258
+ click.echo(f" - {e}")
259
+ else:
260
+ click.echo()
261
+ click.echo("Validation: OK")
@@ -0,0 +1,69 @@
1
+ """Configuration loading with bundled defaults and deep-merge."""
2
+ from importlib import resources
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ from excel_model.exceptions import StyleConfigError
8
+ from excel_model.style import StyleConfig
9
+
10
+
11
+ def _load_default_style_yaml() -> dict:
12
+ """Load the bundled default style config."""
13
+ ref = resources.files("excel_model") / "defaults" / "default_style.yaml"
14
+ return yaml.safe_load(ref.read_text(encoding="utf-8"))
15
+
16
+
17
+ def _deep_merge(base: dict, override: dict) -> dict:
18
+ """Recursively merge override into base. Override values win."""
19
+ result = dict(base)
20
+ for key, value in override.items():
21
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
22
+ result[key] = _deep_merge(result[key], value)
23
+ else:
24
+ result[key] = value
25
+ return result
26
+
27
+
28
+ def load_style(style_path: str | None) -> StyleConfig:
29
+ """Load StyleConfig from YAML, deep-merged with bundled defaults.
30
+
31
+ If style_path is None, returns the bundled defaults only.
32
+ If style_path is provided, user overrides are merged on top of defaults.
33
+ """
34
+ defaults = _load_default_style_yaml()
35
+
36
+ if style_path is not None:
37
+ p = Path(style_path)
38
+ if not p.exists():
39
+ raise StyleConfigError(f"Style config not found: {style_path}")
40
+ with p.open() as f:
41
+ user_data = yaml.safe_load(f) or {}
42
+ merged = _deep_merge(defaults, user_data)
43
+ else:
44
+ merged = defaults
45
+
46
+ required = [
47
+ "header_fill_hex", "header_font_color", "subtotal_fill_hex", "total_fill_hex",
48
+ "history_col_fill_hex", "section_header_fill_hex", "font_name", "font_size",
49
+ "number_format_currency", "number_format_percent",
50
+ "number_format_integer", "number_format_number",
51
+ ]
52
+ missing = [k for k in required if k not in merged]
53
+ if missing:
54
+ raise StyleConfigError(f"Style config missing required keys: {missing}")
55
+
56
+ return StyleConfig(
57
+ header_fill_hex=merged["header_fill_hex"],
58
+ header_font_color=merged["header_font_color"],
59
+ subtotal_fill_hex=merged["subtotal_fill_hex"],
60
+ total_fill_hex=merged["total_fill_hex"],
61
+ history_col_fill_hex=merged["history_col_fill_hex"],
62
+ section_header_fill_hex=merged["section_header_fill_hex"],
63
+ font_name=merged["font_name"],
64
+ font_size=int(merged["font_size"]),
65
+ number_format_currency=merged["number_format_currency"],
66
+ number_format_percent=merged["number_format_percent"],
67
+ number_format_integer=merged["number_format_integer"],
68
+ number_format_number=merged["number_format_number"],
69
+ )
File without changes
@@ -0,0 +1,12 @@
1
+ header_fill_hex: "1F3864"
2
+ header_font_color: "FFFFFF"
3
+ subtotal_fill_hex: "D6E4F0"
4
+ total_fill_hex: "AED6F1"
5
+ history_col_fill_hex: "F2F2F2"
6
+ section_header_fill_hex: "E8F4FD"
7
+ font_name: "Calibri"
8
+ font_size: 10
9
+ number_format_currency: '#,##0'
10
+ number_format_percent: '0.0%'
11
+ number_format_integer: '#,##0'
12
+ number_format_number: '#,##0.00'
@@ -0,0 +1,65 @@
1
+ """Orchestrator: build_workbook() — creates the full Excel workbook."""
2
+ from pathlib import Path
3
+
4
+ from openpyxl import Workbook
5
+
6
+ from excel_model.loader import InputData
7
+ from excel_model.spec import ModelSpec
8
+ from excel_model.style import StyleConfig
9
+ from excel_model.time_engine import generate_periods
10
+
11
+
12
+ def build_workbook(
13
+ spec: ModelSpec,
14
+ inputs: InputData | None,
15
+ output_path: str,
16
+ style: StyleConfig,
17
+ ) -> None:
18
+ """Build and save a complete Excel workbook from the model spec.
19
+
20
+ Creates Assumptions, Inputs, and Model sheets.
21
+ Dispatches to the appropriate model builder based on spec.model_type.
22
+ """
23
+ wb = Workbook()
24
+ # Remove default sheet
25
+ if "Sheet" in wb.sheetnames:
26
+ del wb["Sheet"]
27
+
28
+ if spec.model_type == "comparison":
29
+ # Comparison models don't use time_engine periods
30
+ from excel_model.models.comparison import build_comparison
31
+ build_comparison(wb, spec, inputs, style)
32
+ else:
33
+ periods = generate_periods(
34
+ start_period=spec.start_period,
35
+ n_periods=spec.n_periods,
36
+ n_history=spec.n_history_periods,
37
+ granularity=spec.granularity,
38
+ )
39
+
40
+ if spec.model_type == "p_and_l":
41
+ from excel_model.models.p_and_l import build_p_and_l
42
+ build_p_and_l(wb, spec, inputs, style, periods)
43
+
44
+ elif spec.model_type == "dcf":
45
+ from excel_model.models.dcf import build_dcf
46
+ build_dcf(wb, spec, inputs, style, periods)
47
+
48
+ elif spec.model_type == "budget_vs_actuals":
49
+ from excel_model.models.budget_vs_actuals import build_budget_vs_actuals
50
+ build_budget_vs_actuals(wb, spec, inputs, style, periods)
51
+
52
+ elif spec.model_type == "scenario":
53
+ from excel_model.models.scenario import build_scenario
54
+ build_scenario(wb, spec, inputs, style, periods)
55
+
56
+ elif spec.model_type == "custom":
57
+ from excel_model.models.p_and_l import build_p_and_l
58
+ build_p_and_l(wb, spec, inputs, style, periods)
59
+
60
+ else:
61
+ raise ValueError(f"Unknown model_type: {spec.model_type!r}")
62
+
63
+ out = Path(output_path)
64
+ out.parent.mkdir(parents=True, exist_ok=True)
65
+ wb.save(str(out))
@@ -0,0 +1,21 @@
1
+ """Custom exception classes for excel-model."""
2
+
3
+
4
+ class ExcelModelError(Exception):
5
+ """Base exception for all excel-model errors."""
6
+
7
+
8
+ class SpecValidationError(ExcelModelError):
9
+ """Raised when a model spec fails validation."""
10
+
11
+
12
+ class FormulaError(ExcelModelError):
13
+ """Raised when a formula cannot be rendered."""
14
+
15
+
16
+ class InputDataError(ExcelModelError):
17
+ """Raised when input data is missing, malformed, or incompatible."""
18
+
19
+
20
+ class StyleConfigError(ExcelModelError):
21
+ """Raised when style configuration is invalid or missing."""