pyproforma 0.2.1__tar.gz → 0.2.2__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 (50) hide show
  1. {pyproforma-0.2.1/pyproforma.egg-info → pyproforma-0.2.2}/PKG-INFO +3 -1
  2. pyproforma-0.2.2/pyproforma/explorer/__init__.py +3 -0
  3. pyproforma-0.2.2/pyproforma/explorer/app.py +340 -0
  4. pyproforma-0.2.2/pyproforma/explorer/components.py +42 -0
  5. {pyproforma-0.2.1 → pyproforma-0.2.2/pyproforma.egg-info}/PKG-INFO +3 -1
  6. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma.egg-info/SOURCES.txt +3 -0
  7. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma.egg-info/requires.txt +3 -0
  8. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproject.toml +4 -1
  9. {pyproforma-0.2.1 → pyproforma-0.2.2}/LICENSE +0 -0
  10. {pyproforma-0.2.1 → pyproforma-0.2.2}/MANIFEST.in +0 -0
  11. {pyproforma-0.2.1 → pyproforma-0.2.2}/README.md +0 -0
  12. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/__init__.py +0 -0
  13. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/calculation_engine.py +0 -0
  14. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/chart/__init__.py +0 -0
  15. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/chart/chart.py +0 -0
  16. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/chart/renderers/__init__.py +0 -0
  17. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/chart/renderers/base.py +0 -0
  18. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/chart/renderers/matplotlib_renderer.py +0 -0
  19. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/charts/__init__.py +0 -0
  20. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/charts/chart_def.py +0 -0
  21. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/charts/charts.py +0 -0
  22. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/compare/__init__.py +0 -0
  23. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/compare/model_comparison.py +0 -0
  24. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/line_items/__init__.py +0 -0
  25. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/line_items/debt_line.py +0 -0
  26. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/line_items/fixed_line.py +0 -0
  27. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/line_items/formula_line.py +0 -0
  28. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/line_items/input_line.py +0 -0
  29. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/line_items/line_item.py +0 -0
  30. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/line_items/line_item_result.py +0 -0
  31. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/line_items/line_item_selection.py +0 -0
  32. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/line_items/line_item_values.py +0 -0
  33. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/model_namespace.py +0 -0
  34. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/proforma_model.py +0 -0
  35. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/reserved_words.py +0 -0
  36. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/table/__init__.py +0 -0
  37. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/table/bootstrap_html_renderer.py +0 -0
  38. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/table/colors.py +0 -0
  39. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/table/excel.py +0 -0
  40. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/table/format_value.py +0 -0
  41. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/table/html_renderer.py +0 -0
  42. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/table/table_class.py +0 -0
  43. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/tables/__init__.py +0 -0
  44. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/tables/row_types.py +0 -0
  45. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/tables/table_def.py +0 -0
  46. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/tables/tables.py +0 -0
  47. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma/tags_namespace.py +0 -0
  48. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma.egg-info/dependency_links.txt +0 -0
  49. {pyproforma-0.2.1 → pyproforma-0.2.2}/pyproforma.egg-info/top_level.txt +0 -0
  50. {pyproforma-0.2.1 → pyproforma-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyproforma
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: A Python package for financial modeling and reporting
5
5
  Author-email: Robert Hannay <rhannay@gmail.com>
6
6
  Maintainer-email: Robert Hannay <rhannay@gmail.com>
@@ -33,6 +33,8 @@ Requires-Dist: pytest>=6.0; extra == "dev"
33
33
  Requires-Dist: pytest-cov; extra == "dev"
34
34
  Provides-Extra: notebook
35
35
  Requires-Dist: ipython>=7.0.0; extra == "notebook"
36
+ Provides-Extra: explorer
37
+ Requires-Dist: flask>=2.0.0; extra == "explorer"
36
38
  Dynamic: license-file
37
39
 
38
40
  # pyproforma
@@ -0,0 +1,3 @@
1
+ from .app import create_app
2
+
3
+ __all__ = ["create_app"]
@@ -0,0 +1,340 @@
1
+ """Flask app factory for exploring a ProformaModel in a web browser."""
2
+
3
+ import dataclasses
4
+ import json
5
+ import os
6
+
7
+ from flask import Flask, abort, flash, redirect, render_template, request, url_for
8
+
9
+ from pyproforma.explorer.components import StatCard
10
+ from pyproforma.line_items.fixed_line import FixedLine
11
+ from pyproforma.line_items.formula_line import FormulaLine
12
+ from pyproforma.line_items.input_line import InputLine
13
+ from pyproforma.table import Format
14
+ from pyproforma.tables.row_types import HeaderRow, ItemRow, TagItemsRow
15
+ from pyproforma.tables.table_def import TableDef
16
+
17
+
18
+ def create_app(model, tables=None, charts=None, views=None):
19
+ """Create a Flask app for exploring a ProformaModel.
20
+
21
+ Args:
22
+ model: An instantiated ProformaModel.
23
+
24
+ Returns:
25
+ Flask app instance.
26
+
27
+ Usage:
28
+ from pyproforma import ProformaModel, FixedLine
29
+ from pyproforma.explorer import create_app
30
+
31
+ class MyModel(ProformaModel):
32
+ revenue = FixedLine(values={2024: 100_000, 2025: 110_000})
33
+
34
+ model = MyModel(periods=[2024, 2025])
35
+ app = create_app(model)
36
+ app.run(debug=True)
37
+ """
38
+ app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"))
39
+ app.secret_key = "pyproforma-explorer"
40
+
41
+ class _State:
42
+ pass
43
+
44
+ state = _State()
45
+ state.model = model
46
+ state.model_class = type(model)
47
+ state.periods = model.periods
48
+ state.error = None
49
+
50
+ all_items_def = TableDef(
51
+ rows=[HeaderRow(), *[ItemRow(name=n) for n in model.line_item_names]],
52
+ title="All Line Items",
53
+ )
54
+ state.tables = {"All Line Items": all_items_def, **(tables or {})}
55
+ state.charts = charts or {}
56
+ state.views = views or {}
57
+
58
+ # ------------------------------------------------------------------
59
+ # Helpers
60
+ # ------------------------------------------------------------------
61
+
62
+ @app.context_processor
63
+ def inject_nav():
64
+ return {
65
+ "nav_tables": list(enumerate(state.tables.keys())),
66
+ "nav_charts": list(enumerate(state.charts.keys())),
67
+ "nav_views": list(enumerate(state.views.keys())),
68
+ "nav_tags": state.model.tags,
69
+ }
70
+
71
+ def _format_name(spec):
72
+ if spec is None:
73
+ return "—"
74
+ for name, standard in Format._STRING_MAP.items():
75
+ if spec == standard:
76
+ return name.upper()
77
+ return str(spec)
78
+
79
+ def _build_items(names):
80
+ m = state.model
81
+ items = []
82
+ for name in names:
83
+ item_def = getattr(type(m), name)
84
+ is_scalar = isinstance(item_def, FixedLine) and item_def.is_scalar
85
+ items.append({
86
+ "name": name,
87
+ "label": item_def.label or name,
88
+ "type": type(item_def).__name__,
89
+ "tags": item_def.tags,
90
+ "scalar": is_scalar,
91
+ "value": m[name].formatted_value(m.periods[0]) if (is_scalar and m.periods) else None,
92
+ })
93
+ return items
94
+
95
+ def _build_inputs():
96
+ m = state.model
97
+ inputs = []
98
+ for name in state.model_class._input_line_names:
99
+ spec = getattr(state.model_class, name)
100
+ is_scalar = name in m._scalars
101
+ if is_scalar:
102
+ formatted = [m[name].formatted_value(p) for p in state.periods[:1]]
103
+ else:
104
+ formatted = [m[name].formatted_value(p) for p in state.periods]
105
+ inputs.append({
106
+ "name": name,
107
+ "label": spec.label or name,
108
+ "is_scalar": is_scalar,
109
+ "value": m._scalars[name] if is_scalar else m._input_line_values.get(name, {}),
110
+ "formatted_values": formatted,
111
+ })
112
+ return inputs
113
+
114
+ def _add_hrefs(definition):
115
+ rows = definition.rows if isinstance(definition, TableDef) else definition
116
+ result = []
117
+ for row in rows:
118
+ if isinstance(row, ItemRow):
119
+ result.append(dataclasses.replace(row, href=url_for("line_item", name=row.name)))
120
+ elif isinstance(row, TagItemsRow):
121
+ names = [n for n in state.model.line_item_names
122
+ if row.tag in getattr(type(state.model), n).tags]
123
+ for name in names:
124
+ result.append(ItemRow(name=name, bold=row.bold,
125
+ href=url_for("line_item", name=name)))
126
+ else:
127
+ result.append(row)
128
+ if isinstance(definition, TableDef):
129
+ return TableDef(rows=result, title=definition.title)
130
+ return result
131
+
132
+ # ------------------------------------------------------------------
133
+ # Routes
134
+ # ------------------------------------------------------------------
135
+
136
+ @app.route("/")
137
+ def index():
138
+ m = state.model
139
+ return render_template(
140
+ "index.html",
141
+ model=m,
142
+ items=_build_items(m.line_item_names),
143
+ title=m.__class__.__name__,
144
+ )
145
+
146
+ @app.route("/tag/<tag_name>")
147
+ def tag_view(tag_name):
148
+ m = state.model
149
+ names = [n for n in m.line_item_names if tag_name in getattr(type(m), n).tags]
150
+ if not names:
151
+ abort(404)
152
+
153
+ tag_template = [
154
+ HeaderRow(),
155
+ TagItemsRow(tag=tag_name, include_total_row=True,
156
+ total_row_label=f"Total {tag_name}"),
157
+ ]
158
+ tag_table_html = m.tables.build(_add_hrefs(tag_template)).to_bootstrap_html()
159
+ tag_chart_data = json.dumps(
160
+ m.charts.line_items(names, chart_type="stacked_bar", title=tag_name).to_apexcharts()
161
+ ) if m.periods else None
162
+
163
+ return render_template(
164
+ "index.html",
165
+ model=m,
166
+ items=_build_items(names),
167
+ title=f"Tag: {tag_name}",
168
+ back_link=True,
169
+ tag_table_html=tag_table_html,
170
+ tag_chart_data=tag_chart_data,
171
+ )
172
+
173
+ @app.route("/line_item/<name>")
174
+ def line_item(name):
175
+ m = state.model
176
+ if name not in m.line_item_names:
177
+ abort(404)
178
+ item_def = getattr(type(m), name)
179
+ result = m[name]
180
+
181
+ info = {
182
+ "name": name,
183
+ "label": item_def.label or name,
184
+ "type": type(item_def).__name__,
185
+ "tags": item_def.tags,
186
+ "value_format": _format_name(item_def.value_format),
187
+ }
188
+
189
+ if isinstance(item_def, FormulaLine):
190
+ info["formula_source"] = item_def.formula_source
191
+ info["dependencies"] = item_def.precedents or []
192
+ info["tag_dependencies"] = {
193
+ tag: m.tag[tag].names
194
+ for tag in (item_def.tag_references or [])
195
+ }
196
+ elif isinstance(item_def, FixedLine):
197
+ if item_def.is_scalar:
198
+ info["scalar_value"] = result.formatted_value(m.periods[0]) if m.periods else str(item_def._scalar_value)
199
+ else:
200
+ info["fixed_values"] = item_def.values or {}
201
+ elif isinstance(item_def, InputLine):
202
+ is_scalar = name in m._scalars
203
+ if is_scalar:
204
+ info["scalar_value"] = result.formatted_value(m.periods[0]) if m.periods else str(m._scalars[name])
205
+ else:
206
+ info["input_values"] = m._input_line_values.get(name, {})
207
+ info["is_input"] = True
208
+
209
+ values_table = None
210
+ scalar_input = isinstance(item_def, InputLine) and name in m._scalars
211
+ scalar_fixed = isinstance(item_def, FixedLine) and item_def.is_scalar
212
+ if not (scalar_input or scalar_fixed):
213
+ values_table = m.tables.line_item(name).to_bootstrap_html()
214
+
215
+ precedents_table = None
216
+ if isinstance(item_def, FormulaLine) and item_def.precedents:
217
+ precedents_table = m.tables.precedents(name).to_bootstrap_html()
218
+
219
+ chart_data = json.dumps(m.charts.line_item(name).to_apexcharts()) if m.periods else None
220
+ dependents = m.dependents(name)
221
+
222
+ return render_template(
223
+ "line_item.html",
224
+ model=m,
225
+ info=info,
226
+ values_table=values_table,
227
+ precedents_table=precedents_table,
228
+ chart_data=chart_data,
229
+ dependents=dependents,
230
+ )
231
+
232
+ @app.route("/table/<int:idx>")
233
+ def table_view(idx):
234
+ labels = list(state.tables.keys())
235
+ if idx >= len(labels):
236
+ abort(404)
237
+ label = labels[idx]
238
+ definition = state.tables[label]
239
+ table = state.model.tables.build(_add_hrefs(definition))
240
+ return render_template(
241
+ "table_view.html",
242
+ model=state.model,
243
+ title=table.title or label,
244
+ table_html=table.to_bootstrap_html(),
245
+ )
246
+
247
+ @app.route("/chart/<int:idx>")
248
+ def chart_view(idx):
249
+ labels = list(state.charts.keys())
250
+ if idx >= len(labels):
251
+ abort(404)
252
+ label = labels[idx]
253
+ chart_data = json.dumps(state.model.charts.build(state.charts[label]).to_apexcharts())
254
+ return render_template(
255
+ "chart_view.html",
256
+ model=state.model,
257
+ title=label,
258
+ chart_data=chart_data,
259
+ )
260
+
261
+ @app.route("/view/<int:idx>")
262
+ def view_page(idx):
263
+ labels = list(state.views.keys())
264
+ if idx >= len(labels):
265
+ abort(404)
266
+ label = labels[idx]
267
+ view_def = state.views[label]
268
+
269
+ rows = []
270
+ for row_idx, row in enumerate(view_def):
271
+ col_width = 12 // len(row)
272
+ processed = []
273
+ for col_idx, comp in enumerate(row):
274
+ if isinstance(comp, StatCard):
275
+ c = comp.build(state.model)
276
+ c["col_width"] = col_width
277
+ elif isinstance(comp, dict) and comp.get("type") == "stat":
278
+ c = StatCard(
279
+ name=comp["name"],
280
+ label=comp.get("label"),
281
+ aggregation=comp.get("aggregation", "latest"),
282
+ ).build(state.model)
283
+ c["col_width"] = col_width
284
+ elif comp["type"] == "chart":
285
+ c = dict(comp)
286
+ c["col_width"] = col_width
287
+ c["chart_data"] = json.dumps(
288
+ state.model.charts.build(state.charts[comp["ref"]]).to_apexcharts()
289
+ )
290
+ c["chart_id"] = f"view-chart-{row_idx}-{col_idx}"
291
+ elif comp["type"] == "table":
292
+ built = state.model.tables.build(_add_hrefs(state.tables[comp["ref"]]))
293
+ c = dict(comp)
294
+ c["col_width"] = col_width
295
+ c["html"] = built.to_bootstrap_html()
296
+ c["table_title"] = built.title or comp["ref"]
297
+ processed.append(c)
298
+ rows.append(processed)
299
+
300
+ return render_template(
301
+ "view.html",
302
+ model=state.model,
303
+ title=label,
304
+ rows=rows,
305
+ )
306
+
307
+ @app.route("/inputs", methods=["GET"])
308
+ def inputs():
309
+ m = state.model
310
+ error = state.error
311
+ state.error = None
312
+ return render_template(
313
+ "inputs.html",
314
+ model=m,
315
+ inputs=_build_inputs(),
316
+ error=error,
317
+ )
318
+
319
+ @app.route("/inputs", methods=["POST"])
320
+ def update_inputs():
321
+ m = state.model
322
+ kwargs = {}
323
+ try:
324
+ for name in state.model_class._input_line_names:
325
+ is_scalar = name in m._scalars
326
+ if is_scalar:
327
+ kwargs[name] = float(request.form[name])
328
+ else:
329
+ kwargs[name] = {
330
+ period: float(request.form[f"{name}_{period}"])
331
+ for period in state.periods
332
+ }
333
+ state.model = state.model_class(periods=state.periods, **kwargs)
334
+ state.error = None
335
+ flash("Model updated.")
336
+ except Exception as e:
337
+ state.error = str(e)
338
+ return redirect(url_for("inputs"))
339
+
340
+ return app
@@ -0,0 +1,42 @@
1
+ """
2
+ Explorer components — declarative definitions for view components.
3
+
4
+ Each component has a build(model) method that returns a dict of render data
5
+ consumed by the view template. The template handles all HTML.
6
+
7
+ Components:
8
+ StatCard — displays a single aggregated line item value (min, max, first, latest).
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass
15
+ class StatCard:
16
+ """
17
+ Displays a single aggregated value from a line item.
18
+
19
+ Args:
20
+ name: Line item name.
21
+ label: Display label. Defaults to the line item's own label.
22
+ aggregation: One of "min", "max", "latest", "first". Defaults to "latest".
23
+ value_format: Optional format override. Uses the line item's format if not set.
24
+
25
+ Examples:
26
+ >>> StatCard("dscr", "Min DSCR", aggregation="min")
27
+ >>> StatCard("ending_cash", aggregation="latest")
28
+ """
29
+
30
+ name: str
31
+ label: str | None = None
32
+ aggregation: str = "latest"
33
+ value_format: object = None
34
+
35
+ def build(self, model) -> dict:
36
+ result = model[self.name]
37
+ formatted = getattr(result, f"formatted_{self.aggregation}")(self.value_format)
38
+ return {
39
+ "type": "stat",
40
+ "label": self.label or result.label or self.name,
41
+ "value": formatted,
42
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyproforma
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: A Python package for financial modeling and reporting
5
5
  Author-email: Robert Hannay <rhannay@gmail.com>
6
6
  Maintainer-email: Robert Hannay <rhannay@gmail.com>
@@ -33,6 +33,8 @@ Requires-Dist: pytest>=6.0; extra == "dev"
33
33
  Requires-Dist: pytest-cov; extra == "dev"
34
34
  Provides-Extra: notebook
35
35
  Requires-Dist: ipython>=7.0.0; extra == "notebook"
36
+ Provides-Extra: explorer
37
+ Requires-Dist: flask>=2.0.0; extra == "explorer"
36
38
  Dynamic: license-file
37
39
 
38
40
  # pyproforma
@@ -23,6 +23,9 @@ pyproforma/charts/chart_def.py
23
23
  pyproforma/charts/charts.py
24
24
  pyproforma/compare/__init__.py
25
25
  pyproforma/compare/model_comparison.py
26
+ pyproforma/explorer/__init__.py
27
+ pyproforma/explorer/app.py
28
+ pyproforma/explorer/components.py
26
29
  pyproforma/line_items/__init__.py
27
30
  pyproforma/line_items/debt_line.py
28
31
  pyproforma/line_items/fixed_line.py
@@ -9,5 +9,8 @@ nbformat>=5.0.0
9
9
  pytest>=6.0
10
10
  pytest-cov
11
11
 
12
+ [explorer]
13
+ flask>=2.0.0
14
+
12
15
  [notebook]
13
16
  ipython>=7.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyproforma"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "A Python package for financial modeling and reporting"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -50,6 +50,9 @@ dev = [
50
50
  notebook = [
51
51
  "ipython>=7.0.0",
52
52
  ]
53
+ explorer = [
54
+ "flask>=2.0.0",
55
+ ]
53
56
 
54
57
  [tool.setuptools.packages.find]
55
58
  where = ["."]
File without changes
File without changes
File without changes
File without changes