glidepath 0.2.0__py3-none-any.whl

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 (93) hide show
  1. glidepath/__init__.py +3 -0
  2. glidepath/app/__init__.py +364 -0
  3. glidepath/app/backtest.py +281 -0
  4. glidepath/app/charts.py +759 -0
  5. glidepath/app/copy.py +174 -0
  6. glidepath/app/display.py +148 -0
  7. glidepath/app/drawdown.py +436 -0
  8. glidepath/app/example.py +66 -0
  9. glidepath/app/exports.py +487 -0
  10. glidepath/app/files.py +249 -0
  11. glidepath/app/firstrun.py +114 -0
  12. glidepath/app/forms.py +1750 -0
  13. glidepath/app/inspector.py +506 -0
  14. glidepath/app/labels.py +66 -0
  15. glidepath/app/montecarlo.py +399 -0
  16. glidepath/app/plan.py +354 -0
  17. glidepath/app/retirement.py +446 -0
  18. glidepath/app/scenarios.py +831 -0
  19. glidepath/app/shell.py +185 -0
  20. glidepath/app/tables.py +138 -0
  21. glidepath/core/__init__.py +390 -0
  22. glidepath/core/annuities.py +240 -0
  23. glidepath/core/backtest.py +514 -0
  24. glidepath/core/comparison.py +278 -0
  25. glidepath/core/config.py +82 -0
  26. glidepath/core/contributions.py +337 -0
  27. glidepath/core/engine.py +2811 -0
  28. glidepath/core/entities.py +264 -0
  29. glidepath/core/glide.py +289 -0
  30. glidepath/core/investments.py +175 -0
  31. glidepath/core/money.py +107 -0
  32. glidepath/core/montecarlo.py +609 -0
  33. glidepath/core/pensions.py +298 -0
  34. glidepath/core/periods.py +367 -0
  35. glidepath/core/provenance.py +271 -0
  36. glidepath/core/randomness.py +128 -0
  37. glidepath/core/region.py +46 -0
  38. glidepath/core/reporting.py +231 -0
  39. glidepath/core/results.py +504 -0
  40. glidepath/core/retirement.py +291 -0
  41. glidepath/core/returns.py +312 -0
  42. glidepath/core/scenarios.py +579 -0
  43. glidepath/core/state_pension.py +264 -0
  44. glidepath/core/tax.py +139 -0
  45. glidepath/core/withdrawals.py +461 -0
  46. glidepath/core/wrappers.py +278 -0
  47. glidepath/gui/__init__.py +6 -0
  48. glidepath/gui/assets/icon_128.png +0 -0
  49. glidepath/gui/assets/icon_16.png +0 -0
  50. glidepath/gui/assets/icon_24.png +0 -0
  51. glidepath/gui/assets/icon_256.png +0 -0
  52. glidepath/gui/assets/icon_32.png +0 -0
  53. glidepath/gui/assets/icon_48.png +0 -0
  54. glidepath/gui/assets/icon_64.png +0 -0
  55. glidepath/gui/assets/wordmark.png +0 -0
  56. glidepath/gui/charts.py +829 -0
  57. glidepath/gui/forms.py +359 -0
  58. glidepath/gui/inspector.py +186 -0
  59. glidepath/gui/main.py +51 -0
  60. glidepath/gui/scenarios.py +402 -0
  61. glidepath/gui/style.py +376 -0
  62. glidepath/gui/tableview.py +67 -0
  63. glidepath/gui/widgets.py +989 -0
  64. glidepath/persistence/__init__.py +48 -0
  65. glidepath/persistence/assumptions.py +112 -0
  66. glidepath/persistence/decode.py +747 -0
  67. glidepath/persistence/document.py +101 -0
  68. glidepath/persistence/encode.py +433 -0
  69. glidepath/persistence/migrations.py +158 -0
  70. glidepath/persistence/values.py +298 -0
  71. glidepath/py.typed +0 -0
  72. glidepath/regions/__init__.py +7 -0
  73. glidepath/regions/uk/__init__.py +189 -0
  74. glidepath/regions/uk/ages.py +156 -0
  75. glidepath/regions/uk/contributions.py +717 -0
  76. glidepath/regions/uk/data/age_rules.toml +78 -0
  77. glidepath/regions/uk/data/assumptions_default.toml +170 -0
  78. glidepath/regions/uk/data/returns_history.toml +150 -0
  79. glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
  80. glidepath/regions/uk/extension.py +479 -0
  81. glidepath/regions/uk/loader.py +704 -0
  82. glidepath/regions/uk/region.py +160 -0
  83. glidepath/regions/uk/schema.py +563 -0
  84. glidepath/regions/uk/state_pension.py +129 -0
  85. glidepath/regions/uk/tax.py +466 -0
  86. glidepath/regions/uk/wrappers.py +283 -0
  87. glidepath/regions/uk/years.py +92 -0
  88. glidepath-0.2.0.dist-info/METADATA +189 -0
  89. glidepath-0.2.0.dist-info/RECORD +93 -0
  90. glidepath-0.2.0.dist-info/WHEEL +4 -0
  91. glidepath-0.2.0.dist-info/entry_points.txt +3 -0
  92. glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
  93. glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
@@ -0,0 +1,487 @@
1
+ """Exports: the cash-flow CSV and the printable plan report (roadmap 9.19).
2
+
3
+ Gets the plan out of the app (planning §1, §4.7): a per-year cash-flow
4
+ CSV serialising the active run's report exactly as the charts read it,
5
+ and a print-ready plan report — inputs with their FACTS vs ASSUMPTIONS
6
+ vs DECISIONS provenance, projection results with the three charts, and
7
+ the scenario comparison when scenarios exist. Every export carries the
8
+ §1 disclaimer. Generation is pure app-layer work over the same view
9
+ models the screens render; the shell contributes only the file dialog
10
+ and, for the report, the chart rasteriser and the PDF paint device.
11
+ The report's charts appear in the HTML as ``chart:<index>`` image
12
+ resources — :func:`chart_resource_name` names them — which the shell
13
+ registers against the accompanying :class:`~glidepath.app.ChartSpec`
14
+ list before printing.
15
+ """
16
+
17
+ import csv
18
+ from dataclasses import dataclass
19
+ from html import escape
20
+ from io import StringIO
21
+ from typing import TYPE_CHECKING, Final
22
+
23
+ from glidepath.app.charts import (
24
+ basis_key,
25
+ basis_options,
26
+ build_charts_view_model,
27
+ wrapper_display_labels,
28
+ )
29
+ from glidepath.app.copy import APP_NAME, DISCLAIMER_BODY
30
+ from glidepath.app.display import format_value
31
+ from glidepath.app.files import PLAN_FILE_SUFFIX
32
+ from glidepath.app.inspector import build_inspector_view_model
33
+ from glidepath.app.labels import entity_names
34
+ from glidepath.app.scenarios import BASE_RUN_LABEL, build_scenarios_view_model
35
+ from glidepath.core import ReportBasis, build_report
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Callable, Iterable
39
+ from pathlib import Path
40
+
41
+ from glidepath.app.charts import ChartSpec
42
+ from glidepath.app.plan import PlanState
43
+ from glidepath.core import (
44
+ Money,
45
+ PeriodReportRow,
46
+ ProjectionResult,
47
+ RunMode,
48
+ )
49
+
50
+ EXPORT_CASH_FLOW_LABEL: Final = "Export cash flow (CSV)…"
51
+
52
+ EXPORT_REPORT_LABEL: Final = "Export report (PDF)…"
53
+
54
+ EXPORT_CASH_FLOW_DIALOG_TITLE: Final = "Export cash flow"
55
+
56
+ EXPORT_REPORT_DIALOG_TITLE: Final = "Export report"
57
+
58
+ CASH_FLOW_FILE_SUFFIX: Final = ".csv"
59
+
60
+ CASH_FLOW_FILE_FILTER: Final = "Cash-flow table (*.csv)"
61
+
62
+ REPORT_FILE_SUFFIX: Final = ".pdf"
63
+
64
+ REPORT_FILE_FILTER: Final = "Plan report (*.pdf)"
65
+
66
+ NOTHING_TO_EXPORT_MESSAGE: Final = (
67
+ "Nothing to export yet — save facts on the Facts tab first."
68
+ )
69
+
70
+ REPORT_EXPORT_FAILED_PREFIX: Final = "Could not export the report: "
71
+
72
+ REPORT_NOT_WRITTEN_MESSAGE: Final = (
73
+ f"{REPORT_EXPORT_FAILED_PREFIX}the file could not be written."
74
+ )
75
+
76
+ UNSAVED_PLAN_NAME: Final = "Unsaved plan"
77
+
78
+ _CASH_FLOW_EXPORT_FAILED_PREFIX: Final = "Could not export the cash flow: "
79
+
80
+ _RESULTS_HEADING: Final = "Projection results"
81
+
82
+ _INPUTS_HEADING: Final = "Inputs and provenance"
83
+
84
+ _MONTE_CARLO_HEADING: Final = "Monte Carlo"
85
+
86
+ _METRIC_COLUMNS: Final = ("Metric", "Value")
87
+
88
+ _DETERMINISTIC_RUN_TEXT: Final = "Deterministic"
89
+
90
+ _CHART_RESOURCE_PREFIX: Final = "chart:"
91
+
92
+ _REPORT_CHART_WIDTH: Final = 640
93
+ """The report's chart display width in document pixels.
94
+
95
+ Comfortably inside an A4 page at the 96 dpi the shell prints at, so a
96
+ chart never overflows into the margin.
97
+ """
98
+
99
+ _FLOW_COLUMNS: Final[tuple[tuple[str, Callable[[PeriodReportRow], Money]], ...]] = (
100
+ ("Employment income", lambda row: row.employment_income),
101
+ ("DB income", lambda row: row.db_income),
102
+ ("DB lump sum", lambda row: row.db_lump_sum),
103
+ ("Pension lump sum", lambda row: row.pension_lump_sum),
104
+ ("State pension income", lambda row: row.state_pension_income),
105
+ ("Annuity income", lambda row: row.annuity_income),
106
+ ("Annuity lump sum", lambda row: row.annuity_lump_sum),
107
+ ("Annuity purchases", lambda row: row.annuity_purchases),
108
+ ("Tax due", lambda row: row.tax_due),
109
+ ("Spending need", lambda row: row.spending_need),
110
+ ("Planned outflows", lambda row: row.planned_outflows),
111
+ ("Net withdrawn", lambda row: row.net_withdrawn),
112
+ ("Shortfall", lambda row: row.shortfall),
113
+ ("Contributions", lambda row: row.contributions),
114
+ ("Fees", lambda row: row.fees),
115
+ ("Growth", lambda row: row.growth),
116
+ ("Growth tax", lambda row: row.growth_tax),
117
+ ("AA charge", lambda row: row.aa_charge),
118
+ ("Banked", lambda row: row.banked),
119
+ ("Withdrawals (gross)", lambda row: row.withdrawals_gross),
120
+ ("Closing balance", lambda row: row.closing_balance),
121
+ )
122
+ """The CSV's money columns, in the report row's field order.
123
+
124
+ Each amount serialises as the report model's exact quantized decimal
125
+ (``1234.56``), so the file round-trips the on-screen numbers without a
126
+ formatting layer in between (the 9.19 acceptance criterion).
127
+ """
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class ExportOutcome:
132
+ """Whether an export wrote the file, and the status line saying so."""
133
+
134
+ saved: bool
135
+ message: str
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class ReportRequest:
140
+ """One plan report's presentation choices, as the shell holds them.
141
+
142
+ ``basis`` and ``mode`` are the charts screen's selections and drive
143
+ the report's charts and metrics; ``backtest_year`` is the backtest
144
+ card's starting-year picker, so a trajectory on screen is a
145
+ trajectory in the report; ``comparison_basis`` and
146
+ ``comparison_metric_key`` are the scenarios screen's and drive the
147
+ comparison table.
148
+ """
149
+
150
+ plan_name: str
151
+ basis: ReportBasis
152
+ mode: RunMode
153
+ comparison_basis: ReportBasis
154
+ comparison_metric_key: str
155
+ backtest_year: str = ""
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class PlanReport:
160
+ """A print-ready plan report: the document and its chart resources.
161
+
162
+ ``html`` references each chart as an image resource named by
163
+ :func:`chart_resource_name`; the shell renders ``charts`` over
164
+ ``categories`` and registers the images under those names before
165
+ printing.
166
+ """
167
+
168
+ html: str
169
+ charts: tuple[ChartSpec, ...]
170
+ categories: tuple[str, ...]
171
+
172
+
173
+ def plan_display_name(path: Path | None) -> str:
174
+ """The plan's display name: the file's name, or the unsaved copy."""
175
+ if path is None:
176
+ return UNSAVED_PLAN_NAME
177
+ return path.name.removesuffix(PLAN_FILE_SUFFIX)
178
+
179
+
180
+ def chart_resource_name(index: int) -> str:
181
+ """The resource name the report's HTML gives its ``index``-th chart."""
182
+ return f"{_CHART_RESOURCE_PREFIX}{index}"
183
+
184
+
185
+ def _basis_label(basis: ReportBasis) -> str:
186
+ """The display label of ``basis``, as the basis toggles show it."""
187
+ key = basis_key(basis)
188
+ return next(option.label for option in basis_options() if option.key == key)
189
+
190
+
191
+ def _run_basis_text(result: ProjectionResult) -> str:
192
+ """The run's basis line: deterministic, or Monte Carlo with its seed."""
193
+ seed = result.provenance.seed
194
+ if seed is None:
195
+ return _DETERMINISTIC_RUN_TEXT
196
+ return f"Monte Carlo, seed {seed}"
197
+
198
+
199
+ def cash_flow_csv(
200
+ state: PlanState, *, basis: ReportBasis, plan_name: str
201
+ ) -> str | None:
202
+ """The active run's per-year cash-flow table as CSV text (9.19).
203
+
204
+ A header block names the plan, the run basis, the scenario (the
205
+ active run is always the base plan), the money basis, and the §1
206
+ disclaimer; the table then carries one row per person per period
207
+ with every amount as the report model's exact decimal, so the file
208
+ round-trips the on-screen numbers. ``None`` without a projection.
209
+ """
210
+ if state.result is None:
211
+ return None
212
+ report = build_report(state.result, basis)
213
+ names = entity_names(state.household)
214
+ wrappers = wrapper_display_labels(report.rows)
215
+ buffer = StringIO()
216
+ writer = csv.writer(buffer)
217
+ writer.writerow(("Plan", plan_name))
218
+ writer.writerow(("Run", _run_basis_text(state.result)))
219
+ writer.writerow(("Scenario", BASE_RUN_LABEL))
220
+ writer.writerow(("Money basis", _basis_label(basis)))
221
+ writer.writerow(("Disclaimer", DISCLAIMER_BODY))
222
+ writer.writerow(())
223
+ writer.writerow(
224
+ (
225
+ "Period start",
226
+ "Period end",
227
+ "Person",
228
+ "Age at period start",
229
+ "Life stage",
230
+ "Year fraction",
231
+ *(label for label, _amount in _FLOW_COLUMNS),
232
+ *(f"Closing balance — {label}" for label in wrappers.values()),
233
+ )
234
+ )
235
+ for row in report.rows:
236
+ balances = {
237
+ entry.wrapper_id: entry.closing_balance for entry in row.wrapper_balances
238
+ }
239
+ writer.writerow(
240
+ (
241
+ row.period.start.isoformat(),
242
+ row.period.end.isoformat(),
243
+ names.get(str(row.person_id), str(row.person_id)),
244
+ str(row.age_at_period_start),
245
+ format_value(row.stage),
246
+ str(row.year_fraction),
247
+ *(str(amount(row).amount) for _label, amount in _FLOW_COLUMNS),
248
+ *(
249
+ str(balances[wrapper_id].amount) if wrapper_id in balances else ""
250
+ for wrapper_id in wrappers
251
+ ),
252
+ )
253
+ )
254
+ return buffer.getvalue()
255
+
256
+
257
+ def export_cash_flow_csv(
258
+ state: PlanState, path: Path, *, basis: ReportBasis, plan_name: str
259
+ ) -> ExportOutcome:
260
+ """Write the cash-flow CSV to ``path``, reporting the outcome.
261
+
262
+ Follows the save transition's rule (§4.7): every failure folds
263
+ into a status message, never an exception at a shell.
264
+ """
265
+ text = cash_flow_csv(state, basis=basis, plan_name=plan_name)
266
+ if text is None:
267
+ return ExportOutcome(saved=False, message=NOTHING_TO_EXPORT_MESSAGE)
268
+ try:
269
+ path.write_text(text, encoding="utf-8", newline="")
270
+ except OSError as exc:
271
+ return ExportOutcome(
272
+ saved=False, message=f"{_CASH_FLOW_EXPORT_FAILED_PREFIX}{exc}"
273
+ )
274
+ return ExportOutcome(saved=True, message=f"Cash flow exported to {path}.")
275
+
276
+
277
+ def report_exported_message(path: Path) -> str:
278
+ """The status line for a report the shell finished writing."""
279
+ return f"Report exported to {path}."
280
+
281
+
282
+ def _cell(text: str) -> str:
283
+ """One escaped table cell; multi-line values keep their lines."""
284
+ return escape(text).replace("\n", "<br>")
285
+
286
+
287
+ def _html_table(columns: tuple[str, ...], rows: Iterable[tuple[str, ...]]) -> str:
288
+ """Column headers and rows as an escaped rich-text table."""
289
+ header = "".join(f"<th>{escape(column)}</th>" for column in columns)
290
+ body = "".join(
291
+ "<tr>" + "".join(f"<td>{_cell(cell)}</td>" for cell in row) + "</tr>"
292
+ for row in rows
293
+ )
294
+ return (
295
+ '<table border="1" cellspacing="0" cellpadding="4">'
296
+ f"<tr>{header}</tr>{body}</table>"
297
+ )
298
+
299
+
300
+ def _inputs_sections(state: PlanState) -> list[str]:
301
+ """The report's inputs: facts, assumptions, decisions, structure (§1)."""
302
+ inspector = build_inspector_view_model(state)
303
+ parts = [f"<h2>{escape(_INPUTS_HEADING)}</h2>"]
304
+ parts.append(f"<h3>{escape(inspector.facts_heading)}</h3>")
305
+ parts.append(
306
+ _html_table(
307
+ inspector.facts_columns,
308
+ (
309
+ (row.label, row.value, row.as_of, row.recorded)
310
+ for row in inspector.facts
311
+ ),
312
+ )
313
+ )
314
+ if inspector.roll_forwards:
315
+ parts.append(f"<h3>{escape(inspector.roll_forwards_heading)}</h3>")
316
+ parts.append(
317
+ _html_table(
318
+ inspector.roll_forwards_columns,
319
+ (
320
+ (row.label, row.stated, row.as_of, row.months, row.opening)
321
+ for row in inspector.roll_forwards
322
+ ),
323
+ )
324
+ )
325
+ # The screen's value/default columns truncate long tables; the
326
+ # report is the audit surface, so it prints the complete forms.
327
+ parts.append(f"<h3>{escape(inspector.assumptions_heading)}</h3>")
328
+ parts.append(
329
+ _html_table(
330
+ inspector.assumptions_columns,
331
+ (
332
+ (
333
+ row.label,
334
+ row.edit_text if row.structured else row.value,
335
+ row.default_edit_text,
336
+ row.status,
337
+ row.usage,
338
+ row.source,
339
+ row.recorded,
340
+ )
341
+ for row in inspector.assumptions
342
+ ),
343
+ )
344
+ )
345
+ parts.append(f"<h3>{escape(inspector.decisions_heading)}</h3>")
346
+ parts.append(
347
+ _html_table(
348
+ inspector.decisions_columns,
349
+ ((row.label, row.value, row.recorded) for row in inspector.decisions),
350
+ )
351
+ )
352
+ if inspector.structure:
353
+ parts.append(f"<h3>{escape(inspector.structure_heading)}</h3>")
354
+ parts.append(
355
+ _html_table(
356
+ inspector.structure_columns,
357
+ ((row.entity, row.setting, row.value) for row in inspector.structure),
358
+ )
359
+ )
360
+ return parts
361
+
362
+
363
+ def _results_sections(state: PlanState, request: ReportRequest) -> list[str]:
364
+ """The report's results: charts, Monte Carlo, backtest, retirement."""
365
+ charts = build_charts_view_model(
366
+ state, request.basis, request.mode, backtest_year=request.backtest_year
367
+ )
368
+ parts = [
369
+ f"<h2>{escape(_RESULTS_HEADING)}</h2>",
370
+ f"<p>Money basis: {escape(_basis_label(request.basis))}</p>",
371
+ ]
372
+ if charts.allocation_note:
373
+ parts.append(f"<p>{escape(charts.allocation_note)}</p>")
374
+ for index, chart in enumerate(charts.charts):
375
+ parts.append(f"<h3>{escape(chart.title)}</h3>")
376
+ parts.append(
377
+ f'<img src="{chart_resource_name(index)}" width="{_REPORT_CHART_WIDTH}">'
378
+ )
379
+ panel = charts.monte_carlo
380
+ if panel.metrics:
381
+ # The seed and path count are the run's §4.6 reproducibility
382
+ # manifest — without them the report's metrics are unattributable.
383
+ parts.append(f"<h3>{escape(_MONTE_CARLO_HEADING)}</h3>")
384
+ parts.append(
385
+ _html_table(
386
+ _METRIC_COLUMNS,
387
+ (
388
+ (panel.seed_label, panel.seed_value),
389
+ (panel.paths_label, panel.paths_value),
390
+ *((row.label, row.value) for row in panel.metrics),
391
+ ),
392
+ )
393
+ )
394
+ backtest = charts.backtest
395
+ if backtest.metrics:
396
+ parts.append(f"<h3>{escape(backtest.heading)}</h3>")
397
+ parts.append(
398
+ _html_table(
399
+ _METRIC_COLUMNS,
400
+ tuple((row.label, row.value) for row in backtest.metrics),
401
+ )
402
+ )
403
+ retirement = charts.retirement
404
+ if retirement.answer:
405
+ parts.append(f"<h3>{escape(retirement.heading)}</h3>")
406
+ parts.append(f"<p>{escape(retirement.answer)}</p>")
407
+ if retirement.detail:
408
+ parts.append(f"<p>{escape(retirement.detail)}</p>")
409
+ return parts
410
+
411
+
412
+ def _comparison_sections(state: PlanState, request: ReportRequest) -> list[str]:
413
+ """The scenario comparison table, when there is one to print."""
414
+ scenarios = build_scenarios_view_model(
415
+ state,
416
+ basis=request.comparison_basis,
417
+ metric_key=request.comparison_metric_key,
418
+ )
419
+ if not scenarios.comparison_rows:
420
+ return []
421
+ metric_label = next(
422
+ option.label
423
+ for option in scenarios.metric_options
424
+ if option.key == scenarios.selected_metric_key
425
+ )
426
+ return [
427
+ f"<h2>{escape(scenarios.comparison_heading)}</h2>",
428
+ (
429
+ f"<p>{escape(metric_label)}"
430
+ f" ({escape(_basis_label(request.comparison_basis))})</p>"
431
+ ),
432
+ _html_table(scenarios.comparison_columns, scenarios.comparison_rows),
433
+ ]
434
+
435
+
436
+ def build_plan_report(state: PlanState, request: ReportRequest) -> PlanReport | None:
437
+ """The full printable plan report, or ``None`` without a projection.
438
+
439
+ Inputs with their provenance first, then the projection results
440
+ with the three charts (plus Monte Carlo metrics, backtest metrics,
441
+ and the retirement answer when held), then the scenario comparison
442
+ when scenarios exist — the disclaimer leads the document (§1).
443
+ """
444
+ if state.result is None:
445
+ return None
446
+ charts = build_charts_view_model(
447
+ state, request.basis, request.mode, backtest_year=request.backtest_year
448
+ )
449
+ inspector_summary = build_inspector_view_model(state)
450
+ parts = [
451
+ f"<h1>{escape(APP_NAME)} plan report — {escape(request.plan_name)}</h1>",
452
+ f"<p><i>{escape(DISCLAIMER_BODY)}</i></p>",
453
+ f"<p>{escape(inspector_summary.summary)}</p>",
454
+ ]
455
+ if inspector_summary.summary_detail:
456
+ parts.append(f"<p>{escape(inspector_summary.summary_detail)}</p>")
457
+ parts.extend(_inputs_sections(state))
458
+ parts.extend(_results_sections(state, request))
459
+ parts.extend(_comparison_sections(state, request))
460
+ return PlanReport(
461
+ html="".join(parts), charts=charts.charts, categories=charts.categories
462
+ )
463
+
464
+
465
+ __all__ = [
466
+ "CASH_FLOW_FILE_FILTER",
467
+ "CASH_FLOW_FILE_SUFFIX",
468
+ "EXPORT_CASH_FLOW_DIALOG_TITLE",
469
+ "EXPORT_CASH_FLOW_LABEL",
470
+ "EXPORT_REPORT_DIALOG_TITLE",
471
+ "EXPORT_REPORT_LABEL",
472
+ "NOTHING_TO_EXPORT_MESSAGE",
473
+ "REPORT_EXPORT_FAILED_PREFIX",
474
+ "REPORT_FILE_FILTER",
475
+ "REPORT_FILE_SUFFIX",
476
+ "REPORT_NOT_WRITTEN_MESSAGE",
477
+ "UNSAVED_PLAN_NAME",
478
+ "ExportOutcome",
479
+ "PlanReport",
480
+ "ReportRequest",
481
+ "build_plan_report",
482
+ "cash_flow_csv",
483
+ "chart_resource_name",
484
+ "export_cash_flow_csv",
485
+ "plan_display_name",
486
+ "report_exported_message",
487
+ ]