pyproforma 0.2.0__tar.gz → 0.2.1__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 (56) hide show
  1. {pyproforma-0.2.0/pyproforma.egg-info → pyproforma-0.2.1}/PKG-INFO +1 -2
  2. {pyproforma-0.2.0 → pyproforma-0.2.1}/README.md +0 -1
  3. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/__init__.py +27 -11
  4. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/calculation_engine.py +12 -85
  5. pyproforma-0.2.1/pyproforma/chart/__init__.py +3 -0
  6. pyproforma-0.2.1/pyproforma/chart/chart.py +107 -0
  7. pyproforma-0.2.1/pyproforma/chart/renderers/__init__.py +4 -0
  8. pyproforma-0.2.1/pyproforma/chart/renderers/base.py +17 -0
  9. pyproforma-0.2.1/pyproforma/chart/renderers/matplotlib_renderer.py +115 -0
  10. pyproforma-0.2.1/pyproforma/charts/__init__.py +3 -0
  11. pyproforma-0.2.1/pyproforma/charts/chart_def.py +45 -0
  12. pyproforma-0.2.1/pyproforma/charts/charts.py +185 -0
  13. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/compare/model_comparison.py +5 -5
  14. pyproforma-0.2.1/pyproforma/line_items/debt_line.py +262 -0
  15. pyproforma-0.2.1/pyproforma/line_items/fixed_line.py +72 -0
  16. pyproforma-0.2.1/pyproforma/line_items/formula_line.py +210 -0
  17. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/input_line.py +41 -11
  18. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/line_item_result.py +92 -3
  19. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/line_item_values.py +4 -4
  20. pyproforma-0.2.1/pyproforma/model_namespace.py +68 -0
  21. pyproforma-0.2.1/pyproforma/proforma_model.py +191 -0
  22. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/reserved_words.py +1 -0
  23. pyproforma-0.2.1/pyproforma/table/bootstrap_html_renderer.py +120 -0
  24. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/format_value.py +3 -0
  25. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/html_renderer.py +4 -0
  26. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/table_class.py +25 -3
  27. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/tables/__init__.py +2 -0
  28. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/tables/row_types.py +14 -10
  29. pyproforma-0.2.1/pyproforma/tables/table_def.py +36 -0
  30. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/tables/tables.py +29 -26
  31. {pyproforma-0.2.0 → pyproforma-0.2.1/pyproforma.egg-info}/PKG-INFO +1 -2
  32. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma.egg-info/SOURCES.txt +10 -4
  33. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproject.toml +1 -1
  34. pyproforma-0.2.0/pyproforma/assumption.py +0 -70
  35. pyproforma-0.2.0/pyproforma/assumption_result.py +0 -129
  36. pyproforma-0.2.0/pyproforma/assumption_values.py +0 -75
  37. pyproforma-0.2.0/pyproforma/input_assumption.py +0 -90
  38. pyproforma-0.2.0/pyproforma/line_items/debt_line.py +0 -528
  39. pyproforma-0.2.0/pyproforma/line_items/fixed_line.py +0 -75
  40. pyproforma-0.2.0/pyproforma/line_items/formula_line.py +0 -210
  41. pyproforma-0.2.0/pyproforma/model_namespace.py +0 -82
  42. pyproforma-0.2.0/pyproforma/proforma_model.py +0 -370
  43. {pyproforma-0.2.0 → pyproforma-0.2.1}/LICENSE +0 -0
  44. {pyproforma-0.2.0 → pyproforma-0.2.1}/MANIFEST.in +0 -0
  45. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/compare/__init__.py +0 -0
  46. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/__init__.py +0 -0
  47. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/line_item.py +0 -0
  48. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/line_items/line_item_selection.py +0 -0
  49. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/__init__.py +0 -0
  50. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/colors.py +0 -0
  51. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/table/excel.py +0 -0
  52. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma/tags_namespace.py +0 -0
  53. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma.egg-info/dependency_links.txt +0 -0
  54. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma.egg-info/requires.txt +0 -0
  55. {pyproforma-0.2.0 → pyproforma-0.2.1}/pyproforma.egg-info/top_level.txt +0 -0
  56. {pyproforma-0.2.0 → pyproforma-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyproforma
3
- Version: 0.2.0
3
+ Version: 0.2.1
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>
@@ -146,7 +146,6 @@ class FlexModel(ProformaModel):
146
146
  gross_profit = FormulaLine(formula=lambda li, t: li.revenue[t] - li.cogs[t])
147
147
 
148
148
  base = FlexModel(periods=[2024, 2025])
149
- # InputAssumption lets callers override at instantiation time — coming soon
150
149
  ```
151
150
 
152
151
  Use `ModelComparison` to diff two model instances:
@@ -109,7 +109,6 @@ class FlexModel(ProformaModel):
109
109
  gross_profit = FormulaLine(formula=lambda li, t: li.revenue[t] - li.cogs[t])
110
110
 
111
111
  base = FlexModel(periods=[2024, 2025])
112
- # InputAssumption lets callers override at instantiation time — coming soon
113
112
  ```
114
113
 
115
114
  Use `ModelComparison` to diff two model instances:
@@ -1,13 +1,21 @@
1
1
  """
2
- PyProforma v2 - Simplified modeling framework
3
-
4
- Version 2 provides a cleaner, Pydantic-inspired API for building financial models.
2
+ PyProforma - A lightweight financial modeling framework.
5
3
  """
6
4
 
7
- from .assumption import Assumption
8
- from .assumption_result import AssumptionResult
9
- from .assumption_values import AssumptionValues
10
- from .input_assumption import InputAssumption
5
+ from .charts.chart_def import ChartDef
6
+ from .tables.table_def import TableDef
7
+ from .tables.row_types import (
8
+ BlankRow,
9
+ CumulativeChangeRow,
10
+ CumulativePercentChangeRow,
11
+ HeaderRow,
12
+ ItemRow,
13
+ LabelRow,
14
+ LineItemsTotalRow,
15
+ PercentChangeRow,
16
+ TagItemsRow,
17
+ TagTotalRow,
18
+ )
11
19
  from .line_items import (
12
20
  DebtCalculator,
13
21
  DebtInterestLine,
@@ -30,18 +38,26 @@ from .tags_namespace import ModelTagNamespace
30
38
 
31
39
  __all__ = [
32
40
  "ProformaModel",
41
+ "ChartDef",
42
+ "TableDef",
43
+ "HeaderRow",
44
+ "ItemRow",
45
+ "LabelRow",
46
+ "BlankRow",
47
+ "TagItemsRow",
48
+ "TagTotalRow",
49
+ "LineItemsTotalRow",
50
+ "PercentChangeRow",
51
+ "CumulativeChangeRow",
52
+ "CumulativePercentChangeRow",
33
53
  "FixedLine",
34
54
  "FormulaLine",
35
55
  "InputLine",
36
- "InputAssumption",
37
56
  "DebtPrincipalLine",
38
57
  "DebtInterestLine",
39
58
  "DebtCalculator",
40
59
  "create_debt_lines",
41
- "Assumption",
42
- "AssumptionResult",
43
60
  "LineItem",
44
- "AssumptionValues",
45
61
  "LineItemValues",
46
62
  "LineItemValue",
47
63
  "LineItemResult",
@@ -1,49 +1,32 @@
1
1
  """
2
- Calculation engine for v2 ProformaModel.
2
+ Calculation engine for ProformaModel.
3
3
 
4
- This module contains the logic for calculating line item values from formulas,
5
- handling dependencies, and resolving values across periods.
4
+ Calculates line item values from formulas, handling dependencies and resolving
5
+ values across periods.
6
6
  """
7
7
 
8
8
  from typing import TYPE_CHECKING, Any
9
9
 
10
10
  if TYPE_CHECKING:
11
- from .assumption_values import AssumptionValues
12
11
  from .line_items.line_item_values import LineItemValues
13
12
 
14
13
 
15
14
  def calculate_line_items(
16
15
  model: Any,
17
- av: "AssumptionValues",
16
+ scalars: dict,
18
17
  periods: list[int],
19
18
  ) -> "LineItemValues":
20
19
  """
21
20
  Calculate all line item values for the given model.
22
21
 
23
- This function processes formulas in dependency order, evaluates them for
24
- each period, and returns a populated LineItemValues container.
25
-
26
- Formulas receive three parameters:
27
- - a (AssumptionValues): Access assumptions via a.tax_rate, a.growth_rate, etc.
28
- - li (LineItemValues): Access line items via li.revenue[t], li.revenue[t-1], etc.
29
- - t (int): Current period being calculated
30
-
31
- This function processes each period in order, calculating all line items
32
- for that period before moving to the next. This allows for time-offset
33
- references (e.g., li.revenue[t-1]) to work correctly.
34
-
35
22
  Args:
36
23
  model: The ProformaModel instance containing line item definitions.
37
- av (AssumptionValues): Assumption values for the model.
38
- periods (list[int]): List of periods to calculate.
24
+ scalars: Dict of scalar line item values (FixedLine(value=) or scalar InputLine).
25
+ periods: List of periods to calculate.
39
26
 
40
27
  Returns:
41
28
  LineItemValues: Populated container with all calculated values.
42
-
43
- Raises:
44
- ValueError: If line items are not found or calculation fails.
45
29
  """
46
- # Import here to avoid circular imports
47
30
  from .line_items.debt_line import DebtBase
48
31
  from .line_items.fixed_line import FixedLine
49
32
  from .line_items.formula_line import FormulaLine
@@ -51,10 +34,8 @@ def calculate_line_items(
51
34
  from .line_items.line_item_values import LineItemValues
52
35
  from .model_namespace import ModelNamespace
53
36
 
54
- # Initialize line item values with registered names for validation
55
37
  li = LineItemValues(periods=periods, names=model.line_item_names, model=model)
56
38
 
57
- # Separate fixed and formula line items
58
39
  fixed_items = []
59
40
  formula_items = []
60
41
 
@@ -65,18 +46,14 @@ def calculate_line_items(
65
46
  elif isinstance(line_item, (FormulaLine, DebtBase)):
66
47
  formula_items.append(name)
67
48
 
68
- # Calculate values for each period
69
49
  for period in periods:
70
- # Build a unified namespace for formula evaluation this period
71
- ns = ModelNamespace(li, av)
50
+ ns = ModelNamespace(li, scalars)
72
51
 
73
- # First, calculate all fixed line items (they don't depend on other line items)
74
52
  for name in fixed_items:
75
53
  line_item = getattr(model.__class__, name)
76
54
  value = _calculate_single_line_item(line_item, ns, period, model)
77
55
  li.set(name, period, value)
78
56
 
79
- # Then calculate formula items with dependency resolution
80
57
  remaining = formula_items.copy()
81
58
  max_iterations = len(formula_items) + 1
82
59
  iteration = 0
@@ -91,32 +68,21 @@ def calculate_line_items(
91
68
  value = _calculate_single_line_item(line_item, ns, period, model)
92
69
  li.set(name, period, value)
93
70
  except AttributeError as e:
94
- # AttributeError means accessing unregistered line item (typo)
95
- # LineItemValues now raises helpful error with available names
96
71
  error_msg = str(e)
97
72
  if "is not registered" in error_msg:
98
- # This is a typo - raise immediately with helpful message
99
73
  raise ValueError(
100
74
  f"Error in formula for '{line_item.name}': {e}"
101
75
  ) from e
102
- # Otherwise, it's some other AttributeError - retry
103
76
  still_pending.append(name)
104
77
  except KeyError as e:
105
- # KeyError could mean:
106
- # 1. Dependency not yet calculated for current period (retry)
107
- # 2. Accessing a period not in the model (fatal error)
108
- # Check if the error is about the current period
109
78
  error_msg = str(e)
110
79
  if f"Period {period}" in error_msg:
111
- # Dependency not yet calculated for current period - retry
112
80
  still_pending.append(name)
113
81
  else:
114
- # Accessing a period outside the model - wrap in ValueError
115
82
  raise ValueError(
116
83
  f"Error evaluating formula for '{line_item.name}' in period {period}: {e}"
117
84
  ) from e
118
85
 
119
- # If we made no progress, we have a circular reference
120
86
  if len(still_pending) == len(remaining):
121
87
  raise ValueError(
122
88
  f"Circular reference detected for period {period}. "
@@ -133,36 +99,12 @@ def _calculate_single_line_item(
133
99
  ns: Any,
134
100
  period: int,
135
101
  model: Any = None,
136
- ) -> float:
137
- """
138
- Calculate the value of a single line item for a specific period.
139
-
140
- This is a pure function that computes and returns a value without modifying
141
- the LineItemValues container. The caller is responsible for storing the result.
142
-
143
- Args:
144
- line_item: The line item definition (FixedLine, FormulaLine, etc.).
145
- Must have a .name attribute set by __set_name__.
146
- ns (ModelNamespace): Unified namespace giving formulas access to both
147
- line items and assumptions via a single object.
148
- period (int): The period to calculate for.
149
- model: The ProformaModel instance (needed for InputLine value lookup).
150
-
151
- Returns:
152
- float: The calculated value.
153
-
154
- Raises:
155
- ValueError: If calculation fails.
156
- AttributeError: If a dependency is not yet calculated.
157
- KeyError: If accessing a period not yet calculated.
158
- """
159
- # Import here to avoid circular imports
102
+ ) -> Any:
160
103
  from .line_items.debt_line import DebtBase
161
104
  from .line_items.fixed_line import FixedLine
162
105
  from .line_items.formula_line import FormulaLine
163
106
  from .line_items.input_line import InputLine
164
107
 
165
- # Handle InputLine — values live on the model instance
166
108
  if isinstance(line_item, InputLine):
167
109
  input_values = getattr(model, "_input_line_values", {})
168
110
  period_values = input_values.get(line_item.name, {})
@@ -171,9 +113,8 @@ def _calculate_single_line_item(
171
113
  raise ValueError(
172
114
  f"No input value for '{line_item.name}' in period {period}"
173
115
  )
174
- return float(value)
116
+ return value
175
117
 
176
- # Handle FixedLine
177
118
  if isinstance(line_item, FixedLine):
178
119
  value = line_item.get_value(period)
179
120
  if value is None:
@@ -182,50 +123,36 @@ def _calculate_single_line_item(
182
123
  )
183
124
  return value
184
125
 
185
- # Handle FormulaLine
186
126
  if isinstance(line_item, FormulaLine):
187
- # Check for override value first
188
127
  if period in line_item.values:
189
128
  return line_item.values[period]
190
-
191
129
  try:
192
130
  value = line_item.eval(ns, period)
193
131
  except (AttributeError, KeyError):
194
- # Re-raise these - indicate missing dependencies or periods
195
- # The caller will decide whether to retry or raise ValueError
196
132
  raise
197
133
  except Exception as e:
198
134
  raise ValueError(
199
135
  f"Error evaluating formula for '{line_item.name}' in period {period}: {e}"
200
136
  ) from e
201
-
202
- # Validate the result type
203
- if not isinstance(value, (int, float)):
137
+ if value is None:
204
138
  raise ValueError(
205
- f"Formula for '{line_item.name}' returned invalid type: {type(value)}"
139
+ f"Formula for '{line_item.name}' returned None"
206
140
  )
141
+ return value
207
142
 
208
- return float(value)
209
-
210
- # Handle DebtBase (DebtPrincipalLine, DebtInterestLine)
211
143
  if isinstance(line_item, DebtBase):
212
144
  try:
213
145
  value = line_item.eval(ns, period)
214
146
  except (AttributeError, KeyError):
215
- # Re-raise these - indicate missing dependencies or periods
216
- # The caller will decide whether to retry or raise ValueError
217
147
  raise
218
148
  except Exception as e:
219
149
  raise ValueError(
220
150
  f"Error evaluating debt line for '{line_item.name}' in period {period}: {e}"
221
151
  ) from e
222
-
223
- # Validate the result type
224
152
  if not isinstance(value, (int, float)):
225
153
  raise ValueError(
226
154
  f"Debt line '{line_item.name}' returned invalid type: {type(value)}"
227
155
  )
228
-
229
156
  return float(value)
230
157
 
231
158
  raise ValueError(f"Unknown line item type for '{line_item.name}'")
@@ -0,0 +1,3 @@
1
+ from .chart import Chart, ChartSeries, ChartSpec, ChartType
2
+
3
+ __all__ = ["Chart", "ChartSeries", "ChartSpec", "ChartType"]
@@ -0,0 +1,107 @@
1
+ """
2
+ Chart — generic chart data class, rendering-backend agnostic.
3
+
4
+ Chart and ChartSeries are the intermediate representation between any data
5
+ source and a rendering backend. They are not specific to ProformaModel —
6
+ anything that can populate a list of ChartSeries can produce a Chart.
7
+
8
+ Rendering backends:
9
+ - MatplotlibRenderer → .show() / .figure() for Jupyter and scripts
10
+ - .to_apexcharts() → JSON-ready dict for ApexCharts in web apps
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Literal
17
+
18
+ if TYPE_CHECKING:
19
+ from pyproforma.table.format_value import NumberFormatSpec
20
+
21
+ ChartType = Literal["line", "bar", "stacked_bar"]
22
+
23
+
24
+ @dataclass
25
+ class ChartSeries:
26
+ """A single data series for a chart."""
27
+
28
+ label: str
29
+ x_values: list[int]
30
+ y_values: list[float]
31
+ color: str | None = None
32
+
33
+
34
+ @dataclass
35
+ class Chart:
36
+ """
37
+ Intermediate representation of a chart — pure data, rendering-backend agnostic.
38
+
39
+ Build one via model.charts.line_item() or model.charts.line_items(), then
40
+ render it with .show() / .figure() (matplotlib) or .to_apexcharts() (web/JSON).
41
+ """
42
+
43
+ series: list[ChartSeries]
44
+ chart_type: ChartType = "line"
45
+ title: str | None = None
46
+ x_label: str | None = None
47
+ y_label: str | None = None
48
+ value_format: "NumberFormatSpec | None" = None
49
+
50
+ # ------------------------------------------------------------------
51
+ # Matplotlib convenience (lazy import — matplotlib not required at import time)
52
+ # ------------------------------------------------------------------
53
+
54
+ def figure(self, figsize: tuple[float, float] = (10, 6)):
55
+ """Return a matplotlib Figure for this chart."""
56
+ from pyproforma.chart.renderers.matplotlib_renderer import MatplotlibRenderer
57
+ return MatplotlibRenderer().render(self, figsize=figsize)
58
+
59
+ def show(self, figsize: tuple[float, float] = (10, 6)) -> None:
60
+ """Render and display this chart via matplotlib."""
61
+ import matplotlib.pyplot as plt
62
+ self.figure(figsize=figsize)
63
+ plt.show()
64
+
65
+ # ------------------------------------------------------------------
66
+ # Web / serialisation
67
+ # ------------------------------------------------------------------
68
+
69
+ def to_apexcharts(self) -> dict:
70
+ """Return a dict of ApexCharts options ready for JSON serialization."""
71
+ result = {
72
+ "series": [
73
+ {"name": s.label, "data": s.y_values}
74
+ for s in self.series
75
+ ],
76
+ "categories": self.series[0].x_values if self.series else [],
77
+ "title": self.title or "",
78
+ "chart_type": self.chart_type,
79
+ "yaxis_format": self.value_format.to_dict() if self.value_format else None,
80
+ }
81
+ colors = [s.color for s in self.series]
82
+ if any(c is not None for c in colors):
83
+ result["colors"] = colors
84
+ return result
85
+
86
+ def to_dict(self) -> dict:
87
+ """Serialise this Chart to a plain dict suitable for JSON / web renderers."""
88
+ return {
89
+ "chart_type": self.chart_type,
90
+ "title": self.title,
91
+ "x_label": self.x_label,
92
+ "y_label": self.y_label,
93
+ "series": [
94
+ {
95
+ "label": s.label,
96
+ "x_values": s.x_values,
97
+ "y_values": s.y_values,
98
+ "color": s.color,
99
+ }
100
+ for s in self.series
101
+ ],
102
+ "value_format": self.value_format.to_dict() if self.value_format else None,
103
+ }
104
+
105
+
106
+ # Backwards compatibility alias
107
+ ChartSpec = Chart
@@ -0,0 +1,4 @@
1
+ from .base import ChartRenderer
2
+ from .matplotlib_renderer import MatplotlibRenderer
3
+
4
+ __all__ = ["ChartRenderer", "MatplotlibRenderer"]
@@ -0,0 +1,17 @@
1
+ """
2
+ ChartRenderer — abstract base for rendering a Chart to a specific backend.
3
+
4
+ Implement render(chart, **kwargs) to add a new backend (plotly, charts.js, etc.).
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+ from pyproforma.chart.chart import Chart
10
+
11
+
12
+ class ChartRenderer(ABC):
13
+ """Base class for rendering a Chart to a specific backend."""
14
+
15
+ @abstractmethod
16
+ def render(self, chart: Chart, **kwargs):
17
+ """Render the Chart and return the backend's figure/output object."""
@@ -0,0 +1,115 @@
1
+ """
2
+ MatplotlibRenderer — renders a Chart to a matplotlib Figure.
3
+
4
+ Called by Chart.show() and Chart.figure(). Supports line, bar, and stacked_bar
5
+ chart types. Applies NumberFormatSpec to y-axis tick labels when set.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pyproforma.chart.renderers.base import ChartRenderer
11
+ from pyproforma.chart.chart import Chart as ChartSpec
12
+
13
+
14
+ class MatplotlibRenderer(ChartRenderer):
15
+ """Renders a ChartSpec to a matplotlib Figure."""
16
+
17
+ def render(self, spec: ChartSpec, figsize: tuple[float, float] = (10, 6)):
18
+ """
19
+ Render a ChartSpec and return a matplotlib Figure.
20
+
21
+ Args:
22
+ spec: The chart specification to render.
23
+ figsize: (width, height) in inches. Defaults to (10, 6).
24
+
25
+ Returns:
26
+ matplotlib.figure.Figure
27
+ """
28
+ import matplotlib.pyplot as plt
29
+ import numpy as np
30
+
31
+ fig, ax = plt.subplots(figsize=figsize)
32
+
33
+ if spec.chart_type == "line":
34
+ self._render_line(ax, spec)
35
+ elif spec.chart_type == "bar":
36
+ self._render_bar(ax, spec)
37
+ elif spec.chart_type == "stacked_bar":
38
+ self._render_stacked_bar(ax, spec)
39
+
40
+ self._apply_labels(ax, spec)
41
+ self._apply_y_format(ax, spec)
42
+
43
+ if len(spec.series) > 1:
44
+ ax.legend()
45
+
46
+ ax.grid(True, alpha=0.3)
47
+ plt.tight_layout()
48
+
49
+ return fig
50
+
51
+ # ------------------------------------------------------------------
52
+ # Chart type renderers
53
+ # ------------------------------------------------------------------
54
+
55
+ def _render_line(self, ax, spec: ChartSpec) -> None:
56
+ for series in spec.series:
57
+ ax.plot(
58
+ series.x_values,
59
+ series.y_values,
60
+ label=series.label,
61
+ color=series.color,
62
+ marker="o",
63
+ linewidth=2,
64
+ markersize=5,
65
+ )
66
+
67
+ def _render_bar(self, ax, spec: ChartSpec) -> None:
68
+ import numpy as np
69
+
70
+ n = len(spec.series)
71
+ x = np.arange(len(spec.series[0].x_values))
72
+ width = 0.8 / n
73
+
74
+ for i, series in enumerate(spec.series):
75
+ offset = (i - n / 2 + 0.5) * width
76
+ ax.bar(x + offset, series.y_values, width, label=series.label, color=series.color)
77
+
78
+ ax.set_xticks(x)
79
+ ax.set_xticklabels([str(v) for v in spec.series[0].x_values])
80
+
81
+ def _render_stacked_bar(self, ax, spec: ChartSpec) -> None:
82
+ import numpy as np
83
+
84
+ x = np.arange(len(spec.series[0].x_values))
85
+ bottom = np.zeros(len(spec.series[0].x_values))
86
+
87
+ for series in spec.series:
88
+ y = np.array(series.y_values)
89
+ ax.bar(x, y, bottom=bottom, label=series.label, color=series.color)
90
+ bottom += y
91
+
92
+ ax.set_xticks(x)
93
+ ax.set_xticklabels([str(v) for v in spec.series[0].x_values])
94
+
95
+ # ------------------------------------------------------------------
96
+ # Shared helpers
97
+ # ------------------------------------------------------------------
98
+
99
+ def _apply_labels(self, ax, spec: ChartSpec) -> None:
100
+ if spec.title:
101
+ ax.set_title(spec.title)
102
+ if spec.x_label:
103
+ ax.set_xlabel(spec.x_label)
104
+ if spec.y_label:
105
+ ax.set_ylabel(spec.y_label)
106
+
107
+ def _apply_y_format(self, ax, spec: ChartSpec) -> None:
108
+ if spec.value_format is None:
109
+ return
110
+
111
+ from matplotlib.ticker import FuncFormatter
112
+ from pyproforma.table.format_value import format_value
113
+
114
+ fmt = spec.value_format
115
+ ax.yaxis.set_major_formatter(FuncFormatter(lambda val, _pos: format_value(val, fmt)))
@@ -0,0 +1,3 @@
1
+ from .charts import Charts
2
+
3
+ __all__ = ["Charts"]
@@ -0,0 +1,45 @@
1
+ """
2
+ ChartDef — declarative definition of a chart to build from a model.
3
+
4
+ Used with model.charts.build() to produce a Chart. Holds the line item names,
5
+ chart type, title, and optional per-series colors. Accepts either the dataclass
6
+ form (IDE autocomplete, validation) or a plain dict (JSON-serializable configs).
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Union
11
+
12
+ from pyproforma.chart.chart import ChartType
13
+
14
+
15
+ @dataclass
16
+ class ChartDef:
17
+ """
18
+ Declarative definition of a chart to build from a model.
19
+
20
+ Used with model.charts.from_template() to produce a ChartSpec.
21
+ Accepts either the dataclass form (for Python code with IDE support)
22
+ or a plain dict (for JSON-serializable configs).
23
+
24
+ Examples:
25
+ >>> ChartDef(names=["revenue", "expenses"])
26
+ >>> ChartDef(names=["revenue"], chart_type="bar", title="Revenue")
27
+ >>> ChartDef(names=["revenue", "expenses"], colors=["#206bc4", "#d63939"])
28
+
29
+ The dict form is equivalent:
30
+ >>> {"names": ["revenue"], "chart_type": "bar", "colors": ["#206bc4"]}
31
+ """
32
+
33
+ names: list[str]
34
+ chart_type: ChartType = "line"
35
+ title: str | None = None
36
+ colors: list[str] | None = None
37
+
38
+ @classmethod
39
+ def from_dict(cls, data: dict) -> "ChartDef":
40
+ return cls(
41
+ names=data["names"],
42
+ chart_type=data.get("chart_type", "line"),
43
+ title=data.get("title"),
44
+ colors=data.get("colors"),
45
+ )