glidepath 1.0.2__tar.gz → 1.1.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 (94) hide show
  1. {glidepath-1.0.2 → glidepath-1.1.1}/LICENSE-DATA +5 -2
  2. {glidepath-1.0.2 → glidepath-1.1.1}/PKG-INFO +43 -6
  3. {glidepath-1.0.2 → glidepath-1.1.1}/README.md +41 -5
  4. {glidepath-1.0.2 → glidepath-1.1.1}/pyproject.toml +34 -2
  5. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/files.py +1 -1
  6. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/retirement.py +30 -20
  7. glidepath-1.1.1/src/glidepath/gui/assets/glidepath.ico +0 -0
  8. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/forms.py +6 -3
  9. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/widgets.py +18 -17
  10. {glidepath-1.0.2 → glidepath-1.1.1}/LICENSE +0 -0
  11. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/__init__.py +0 -0
  12. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/__init__.py +0 -0
  13. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/backtest.py +0 -0
  14. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/charts.py +0 -0
  15. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/copy.py +0 -0
  16. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/display.py +0 -0
  17. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/drawdown.py +0 -0
  18. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/example.py +0 -0
  19. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/exports.py +0 -0
  20. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/firstrun.py +0 -0
  21. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/forms.py +0 -0
  22. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/inspector.py +0 -0
  23. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/labels.py +0 -0
  24. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/montecarlo.py +0 -0
  25. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/outlook.py +0 -0
  26. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/plan.py +0 -0
  27. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/retirement.py +0 -0
  28. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/scenarios.py +0 -0
  29. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/shell.py +0 -0
  30. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/app/tables.py +0 -0
  31. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/__init__.py +0 -0
  32. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/annuities.py +0 -0
  33. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/backtest.py +0 -0
  34. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/comparison.py +0 -0
  35. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/config.py +0 -0
  36. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/contributions.py +0 -0
  37. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/engine.py +0 -0
  38. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/entities.py +0 -0
  39. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/glide.py +0 -0
  40. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/investments.py +0 -0
  41. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/money.py +0 -0
  42. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/montecarlo.py +0 -0
  43. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/pensions.py +0 -0
  44. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/periods.py +0 -0
  45. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/provenance.py +0 -0
  46. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/randomness.py +0 -0
  47. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/region.py +0 -0
  48. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/reporting.py +0 -0
  49. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/results.py +0 -0
  50. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/returns.py +0 -0
  51. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/scenarios.py +0 -0
  52. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/state_pension.py +0 -0
  53. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/tax.py +0 -0
  54. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/withdrawals.py +0 -0
  55. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/core/wrappers.py +0 -0
  56. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/__init__.py +0 -0
  57. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/assets/icon_128.png +0 -0
  58. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/assets/icon_16.png +0 -0
  59. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/assets/icon_24.png +0 -0
  60. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/assets/icon_256.png +0 -0
  61. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/assets/icon_32.png +0 -0
  62. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/assets/icon_48.png +0 -0
  63. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/assets/icon_64.png +0 -0
  64. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/assets/wordmark.png +0 -0
  65. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/charts.py +0 -0
  66. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/inspector.py +0 -0
  67. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/main.py +0 -0
  68. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/scenarios.py +0 -0
  69. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/style.py +0 -0
  70. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/gui/tableview.py +0 -0
  71. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/persistence/__init__.py +0 -0
  72. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/persistence/assumptions.py +0 -0
  73. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/persistence/decode.py +0 -0
  74. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/persistence/document.py +0 -0
  75. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/persistence/encode.py +0 -0
  76. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/persistence/migrations.py +0 -0
  77. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/persistence/values.py +0 -0
  78. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/py.typed +0 -0
  79. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/__init__.py +0 -0
  80. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/__init__.py +0 -0
  81. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/ages.py +0 -0
  82. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/contributions.py +0 -0
  83. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/data/age_rules.toml +0 -0
  84. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/data/assumptions_default.toml +0 -0
  85. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/data/returns_history.toml +0 -0
  86. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +0 -0
  87. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/extension.py +0 -0
  88. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/loader.py +0 -0
  89. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/region.py +0 -0
  90. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/schema.py +0 -0
  91. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/state_pension.py +0 -0
  92. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/tax.py +0 -0
  93. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/wrappers.py +0 -0
  94. {glidepath-1.0.2 → glidepath-1.1.1}/src/glidepath/regions/uk/years.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Data licence notice
2
2
  ===================
3
3
 
4
- Everything in this repository is MIT-licensed (see LICENSE) EXCEPT the
5
- following bundled data file:
4
+ Glidepath's own code is MIT-licensed (see LICENSE). The following bundled
5
+ data file has a separate licence:
6
6
 
7
7
  src/glidepath/regions/uk/data/returns_history.toml
8
8
 
@@ -26,3 +26,6 @@ The MIT licence grant does not extend to that file. To obtain a
26
26
  distribution free of CC BY-NC-SA material, delete the file and
27
27
  regenerate a replacement from a source of your choosing with
28
28
  scripts/build_returns_history.py as a starting point.
29
+
30
+ Third-party runtime licences and notices in third-party-licences/ retain
31
+ their original terms; Glidepath's MIT licence does not replace them.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glidepath
3
- Version: 1.0.2
3
+ Version: 1.1.1
4
4
  Summary: Desktop retirement and investment planner (UK-first, region-extensible).
5
5
  Keywords: retirement,pension,financial-planning,investment,uk,sipp,isa,monte-carlo
6
6
  Author: James Williams
@@ -24,6 +24,7 @@ Project-URL: Homepage, https://williajm.github.io/glidepath/
24
24
  Project-URL: Repository, https://github.com/williajm/glidepath
25
25
  Project-URL: Changelog, https://github.com/williajm/glidepath/blob/main/CHANGELOG.md
26
26
  Project-URL: Issues, https://github.com/williajm/glidepath/issues
27
+ Project-URL: Statistics, https://pyrank.org/package/glidepath/
27
28
  Description-Content-Type: text/markdown
28
29
 
29
30
  <!-- Image and badge URLs are absolute so the README renders on PyPI,
@@ -45,6 +46,7 @@ Description-Content-Type: text/markdown
45
46
 
46
47
  <p align="center">
47
48
  <a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
49
+ · <a href="https://pyrank.org/package/glidepath/">PyPI and GitHub stats on PyRank</a>
48
50
  </p>
49
51
 
50
52
  A desktop retirement and investment planner. UK-first, built so other
@@ -132,7 +134,32 @@ based solely on this tool.
132
134
 
133
135
  ## Install
134
136
 
135
- Glidepath is installed from [PyPI](https://pypi.org/project/glidepath/)
137
+ ### Windows download
138
+
139
+ From **v1.1.0**, Windows x64 users can run Glidepath without Python, uv
140
+ or terminal commands:
141
+
142
+ 1. Open the [latest GitHub Release](https://github.com/williajm/glidepath/releases/latest).
143
+ 2. Under **Assets**, download `glidepath-X.Y.Z-windows-x64.exe`.
144
+ 3. Keep it in a folder of your choice and double-click it to launch.
145
+
146
+ This is a runnable app, not an installer; it does not add shortcuts or
147
+ update itself. For a newer version, close Glidepath and download the new
148
+ executable. Your saved `.glidepath.json` plans stay where you put them.
149
+
150
+ The executable is **not yet signed with a Windows publisher certificate**.
151
+ Windows may show “Windows protected your PC” or “unrecognised app”. Check
152
+ that the file came from this repository's release before deciding whether
153
+ to proceed. GitHub build provenance does not remove that warning.
154
+ If the latest release has no `.exe` yet, use the uv installation below.
155
+
156
+ Runtime licences and attribution notices are included in the executable
157
+ and in the release's `glidepath-X.Y.Z-third-party-licences.zip` download.
158
+ That archive also lists the corresponding upstream sources.
159
+
160
+ ### Install with uv
161
+
162
+ Alternatively, install Glidepath from [PyPI](https://pypi.org/project/glidepath/)
136
163
  with [uv](https://docs.astral.sh/uv/), which also fetches the Python it
137
164
  needs — you do not need Python installed first.
138
165
 
@@ -238,14 +265,24 @@ Releases are `vX.Y.Z` tags on `main`. Each one is published to
238
265
  trusted publishing with PEP 740 attestations, and as a GitHub Release
239
266
  carrying its notes from
240
267
  [`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
241
- with the same artifacts attached, each carrying signed build
268
+ with the same Python artifacts and a tested Windows x64 executable attached,
269
+ each carrying signed build
242
270
  provenance — verify a downloaded file with
243
271
  `gh attestation verify <file> -R williajm/glidepath`. The signed
244
272
  provenance bundle is attached to each release too
245
273
  (`glidepath-X.Y.Z-provenance.intoto.jsonl`), so verification also
246
- works offline with `--bundle`. There are no
247
- packaged binary builds (installer/exe) yet — install from PyPI as
248
- above.
274
+ works offline with `--bundle`.
275
+
276
+ From v1.1.0, the tag-triggered workflow also builds
277
+ `glidepath-X.Y.Z-windows-x64.exe` with Nuitka. Download it from the
278
+ GitHub Release and double-click to run; Python and uv are not required.
279
+ The Windows build runs only for release tags, after tag validation, and
280
+ must pass its compiled-app checks before publication. Ordinary branch
281
+ pushes and PRs run the normal CI checks without compiling an executable.
282
+ The executable is not yet Authenticode-signed, so Windows may show a
283
+ SmartScreen warning; the build provenance above is a separate mechanism.
284
+ See [`docs/packaging.md`](https://github.com/williajm/glidepath/blob/main/docs/packaging.md)
285
+ for local builds and the release workflow.
249
286
 
250
287
  ## Data licences
251
288
 
@@ -17,6 +17,7 @@
17
17
 
18
18
  <p align="center">
19
19
  <a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
20
+ · <a href="https://pyrank.org/package/glidepath/">PyPI and GitHub stats on PyRank</a>
20
21
  </p>
21
22
 
22
23
  A desktop retirement and investment planner. UK-first, built so other
@@ -104,7 +105,32 @@ based solely on this tool.
104
105
 
105
106
  ## Install
106
107
 
107
- Glidepath is installed from [PyPI](https://pypi.org/project/glidepath/)
108
+ ### Windows download
109
+
110
+ From **v1.1.0**, Windows x64 users can run Glidepath without Python, uv
111
+ or terminal commands:
112
+
113
+ 1. Open the [latest GitHub Release](https://github.com/williajm/glidepath/releases/latest).
114
+ 2. Under **Assets**, download `glidepath-X.Y.Z-windows-x64.exe`.
115
+ 3. Keep it in a folder of your choice and double-click it to launch.
116
+
117
+ This is a runnable app, not an installer; it does not add shortcuts or
118
+ update itself. For a newer version, close Glidepath and download the new
119
+ executable. Your saved `.glidepath.json` plans stay where you put them.
120
+
121
+ The executable is **not yet signed with a Windows publisher certificate**.
122
+ Windows may show “Windows protected your PC” or “unrecognised app”. Check
123
+ that the file came from this repository's release before deciding whether
124
+ to proceed. GitHub build provenance does not remove that warning.
125
+ If the latest release has no `.exe` yet, use the uv installation below.
126
+
127
+ Runtime licences and attribution notices are included in the executable
128
+ and in the release's `glidepath-X.Y.Z-third-party-licences.zip` download.
129
+ That archive also lists the corresponding upstream sources.
130
+
131
+ ### Install with uv
132
+
133
+ Alternatively, install Glidepath from [PyPI](https://pypi.org/project/glidepath/)
108
134
  with [uv](https://docs.astral.sh/uv/), which also fetches the Python it
109
135
  needs — you do not need Python installed first.
110
136
 
@@ -210,14 +236,24 @@ Releases are `vX.Y.Z` tags on `main`. Each one is published to
210
236
  trusted publishing with PEP 740 attestations, and as a GitHub Release
211
237
  carrying its notes from
212
238
  [`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
213
- with the same artifacts attached, each carrying signed build
239
+ with the same Python artifacts and a tested Windows x64 executable attached,
240
+ each carrying signed build
214
241
  provenance — verify a downloaded file with
215
242
  `gh attestation verify <file> -R williajm/glidepath`. The signed
216
243
  provenance bundle is attached to each release too
217
244
  (`glidepath-X.Y.Z-provenance.intoto.jsonl`), so verification also
218
- works offline with `--bundle`. There are no
219
- packaged binary builds (installer/exe) yet — install from PyPI as
220
- above.
245
+ works offline with `--bundle`.
246
+
247
+ From v1.1.0, the tag-triggered workflow also builds
248
+ `glidepath-X.Y.Z-windows-x64.exe` with Nuitka. Download it from the
249
+ GitHub Release and double-click to run; Python and uv are not required.
250
+ The Windows build runs only for release tags, after tag validation, and
251
+ must pass its compiled-app checks before publication. Ordinary branch
252
+ pushes and PRs run the normal CI checks without compiling an executable.
253
+ The executable is not yet Authenticode-signed, so Windows may show a
254
+ SmartScreen warning; the build provenance above is a separate mechanism.
255
+ See [`docs/packaging.md`](https://github.com/williajm/glidepath/blob/main/docs/packaging.md)
256
+ for local builds and the release workflow.
221
257
 
222
258
  ## Data licences
223
259
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "glidepath"
3
- version = "1.0.2"
3
+ version = "1.1.1"
4
4
  description = "Desktop retirement and investment planner (UK-first, region-extensible)."
5
5
  readme = "README.md"
6
6
  # MIT code plus one CC BY-NC-SA 4.0 data file (returns_history.toml —
@@ -49,11 +49,18 @@ Homepage = "https://williajm.github.io/glidepath/"
49
49
  Repository = "https://github.com/williajm/glidepath"
50
50
  Changelog = "https://github.com/williajm/glidepath/blob/main/CHANGELOG.md"
51
51
  Issues = "https://github.com/williajm/glidepath/issues"
52
+ Statistics = "https://pyrank.org/package/glidepath/"
52
53
 
53
54
  [project.gui-scripts]
54
55
  glidepath = "glidepath.gui.main:main"
55
56
 
56
57
  [dependency-groups]
58
+ binary = [
59
+ "nuitka>=4.2",
60
+ "setuptools>=84",
61
+ "wheel>=0.48",
62
+ "zstandard",
63
+ ]
57
64
  dev = [
58
65
  "hypothesis",
59
66
  "mypy",
@@ -70,11 +77,36 @@ dev = [
70
77
  requires = ["uv_build>=0.11,<0.13"]
71
78
  build-backend = "uv_build"
72
79
 
80
+ # Shared by the local build command and the tagged release workflow.
81
+ [tool.glidepath.binary]
82
+ windows-icon = "src/glidepath/gui/assets/glidepath.ico"
83
+ # Dependency Walker 2.2.6000 (2006), required by Nuitka on Windows x64.
84
+ # Build tool only: never included in the distributed application.
85
+ dependency-walker-url = "https://dependencywalker.com/depends22_x64.zip"
86
+ dependency-walker-sha256 = "35db68a613874a2e8c1422eb0ea7861f825fc71717d46dabf1f249ce9634b4f1"
87
+ nuitka-options = [
88
+ "--update-check=never",
89
+ "--enable-plugin=pyside6",
90
+ "--include-qt-plugins=platforms,imageformats",
91
+ "--include-package-data=glidepath",
92
+ "--include-distribution-metadata=glidepath",
93
+ "--include-data-files=LICENSE=LICENSE",
94
+ "--include-data-files=LICENSE-DATA=LICENSE-DATA",
95
+ "--include-data-dir=third-party-licences=third-party-licences",
96
+ "--product-name=Glidepath",
97
+ "--file-description=Glidepath retirement planner",
98
+ "--company-name=James Williams",
99
+ ]
100
+
73
101
  # --- uv ---------------------------------------------------------------------
74
102
  [tool.uv]
75
103
  # Supply-chain cooldown: resolution ignores anything published to PyPI after
76
104
  # this timestamp (now minus 7 days). Updated ONLY by `make deps`.
77
- exclude-newer = "2026-08-19T15:28:17Z"
105
+ exclude-newer = "2026-08-29T16:19:47Z"
106
+ # Nuitka is source-only. uv installs its build requirements from the binary
107
+ # group first, then builds it in that environment: the lockfile hashes, age
108
+ # check and all-groups audit cover the exact setuptools/wheel versions used.
109
+ no-build-isolation-package = ["nuitka"]
78
110
 
79
111
  # PyPI is the ONLY permitted index (supply-chain policy; see CLAUDE.md).
80
112
  [[tool.uv.index]]
@@ -60,7 +60,7 @@ SAVE_DIALOG_TITLE: Final = "Save plan"
60
60
  UNSAVED_CHANGES_TITLE: Final = "Unsaved changes"
61
61
 
62
62
  UNSAVED_CHANGES_PROMPT: Final = (
63
- "This plan has unsaved changes. Save them before closing?"
63
+ "This plan has unsaved changes. Save them before continuing?"
64
64
  )
65
65
 
66
66
  NOTHING_TO_SAVE_MESSAGE: Final = (
@@ -11,11 +11,14 @@ candidate: the returned age is exactly the earliest succeeding one
11
11
  even when success is not monotone in age (a DB scheme's early-payment
12
12
  factors or a dated outflow can make it dip), and every answer was
13
13
  actually probed, never interpolated. Household spending begins once
14
- *every* person has retired (planning §4.11), so a candidate has a
14
+ every living person has retired (planning §4.11), so a candidate has a
15
15
  retired period to test the income in only when the whole household —
16
16
  the candidate age for the selected person, the partner's stated
17
17
  decision held fixed — is retired inside the run's horizon; a
18
- candidate without one fails rather than succeeding vacuously.
18
+ candidate without one fails rather than succeeding vacuously. Death
19
+ uses the engine's period-start gate: at least one person must remain
20
+ alive, and a deceased partner's retirement age no longer constrains
21
+ the survivor's spending.
19
22
 
20
23
  Each probe replaces the *selected* person's retirement-age decision
21
24
  with the candidate — a partner's decision is held fixed (planning
@@ -142,11 +145,9 @@ def earliest_retirement_age( # noqa: PLR0913
142
145
  returns the first that succeeds — exactly the earliest, whatever
143
146
  the success shape over ages (module docstring) — or ``None`` when
144
147
  no age in the bracket does. A candidate with no *retirement
145
- exposure* — no projected period opening the whole household
146
- retired under the §4.1 gate convention (household spending starts
147
- once every person has retired, §4.11), because the candidate's or
148
- a partner's fixed retirement date falls at or past the run's
149
- horizon — never tests the target income at all, so it fails
148
+ exposure* — no projected period opening with at least one living
149
+ person and all living persons retired under the §4.1 gate
150
+ convention — never tests the target income at all, so it fails
150
151
  rather than succeeding vacuously; such candidates are never
151
152
  probed. The plan's stated retirement age and spending level are
152
153
  irrelevant to the search: each probe carries the candidate age and
@@ -168,7 +169,7 @@ def earliest_retirement_age( # noqa: PLR0913
168
169
  periods = _projected_periods(plan, assumptions, region, config)
169
170
 
170
171
  def has_retired_period(age: int) -> bool:
171
- """Whether any period opens the whole household retired (§4.1)."""
172
+ """Whether any period opens a living household retired (§4.1)."""
172
173
  return any(
173
174
  _household_retired_by(period, plan.persons, selected.id, age)
174
175
  for period in periods
@@ -227,12 +228,11 @@ def sustainable_income_at_age( # noqa: PLR0913
227
228
  age and the candidate spending instead, everything else unchanged.
228
229
 
229
230
  An ``age`` with no *retirement exposure* — no projected period
230
- opening the whole household retired under the §4.1 gate convention
231
- (household spending starts once every person has retired, §4.11),
232
- because the chosen or a partner's fixed retirement date falls at
233
- or past the run's horizon — has no retired period to test any
234
- income in: spending is modelled only in retirement, so every level
235
- would succeed vacuously. It answers ``None`` without probing,
231
+ opening with at least one living person and all living persons
232
+ retired under the §4.1 gate convention — has no retired period to
233
+ test any income in: spending is modelled only in a living
234
+ household's retirement, so every level would succeed vacuously.
235
+ It answers ``None`` without probing,
236
236
  exactly as such candidates fail in the age search. ``None``
237
237
  otherwise means what the income search means by it: not even zero
238
238
  spending survives the plan's outflows.
@@ -303,21 +303,31 @@ def _household_retired_by(
303
303
  selected_id: EntityId,
304
304
  age: int,
305
305
  ) -> bool:
306
- """Whether every person opens ``period`` retired (§4.1, §4.11).
306
+ """Whether a living household opens ``period`` retired (§4.1, §4.11).
307
307
 
308
308
  The exposure predicate of both solvers: the selected person
309
309
  retires at the probed ``age`` while a partner's stated
310
- retirement-age decision is held fixed — matching the engine, where
311
- household spending begins once every person has retired, so only
312
- such a period can test a retirement income at all.
310
+ retirement-age decision is held fixed. Match the engine's death
311
+ gate: a person is no longer living once their death age has been
312
+ attained by the period start. Spending begins once all living
313
+ persons retire and stops when none remain, so only such a period
314
+ can test retirement income.
313
315
  """
314
- return all(
316
+ living = tuple(
317
+ person
318
+ for person in persons
319
+ if person.death_age is None
320
+ or not is_age_attained_by_period_start(
321
+ person.date_of_birth.value, person.death_age.value, period
322
+ )
323
+ )
324
+ return bool(living) and all(
315
325
  is_age_attained_by_period_start(
316
326
  person.date_of_birth.value,
317
327
  age if person.id == selected_id else person.target_retirement_age.value,
318
328
  period,
319
329
  )
320
- for person in persons
330
+ for person in living
321
331
  )
322
332
 
323
333
 
@@ -393,7 +393,7 @@ class FactsEntryPane(QWidget):
393
393
  self,
394
394
  view_model: FactsFormViewModel,
395
395
  on_submit: Callable[[FactsFormData], FactsSubmissionOutcome],
396
- on_clear: Callable[[], str],
396
+ on_clear: Callable[[], str | None],
397
397
  parent: QWidget | None = None,
398
398
  ) -> None:
399
399
  """Render the form and wire the submit and clear callbacks."""
@@ -754,10 +754,13 @@ class FactsEntryPane(QWidget):
754
754
  self._show_errors(outcome.errors)
755
755
 
756
756
  def clear(self) -> None:
757
- """Empty every section, then show the clear callback's status."""
757
+ """Empty every section only when the callback allows the clear."""
758
+ status = self._on_clear()
759
+ if status is None:
760
+ return
758
761
  self._clear_all_errors()
759
762
  self.set_form_data(FactsFormData())
760
- self.status_label.setText(self._on_clear())
763
+ self.status_label.setText(status)
761
764
 
762
765
 
763
766
  __all__ = [
@@ -381,6 +381,8 @@ class MainWindow(QMainWindow):
381
381
  starts from what the file says. On failure the session is left
382
382
  untouched and the status bar explains.
383
383
  """
384
+ if not self._confirm_plan_replacement():
385
+ return False
384
386
  outcome = load_plan_state(path, today=_today())
385
387
  self.statusBar().showMessage(outcome.message)
386
388
  if outcome.state is None:
@@ -545,17 +547,21 @@ class MainWindow(QMainWindow):
545
547
  self._remember_plan_path(path)
546
548
 
547
549
  def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
548
- """Ask before a close discards unsaved plan edits (issue #136).
550
+ """Close only after unsaved plan edits have been dealt with."""
551
+ if self._confirm_plan_replacement():
552
+ event.accept()
553
+ else:
554
+ event.ignore()
555
+
556
+ def _confirm_plan_replacement(self) -> bool:
557
+ """Ask before closing, opening, or clearing discards plan edits.
549
558
 
550
559
  Save runs the normal save flow — asking for a path when the
551
- session has none — and the window closes only once the changes
552
- are actually saved: a failed or cancelled save keeps it open
553
- rather than silently discarding the edits it just promised to
554
- keep.
560
+ session has none. A failed or cancelled save abandons the
561
+ pending action and preserves the session and form.
555
562
  """
556
563
  if not has_unsaved_changes(self._state):
557
- event.accept()
558
- return
564
+ return True
559
565
  choice = QMessageBox.question(
560
566
  self,
561
567
  UNSAVED_CHANGES_TITLE,
@@ -567,15 +573,8 @@ class MainWindow(QMainWindow):
567
573
  )
568
574
  if choice == QMessageBox.StandardButton.Save:
569
575
  self.save_plan()
570
- if has_unsaved_changes(self._state):
571
- event.ignore()
572
- return
573
- event.accept()
574
- return
575
- if choice == QMessageBox.StandardButton.Discard:
576
- event.accept()
577
- return
578
- event.ignore()
576
+ return not has_unsaved_changes(self._state)
577
+ return choice == QMessageBox.StandardButton.Discard
579
578
 
580
579
  def _remember_plan_path(self, path: Path) -> None:
581
580
  """Record the plan path for the next launch, best-effort.
@@ -611,13 +610,15 @@ class MainWindow(QMainWindow):
611
610
  self._state = state_marked_saved(self._state)
612
611
  self.facts_pane.status_label.setText(self._view_model.facts_form.example_note)
613
612
 
614
- def _handle_cleared(self) -> str:
613
+ def _handle_cleared(self) -> str | None:
615
614
  """Reset the session to no plan and re-render the result panes.
616
615
 
617
616
  The session's plan file detaches too: after a clear, the next
618
617
  Save asks where to write rather than overwriting the file the
619
618
  cleared plan came from.
620
619
  """
620
+ if not self._confirm_plan_replacement():
621
+ return None
621
622
  self._state = initial_plan_state()
622
623
  self._plan_path = None
623
624
  self._refresh_result_panes()
File without changes