glidepath 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
glidepath/app/files.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Plan file save and load for shells (planning §4.5, §4.7).
|
|
2
|
+
|
|
3
|
+
The app-layer face of :mod:`glidepath.persistence`: the File menu's
|
|
4
|
+
copy, the session-state ↔ plan-document conversions, and save/load
|
|
5
|
+
transitions that fold every failure into a status message instead of
|
|
6
|
+
raising at a shell. The document stores the household, the scenarios,
|
|
7
|
+
and the user's assumption *overrides*; shipped defaults re-resolve on
|
|
8
|
+
every load, and a load records nothing back to disk — plans live
|
|
9
|
+
wherever the user chooses (§4.5).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Final
|
|
15
|
+
|
|
16
|
+
from glidepath.app.forms import form_cannot_represent
|
|
17
|
+
from glidepath.app.plan import (
|
|
18
|
+
PlanState,
|
|
19
|
+
check_table_override,
|
|
20
|
+
region_for,
|
|
21
|
+
replanned_state,
|
|
22
|
+
)
|
|
23
|
+
from glidepath.core import AssumptionTarget
|
|
24
|
+
from glidepath.persistence import (
|
|
25
|
+
PersistenceError,
|
|
26
|
+
PlanDocument,
|
|
27
|
+
assumption_overrides_from,
|
|
28
|
+
load_plan,
|
|
29
|
+
resolve_assumptions,
|
|
30
|
+
save_plan,
|
|
31
|
+
)
|
|
32
|
+
from glidepath.regions.uk import default_assumption_set
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from datetime import date
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from glidepath.core import AssumptionKey
|
|
39
|
+
|
|
40
|
+
PLAN_REGION: Final = "uk"
|
|
41
|
+
|
|
42
|
+
PLAN_FILE_SUFFIX: Final = ".glidepath.json"
|
|
43
|
+
|
|
44
|
+
PLAN_FILE_FILTER: Final = "glidepath plan (*.glidepath.json)"
|
|
45
|
+
|
|
46
|
+
FILE_MENU_LABEL: Final = "File"
|
|
47
|
+
|
|
48
|
+
OPEN_PLAN_LABEL: Final = "Open plan…"
|
|
49
|
+
|
|
50
|
+
SAVE_PLAN_LABEL: Final = "Save plan"
|
|
51
|
+
|
|
52
|
+
SAVE_PLAN_AS_LABEL: Final = "Save plan as…"
|
|
53
|
+
|
|
54
|
+
QUIT_LABEL: Final = "Quit"
|
|
55
|
+
|
|
56
|
+
OPEN_DIALOG_TITLE: Final = "Open plan"
|
|
57
|
+
|
|
58
|
+
SAVE_DIALOG_TITLE: Final = "Save plan"
|
|
59
|
+
|
|
60
|
+
UNSAVED_CHANGES_TITLE: Final = "Unsaved changes"
|
|
61
|
+
|
|
62
|
+
UNSAVED_CHANGES_PROMPT: Final = (
|
|
63
|
+
"This plan has unsaved changes. Save them before closing?"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
NOTHING_TO_SAVE_MESSAGE: Final = (
|
|
67
|
+
"Nothing to save yet — save facts on the Facts tab first."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_SAVE_FAILED_PREFIX: Final = "Could not save the plan: "
|
|
71
|
+
|
|
72
|
+
_OPEN_FAILED_PREFIX: Final = "Could not open the plan: "
|
|
73
|
+
|
|
74
|
+
_WRONG_REGION_MESSAGE: Final = "this plan's region {region!r} is not supported"
|
|
75
|
+
|
|
76
|
+
_UNREPRESENTABLE_MESSAGE: Final = (
|
|
77
|
+
"this plan holds {reason}, which the facts form cannot edit yet — "
|
|
78
|
+
"opening it would lose that data on the next save"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
_DEFAULTS_MOVED_NOTE: Final = (
|
|
82
|
+
"Note: the shipped default assumptions have changed since this plan "
|
|
83
|
+
"was last saved — review the stated-vs-assumed view."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class SaveOutcome:
|
|
89
|
+
"""Whether a save wrote the file, and the status line saying so."""
|
|
90
|
+
|
|
91
|
+
saved: bool
|
|
92
|
+
message: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class LoadOutcome:
|
|
97
|
+
"""The loaded session state, or the message explaining the failure.
|
|
98
|
+
|
|
99
|
+
``message`` always carries the status line to show — success and
|
|
100
|
+
failure alike.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
state: PlanState | None
|
|
104
|
+
message: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _shipped_data_version() -> str:
|
|
108
|
+
"""The shipped region data's content version, defaults applied.
|
|
109
|
+
|
|
110
|
+
Computed from the shipped defaults rather than the session's
|
|
111
|
+
effective assumptions, so the recorded version moves exactly when
|
|
112
|
+
the shipped data (or a shipped default policy) moves — never when
|
|
113
|
+
the user overrides an assumption.
|
|
114
|
+
"""
|
|
115
|
+
return region_for(default_assumption_set()).data_version
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def document_from_state(state: PlanState) -> PlanDocument | None:
|
|
119
|
+
"""The session as a storable plan document; ``None`` without a plan."""
|
|
120
|
+
if state.household is None:
|
|
121
|
+
return None
|
|
122
|
+
return PlanDocument(
|
|
123
|
+
region=PLAN_REGION,
|
|
124
|
+
assumptions_resolved_against=_shipped_data_version(),
|
|
125
|
+
household=state.household,
|
|
126
|
+
assumption_overrides=assumption_overrides_from(state.assumptions),
|
|
127
|
+
scenarios=state.scenarios,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def has_unsaved_changes(state: PlanState) -> bool:
|
|
132
|
+
"""Whether closing now would discard plan edits (issue #136).
|
|
133
|
+
|
|
134
|
+
True only when a plan-mutating transition has touched the state
|
|
135
|
+
since the last save or load *and* there is a plan a save could
|
|
136
|
+
write — an assumption overridden before any facts exist has no
|
|
137
|
+
document to preserve, so a prompt would offer a save that does
|
|
138
|
+
nothing.
|
|
139
|
+
"""
|
|
140
|
+
return state.modified and state.household is not None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def save_plan_state(state: PlanState, path: Path) -> SaveOutcome:
|
|
144
|
+
"""Write the session's plan to ``path``, reporting the outcome."""
|
|
145
|
+
document = document_from_state(state)
|
|
146
|
+
if document is None:
|
|
147
|
+
return SaveOutcome(saved=False, message=NOTHING_TO_SAVE_MESSAGE)
|
|
148
|
+
try:
|
|
149
|
+
save_plan(document, path)
|
|
150
|
+
except (PersistenceError, OSError) as exc:
|
|
151
|
+
return SaveOutcome(saved=False, message=f"{_SAVE_FAILED_PREFIX}{exc}")
|
|
152
|
+
return SaveOutcome(saved=True, message=f"Plan saved to {path}.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _table_error(key: AssumptionKey, value: object) -> str | None:
|
|
156
|
+
"""The policy parser's objection to a stored table value, if any."""
|
|
157
|
+
if not isinstance(value, Mapping):
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
check_table_override(key, value)
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
return f"override on {key.value!r}: {exc}"
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _table_override_error(document: PlanDocument) -> str | None:
|
|
167
|
+
"""The first stored table override its policy parser rejects.
|
|
168
|
+
|
|
169
|
+
``resolve_assumptions`` checks only broad value shape (mapping vs
|
|
170
|
+
scalar); a structurally defective table would otherwise decode
|
|
171
|
+
cleanly and raise mid-run, past the load's failure boundary. The
|
|
172
|
+
same policy parsers the editors use are the contract here (issue
|
|
173
|
+
#71), for the base overrides and every scenario's alike.
|
|
174
|
+
"""
|
|
175
|
+
for override in document.assumption_overrides:
|
|
176
|
+
error = _table_error(override.key, override.value)
|
|
177
|
+
if error is not None:
|
|
178
|
+
return error
|
|
179
|
+
for scenario in document.scenarios:
|
|
180
|
+
for entry in scenario.overrides:
|
|
181
|
+
target = entry.target
|
|
182
|
+
if not isinstance(target, AssumptionTarget):
|
|
183
|
+
continue
|
|
184
|
+
error = _table_error(target.key, entry.value)
|
|
185
|
+
if error is not None:
|
|
186
|
+
return f"scenario {scenario.name!r} {error}"
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_plan_state(path: Path, *, today: date) -> LoadOutcome:
|
|
191
|
+
"""Read the plan at ``path`` into a freshly projected session state.
|
|
192
|
+
|
|
193
|
+
Assumptions re-resolve against the current shipped defaults with
|
|
194
|
+
the stored overrides on top (§4.5); when the shipped data moved
|
|
195
|
+
since the file was saved, the status message says so rather than
|
|
196
|
+
silently re-pricing the plan. A run failure inside an otherwise
|
|
197
|
+
loadable plan still loads — the state carries the run error, and
|
|
198
|
+
the result panes report it exactly as they do after a facts edit.
|
|
199
|
+
A plan the facts form cannot faithfully edit is refused outright:
|
|
200
|
+
opening it would silently discard the extra data on the next save.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
document = load_plan(path)
|
|
204
|
+
except (PersistenceError, OSError) as exc:
|
|
205
|
+
return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{exc}")
|
|
206
|
+
if document.region != PLAN_REGION:
|
|
207
|
+
detail = _WRONG_REGION_MESSAGE.format(region=document.region)
|
|
208
|
+
return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{detail}")
|
|
209
|
+
reason = form_cannot_represent(document.household)
|
|
210
|
+
if reason is not None:
|
|
211
|
+
detail = _UNREPRESENTABLE_MESSAGE.format(reason=reason)
|
|
212
|
+
return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{detail}")
|
|
213
|
+
table_error = _table_override_error(document)
|
|
214
|
+
if table_error is not None:
|
|
215
|
+
return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{table_error}")
|
|
216
|
+
try:
|
|
217
|
+
assumptions = resolve_assumptions(document, default_assumption_set())
|
|
218
|
+
except PersistenceError as exc:
|
|
219
|
+
return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{exc}")
|
|
220
|
+
state = replanned_state(
|
|
221
|
+
assumptions, document.household, document.scenarios, today=today, modified=False
|
|
222
|
+
)
|
|
223
|
+
message = f"Plan loaded from {path}."
|
|
224
|
+
if document.assumptions_resolved_against != _shipped_data_version():
|
|
225
|
+
message = f"{message} {_DEFAULTS_MOVED_NOTE}"
|
|
226
|
+
return LoadOutcome(state=state, message=message)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
__all__ = [
|
|
230
|
+
"FILE_MENU_LABEL",
|
|
231
|
+
"NOTHING_TO_SAVE_MESSAGE",
|
|
232
|
+
"OPEN_DIALOG_TITLE",
|
|
233
|
+
"OPEN_PLAN_LABEL",
|
|
234
|
+
"PLAN_FILE_FILTER",
|
|
235
|
+
"PLAN_FILE_SUFFIX",
|
|
236
|
+
"PLAN_REGION",
|
|
237
|
+
"QUIT_LABEL",
|
|
238
|
+
"SAVE_DIALOG_TITLE",
|
|
239
|
+
"SAVE_PLAN_AS_LABEL",
|
|
240
|
+
"SAVE_PLAN_LABEL",
|
|
241
|
+
"UNSAVED_CHANGES_PROMPT",
|
|
242
|
+
"UNSAVED_CHANGES_TITLE",
|
|
243
|
+
"LoadOutcome",
|
|
244
|
+
"SaveOutcome",
|
|
245
|
+
"document_from_state",
|
|
246
|
+
"has_unsaved_changes",
|
|
247
|
+
"load_plan_state",
|
|
248
|
+
"save_plan_state",
|
|
249
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Local per-user settings: disclaimer state and the last plan (§1, §4.7).
|
|
2
|
+
|
|
3
|
+
A tiny local JSON file records the disclaimer acknowledgement date and
|
|
4
|
+
the path of the last plan file saved or opened, so the next launch can
|
|
5
|
+
skip the first-run screen and reopen the user's plan. Anything
|
|
6
|
+
unreadable — missing file, bad JSON, wrong shape, a schema version
|
|
7
|
+
this reader does not know — degrades to an empty state, so the
|
|
8
|
+
disclaimer is shown again (and no plan auto-opens) rather than
|
|
9
|
+
guessing. The state lives in the platform's per-user configuration
|
|
10
|
+
directory; shells pass ``default_state_path()`` (or a substitute) in,
|
|
11
|
+
so this module never touches Qt or a display.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import date
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
_STATE_SCHEMA_VERSION = 1
|
|
23
|
+
_SCHEMA_KEY = "schema"
|
|
24
|
+
_ACKNOWLEDGED_KEY = "disclaimer_acknowledged_on"
|
|
25
|
+
_LAST_PLAN_KEY = "last_plan_path"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class FirstRunState:
|
|
30
|
+
"""What the local state file says about previous runs."""
|
|
31
|
+
|
|
32
|
+
disclaimer_acknowledged_on: date | None
|
|
33
|
+
last_plan_path: Path | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_EMPTY_STATE = FirstRunState(disclaimer_acknowledged_on=None)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def default_state_path() -> Path:
|
|
40
|
+
"""Per-user settings file location, following platform convention."""
|
|
41
|
+
if sys.platform == "win32":
|
|
42
|
+
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
|
43
|
+
else:
|
|
44
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
45
|
+
return base / "glidepath" / "settings.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _acknowledged_from(raw: dict[str, Any]) -> date | None:
|
|
49
|
+
"""The recorded acknowledgement date; ``None`` on any defect."""
|
|
50
|
+
try:
|
|
51
|
+
return date.fromisoformat(raw[_ACKNOWLEDGED_KEY])
|
|
52
|
+
except ValueError, KeyError, TypeError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _last_plan_from(raw: dict[str, Any]) -> Path | None:
|
|
57
|
+
"""The recorded last plan path; ``None`` on any defect."""
|
|
58
|
+
text = raw.get(_LAST_PLAN_KEY)
|
|
59
|
+
if not isinstance(text, str) or not text:
|
|
60
|
+
return None
|
|
61
|
+
return Path(text)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_state(path: Path) -> FirstRunState:
|
|
65
|
+
"""Read the local state, degrading to an empty state on any defect.
|
|
66
|
+
|
|
67
|
+
Each field degrades independently: a defective acknowledgement
|
|
68
|
+
date never discards a readable last-plan path, and vice versa.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
if raw[_SCHEMA_KEY] != _STATE_SCHEMA_VERSION:
|
|
73
|
+
return _EMPTY_STATE
|
|
74
|
+
except OSError, ValueError, KeyError, TypeError:
|
|
75
|
+
return _EMPTY_STATE
|
|
76
|
+
return FirstRunState(
|
|
77
|
+
disclaimer_acknowledged_on=_acknowledged_from(raw),
|
|
78
|
+
last_plan_path=_last_plan_from(raw),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _write_state(path: Path, state: FirstRunState) -> None:
|
|
83
|
+
"""Persist the whole state, omitting unset fields."""
|
|
84
|
+
payload: dict[str, Any] = {_SCHEMA_KEY: _STATE_SCHEMA_VERSION}
|
|
85
|
+
if state.disclaimer_acknowledged_on is not None:
|
|
86
|
+
payload[_ACKNOWLEDGED_KEY] = state.disclaimer_acknowledged_on.isoformat()
|
|
87
|
+
if state.last_plan_path is not None:
|
|
88
|
+
payload[_LAST_PLAN_KEY] = str(state.last_plan_path)
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def record_disclaimer_acknowledged(path: Path, acknowledged_on: date) -> None:
|
|
94
|
+
"""Persist the acknowledgement so later runs skip the first-run screen."""
|
|
95
|
+
state = load_state(path)
|
|
96
|
+
_write_state(
|
|
97
|
+
path,
|
|
98
|
+
FirstRunState(
|
|
99
|
+
disclaimer_acknowledged_on=acknowledged_on,
|
|
100
|
+
last_plan_path=state.last_plan_path,
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def record_last_plan_path(path: Path, plan_path: Path) -> None:
|
|
106
|
+
"""Persist the plan file just saved or opened, for the next launch."""
|
|
107
|
+
state = load_state(path)
|
|
108
|
+
_write_state(
|
|
109
|
+
path,
|
|
110
|
+
FirstRunState(
|
|
111
|
+
disclaimer_acknowledged_on=state.disclaimer_acknowledged_on,
|
|
112
|
+
last_plan_path=plan_path,
|
|
113
|
+
),
|
|
114
|
+
)
|