35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129 | @main.command()
@click.option("--spec", required=True, type=click.Path(exists=True), help="Path to model spec YAML")
@click.option("--output", required=True, type=click.Path(), help="Path for output .xlsx file")
@click.option(
"--style",
required=False,
type=click.Path(exists=True),
help="Path to style config YAML (uses bundled defaults if omitted)",
)
@click.option("--data", required=False, type=click.Path(exists=True), help="Path to input data file")
@click.option(
"--mode",
required=True,
type=click.Choice(["batch", "interactive"]),
help="batch = JSON to stdout; interactive = verbose narrative",
)
def build(spec: str, output: str, style: str | None, data: str | None, mode: str) -> None:
"""Build an Excel financial model from a YAML spec."""
def emit_error(message: str) -> None:
if mode == "batch":
click.echo(json.dumps({"status": "error", "message": message}))
else:
click.echo(f"ERROR: {message}", err=True)
sys.exit(1)
def emit_info(message: str) -> None:
if mode == "interactive":
click.echo(message)
# Load spec
emit_info(f"Loading model spec: {spec}")
try:
loaded_spec = load_spec(spec)
except (FileNotFoundError, ValueError, KeyError) as e:
emit_error(f"Failed to load spec: {e}")
return # pragma: no cover
# Validate spec
emit_info("Validating model spec...")
errors = validate_spec(loaded_spec)
if errors:
emit_error("Spec validation failed:\n" + "\n".join(f" - {e}" for e in errors))
emit_info(f" Model type: {loaded_spec.model_type}")
emit_info(f" Title: {loaded_spec.title}")
emit_info(f" Currency: {loaded_spec.currency}")
emit_info(
f" Periods: {loaded_spec.n_history_periods} history + {loaded_spec.n_periods} projection ({loaded_spec.granularity})"
)
emit_info(f" Assumptions: {len(loaded_spec.assumptions)}")
emit_info(f" Line items: {len(loaded_spec.line_items)}")
# Load style
emit_info(f"Loading style config: {style or '(bundled defaults)'}")
try:
loaded_style = load_style(style)
except StyleConfigError as e:
emit_error(f"Failed to load style config: {e}")
return # pragma: no cover
# Load input data (optional)
inputs = None
if data:
emit_info(f"Loading input data: {data}")
try:
value_cols = list(loaded_spec.inputs.value_cols.values())
inputs = load(
source_path=data,
period_col=loaded_spec.inputs.period_col,
value_cols=value_cols,
sheet=loaded_spec.inputs.sheet,
)
emit_info(f" Loaded {len(inputs.df)} rows")
input_errors = validate_inputs_against_spec(loaded_spec, inputs)
if input_errors:
emit_error("Input data validation failed:\n" + "\n".join(f" - {e}" for e in input_errors))
except (FileNotFoundError, ValueError) as e:
emit_error(f"Failed to load input data: {e}")
# Build workbook
emit_info("Building workbook...")
try:
build_workbook(spec=loaded_spec, inputs=inputs, output_path=output, style=loaded_style)
except ExcelModelError as e:
emit_error(f"Failed to build workbook: {e}")
except (ValueError, KeyError, FileNotFoundError) as e:
emit_error(f"Failed to build workbook: {e}")
output_path = str(Path(output).resolve())
emit_info(f"Workbook saved to: {output_path}")
if mode == "batch":
click.echo(json.dumps({"status": "ok", "output": output_path}))
|