glidepath 0.2.1__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.1 → glidepath-0.3.0}/PKG-INFO +20 -12
  2. {glidepath-0.2.1 → glidepath-0.3.0}/README.md +15 -9
  3. {glidepath-0.2.1 → glidepath-0.3.0}/pyproject.toml +25 -31
  4. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/backtest.py +1 -1
  5. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/drawdown.py +1 -1
  6. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/montecarlo.py +1 -1
  7. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/retirement.py +1 -1
  8. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/backtest.py +5 -2
  9. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/money.py +3 -1
  10. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/montecarlo.py +5 -2
  11. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/retirement.py +5 -2
  12. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/main.py +13 -3
  13. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/widgets.py +12 -1
  14. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/persistence/encode.py +25 -5
  15. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/extension.py +3 -1
  16. {glidepath-0.2.1 → glidepath-0.3.0}/LICENSE +0 -0
  17. {glidepath-0.2.1 → glidepath-0.3.0}/LICENSE-DATA +0 -0
  18. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/__init__.py +0 -0
  19. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/__init__.py +0 -0
  20. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/charts.py +0 -0
  21. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/copy.py +0 -0
  22. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/display.py +0 -0
  23. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/example.py +0 -0
  24. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/exports.py +0 -0
  25. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/files.py +0 -0
  26. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/firstrun.py +0 -0
  27. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/forms.py +0 -0
  28. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/inspector.py +0 -0
  29. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/labels.py +0 -0
  30. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/plan.py +0 -0
  31. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/scenarios.py +0 -0
  32. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/shell.py +0 -0
  33. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/app/tables.py +0 -0
  34. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/__init__.py +0 -0
  35. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/annuities.py +0 -0
  36. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/comparison.py +0 -0
  37. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/config.py +0 -0
  38. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/contributions.py +0 -0
  39. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/engine.py +0 -0
  40. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/entities.py +0 -0
  41. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/glide.py +0 -0
  42. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/investments.py +0 -0
  43. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/pensions.py +0 -0
  44. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/periods.py +0 -0
  45. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/provenance.py +0 -0
  46. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/randomness.py +0 -0
  47. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/region.py +0 -0
  48. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/reporting.py +0 -0
  49. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/results.py +0 -0
  50. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/returns.py +0 -0
  51. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/scenarios.py +0 -0
  52. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/state_pension.py +0 -0
  53. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/tax.py +0 -0
  54. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/withdrawals.py +0 -0
  55. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/core/wrappers.py +0 -0
  56. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/__init__.py +0 -0
  57. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_128.png +0 -0
  58. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_16.png +0 -0
  59. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_24.png +0 -0
  60. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_256.png +0 -0
  61. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_32.png +0 -0
  62. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_48.png +0 -0
  63. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_64.png +0 -0
  64. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/assets/wordmark.png +0 -0
  65. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/charts.py +0 -0
  66. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/forms.py +0 -0
  67. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/inspector.py +0 -0
  68. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/scenarios.py +0 -0
  69. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/style.py +0 -0
  70. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/gui/tableview.py +0 -0
  71. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/persistence/__init__.py +0 -0
  72. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/persistence/assumptions.py +0 -0
  73. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/persistence/decode.py +0 -0
  74. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/persistence/document.py +0 -0
  75. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/persistence/migrations.py +0 -0
  76. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/persistence/values.py +0 -0
  77. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/py.typed +0 -0
  78. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/__init__.py +0 -0
  79. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/__init__.py +0 -0
  80. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/ages.py +0 -0
  81. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/contributions.py +0 -0
  82. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/data/age_rules.toml +0 -0
  83. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/data/assumptions_default.toml +0 -0
  84. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/data/returns_history.toml +0 -0
  85. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +0 -0
  86. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/loader.py +0 -0
  87. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/region.py +0 -0
  88. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/schema.py +0 -0
  89. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/state_pension.py +0 -0
  90. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/tax.py +0 -0
  91. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/wrappers.py +0 -0
  92. {glidepath-0.2.1 → glidepath-0.3.0}/src/glidepath/regions/uk/years.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glidepath
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
@@ -12,11 +12,13 @@ Classifier: Environment :: Win32 (MS Windows)
12
12
  Classifier: Environment :: X11 Applications :: Qt
13
13
  Classifier: Intended Audience :: End Users/Desktop
14
14
  Classifier: Intended Audience :: Financial and Insurance Industry
15
- Classifier: Operating System :: OS Independent
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
16
17
  Classifier: Programming Language :: Python :: 3 :: Only
17
18
  Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: Topic :: Office/Business :: Financial :: Investment
19
- Requires-Dist: pyside6>=6.11.1
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: pyside6==6.11.1
20
22
  Requires-Python: >=3.14
21
23
  Project-URL: Homepage, https://williajm.github.io/glidepath/
22
24
  Project-URL: Repository, https://github.com/williajm/glidepath
@@ -24,8 +26,10 @@ Project-URL: Changelog, https://github.com/williajm/glidepath/blob/main/CHANGELO
24
26
  Project-URL: Issues, https://github.com/williajm/glidepath/issues
25
27
  Description-Content-Type: text/markdown
26
28
 
29
+ <!-- Image and badge URLs are absolute so the README renders on PyPI,
30
+ which does not resolve repository-relative paths. -->
27
31
  <p align="center">
28
- <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">
29
33
  </p>
30
34
 
31
35
  [![CI](https://github.com/williajm/glidepath/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
@@ -35,7 +39,7 @@ Description-Content-Type: text/markdown
35
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)
36
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)
37
41
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
38
- [![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)
39
43
 
40
44
  <p align="center">
41
45
  <a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
@@ -48,7 +52,7 @@ stated, a **decision** you made, or an **assumption** the app defaulted
48
52
  nothing is transmitted.
49
53
 
50
54
  <p align="center">
51
- <img src="docs/screenshots/charts.png"
55
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/charts.png"
52
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"
53
57
  width="800">
54
58
  </p>
@@ -94,7 +98,7 @@ a choice you make; anything estimated lives in the assumptions
94
98
  inspector instead:
95
99
 
96
100
  <p align="center">
97
- <img src="docs/screenshots/facts.png"
101
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/facts.png"
98
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"
99
103
  width="800">
100
104
  </p>
@@ -104,7 +108,7 @@ choices in effect, and every assumption the run used with its value,
104
108
  default/overridden status, source, and date:
105
109
 
106
110
  <p align="center">
107
- <img src="docs/screenshots/stated_vs_assumed.png"
111
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/stated_vs_assumed.png"
108
112
  alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
109
113
  width="800">
110
114
  </p>
@@ -169,7 +173,7 @@ fine.)
169
173
  ## Everyday commands
170
174
 
171
175
  ```sh
172
- 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
173
177
  make fix # auto-fix lint issues and reformat
174
178
  make test # tests with coverage
175
179
  make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
@@ -184,9 +188,13 @@ clean `make check` locally does not quite guarantee a green pipeline. See
184
188
 
185
189
  ## Releases
186
190
 
187
- Releases are `vX.Y.Z` tags on `main`; each GitHub Release carries its
188
- notes from [`CHANGELOG.md`](CHANGELOG.md). There are no packaged builds
189
- 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.
190
198
 
191
199
  ## Data licences
192
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "glidepath"
3
- version = "0.2.1"
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 —
@@ -27,13 +27,21 @@ classifiers = [
27
27
  "Environment :: X11 Applications :: Qt",
28
28
  "Intended Audience :: End Users/Desktop",
29
29
  "Intended Audience :: Financial and Insurance Industry",
30
- "Operating System :: OS Independent",
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",
31
34
  "Programming Language :: Python :: 3 :: Only",
32
35
  "Programming Language :: Python :: 3.14",
33
36
  "Topic :: Office/Business :: Financial :: Investment",
37
+ "Typing :: Typed",
34
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`.
35
43
  dependencies = [
36
- "pyside6>=6.11.1",
44
+ "pyside6==6.11.1",
37
45
  ]
38
46
 
39
47
  [project.urls]
@@ -65,7 +73,7 @@ build-backend = "uv_build"
65
73
  [tool.uv]
66
74
  # Supply-chain cooldown: resolution ignores anything published to PyPI after
67
75
  # this timestamp (now minus 7 days). Updated ONLY by `make deps`.
68
- exclude-newer = "2026-07-30T09:40:45Z"
76
+ exclude-newer = "2026-08-01T17:09:10Z"
69
77
 
70
78
  # PyPI is the ONLY permitted index (supply-chain policy; see CLAUDE.md).
71
79
  [[tool.uv.index]]
@@ -91,34 +99,16 @@ convention = "google"
91
99
  [tool.ruff.lint.mccabe]
92
100
  max-complexity = 10
93
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.
94
105
  [tool.ruff.lint.per-file-ignores]
95
106
  "src/glidepath/core/randomness.py" = [
96
107
  "S311", # Seeded Monte Carlo simulation draws, never security material (planning §4.6).
97
108
  ]
98
- "src/glidepath/core/montecarlo.py" = [
99
- "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.
100
- ]
101
- "src/glidepath/core/backtest.py" = [
102
- "PLR0913", # run_windows and its chunk worker mirror the §5.2 runner signature (see core/montecarlo.py).
103
- ]
104
109
  "scripts/build_returns_history.py" = [
105
110
  "S314", # xml.etree parses the JST workbook the developer supplies locally, never untrusted input.
106
111
  ]
107
- "src/glidepath/core/retirement.py" = [
108
- "PLR0913", # earliest_retirement_age mirrors the §5.2 runner signature (see core/montecarlo.py).
109
- ]
110
- "src/glidepath/app/montecarlo.py" = [
111
- "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.
112
- ]
113
- "src/glidepath/app/retirement.py" = [
114
- "BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
115
- ]
116
- "src/glidepath/app/drawdown.py" = [
117
- "BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
118
- ]
119
- "src/glidepath/app/backtest.py" = [
120
- "BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
121
- ]
122
112
  "tests/**" = [
123
113
  "S101", # pytest tests use bare assert by design.
124
114
  "PLR2004", # Literal expected values in assertions are idiomatic in tests.
@@ -133,12 +123,15 @@ max-complexity = 10
133
123
  # --- mypy -------------------------------------------------------------------
134
124
  [tool.mypy]
135
125
  strict = true
136
- 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
137
129
  python_version = "3.14"
138
130
  files = ["src", "tests", "scripts"]
139
131
  # Lets the tests' top-level script imports (e.g. `import check_dep_age`)
140
- # resolve to scripts/ — mirrors pytest's pythonpath below.
141
- mypy_path = ["scripts"]
132
+ # and the shared test factories (`import factories`) resolve — mirrors
133
+ # pytest's pythonpath below.
134
+ mypy_path = ["scripts", "tests"]
142
135
 
143
136
  # --- pytest / coverage ------------------------------------------------------
144
137
  [tool.pytest.ini_options]
@@ -152,9 +145,10 @@ addopts = [
152
145
  "--cov-report=xml",
153
146
  ]
154
147
  testpaths = ["tests"]
155
- # scripts/ is not a package; tests import the operational scripts (the
156
- # gates behind `make deps`/`make bump`/releases) as top-level modules.
157
- 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"]
158
152
  # Fail a stuck test fast instead of hanging CI for the runner limit: the
159
153
  # GUI suite waits on worker pools for up to 60s, so the ceiling sits
160
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