pyproforma 0.3.0__tar.gz → 0.3.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 (71) hide show
  1. {pyproforma-0.3.0/pyproforma.egg-info → pyproforma-0.3.2}/PKG-INFO +9 -1
  2. {pyproforma-0.3.0 → pyproforma-0.3.2}/README.md +6 -0
  3. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/chart/chart.py +6 -2
  4. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/chart/renderers/matplotlib_renderer.py +23 -5
  5. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/charts/chart_def.py +9 -2
  6. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/charts/charts.py +114 -5
  7. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/engine/calculation_engine.py +2 -3
  8. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/components.py +2 -1
  9. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/results/line_item_selection.py +4 -1
  10. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/specs/fixed_line.py +10 -1
  11. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/table/excel.py +2 -0
  12. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/tables/row_types.py +6 -2
  13. {pyproforma-0.3.0 → pyproforma-0.3.2/pyproforma.egg-info}/PKG-INFO +9 -1
  14. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma.egg-info/requires.txt +3 -0
  15. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproject.toml +4 -1
  16. {pyproforma-0.3.0 → pyproforma-0.3.2}/LICENSE +0 -0
  17. {pyproforma-0.3.0 → pyproforma-0.3.2}/MANIFEST.in +0 -0
  18. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/__init__.py +0 -0
  19. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/chart/__init__.py +0 -0
  20. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/chart/renderers/__init__.py +0 -0
  21. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/chart/renderers/base.py +0 -0
  22. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/charts/__init__.py +0 -0
  23. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/cli.py +0 -0
  24. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/compare/__init__.py +0 -0
  25. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/compare/model_comparison.py +0 -0
  26. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/engine/__init__.py +0 -0
  27. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/engine/line_item_values.py +0 -0
  28. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/engine/model_namespace.py +0 -0
  29. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/__init__.py +0 -0
  30. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/app.py +0 -0
  31. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/compare_defs.py +0 -0
  32. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/config.py +0 -0
  33. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/scenario_app.py +0 -0
  34. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/templates/base.html +0 -0
  35. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/templates/chart_view.html +0 -0
  36. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/templates/compare_item.html +0 -0
  37. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/templates/compare_items.html +0 -0
  38. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/templates/compare_overview.html +0 -0
  39. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/templates/index.html +0 -0
  40. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/templates/line_item.html +0 -0
  41. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/templates/table_view.html +0 -0
  42. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/explorer/templates/view.html +0 -0
  43. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/proforma_model.py +0 -0
  44. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/reserved_words.py +0 -0
  45. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/results/__init__.py +0 -0
  46. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/results/line_item_result.py +0 -0
  47. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/results/line_item_stat.py +0 -0
  48. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/results/scalar_result.py +0 -0
  49. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/results/tags_namespace.py +0 -0
  50. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/specs/__init__.py +0 -0
  51. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/specs/debt_line.py +0 -0
  52. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/specs/formula_line.py +0 -0
  53. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/specs/input_line.py +0 -0
  54. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/specs/line_item.py +0 -0
  55. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/specs/scalar_input_line.py +0 -0
  56. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/specs/scalar_line.py +0 -0
  57. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/table/__init__.py +0 -0
  58. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/table/bootstrap_html_renderer.py +0 -0
  59. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/table/col_widths.py +0 -0
  60. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/table/colors.py +0 -0
  61. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/table/format_value.py +0 -0
  62. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/table/html_renderer.py +0 -0
  63. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/table/table_class.py +0 -0
  64. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/tables/__init__.py +0 -0
  65. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/tables/table_def.py +0 -0
  66. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma/tables/tables.py +0 -0
  67. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma.egg-info/SOURCES.txt +0 -0
  68. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma.egg-info/dependency_links.txt +0 -0
  69. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma.egg-info/entry_points.txt +0 -0
  70. {pyproforma-0.3.0 → pyproforma-0.3.2}/pyproforma.egg-info/top_level.txt +0 -0
  71. {pyproforma-0.3.0 → pyproforma-0.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyproforma
3
- Version: 0.3.0
3
+ Version: 0.3.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: ipython>=7.0.0; extra == "notebook"
33
33
  Provides-Extra: explorer
34
34
  Requires-Dist: flask>=2.0.0; extra == "explorer"
35
35
  Requires-Dist: PyYAML>=6.0.0; extra == "explorer"
36
+ Provides-Extra: all
37
+ Requires-Dist: pyproforma[charts,excel,explorer,notebook,pandas]; extra == "all"
36
38
  Provides-Extra: dev
37
39
  Requires-Dist: pytest>=6.0; extra == "dev"
38
40
  Requires-Dist: pytest-cov; extra == "dev"
@@ -230,6 +232,12 @@ model.charts.line_item("net_income", chart_type="bar").show()
230
232
  model.charts.line_items(["revenue", "gross_profit", "net_income"]).show()
231
233
  ```
232
234
 
235
+ Compare items with different units or scales by rebasing them all to 100 at a common period:
236
+
237
+ ```python
238
+ model.charts.indexed_line_items(["revenue", "headcount"]).show()
239
+ ```
240
+
233
241
  Charts return a `ChartSpec` which can also render to a matplotlib `Figure`:
234
242
 
235
243
  ```python
@@ -188,6 +188,12 @@ model.charts.line_item("net_income", chart_type="bar").show()
188
188
  model.charts.line_items(["revenue", "gross_profit", "net_income"]).show()
189
189
  ```
190
190
 
191
+ Compare items with different units or scales by rebasing them all to 100 at a common period:
192
+
193
+ ```python
194
+ model.charts.indexed_line_items(["revenue", "headcount"]).show()
195
+ ```
196
+
191
197
  Charts return a `ChartSpec` which can also render to a matplotlib `Figure`:
192
198
 
193
199
  ```python
@@ -23,11 +23,15 @@ ChartType = Literal["line", "bar", "stacked_bar"]
23
23
 
24
24
  @dataclass
25
25
  class ChartSeries:
26
- """A single data series for a chart."""
26
+ """A single data series for a chart.
27
+
28
+ A y-value of None means "no data for this period" (e.g. a line item not
29
+ yet applicable) — renderers treat it as a gap, not zero.
30
+ """
27
31
 
28
32
  label: str
29
33
  x_values: list[int]
30
- y_values: list[float]
34
+ y_values: list[float | None]
31
35
  color: str | None = None
32
36
 
33
37
 
@@ -11,6 +11,15 @@ from pyproforma.chart.chart import Chart as ChartSpec
11
11
  from pyproforma.chart.renderers.base import ChartRenderer
12
12
 
13
13
 
14
+ def _as_gaps(y_values: list[float | None]) -> list[float]:
15
+ """Convert None to NaN — matplotlib's documented "break the plot here" sentinel.
16
+
17
+ A plain Python list mixing floats and None becomes a numpy object array,
18
+ which matplotlib doesn't reliably render; NaN keeps it a clean float array.
19
+ """
20
+ return [v if v is not None else float("nan") for v in y_values]
21
+
22
+
14
23
  class MatplotlibRenderer(ChartRenderer):
15
24
  """Renders a ChartSpec to a matplotlib Figure."""
16
25
 
@@ -55,7 +64,7 @@ class MatplotlibRenderer(ChartRenderer):
55
64
  for series in spec.series:
56
65
  ax.plot(
57
66
  series.x_values,
58
- series.y_values,
67
+ _as_gaps(series.y_values),
59
68
  label=series.label,
60
69
  color=series.color,
61
70
  marker="o",
@@ -72,7 +81,10 @@ class MatplotlibRenderer(ChartRenderer):
72
81
 
73
82
  for i, series in enumerate(spec.series):
74
83
  offset = (i - n / 2 + 0.5) * width
75
- ax.bar(x + offset, series.y_values, width, label=series.label, color=series.color)
84
+ ax.bar(
85
+ x + offset, _as_gaps(series.y_values), width,
86
+ label=series.label, color=series.color,
87
+ )
76
88
 
77
89
  ax.set_xticks(x)
78
90
  ax.set_xticklabels([str(v) for v in spec.series[0].x_values])
@@ -84,9 +96,15 @@ class MatplotlibRenderer(ChartRenderer):
84
96
  bottom = np.zeros(len(spec.series[0].x_values))
85
97
 
86
98
  for series in spec.series:
87
- y = np.array(series.y_values)
88
- ax.bar(x, y, bottom=bottom, label=series.label, color=series.color)
89
- bottom += y
99
+ # None contributes nothing to the running stack height, but its own
100
+ # segment doesn't draw (NaN) rather than rendering as a visible
101
+ # zero-height bar — so a gap in one series doesn't blank out the
102
+ # segments stacked above it.
103
+ ax.bar(
104
+ x, _as_gaps(series.y_values), bottom=bottom,
105
+ label=series.label, color=series.color,
106
+ )
107
+ bottom += np.array([v if v is not None else 0.0 for v in series.y_values])
90
108
 
91
109
  ax.set_xticks(x)
92
110
  ax.set_xticklabels([str(v) for v in spec.series[0].x_values])
@@ -2,8 +2,9 @@
2
2
  ChartDef — declarative definition of a chart to build from a model.
3
3
 
4
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).
5
+ chart type, title, optional per-series colors, and an optional value transform
6
+ ("indexed"). Accepts either the dataclass form (IDE autocomplete, validation)
7
+ or a plain dict (JSON-serializable configs).
7
8
  """
8
9
 
9
10
  from dataclasses import dataclass
@@ -24,15 +25,19 @@ class ChartDef:
24
25
  >>> ChartDef(names=["revenue", "expenses"])
25
26
  >>> ChartDef(names=["revenue"], chart_type="bar", title="Revenue")
26
27
  >>> ChartDef(names=["revenue", "expenses"], colors=["#206bc4", "#d63939"])
28
+ >>> ChartDef(names=["revenue", "headcount"], transform="indexed")
27
29
 
28
30
  The dict form is equivalent:
29
31
  >>> {"names": ["revenue"], "chart_type": "bar", "colors": ["#206bc4"]}
32
+ >>> {"names": ["revenue", "headcount"], "transform": "indexed"}
30
33
  """
31
34
 
32
35
  names: list[str]
33
36
  chart_type: ChartType = "line"
34
37
  title: str | None = None
35
38
  colors: list[str] | None = None
39
+ transform: str | None = None
40
+ base_period: int | None = None
36
41
 
37
42
  @classmethod
38
43
  def from_dict(cls, data: dict) -> "ChartDef":
@@ -41,4 +46,6 @@ class ChartDef:
41
46
  chart_type=data.get("chart_type", "line"),
42
47
  title=data.get("title"),
43
48
  colors=data.get("colors"),
49
+ transform=data.get("transform"),
50
+ base_period=data.get("base_period"),
44
51
  )
@@ -10,14 +10,17 @@ This layer knows about ProformaModel; the Chart class beneath it does not.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- from typing import TYPE_CHECKING
13
+ from typing import TYPE_CHECKING, Literal
14
14
 
15
15
  from pyproforma.chart.chart import Chart, ChartSeries, ChartType
16
+ from pyproforma.table import Format
16
17
 
17
18
  if TYPE_CHECKING:
18
19
  from pyproforma.charts.chart_def import ChartDef
19
20
  from pyproforma.proforma_model import ProformaModel
20
21
 
22
+ Transform = Literal["indexed"]
23
+
21
24
 
22
25
  class Charts:
23
26
  """
@@ -92,6 +95,8 @@ class Charts:
92
95
  chart_type: ChartType = "line",
93
96
  title: str | None = None,
94
97
  value_format=None,
98
+ transform: Transform | None = None,
99
+ base_period: int | None = None,
95
100
  ) -> Chart:
96
101
  """
97
102
  Build a chart with one series per line item.
@@ -104,12 +109,25 @@ class Charts:
104
109
  chart_type: One of "line", "bar", "stacked_bar". Defaults to "line".
105
110
  title: Chart title. Defaults to None (no title).
106
111
  value_format: Override the auto-detected format for the y-axis.
112
+ Defaults to Format.NO_DECIMALS when transform="indexed".
113
+ transform: Optional value transform applied to every series before
114
+ charting. "indexed" rebases each series to 100 at base_period:
115
+ value[t] / value[base_period] * 100 — useful for comparing
116
+ items with different units or scales (e.g. revenue vs.
117
+ headcount) on a common axis. Defaults to None (raw values).
118
+ base_period: Reference period for transform="indexed". Defaults to
119
+ the model's first period. Raising if set without a transform
120
+ catches the likely mistake of forgetting transform="indexed".
107
121
 
108
122
  Returns:
109
123
  Chart ready for rendering.
110
124
 
111
125
  Raises:
112
- ValueError: If any line item doesn't exist in the model.
126
+ ValueError: If any line item doesn't exist in the model, transform
127
+ is not a recognized value, base_period is set without
128
+ transform, base_period is not one of the model's periods, or
129
+ (for transform="indexed") a series' base_period value is None
130
+ or 0.
113
131
 
114
132
  Examples:
115
133
  >>> model.charts.line_items(["revenue", "expenses"]).show()
@@ -117,32 +135,114 @@ class Charts:
117
135
  >>> model.charts.line_items( # noqa: E501
118
136
  ... ["revenue", "expenses"], value_format=Format.MILLIONS_M
119
137
  ... ).show()
138
+ >>> # Compare growth trajectories on a common scale
139
+ >>> model.charts.line_items(["revenue", "headcount"], transform="indexed").show()
120
140
  """
121
141
  for name in names:
122
142
  self._validate_line_item(name)
123
143
 
144
+ if transform is not None and transform != "indexed":
145
+ raise ValueError(f"Unrecognized transform {transform!r}. Valid values: 'indexed'.")
146
+ if base_period is not None and transform is None:
147
+ raise ValueError(
148
+ "base_period is only valid with transform='indexed'. "
149
+ "Did you forget to pass transform='indexed'?"
150
+ )
151
+
152
+ resolved_base_period = base_period
153
+ if transform == "indexed" and resolved_base_period is None:
154
+ resolved_base_period = self._model.periods[0]
155
+
124
156
  series = []
125
157
  for name in names:
126
158
  result = self._model[name]
159
+ y_values = [result[p] for p in self._model.periods]
160
+ if transform == "indexed":
161
+ y_values = self._index_values(name, y_values, resolved_base_period)
127
162
  series.append(
128
163
  ChartSeries(
129
164
  label=result.label or name,
130
165
  x_values=list(self._model.periods),
131
- y_values=[result[p] for p in self._model.periods],
166
+ y_values=y_values,
132
167
  )
133
168
  )
134
169
 
135
170
  if value_format is None:
136
- formats = [self._model[n].value_format for n in names]
137
- value_format = formats[0] if len(set(formats)) == 1 else None
171
+ if transform == "indexed":
172
+ value_format = Format.NO_DECIMALS
173
+ else:
174
+ formats = [self._model[n].value_format for n in names]
175
+ value_format = formats[0] if len(set(formats)) == 1 else None
176
+
177
+ y_label = f"Index (Base = 100, {resolved_base_period})" if transform == "indexed" else None
138
178
 
139
179
  return Chart(
140
180
  series=series,
141
181
  chart_type=chart_type,
142
182
  title=title,
143
183
  value_format=value_format,
184
+ y_label=y_label,
144
185
  )
145
186
 
187
+ def indexed_line_items(
188
+ self,
189
+ names: list[str],
190
+ base_period: int | None = None,
191
+ title: str | None = None,
192
+ value_format=None,
193
+ ) -> Chart:
194
+ """
195
+ Build a line chart with every series rebased to 100 at base_period.
196
+
197
+ Convenience wrapper for line_items(names, transform="indexed", ...).
198
+ Lets you compare line items with different units or scales (e.g.
199
+ revenue in dollars vs. headcount) on a common relative axis.
200
+
201
+ Args:
202
+ names: List of line item names to include as series.
203
+ base_period: Reference period; value[t] / value[base_period] * 100.
204
+ Defaults to the model's first period.
205
+ title: Chart title. Defaults to None (no title).
206
+ value_format: Override the default (Format.NO_DECIMALS) y-axis format.
207
+
208
+ Returns:
209
+ Chart ready for rendering.
210
+
211
+ Raises:
212
+ ValueError: If any line item doesn't exist in the model, base_period
213
+ is not one of the model's periods, or a series' base_period
214
+ value is None or 0.
215
+
216
+ Examples:
217
+ >>> model.charts.indexed_line_items(["revenue", "headcount"]).show()
218
+ >>> model.charts.indexed_line_items(["revenue"], base_period=2025).show()
219
+ """
220
+ return self.line_items(
221
+ names,
222
+ title=title,
223
+ value_format=value_format,
224
+ transform="indexed",
225
+ base_period=base_period,
226
+ )
227
+
228
+ def _index_values(
229
+ self, name: str, y_values: list[float | None], base_period: int
230
+ ) -> list[float | None]:
231
+ """Rebase a series to 100 at base_period. None stays None (a gap)."""
232
+ try:
233
+ base_index = self._model.periods.index(base_period)
234
+ except ValueError:
235
+ raise ValueError(
236
+ f"base_period {base_period} is not in model periods {self._model.periods}"
237
+ ) from None
238
+ base_value = y_values[base_index]
239
+ if not base_value:
240
+ raise ValueError(
241
+ f"Cannot index '{name}': base period {base_period} value is "
242
+ f"{base_value!r} (must be a non-zero number)."
243
+ )
244
+ return [v / base_value * 100 if v is not None else None for v in y_values]
245
+
146
246
  def build(self, template: "ChartDef | dict") -> Chart:
147
247
  """
148
248
  Build a Chart from a ChartDef or equivalent dict.
@@ -155,6 +255,10 @@ class Charts:
155
255
  - names (list[str]): Line item names to include as series.
156
256
  - chart_type (str, optional): "line", "bar", or "stacked_bar". Defaults to "line".
157
257
  - title (str, optional): Chart title.
258
+ - transform (str, optional): "indexed" to rebase every series to
259
+ 100 at base_period. Defaults to None (raw values).
260
+ - base_period (int, optional): Reference period for
261
+ transform="indexed". Defaults to the model's first period.
158
262
 
159
263
  Returns:
160
264
  Chart ready for rendering.
@@ -162,6 +266,9 @@ class Charts:
162
266
  Examples:
163
267
  >>> model.charts.from_template(ChartDef(names=["revenue", "expenses"]))
164
268
  >>> model.charts.from_template({"names": ["revenue"], "chart_type": "bar"})
269
+ >>> model.charts.from_template(
270
+ ... {"names": ["revenue", "headcount"], "transform": "indexed"}
271
+ ... )
165
272
  """
166
273
  from pyproforma.charts.chart_def import ChartDef
167
274
  if isinstance(template, dict):
@@ -170,6 +277,8 @@ class Charts:
170
277
  names=template.names,
171
278
  chart_type=template.chart_type,
172
279
  title=template.title,
280
+ transform=template.transform,
281
+ base_period=template.base_period,
173
282
  )
174
283
  if template.colors:
175
284
  for series, color in zip(chart_spec.series, template.colors):
@@ -117,12 +117,11 @@ def _calculate_single_line_item(
117
117
  return period_values[period] # None is a valid value — means "no input this period"
118
118
 
119
119
  if isinstance(line_item, FixedLine):
120
- value = line_item.get_value(period)
121
- if value is None:
120
+ if period not in line_item.values:
122
121
  raise ValueError(
123
122
  f"No value defined for '{line_item.name}' in period {period}"
124
123
  )
125
- return value
124
+ return line_item.get_value(period) # None is a valid value — means "not applicable"
126
125
 
127
126
  if isinstance(line_item, FormulaLine):
128
127
  if period in line_item.values:
@@ -20,7 +20,8 @@ class StatCard:
20
20
  Args:
21
21
  name: Line item name.
22
22
  label: Display label. Defaults to the line item's own label.
23
- aggregation: One of "min", "max", "latest", "first", "sum", "avg", "cagr". Defaults to "latest".
23
+ aggregation: One of "min", "max", "latest", "first", "sum", "avg", "cagr".
24
+ Defaults to "latest".
24
25
  value_format: Optional format override. Uses the line item's format if not set.
25
26
  start: Optional start period for the aggregation range.
26
27
  end: Optional end period for the aggregation range.
@@ -95,6 +95,9 @@ class LineItemSelection:
95
95
  """
96
96
  Get the sum of all selected line items for a specific period.
97
97
 
98
+ Line items with a value of None for the period (e.g. a FixedLine or
99
+ InputLine period marked "not applicable") are skipped, not treated as 0.
100
+
98
101
  Args:
99
102
  period: The period to sum values for.
100
103
 
@@ -113,7 +116,7 @@ class LineItemSelection:
113
116
  105
114
117
  """
115
118
  values = self.value(period)
116
- return sum(values.values())
119
+ return sum(v for v in values.values() if v is not None)
117
120
 
118
121
  def table(
119
122
  self,
@@ -12,13 +12,22 @@ class FixedLine(LineItem):
12
12
  Values are supplied as a dict mapping periods to floats. For a scalar
13
13
  constant that applies across all periods use ``ScalarLine`` instead.
14
14
 
15
+ A period may be explicitly mapped to ``None`` to mean "not applicable"
16
+ (e.g. a line item that doesn't exist yet in an early period) — it
17
+ resolves to ``None`` rather than raising, formats as blank in tables, and
18
+ is skipped by tag sums. This is different from omitting the period
19
+ entirely, which still raises ``ValueError`` when the model tries to
20
+ calculate it.
21
+
15
22
  Examples:
16
23
  >>> revenue = FixedLine(values={2024: 100_000, 2025: 110_000})
24
+ >>> # 2024 not applicable — the product launched in 2025
25
+ >>> new_product_revenue = FixedLine(values={2024: None, 2025: 50_000})
17
26
  """
18
27
 
19
28
  def __init__(
20
29
  self,
21
- values: dict[int, float] | None = None,
30
+ values: dict[int, float | None] | None = None,
22
31
  label: str | None = None,
23
32
  tags: list[str] | None = None,
24
33
  value_format: Union[str, NumberFormatSpec, dict, None] = None,
@@ -1,6 +1,8 @@
1
1
  from typing import TYPE_CHECKING, Optional, Union
2
2
 
3
3
  if TYPE_CHECKING:
4
+ from io import BytesIO
5
+
4
6
  from .table_class import Table
5
7
 
6
8
  from .colors import color_to_rgb
@@ -482,9 +482,13 @@ class LineItemsTotalRow(BaseRow):
482
482
  for _ in range(label_col_count - len(cells)):
483
483
  cells.append(Cell(value=""))
484
484
 
485
- # Calculate totals for each period
485
+ # Calculate totals for each period. Items with a None value (e.g. a
486
+ # FixedLine period marked "not applicable") are skipped, not treated as 0.
486
487
  for period in model.periods:
487
- total = sum(model[name][period] for name in self.line_item_names)
488
+ total = sum(
489
+ v for name in self.line_item_names
490
+ if (v := model[name][period]) is not None
491
+ )
488
492
  cells.append(
489
493
  Cell(
490
494
  value=total,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyproforma
3
- Version: 0.3.0
3
+ Version: 0.3.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: ipython>=7.0.0; extra == "notebook"
33
33
  Provides-Extra: explorer
34
34
  Requires-Dist: flask>=2.0.0; extra == "explorer"
35
35
  Requires-Dist: PyYAML>=6.0.0; extra == "explorer"
36
+ Provides-Extra: all
37
+ Requires-Dist: pyproforma[charts,excel,explorer,notebook,pandas]; extra == "all"
36
38
  Provides-Extra: dev
37
39
  Requires-Dist: pytest>=6.0; extra == "dev"
38
40
  Requires-Dist: pytest-cov; extra == "dev"
@@ -230,6 +232,12 @@ model.charts.line_item("net_income", chart_type="bar").show()
230
232
  model.charts.line_items(["revenue", "gross_profit", "net_income"]).show()
231
233
  ```
232
234
 
235
+ Compare items with different units or scales by rebasing them all to 100 at a common period:
236
+
237
+ ```python
238
+ model.charts.indexed_line_items(["revenue", "headcount"]).show()
239
+ ```
240
+
233
241
  Charts return a `ChartSpec` which can also render to a matplotlib `Figure`:
234
242
 
235
243
  ```python
@@ -1,4 +1,7 @@
1
1
 
2
+ [all]
3
+ pyproforma[charts,excel,explorer,notebook,pandas]
4
+
2
5
  [charts]
3
6
  matplotlib>=3.5.0
4
7
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyproforma"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "A Python package for financial modeling and reporting"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -55,6 +55,9 @@ explorer = [
55
55
  "flask>=2.0.0",
56
56
  "PyYAML>=6.0.0",
57
57
  ]
58
+ all = [
59
+ "pyproforma[pandas,excel,charts,notebook,explorer]",
60
+ ]
58
61
  dev = [
59
62
  "pytest>=6.0",
60
63
  "pytest-cov",
File without changes
File without changes
File without changes
File without changes