pyproforma 0.2.4__tar.gz → 0.2.5__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 (67) hide show
  1. pyproforma-0.2.5/PKG-INFO +301 -0
  2. pyproforma-0.2.5/README.md +260 -0
  3. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/app.py +61 -34
  4. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/base.html +4 -3
  5. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/table_view.html +10 -0
  6. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/view.html +13 -0
  7. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/proforma_model.py +22 -11
  8. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/reserved_words.py +0 -1
  9. pyproforma-0.2.5/pyproforma/results/line_item_stat.py +133 -0
  10. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/excel.py +50 -54
  11. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/table_class.py +5 -0
  12. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/tables/row_types.py +3 -0
  13. pyproforma-0.2.5/pyproforma.egg-info/PKG-INFO +301 -0
  14. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma.egg-info/SOURCES.txt +0 -1
  15. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproject.toml +1 -1
  16. pyproforma-0.2.4/PKG-INFO +0 -244
  17. pyproforma-0.2.4/README.md +0 -203
  18. pyproforma-0.2.4/pyproforma/explorer/templates/inputs.html +0 -107
  19. pyproforma-0.2.4/pyproforma/results/line_item_stat.py +0 -120
  20. pyproforma-0.2.4/pyproforma.egg-info/PKG-INFO +0 -244
  21. {pyproforma-0.2.4 → pyproforma-0.2.5}/LICENSE +0 -0
  22. {pyproforma-0.2.4 → pyproforma-0.2.5}/MANIFEST.in +0 -0
  23. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/__init__.py +0 -0
  24. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/__init__.py +0 -0
  25. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/chart.py +0 -0
  26. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/renderers/__init__.py +0 -0
  27. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/renderers/base.py +0 -0
  28. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/chart/renderers/matplotlib_renderer.py +0 -0
  29. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/charts/__init__.py +0 -0
  30. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/charts/chart_def.py +0 -0
  31. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/charts/charts.py +0 -0
  32. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/compare/__init__.py +0 -0
  33. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/compare/model_comparison.py +0 -0
  34. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/engine/__init__.py +0 -0
  35. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/engine/calculation_engine.py +0 -0
  36. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/engine/line_item_values.py +0 -0
  37. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/engine/model_namespace.py +0 -0
  38. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/__init__.py +0 -0
  39. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/components.py +0 -0
  40. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/chart_view.html +0 -0
  41. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/index.html +0 -0
  42. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/explorer/templates/line_item.html +0 -0
  43. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/__init__.py +0 -0
  44. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/line_item_result.py +0 -0
  45. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/line_item_selection.py +0 -0
  46. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/scalar_result.py +0 -0
  47. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/results/tags_namespace.py +0 -0
  48. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/__init__.py +0 -0
  49. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/debt_line.py +0 -0
  50. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/fixed_line.py +0 -0
  51. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/formula_line.py +0 -0
  52. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/input_line.py +0 -0
  53. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/line_item.py +0 -0
  54. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/scalar_input_line.py +0 -0
  55. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/specs/scalar_line.py +0 -0
  56. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/__init__.py +0 -0
  57. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/bootstrap_html_renderer.py +0 -0
  58. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/colors.py +0 -0
  59. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/format_value.py +0 -0
  60. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/table/html_renderer.py +0 -0
  61. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/tables/__init__.py +0 -0
  62. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/tables/table_def.py +0 -0
  63. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma/tables/tables.py +0 -0
  64. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma.egg-info/dependency_links.txt +0 -0
  65. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma.egg-info/requires.txt +0 -0
  66. {pyproforma-0.2.4 → pyproforma-0.2.5}/pyproforma.egg-info/top_level.txt +0 -0
  67. {pyproforma-0.2.4 → pyproforma-0.2.5}/setup.cfg +0 -0
@@ -0,0 +1,301 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyproforma
3
+ Version: 0.2.5
4
+ Summary: A Python package for financial modeling and reporting
5
+ Author-email: Robert Hannay <rhannay@gmail.com>
6
+ Maintainer-email: Robert Hannay <rhannay@gmail.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/rhannay/pyproforma
9
+ Project-URL: Repository, https://github.com/rhannay/pyproforma
10
+ Project-URL: Documentation, https://pyproforma.readthedocs.io/en/latest/
11
+ Project-URL: Issues, https://github.com/rhannay/pyproforma/issues
12
+ Keywords: finance,modeling,reporting,excel,proforma
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Topic :: Office/Business :: Financial
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Operating System :: OS Independent
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: pandas
26
+ Requires-Dist: pandas>=1.3.0; extra == "pandas"
27
+ Provides-Extra: excel
28
+ Requires-Dist: openpyxl>=3.0.0; extra == "excel"
29
+ Provides-Extra: charts
30
+ Requires-Dist: matplotlib>=3.5.0; extra == "charts"
31
+ Provides-Extra: notebook
32
+ Requires-Dist: ipython>=7.0.0; extra == "notebook"
33
+ Provides-Extra: explorer
34
+ Requires-Dist: flask>=2.0.0; extra == "explorer"
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest>=6.0; extra == "dev"
37
+ Requires-Dist: pytest-cov; extra == "dev"
38
+ Requires-Dist: pandas>=1.3.0; extra == "dev"
39
+ Requires-Dist: openpyxl>=3.0.0; extra == "dev"
40
+ Dynamic: license-file
41
+
42
+ # pyproforma
43
+
44
+ A Python library for building financial models — a code-first alternative to Excel for pro formas, projections, and structured financial tables.
45
+
46
+ ```bash
47
+ pip install pyproforma
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Why
53
+
54
+ Spreadsheets are the default tool for financial modeling, but they have real problems: no version control, no testing, formulas hidden inside cells, and no easy way to generate the same model for multiple scenarios. pyproforma is designed for analysts who want the benefits of code — reproducibility, testability, version history — without giving up the tabular output that finance people actually use.
55
+
56
+ ---
57
+
58
+ ## How it works
59
+
60
+ Define a model by subclassing `ProformaModel` and declaring line items as class attributes. Instantiate it with a list of periods and the library calculates everything.
61
+
62
+ ```python
63
+ from pyproforma import ProformaModel, FixedLine, FormulaLine, ScalarLine, Format
64
+
65
+ class IncomeStatement(ProformaModel):
66
+ default_periods = [2024, 2025, 2026]
67
+
68
+ tax_rate = ScalarLine(value=0.21, label="Tax Rate")
69
+
70
+ revenue = FixedLine(
71
+ values={2024: 500_000, 2025: 550_000, 2026: 605_000},
72
+ label="Revenue",
73
+ tags=["operating"],
74
+ value_format=Format.CURRENCY_NO_DECIMALS,
75
+ )
76
+ cogs = FormulaLine(
77
+ formula=lambda li, t: li.revenue[t] * 0.55,
78
+ label="Cost of Goods Sold",
79
+ value_format=Format.CURRENCY_NO_DECIMALS,
80
+ )
81
+ gross_profit = FormulaLine(
82
+ formula=lambda li, t: li.revenue[t] - li.cogs[t],
83
+ label="Gross Profit",
84
+ value_format=Format.CURRENCY_NO_DECIMALS,
85
+ )
86
+ net_income = FormulaLine(
87
+ formula=lambda li, t: li.gross_profit[t] * (1 - li.tax_rate),
88
+ label="Net Income",
89
+ value_format=Format.CURRENCY_NO_DECIMALS,
90
+ )
91
+
92
+ model = IncomeStatement() # uses default_periods
93
+ ```
94
+
95
+ Access results with dot notation (primary) or bracket notation (useful when the name is in a variable):
96
+
97
+ ```python
98
+ model.net_income[2025] # 173_745.0 — dot notation
99
+ model["net_income"][2025] # same value — bracket notation
100
+
101
+ model.tax_rate.value # 0.21 — scalars have .value, not [t]
102
+
103
+ model.periods # [2024, 2025, 2026]
104
+ model.line_item_names # ["revenue", "cogs", "gross_profit", "net_income"]
105
+ model.scalar_names # ["tax_rate"]
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Line item types
111
+
112
+ | Type | Use |
113
+ |------|-----|
114
+ | `FixedLine(values, ...)` | Hardcoded values per period |
115
+ | `FormulaLine(formula, ...)` | Calculated from other items via a lambda |
116
+ | `ScalarLine(value, ...)` | A single value shared across all periods |
117
+ | `InputLine(default, ...)` | Period-indexed values supplied at instantiation |
118
+ | `ScalarInputLine(default, ...)` | A single value supplied at instantiation |
119
+
120
+ Formula lambdas receive `li` (the model namespace) and `t` (the current period). Period-indexed items use `li.name[t]`; scalars use `li.name` (no `[t]`). Reference prior periods with `li.name[t-1]`.
121
+
122
+ ---
123
+
124
+ ## Scenario inputs
125
+
126
+ `InputLine` and `ScalarInputLine` let callers supply values at instantiation without subclassing again — useful for scenario analysis.
127
+
128
+ ```python
129
+ from pyproforma import InputLine, ScalarInputLine
130
+
131
+ class FlexModel(ProformaModel):
132
+ default_periods = [2024, 2025, 2026]
133
+
134
+ margin = ScalarInputLine(default=0.45, label="Gross Margin")
135
+ revenue = FixedLine(
136
+ values={2024: 500_000, 2025: 550_000, 2026: 605_000},
137
+ label="Revenue",
138
+ value_format=Format.CURRENCY_NO_DECIMALS,
139
+ )
140
+ gross_profit = FormulaLine(
141
+ formula=lambda li, t: li.revenue[t] * li.margin,
142
+ label="Gross Profit",
143
+ value_format=Format.CURRENCY_NO_DECIMALS,
144
+ )
145
+
146
+ base = FlexModel() # uses default margin of 0.45
147
+ upside = FlexModel(margin=0.52) # override at instantiation
148
+ ```
149
+
150
+ Use `model.compare()` to diff two instances:
151
+
152
+ ```python
153
+ comparison = base.compare(upside, labels=["Base", "Upside"])
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Time-series formulas
159
+
160
+ Seed a value in the first period and let the formula compound from there — no `if t == first_year` guards needed:
161
+
162
+ ```python
163
+ revenue = FormulaLine(
164
+ formula=lambda li, t: li.revenue[t-1] * (1 + li.growth_rate),
165
+ values={2024: 500_000}, # engine uses this for 2024; formula runs from 2025 onward
166
+ label="Revenue",
167
+ )
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Tags
173
+
174
+ Tag line items to group them without fixed categories:
175
+
176
+ ```python
177
+ water_sales = FixedLine(values={...}, tags=["revenue"])
178
+ power_sales = FixedLine(values={...}, tags=["revenue"])
179
+
180
+ # Sum all "revenue"-tagged items in a formula
181
+ total_revenue = FormulaLine(formula=lambda li, t: li.tag["revenue"][t])
182
+ ```
183
+
184
+ Tags also work in table templates:
185
+
186
+ ```python
187
+ from pyproforma import TagTotalRow
188
+ TagTotalRow(tag="revenue", label="Total Revenue")
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Tables
194
+
195
+ Generate formatted tables for HTML, Excel, or pandas. `from_template` gives full control over layout:
196
+
197
+ ```python
198
+ from pyproforma import HeaderRow, LabelRow, ItemRow, BlankRow, LineItemsTotalRow
199
+
200
+ table = model.tables.from_template([
201
+ HeaderRow(),
202
+ LabelRow("Income Statement"),
203
+ ItemRow("revenue"),
204
+ ItemRow("cogs", reverse_sign=True), # display as positive deduction
205
+ ItemRow("gross_profit", bold=True, borders="top"),
206
+ BlankRow(),
207
+ ItemRow("net_income", bold=True),
208
+ ])
209
+
210
+ table.show() # inline in Jupyter
211
+ table.to_excel("output.xlsx") # Excel with formatting preserved
212
+ table.to_dataframe() # pandas DataFrame
213
+ ```
214
+
215
+ Convenience builders for common layouts:
216
+
217
+ ```python
218
+ model.tables.line_items().show() # all line items
219
+ model.tables.line_item("net_income", include_percent_change=True).show()
220
+ model.tables.precedents("net_income").show() # formula dependency tree
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Charts
226
+
227
+ ```python
228
+ model.charts.line_item("net_income", chart_type="bar").show()
229
+ model.charts.line_items(["revenue", "gross_profit", "net_income"]).show()
230
+ ```
231
+
232
+ Charts return a `ChartSpec` which can also render to a matplotlib `Figure`:
233
+
234
+ ```python
235
+ fig = model.charts.line_item("net_income").figure()
236
+ ```
237
+
238
+ Requires `pip install pyproforma[charts]`.
239
+
240
+ ---
241
+
242
+ ## Number formatting
243
+
244
+ Named format constants flow through to both HTML and Excel output:
245
+
246
+ ```python
247
+ from pyproforma import Format
248
+
249
+ ItemRow("revenue", value_format=Format.CURRENCY_NO_DECIMALS) # $500,000
250
+ ItemRow("revenue", value_format=Format.THOUSANDS_K) # 500.0K
251
+ ItemRow("margin", value_format=Format.PERCENT_ONE_DECIMAL) # 45.0%
252
+ ItemRow("net_income", value_format=Format.MILLIONS_M) # $0.2M
253
+ ```
254
+
255
+ Custom formats via `NumberFormatSpec`:
256
+
257
+ ```python
258
+ from pyproforma import NumberFormatSpec
259
+
260
+ fmt = NumberFormatSpec(decimals=1, scale="millions", prefix="$", suffix="M")
261
+ # 500_000 → "$0.5M"
262
+ ```
263
+
264
+ ---
265
+
266
+ ## Explorer
267
+
268
+ A lightweight Flask web app for browsing any model interactively:
269
+
270
+ ```python
271
+ from pyproforma.explorer import create_app
272
+
273
+ app = create_app(model)
274
+ app.run(debug=True)
275
+ ```
276
+
277
+ Requires `pip install pyproforma[explorer]`. The app shows all line items, their values, formula sources, and lets you update `InputLine` / `ScalarInputLine` values live. You can also pass named tables, charts, and views to build a richer dashboard.
278
+
279
+ ---
280
+
281
+ ## Installation
282
+
283
+ ```bash
284
+ pip install pyproforma # core only
285
+ pip install pyproforma[charts] # + matplotlib
286
+ pip install pyproforma[excel] # + openpyxl
287
+ pip install pyproforma[explorer] # + Flask
288
+ pip install pyproforma[pandas] # + pandas
289
+ ```
290
+
291
+ Requires Python 3.9+.
292
+
293
+ ---
294
+
295
+ ## Status
296
+
297
+ Active development. Core modeling, table export, charts, and the Flask explorer are all stable. Feedback welcome — open an issue on GitHub.
298
+
299
+ ## License
300
+
301
+ MIT
@@ -0,0 +1,260 @@
1
+ # pyproforma
2
+
3
+ A Python library for building financial models — a code-first alternative to Excel for pro formas, projections, and structured financial tables.
4
+
5
+ ```bash
6
+ pip install pyproforma
7
+ ```
8
+
9
+ ---
10
+
11
+ ## Why
12
+
13
+ Spreadsheets are the default tool for financial modeling, but they have real problems: no version control, no testing, formulas hidden inside cells, and no easy way to generate the same model for multiple scenarios. pyproforma is designed for analysts who want the benefits of code — reproducibility, testability, version history — without giving up the tabular output that finance people actually use.
14
+
15
+ ---
16
+
17
+ ## How it works
18
+
19
+ Define a model by subclassing `ProformaModel` and declaring line items as class attributes. Instantiate it with a list of periods and the library calculates everything.
20
+
21
+ ```python
22
+ from pyproforma import ProformaModel, FixedLine, FormulaLine, ScalarLine, Format
23
+
24
+ class IncomeStatement(ProformaModel):
25
+ default_periods = [2024, 2025, 2026]
26
+
27
+ tax_rate = ScalarLine(value=0.21, label="Tax Rate")
28
+
29
+ revenue = FixedLine(
30
+ values={2024: 500_000, 2025: 550_000, 2026: 605_000},
31
+ label="Revenue",
32
+ tags=["operating"],
33
+ value_format=Format.CURRENCY_NO_DECIMALS,
34
+ )
35
+ cogs = FormulaLine(
36
+ formula=lambda li, t: li.revenue[t] * 0.55,
37
+ label="Cost of Goods Sold",
38
+ value_format=Format.CURRENCY_NO_DECIMALS,
39
+ )
40
+ gross_profit = FormulaLine(
41
+ formula=lambda li, t: li.revenue[t] - li.cogs[t],
42
+ label="Gross Profit",
43
+ value_format=Format.CURRENCY_NO_DECIMALS,
44
+ )
45
+ net_income = FormulaLine(
46
+ formula=lambda li, t: li.gross_profit[t] * (1 - li.tax_rate),
47
+ label="Net Income",
48
+ value_format=Format.CURRENCY_NO_DECIMALS,
49
+ )
50
+
51
+ model = IncomeStatement() # uses default_periods
52
+ ```
53
+
54
+ Access results with dot notation (primary) or bracket notation (useful when the name is in a variable):
55
+
56
+ ```python
57
+ model.net_income[2025] # 173_745.0 — dot notation
58
+ model["net_income"][2025] # same value — bracket notation
59
+
60
+ model.tax_rate.value # 0.21 — scalars have .value, not [t]
61
+
62
+ model.periods # [2024, 2025, 2026]
63
+ model.line_item_names # ["revenue", "cogs", "gross_profit", "net_income"]
64
+ model.scalar_names # ["tax_rate"]
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Line item types
70
+
71
+ | Type | Use |
72
+ |------|-----|
73
+ | `FixedLine(values, ...)` | Hardcoded values per period |
74
+ | `FormulaLine(formula, ...)` | Calculated from other items via a lambda |
75
+ | `ScalarLine(value, ...)` | A single value shared across all periods |
76
+ | `InputLine(default, ...)` | Period-indexed values supplied at instantiation |
77
+ | `ScalarInputLine(default, ...)` | A single value supplied at instantiation |
78
+
79
+ Formula lambdas receive `li` (the model namespace) and `t` (the current period). Period-indexed items use `li.name[t]`; scalars use `li.name` (no `[t]`). Reference prior periods with `li.name[t-1]`.
80
+
81
+ ---
82
+
83
+ ## Scenario inputs
84
+
85
+ `InputLine` and `ScalarInputLine` let callers supply values at instantiation without subclassing again — useful for scenario analysis.
86
+
87
+ ```python
88
+ from pyproforma import InputLine, ScalarInputLine
89
+
90
+ class FlexModel(ProformaModel):
91
+ default_periods = [2024, 2025, 2026]
92
+
93
+ margin = ScalarInputLine(default=0.45, label="Gross Margin")
94
+ revenue = FixedLine(
95
+ values={2024: 500_000, 2025: 550_000, 2026: 605_000},
96
+ label="Revenue",
97
+ value_format=Format.CURRENCY_NO_DECIMALS,
98
+ )
99
+ gross_profit = FormulaLine(
100
+ formula=lambda li, t: li.revenue[t] * li.margin,
101
+ label="Gross Profit",
102
+ value_format=Format.CURRENCY_NO_DECIMALS,
103
+ )
104
+
105
+ base = FlexModel() # uses default margin of 0.45
106
+ upside = FlexModel(margin=0.52) # override at instantiation
107
+ ```
108
+
109
+ Use `model.compare()` to diff two instances:
110
+
111
+ ```python
112
+ comparison = base.compare(upside, labels=["Base", "Upside"])
113
+ ```
114
+
115
+ ---
116
+
117
+ ## Time-series formulas
118
+
119
+ Seed a value in the first period and let the formula compound from there — no `if t == first_year` guards needed:
120
+
121
+ ```python
122
+ revenue = FormulaLine(
123
+ formula=lambda li, t: li.revenue[t-1] * (1 + li.growth_rate),
124
+ values={2024: 500_000}, # engine uses this for 2024; formula runs from 2025 onward
125
+ label="Revenue",
126
+ )
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Tags
132
+
133
+ Tag line items to group them without fixed categories:
134
+
135
+ ```python
136
+ water_sales = FixedLine(values={...}, tags=["revenue"])
137
+ power_sales = FixedLine(values={...}, tags=["revenue"])
138
+
139
+ # Sum all "revenue"-tagged items in a formula
140
+ total_revenue = FormulaLine(formula=lambda li, t: li.tag["revenue"][t])
141
+ ```
142
+
143
+ Tags also work in table templates:
144
+
145
+ ```python
146
+ from pyproforma import TagTotalRow
147
+ TagTotalRow(tag="revenue", label="Total Revenue")
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Tables
153
+
154
+ Generate formatted tables for HTML, Excel, or pandas. `from_template` gives full control over layout:
155
+
156
+ ```python
157
+ from pyproforma import HeaderRow, LabelRow, ItemRow, BlankRow, LineItemsTotalRow
158
+
159
+ table = model.tables.from_template([
160
+ HeaderRow(),
161
+ LabelRow("Income Statement"),
162
+ ItemRow("revenue"),
163
+ ItemRow("cogs", reverse_sign=True), # display as positive deduction
164
+ ItemRow("gross_profit", bold=True, borders="top"),
165
+ BlankRow(),
166
+ ItemRow("net_income", bold=True),
167
+ ])
168
+
169
+ table.show() # inline in Jupyter
170
+ table.to_excel("output.xlsx") # Excel with formatting preserved
171
+ table.to_dataframe() # pandas DataFrame
172
+ ```
173
+
174
+ Convenience builders for common layouts:
175
+
176
+ ```python
177
+ model.tables.line_items().show() # all line items
178
+ model.tables.line_item("net_income", include_percent_change=True).show()
179
+ model.tables.precedents("net_income").show() # formula dependency tree
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Charts
185
+
186
+ ```python
187
+ model.charts.line_item("net_income", chart_type="bar").show()
188
+ model.charts.line_items(["revenue", "gross_profit", "net_income"]).show()
189
+ ```
190
+
191
+ Charts return a `ChartSpec` which can also render to a matplotlib `Figure`:
192
+
193
+ ```python
194
+ fig = model.charts.line_item("net_income").figure()
195
+ ```
196
+
197
+ Requires `pip install pyproforma[charts]`.
198
+
199
+ ---
200
+
201
+ ## Number formatting
202
+
203
+ Named format constants flow through to both HTML and Excel output:
204
+
205
+ ```python
206
+ from pyproforma import Format
207
+
208
+ ItemRow("revenue", value_format=Format.CURRENCY_NO_DECIMALS) # $500,000
209
+ ItemRow("revenue", value_format=Format.THOUSANDS_K) # 500.0K
210
+ ItemRow("margin", value_format=Format.PERCENT_ONE_DECIMAL) # 45.0%
211
+ ItemRow("net_income", value_format=Format.MILLIONS_M) # $0.2M
212
+ ```
213
+
214
+ Custom formats via `NumberFormatSpec`:
215
+
216
+ ```python
217
+ from pyproforma import NumberFormatSpec
218
+
219
+ fmt = NumberFormatSpec(decimals=1, scale="millions", prefix="$", suffix="M")
220
+ # 500_000 → "$0.5M"
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Explorer
226
+
227
+ A lightweight Flask web app for browsing any model interactively:
228
+
229
+ ```python
230
+ from pyproforma.explorer import create_app
231
+
232
+ app = create_app(model)
233
+ app.run(debug=True)
234
+ ```
235
+
236
+ Requires `pip install pyproforma[explorer]`. The app shows all line items, their values, formula sources, and lets you update `InputLine` / `ScalarInputLine` values live. You can also pass named tables, charts, and views to build a richer dashboard.
237
+
238
+ ---
239
+
240
+ ## Installation
241
+
242
+ ```bash
243
+ pip install pyproforma # core only
244
+ pip install pyproforma[charts] # + matplotlib
245
+ pip install pyproforma[excel] # + openpyxl
246
+ pip install pyproforma[explorer] # + Flask
247
+ pip install pyproforma[pandas] # + pandas
248
+ ```
249
+
250
+ Requires Python 3.9+.
251
+
252
+ ---
253
+
254
+ ## Status
255
+
256
+ Active development. Core modeling, table export, charts, and the Flask explorer are all stable. Feedback welcome — open an issue on GitHub.
257
+
258
+ ## License
259
+
260
+ MIT
@@ -51,7 +51,18 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
51
51
  state.model = model
52
52
  state.model_class = type(model)
53
53
  state.periods = model.periods
54
- state.error = None
54
+
55
+ all_input_names = type(model)._scalar_input_names + type(model)._input_line_names
56
+ state.inputs_group = (
57
+ InputGroup(names=all_input_names, orient="horizontal")
58
+ if all_input_names else None
59
+ )
60
+
61
+ try:
62
+ import openpyxl # noqa: F401
63
+ state.excel_available = True
64
+ except ImportError:
65
+ state.excel_available = False
55
66
 
56
67
  all_items_def = TableDef(
57
68
  rows=[HeaderRow(), *[ItemRow(name=n) for n in model.line_item_names]],
@@ -89,6 +100,8 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
89
100
  "nav_charts": list(enumerate(state.charts.keys())),
90
101
  "nav_views": list(enumerate(state.views.keys())),
91
102
  "nav_tags": state.model.tags,
103
+ "nav_has_inputs": state.inputs_group is not None,
104
+ "excel_available": state.excel_available,
92
105
  }
93
106
 
94
107
  def _format_name(spec):
@@ -123,29 +136,6 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
123
136
  })
124
137
  return items
125
138
 
126
- def _build_inputs():
127
- m = state.model
128
- inputs = []
129
- for name in state.model_class._scalar_input_names:
130
- spec = getattr(state.model_class, name)
131
- inputs.append({
132
- "name": name,
133
- "label": spec.label or name,
134
- "is_scalar": True,
135
- "value": m._scalars[name],
136
- "formatted_values": [m[name].formatted_value],
137
- })
138
- for name in state.model_class._input_line_names:
139
- spec = getattr(state.model_class, name)
140
- inputs.append({
141
- "name": name,
142
- "label": spec.label or name,
143
- "is_scalar": False,
144
- "value": m._input_line_values.get(name, {}),
145
- "formatted_values": [m[name].formatted_value(p) for p in state.periods],
146
- })
147
- return inputs
148
-
149
139
  def _add_hrefs(definition):
150
140
  rows = definition.rows if isinstance(definition, TableDef) else definition
151
141
  result = []
@@ -285,11 +275,33 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
285
275
  label = labels[idx]
286
276
  definition = state.tables[label]
287
277
  table = state.model.tables.build(_add_hrefs(definition))
278
+ download_url = url_for("table_download", idx=idx) if state.excel_available else None
288
279
  return render_template(
289
280
  "table_view.html",
290
281
  model=state.model,
291
282
  title=table.title or label,
292
283
  table_html=table.to_bootstrap_html(),
284
+ download_url=download_url,
285
+ )
286
+
287
+ @app.route("/table/<int:idx>/download")
288
+ def table_download(idx):
289
+ labels = list(state.tables.keys())
290
+ if idx >= len(labels):
291
+ abort(404)
292
+ if not state.excel_available:
293
+ abort(501)
294
+ from flask import send_file
295
+ label = labels[idx]
296
+ definition = state.tables[label]
297
+ table = state.model.tables.build(_add_hrefs(definition))
298
+ buf = table.to_excel_bytes()
299
+ filename = label.lower().replace(" ", "_") + ".xlsx"
300
+ return send_file(
301
+ buf,
302
+ as_attachment=True,
303
+ download_name=filename,
304
+ mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
293
305
  )
294
306
 
295
307
  @app.route("/chart/<int:idx>")
@@ -354,6 +366,11 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
354
366
  c["col_width"] = col_width
355
367
  c["html"] = built.to_bootstrap_html()
356
368
  c["table_title"] = built.title or comp["ref"]
369
+ if state.excel_available:
370
+ table_idx = list(state.tables.keys()).index(comp["ref"])
371
+ c["download_url"] = url_for("table_download", idx=table_idx)
372
+ else:
373
+ c["download_url"] = None
357
374
  processed.append(c)
358
375
  rows.append(processed)
359
376
 
@@ -368,14 +385,25 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
368
385
 
369
386
  @app.route("/inputs", methods=["GET"])
370
387
  def inputs():
371
- m = state.model
372
- error = state.error
373
- state.error = None
388
+ if state.inputs_group is None:
389
+ return render_template(
390
+ "view.html",
391
+ model=state.model,
392
+ title="Inputs",
393
+ rows=[],
394
+ has_inputs=False,
395
+ form_action=None,
396
+ empty_message="This model has no input line items.",
397
+ )
398
+ built = state.inputs_group.build(state.model)
399
+ built["col_width"] = 12
374
400
  return render_template(
375
- "inputs.html",
376
- model=m,
377
- inputs=_build_inputs(),
378
- error=error,
401
+ "view.html",
402
+ model=state.model,
403
+ title="Inputs",
404
+ rows=[[built]],
405
+ has_inputs=True,
406
+ form_action=url_for("update_inputs"),
379
407
  )
380
408
 
381
409
  @app.route("/inputs", methods=["POST"])
@@ -402,10 +430,9 @@ def create_app(model, *, tables=None, charts=None, views=None, home_view=None):
402
430
  else:
403
431
  kwargs[name] = {p: v for p, v in current.items() if p not in locked}
404
432
  state.model = state.model_class(periods=state.periods, **kwargs)
405
- state.error = None
406
- flash("Model updated.")
433
+ flash("Model updated.", "success")
407
434
  except Exception as e:
408
- state.error = str(e)
435
+ flash(str(e), "danger")
409
436
  next_url = request.args.get("next") or url_for("inputs")
410
437
  return redirect(next_url)
411
438