glidepath 0.2.0__tar.gz → 0.3.0__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 (92) hide show
  1. {glidepath-0.2.0 → glidepath-0.3.0}/PKG-INFO +34 -11
  2. {glidepath-0.2.0 → glidepath-0.3.0}/README.md +15 -9
  3. {glidepath-0.2.0 → glidepath-0.3.0}/pyproject.toml +54 -30
  4. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/backtest.py +1 -1
  5. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/drawdown.py +1 -1
  6. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/montecarlo.py +1 -1
  7. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/retirement.py +1 -1
  8. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/backtest.py +5 -2
  9. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/money.py +3 -1
  10. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/montecarlo.py +5 -2
  11. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/retirement.py +5 -2
  12. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/main.py +13 -3
  13. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/widgets.py +12 -1
  14. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/encode.py +25 -5
  15. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/extension.py +3 -1
  16. {glidepath-0.2.0 → glidepath-0.3.0}/LICENSE +0 -0
  17. {glidepath-0.2.0 → glidepath-0.3.0}/LICENSE-DATA +0 -0
  18. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/__init__.py +0 -0
  19. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/__init__.py +0 -0
  20. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/charts.py +0 -0
  21. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/copy.py +0 -0
  22. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/display.py +0 -0
  23. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/example.py +0 -0
  24. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/exports.py +0 -0
  25. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/files.py +0 -0
  26. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/firstrun.py +0 -0
  27. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/forms.py +0 -0
  28. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/inspector.py +0 -0
  29. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/labels.py +0 -0
  30. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/plan.py +0 -0
  31. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/scenarios.py +0 -0
  32. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/shell.py +0 -0
  33. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/tables.py +0 -0
  34. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/__init__.py +0 -0
  35. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/annuities.py +0 -0
  36. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/comparison.py +0 -0
  37. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/config.py +0 -0
  38. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/contributions.py +0 -0
  39. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/engine.py +0 -0
  40. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/entities.py +0 -0
  41. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/glide.py +0 -0
  42. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/investments.py +0 -0
  43. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/pensions.py +0 -0
  44. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/periods.py +0 -0
  45. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/provenance.py +0 -0
  46. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/randomness.py +0 -0
  47. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/region.py +0 -0
  48. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/reporting.py +0 -0
  49. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/results.py +0 -0
  50. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/returns.py +0 -0
  51. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/scenarios.py +0 -0
  52. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/state_pension.py +0 -0
  53. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/tax.py +0 -0
  54. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/withdrawals.py +0 -0
  55. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/wrappers.py +0 -0
  56. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/__init__.py +0 -0
  57. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_128.png +0 -0
  58. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_16.png +0 -0
  59. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_24.png +0 -0
  60. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_256.png +0 -0
  61. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_32.png +0 -0
  62. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_48.png +0 -0
  63. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_64.png +0 -0
  64. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/wordmark.png +0 -0
  65. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/charts.py +0 -0
  66. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/forms.py +0 -0
  67. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/inspector.py +0 -0
  68. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/scenarios.py +0 -0
  69. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/style.py +0 -0
  70. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/tableview.py +0 -0
  71. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/__init__.py +0 -0
  72. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/assumptions.py +0 -0
  73. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/decode.py +0 -0
  74. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/document.py +0 -0
  75. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/migrations.py +0 -0
  76. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/values.py +0 -0
  77. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/py.typed +0 -0
  78. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/__init__.py +0 -0
  79. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/__init__.py +0 -0
  80. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/ages.py +0 -0
  81. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/contributions.py +0 -0
  82. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/data/age_rules.toml +0 -0
  83. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/data/assumptions_default.toml +0 -0
  84. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/data/returns_history.toml +0 -0
  85. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +0 -0
  86. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/loader.py +0 -0
  87. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/region.py +0 -0
  88. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/schema.py +0 -0
  89. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/state_pension.py +0 -0
  90. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/tax.py +0 -0
  91. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/wrappers.py +0 -0
  92. {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/years.py +0 -0
@@ -1,16 +1,35 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glidepath
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Desktop retirement and investment planner (UK-first, region-extensible).
5
+ Keywords: retirement,pension,financial-planning,investment,uk,sipp,isa,monte-carlo
6
+ Author: James Williams
5
7
  License-Expression: MIT AND CC-BY-NC-SA-4.0
6
8
  License-File: LICENSE
7
9
  License-File: LICENSE-DATA
8
- Requires-Dist: pyside6>=6.11.1
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Win32 (MS Windows)
12
+ Classifier: Environment :: X11 Applications :: Qt
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Office/Business :: Financial :: Investment
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: pyside6==6.11.1
9
22
  Requires-Python: >=3.14
23
+ Project-URL: Homepage, https://williajm.github.io/glidepath/
24
+ Project-URL: Repository, https://github.com/williajm/glidepath
25
+ Project-URL: Changelog, https://github.com/williajm/glidepath/blob/main/CHANGELOG.md
26
+ Project-URL: Issues, https://github.com/williajm/glidepath/issues
10
27
  Description-Content-Type: text/markdown
11
28
 
29
+ <!-- Image and badge URLs are absolute so the README renders on PyPI,
30
+ which does not resolve repository-relative paths. -->
12
31
  <p align="center">
13
- <img src="src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
32
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
14
33
  </p>
15
34
 
16
35
  [![CI](https://github.com/williajm/glidepath/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
@@ -20,7 +39,7 @@ Description-Content-Type: text/markdown
20
39
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
21
40
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
22
41
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
23
- [![License: MIT](https://img.shields.io/github/license/williajm/glidepath)](LICENSE)
42
+ [![License: MIT + CC BY-NC-SA 4.0 data](https://img.shields.io/badge/license-MIT%20%2B%20CC%20BY--NC--SA%204.0%20data-blue)](https://github.com/williajm/glidepath/blob/main/LICENSE)
24
43
 
25
44
  <p align="center">
26
45
  <a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
@@ -33,7 +52,7 @@ stated, a **decision** you made, or an **assumption** the app defaulted
33
52
  nothing is transmitted.
34
53
 
35
54
  <p align="center">
36
- <img src="docs/screenshots/charts.png"
55
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/charts.png"
37
56
  alt="The charts tab: the Monte Carlo fan chart — nested percentile bands deepening toward the median line — beside the success-rate readout and the retirement-age and backtest cards"
38
57
  width="800">
39
58
  </p>
@@ -79,7 +98,7 @@ a choice you make; anything estimated lives in the assumptions
79
98
  inspector instead:
80
99
 
81
100
  <p align="center">
82
- <img src="docs/screenshots/facts.png"
101
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/facts.png"
83
102
  alt="The facts tab: the About you, Household spending, and State pension cards of the entry form, with the example plan's values filled in"
84
103
  width="800">
85
104
  </p>
@@ -89,7 +108,7 @@ choices in effect, and every assumption the run used with its value,
89
108
  default/overridden status, source, and date:
90
109
 
91
110
  <p align="center">
92
- <img src="docs/screenshots/stated_vs_assumed.png"
111
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/stated_vs_assumed.png"
93
112
  alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
94
113
  width="800">
95
114
  </p>
@@ -154,7 +173,7 @@ fine.)
154
173
  ## Everyday commands
155
174
 
156
175
  ```sh
157
- make check # all merge gates: ruff, format, mypy --strict, pytest (>=90% cov), dep age
176
+ make check # all merge gates: ruff, format, mypy --strict, pytest (>=96% cov), dep age
158
177
  make fix # auto-fix lint issues and reformat
159
178
  make test # tests with coverage
160
179
  make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
@@ -169,9 +188,13 @@ clean `make check` locally does not quite guarantee a green pipeline. See
169
188
 
170
189
  ## Releases
171
190
 
172
- Releases are `vX.Y.Z` tags on `main`; each GitHub Release carries its
173
- notes from [`CHANGELOG.md`](CHANGELOG.md). There are no packaged builds
174
- yet run from source as above.
191
+ Releases are `vX.Y.Z` tags on `main`. Each one is published to
192
+ [PyPI](https://pypi.org/project/glidepath/) as an sdist and wheel via
193
+ trusted publishing with PEP 740 attestations, and as a GitHub Release
194
+ carrying its notes from
195
+ [`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
196
+ with the same artifacts attached. There are no packaged binary builds
197
+ (installer/exe) yet — install from PyPI as above.
175
198
 
176
199
  ## Data licences
177
200
 
@@ -1,5 +1,7 @@
1
+ <!-- Image and badge URLs are absolute so the README renders on PyPI,
2
+ which does not resolve repository-relative paths. -->
1
3
  <p align="center">
2
- <img src="src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
4
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
3
5
  </p>
4
6
 
5
7
  [![CI](https://github.com/williajm/glidepath/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
@@ -9,7 +11,7 @@
9
11
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
10
12
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
11
13
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
12
- [![License: MIT](https://img.shields.io/github/license/williajm/glidepath)](LICENSE)
14
+ [![License: MIT + CC BY-NC-SA 4.0 data](https://img.shields.io/badge/license-MIT%20%2B%20CC%20BY--NC--SA%204.0%20data-blue)](https://github.com/williajm/glidepath/blob/main/LICENSE)
13
15
 
14
16
  <p align="center">
15
17
  <a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
@@ -22,7 +24,7 @@ stated, a **decision** you made, or an **assumption** the app defaulted
22
24
  nothing is transmitted.
23
25
 
24
26
  <p align="center">
25
- <img src="docs/screenshots/charts.png"
27
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/charts.png"
26
28
  alt="The charts tab: the Monte Carlo fan chart — nested percentile bands deepening toward the median line — beside the success-rate readout and the retirement-age and backtest cards"
27
29
  width="800">
28
30
  </p>
@@ -68,7 +70,7 @@ a choice you make; anything estimated lives in the assumptions
68
70
  inspector instead:
69
71
 
70
72
  <p align="center">
71
- <img src="docs/screenshots/facts.png"
73
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/facts.png"
72
74
  alt="The facts tab: the About you, Household spending, and State pension cards of the entry form, with the example plan's values filled in"
73
75
  width="800">
74
76
  </p>
@@ -78,7 +80,7 @@ choices in effect, and every assumption the run used with its value,
78
80
  default/overridden status, source, and date:
79
81
 
80
82
  <p align="center">
81
- <img src="docs/screenshots/stated_vs_assumed.png"
83
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/stated_vs_assumed.png"
82
84
  alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
83
85
  width="800">
84
86
  </p>
@@ -143,7 +145,7 @@ fine.)
143
145
  ## Everyday commands
144
146
 
145
147
  ```sh
146
- make check # all merge gates: ruff, format, mypy --strict, pytest (>=90% cov), dep age
148
+ make check # all merge gates: ruff, format, mypy --strict, pytest (>=96% cov), dep age
147
149
  make fix # auto-fix lint issues and reformat
148
150
  make test # tests with coverage
149
151
  make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
@@ -158,9 +160,13 @@ clean `make check` locally does not quite guarantee a green pipeline. See
158
160
 
159
161
  ## Releases
160
162
 
161
- Releases are `vX.Y.Z` tags on `main`; each GitHub Release carries its
162
- notes from [`CHANGELOG.md`](CHANGELOG.md). There are no packaged builds
163
- yet run from source as above.
163
+ Releases are `vX.Y.Z` tags on `main`. Each one is published to
164
+ [PyPI](https://pypi.org/project/glidepath/) as an sdist and wheel via
165
+ trusted publishing with PEP 740 attestations, and as a GitHub Release
166
+ carrying its notes from
167
+ [`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
168
+ with the same artifacts attached. There are no packaged binary builds
169
+ (installer/exe) yet — install from PyPI as above.
164
170
 
165
171
  ## Data licences
166
172
 
@@ -1,17 +1,55 @@
1
1
  [project]
2
2
  name = "glidepath"
3
- version = "0.2.0"
3
+ version = "0.3.0"
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 —
7
7
  # scope and attribution in LICENSE-DATA).
8
8
  license = "MIT AND CC-BY-NC-SA-4.0"
9
9
  license-files = ["LICENSE", "LICENSE-DATA"]
10
+ authors = [{ name = "James Williams" }]
10
11
  requires-python = ">=3.14"
12
+ keywords = [
13
+ "retirement",
14
+ "pension",
15
+ "financial-planning",
16
+ "investment",
17
+ "uk",
18
+ "sipp",
19
+ "isa",
20
+ "monte-carlo",
21
+ ]
22
+ # No License :: classifier — PEP 639: the SPDX expression above is the
23
+ # license metadata, and mixing it with license classifiers is deprecated.
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Environment :: Win32 (MS Windows)",
27
+ "Environment :: X11 Applications :: Qt",
28
+ "Intended Audience :: End Users/Desktop",
29
+ "Intended Audience :: Financial and Insurance Industry",
30
+ # The platforms the suite actually runs on (ci.yml); no macOS claim
31
+ # until it is tested there.
32
+ "Operating System :: Microsoft :: Windows",
33
+ "Operating System :: POSIX :: Linux",
34
+ "Programming Language :: Python :: 3 :: Only",
35
+ "Programming Language :: Python :: 3.14",
36
+ "Topic :: Office/Business :: Financial :: Investment",
37
+ "Typing :: Typed",
38
+ ]
39
+ # glidepath is an application, not a library: the runtime dependency is
40
+ # pinned exactly so `uv tool install glidepath` installs the same PySide6
41
+ # the release was tested against (uv.lock does not apply to end users).
42
+ # Bump the pin only via `make deps`.
11
43
  dependencies = [
12
- "pyside6>=6.11.1",
44
+ "pyside6==6.11.1",
13
45
  ]
14
46
 
47
+ [project.urls]
48
+ Homepage = "https://williajm.github.io/glidepath/"
49
+ Repository = "https://github.com/williajm/glidepath"
50
+ Changelog = "https://github.com/williajm/glidepath/blob/main/CHANGELOG.md"
51
+ Issues = "https://github.com/williajm/glidepath/issues"
52
+
15
53
  [project.gui-scripts]
16
54
  glidepath = "glidepath.gui.main:main"
17
55
 
@@ -35,7 +73,7 @@ build-backend = "uv_build"
35
73
  [tool.uv]
36
74
  # Supply-chain cooldown: resolution ignores anything published to PyPI after
37
75
  # this timestamp (now minus 7 days). Updated ONLY by `make deps`.
38
- exclude-newer = "2026-07-30T09:40:45Z"
76
+ exclude-newer = "2026-08-01T17:09:10Z"
39
77
 
40
78
  # PyPI is the ONLY permitted index (supply-chain policy; see CLAUDE.md).
41
79
  [[tool.uv.index]]
@@ -61,34 +99,16 @@ convention = "google"
61
99
  [tool.ruff.lint.mccabe]
62
100
  max-complexity = 10
63
101
 
102
+ # BLE001 (app/ slow-run transitions) and PLR0913 (§5.2 runner signatures)
103
+ # are granted inline at the specific lines that earned them, not file-wide,
104
+ # so new violations elsewhere in those modules still fail lint.
64
105
  [tool.ruff.lint.per-file-ignores]
65
106
  "src/glidepath/core/randomness.py" = [
66
107
  "S311", # Seeded Monte Carlo simulation draws, never security material (planning §4.6).
67
108
  ]
68
- "src/glidepath/core/montecarlo.py" = [
69
- "PLR0913", # run_paths/sustainable_income take the §5.2 engine quartet plus keyword-only run knobs; bundling would obscure the run(plan, assumptions, region, config) shape.
70
- ]
71
- "src/glidepath/core/backtest.py" = [
72
- "PLR0913", # run_windows and its chunk worker mirror the §5.2 runner signature (see core/montecarlo.py).
73
- ]
74
109
  "scripts/build_returns_history.py" = [
75
110
  "S314", # xml.etree parses the JST workbook the developer supplies locally, never untrusted input.
76
111
  ]
77
- "src/glidepath/core/retirement.py" = [
78
- "PLR0913", # earliest_retirement_age mirrors the §5.2 runner signature (see core/montecarlo.py).
79
- ]
80
- "src/glidepath/app/montecarlo.py" = [
81
- "BLE001", # The slow-run transition must fold ANY failure into state (§4.7): an exception escaping past the shell's worker thread holds the in-flight guard forever.
82
- ]
83
- "src/glidepath/app/retirement.py" = [
84
- "BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
85
- ]
86
- "src/glidepath/app/drawdown.py" = [
87
- "BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
88
- ]
89
- "src/glidepath/app/backtest.py" = [
90
- "BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
91
- ]
92
112
  "tests/**" = [
93
113
  "S101", # pytest tests use bare assert by design.
94
114
  "PLR2004", # Literal expected values in assertions are idiomatic in tests.
@@ -103,12 +123,15 @@ max-complexity = 10
103
123
  # --- mypy -------------------------------------------------------------------
104
124
  [tool.mypy]
105
125
  strict = true
106
- warn_unused_ignores = true
126
+ # Not implied by strict: flags code mypy can prove unreachable — high-yield
127
+ # in Decimal/None-narrowing-heavy code.
128
+ warn_unreachable = true
107
129
  python_version = "3.14"
108
130
  files = ["src", "tests", "scripts"]
109
131
  # Lets the tests' top-level script imports (e.g. `import check_dep_age`)
110
- # resolve to scripts/ mirrors pytest's pythonpath below.
111
- mypy_path = ["scripts"]
132
+ # and the shared test factories (`import factories`) resolve — mirrors
133
+ # pytest's pythonpath below.
134
+ mypy_path = ["scripts", "tests"]
112
135
 
113
136
  # --- pytest / coverage ------------------------------------------------------
114
137
  [tool.pytest.ini_options]
@@ -122,9 +145,10 @@ addopts = [
122
145
  "--cov-report=xml",
123
146
  ]
124
147
  testpaths = ["tests"]
125
- # scripts/ is not a package; tests import the operational scripts (the
126
- # gates behind `make deps`/`make bump`/releases) as top-level modules.
127
- pythonpath = ["scripts"]
148
+ # Neither scripts/ nor tests/ is a package; tests import the operational
149
+ # scripts (the gates behind `make deps`/`make bump`/releases) and the
150
+ # shared test-data factories (tests/factories.py) as top-level modules.
151
+ pythonpath = ["scripts", "tests"]
128
152
  # Fail a stuck test fast instead of hanging CI for the runner limit: the
129
153
  # GUI suite waits on worker pools for up to 60s, so the ceiling sits
130
154
  # safely above that while still cutting a wedged run short.
@@ -132,7 +132,7 @@ def state_with_backtest(state: PlanState, *, today: date) -> PlanState:
132
132
  config,
133
133
  series=series,
134
134
  )
135
- except Exception as exc:
135
+ except Exception as exc: # noqa: BLE001
136
136
  # Broad by design, mirroring the Monte Carlo transition: an
137
137
  # escape past the shell's worker thread would leave the
138
138
  # in-flight guard held (buttons disabled, spinner running)
@@ -254,7 +254,7 @@ def state_with_drawdown(
254
254
  search=search,
255
255
  parallelism=parallelism,
256
256
  )
257
- except Exception as exc:
257
+ except Exception as exc: # noqa: BLE001
258
258
  # Broad by design, exactly as in state_with_monte_carlo: a
259
259
  # process-pool failure escaping here would hold the shell's
260
260
  # in-flight guard forever, so every failure folds into the
@@ -316,7 +316,7 @@ def state_with_monte_carlo(
316
316
  paths=paths,
317
317
  parallelism=parallelism,
318
318
  )
319
- except Exception as exc:
319
+ except Exception as exc: # noqa: BLE001
320
320
  # Broad by design: beyond the engine's ValueErrors, the process
321
321
  # pool can raise OSError at spawn, pickling TypeErrors, or a
322
322
  # BrokenExecutor — an escape here would leave the shell's
@@ -234,7 +234,7 @@ def state_with_retirement(
234
234
  search,
235
235
  parallelism=parallelism,
236
236
  )
237
- except Exception as exc:
237
+ except Exception as exc: # noqa: BLE001
238
238
  # Broad by design, exactly as in state_with_monte_carlo: a
239
239
  # process-pool failure escaping here would hold the shell's
240
240
  # in-flight guard forever, so every failure folds into the
@@ -392,7 +392,9 @@ class BacktestResult:
392
392
  )
393
393
 
394
394
 
395
- def run_windows(
395
+ # The §5.2 engine quartet plus keyword-only run knobs; bundling would
396
+ # obscure the run(plan, assumptions, region, config) shape.
397
+ def run_windows( # noqa: PLR0913
396
398
  plan: Household,
397
399
  assumptions: AssumptionSet,
398
400
  region: Region,
@@ -477,7 +479,8 @@ def run_windows(
477
479
  )
478
480
 
479
481
 
480
- def _run_window_range(
482
+ # Mirrors the run_windows signature above.
483
+ def _run_window_range( # noqa: PLR0913
481
484
  plan: Household,
482
485
  assumptions: AssumptionSet,
483
486
  region: Region,
@@ -15,7 +15,9 @@ _PENNY = Decimal("0.01")
15
15
  def _require_finite_decimal(value: Decimal, field_name: str) -> None:
16
16
  """Reject non-Decimal and non-finite amounts at construction time."""
17
17
  if not isinstance(value, Decimal):
18
- msg = f"{field_name} must be Decimal, got {type(value).__name__}"
18
+ # Reachable from untyped callers (decoded plan JSON, region TOML)
19
+ # even though the annotation lets mypy prove the branch dead.
20
+ msg = f"{field_name} must be Decimal, got {type(value).__name__}" # type: ignore[unreachable]
19
21
  raise TypeError(msg)
20
22
  if not value.is_finite():
21
23
  msg = f"{field_name} must be finite"
@@ -242,7 +242,9 @@ class PathParallelism:
242
242
  raise ValueError(msg)
243
243
 
244
244
 
245
- def run_paths(
245
+ # The §5.2 engine quartet plus keyword-only run knobs; bundling would
246
+ # obscure the run(plan, assumptions, region, config) shape.
247
+ def run_paths( # noqa: PLR0913
246
248
  plan: Household,
247
249
  assumptions: AssumptionSet,
248
250
  region: Region,
@@ -392,7 +394,8 @@ class SustainableIncomeSearch:
392
394
  raise ValueError(msg)
393
395
 
394
396
 
395
- def sustainable_income(
397
+ # Mirrors the run_paths signature above.
398
+ def sustainable_income( # noqa: PLR0913
396
399
  plan: Household,
397
400
  assumptions: AssumptionSet,
398
401
  region: Region,
@@ -113,7 +113,9 @@ class RetirementAgeSearch:
113
113
  raise ValueError(msg)
114
114
 
115
115
 
116
- def earliest_retirement_age(
116
+ # The §5.2 engine quartet plus keyword-only run knobs; bundling would
117
+ # obscure the run(plan, assumptions, region, config) shape.
118
+ def earliest_retirement_age( # noqa: PLR0913
117
119
  plan: Household,
118
120
  assumptions: AssumptionSet,
119
121
  region: Region,
@@ -180,7 +182,8 @@ def earliest_retirement_age(
180
182
  return None
181
183
 
182
184
 
183
- def sustainable_income_at_age(
185
+ # Mirrors the earliest_retirement_age signature above.
186
+ def sustainable_income_at_age( # noqa: PLR0913
184
187
  plan: Household,
185
188
  assumptions: AssumptionSet,
186
189
  region: Region,
@@ -4,7 +4,7 @@ The disclaimer must be accepted before the shell opens; declining
4
4
  exits without recording anything, so the next launch asks again.
5
5
  """
6
6
 
7
- import contextlib
7
+ import logging
8
8
  import sys
9
9
  from datetime import UTC, datetime
10
10
 
@@ -20,6 +20,8 @@ from glidepath.app import (
20
20
  from glidepath.gui import widgets
21
21
  from glidepath.gui.style import apply_theme
22
22
 
23
+ _logger = logging.getLogger(__name__)
24
+
23
25
 
24
26
  def run(argv: list[str] | None = None) -> int:
25
27
  """Run the desktop shell; returns the process exit code."""
@@ -34,9 +36,17 @@ def run(argv: list[str] | None = None) -> int:
34
36
  return 0
35
37
  # The acknowledgement file only skips future prompts; an unwritable
36
38
  # config directory must not kill the session the user just accepted
37
- # (worst case the next launch asks again).
38
- with contextlib.suppress(OSError):
39
+ # (worst case the next launch asks again) — but it must leave a
40
+ # diagnostic trail for "it keeps asking me" reports.
41
+ try:
39
42
  record_disclaimer_acknowledged(state_path, datetime.now(tz=UTC).date())
43
+ except OSError:
44
+ _logger.warning(
45
+ "Could not record the disclaimer acknowledgement at %s; "
46
+ "the next launch will ask again.",
47
+ state_path,
48
+ exc_info=True,
49
+ )
40
50
  window = widgets.MainWindow(view_model, settings_path=state_path)
41
51
  # Reopen the last saved or opened plan; if it fails to load, the
42
52
  # launch example stays on screen and the status bar explains.
@@ -6,6 +6,7 @@ render view models and forward raw user input back.
6
6
  """
7
7
 
8
8
  import contextlib
9
+ import logging
9
10
  from datetime import UTC, date, datetime
10
11
  from pathlib import Path
11
12
  from typing import TYPE_CHECKING
@@ -105,6 +106,8 @@ from glidepath.gui.style import wordmark_pixmap
105
106
  if TYPE_CHECKING:
106
107
  from collections.abc import Callable
107
108
 
109
+ _logger = logging.getLogger(__name__)
110
+
108
111
  _REPORT_DPI = 96
109
112
  """The PDF paint device's resolution.
110
113
 
@@ -582,8 +585,16 @@ class MainWindow(QMainWindow):
582
585
  """
583
586
  if self._settings_path is None:
584
587
  return
585
- with contextlib.suppress(OSError):
588
+ try:
586
589
  record_last_plan_path(self._settings_path, path)
590
+ except OSError:
591
+ _logger.warning(
592
+ "Could not remember the plan path %s in %s; the next "
593
+ "launch will start on the example plan.",
594
+ path,
595
+ self._settings_path,
596
+ exc_info=True,
597
+ )
587
598
 
588
599
  def _load_example(self) -> None:
589
600
  """Open with the example plan on screen and projected (§4.9).
@@ -18,6 +18,7 @@ ids) is rejected here too.
18
18
 
19
19
  import contextlib
20
20
  import json
21
+ import os
21
22
  from typing import TYPE_CHECKING
22
23
 
23
24
  from glidepath.core import (
@@ -87,23 +88,42 @@ def save_plan(document: PlanDocument, path: Path) -> None:
87
88
 
88
89
  Serialization completes before any file is opened, so a document
89
90
  that cannot be encoded never touches the disk; the text is then
90
- written to a sibling temporary file and moved over ``path`` only
91
- once the write has closed cleanly, so a mid-write failure (disk
92
- full, power loss) can never truncate the last saved plan.
93
- ``newline=""`` disables platform newline translation so the file
94
- carries LF endings on every platform (planning §4.5).
91
+ written to a sibling temporary file, flushed to stable storage
92
+ with ``fsync``, and only then moved over ``path`` — so a mid-write
93
+ failure (disk full, power loss) can never truncate the last saved
94
+ plan, and the rename never publishes data still sitting in the OS
95
+ write cache. ``newline=""`` disables platform newline translation
96
+ so the file carries LF endings on every platform (planning §4.5).
95
97
  """
96
98
  text = dumps_plan(document)
97
99
  temp = path.with_name(path.name + ".tmp")
98
100
  try:
99
101
  with temp.open("w", encoding="utf-8", newline="") as handle:
100
102
  handle.write(text)
103
+ handle.flush()
104
+ os.fsync(handle.fileno())
101
105
  temp.replace(path)
106
+ _fsync_directory(path.parent)
102
107
  finally:
103
108
  with contextlib.suppress(OSError):
104
109
  temp.unlink(missing_ok=True)
105
110
 
106
111
 
112
+ def _fsync_directory(directory: Path) -> None:
113
+ """Flush the directory entry so the rename itself survives power loss.
114
+
115
+ POSIX only: directories cannot be opened for fsync on Windows,
116
+ where the ``replace`` above is journalled by NTFS instead.
117
+ """
118
+ if os.name != "posix":
119
+ return
120
+ descriptor = os.open(directory, os.O_RDONLY)
121
+ try:
122
+ os.fsync(descriptor)
123
+ finally:
124
+ os.close(descriptor)
125
+
126
+
107
127
  def _document_payload(document: PlanDocument) -> dict[str, object]:
108
128
  """The whole document as a JSON-ready payload."""
109
129
  return {
@@ -189,7 +189,9 @@ class FutureYearsPolicy:
189
189
  Scottish band uppers (module docstring).
190
190
  """
191
191
  if not isinstance(self.mode, FutureYearsMode):
192
- type_name = type(self.mode).__name__
192
+ # Reachable from raw TOML data even though the annotation
193
+ # lets mypy prove the branch dead.
194
+ type_name = type(self.mode).__name__ # type: ignore[unreachable]
193
195
  _fail(_CONTEXT, f"mode must be a FutureYearsMode member, got {type_name}")
194
196
  needs_until = self.mode is FutureYearsMode.FROZEN_THEN_CPI_INDEXED
195
197
  if needs_until and self.frozen_until_start_year is None:
File without changes
File without changes