excel-model 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """excel_model — Excel financial model builder."""
excel_model/cli.py ADDED
@@ -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")
excel_model/config.py ADDED
@@ -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."""