macsetup 0.1.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.
macsetup/manifest.py ADDED
@@ -0,0 +1,321 @@
1
+ """Manifest loading and validation.
2
+
3
+ Manifests are the source of truth: adding a package should be a data edit, not
4
+ a code change. Pydantic catches a typo'd key at load time rather than halfway
5
+ through an install.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections.abc import Iterable
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ import yaml
16
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
17
+
18
+ ALL = "*"
19
+
20
+
21
+ class ManifestError(RuntimeError):
22
+ pass
23
+
24
+
25
+ class Package(BaseModel):
26
+ """One installable. Accepts shorthand: `- git` or `- {name: arc, cask: true}`."""
27
+
28
+ model_config = ConfigDict(frozen=True, extra="forbid")
29
+
30
+ name: str
31
+ cask: bool = False
32
+ mas: int | None = None
33
+
34
+ @model_validator(mode="before")
35
+ @classmethod
36
+ def _shorthand(cls, value: Any) -> Any:
37
+ return {"name": value} if isinstance(value, str) else value
38
+
39
+ def brewfile_line(self) -> str:
40
+ if self.mas is not None:
41
+ return f'mas "{self.name}", id: {self.mas}'
42
+ verb = "cask" if self.cask else "brew"
43
+ return f'{verb} "{self.name}"'
44
+
45
+
46
+ class FileSpec(BaseModel):
47
+ """One file action. Exactly one of copy/render/block names the source.
48
+
49
+ copy -- verbatim, e.g. a zsh fragment
50
+ render -- Jinja2 with machine vars, e.g. the ssh config
51
+ block -- marker-delimited region inside a file macsetup does not own
52
+ """
53
+
54
+ model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
55
+
56
+ # `copy_` is aliased because a field literally named `copy` shadows
57
+ # BaseModel.copy; the manifest key stays `copy:`.
58
+ copy_: str | None = Field(default=None, alias="copy")
59
+ render: str | None = None
60
+ block: str | None = None
61
+ to: str | None = None
62
+ into: str | None = None
63
+ mode: str | None = None
64
+ comment: str = "#"
65
+ position: Literal["top", "bottom"] = "bottom"
66
+
67
+ _FIELDS = {"copy": "copy_", "render": "render", "block": "block"}
68
+
69
+ @model_validator(mode="after")
70
+ def _check(self) -> FileSpec:
71
+ kinds = [k for k, f in self._FIELDS.items() if getattr(self, f)]
72
+ if len(kinds) != 1:
73
+ raise ValueError(f"need exactly one of copy/render/block, got {kinds or 'none'}")
74
+ if self.kind == "block":
75
+ if not self.into:
76
+ raise ValueError("`block` needs `into:` (the file to edit)")
77
+ if self.to:
78
+ raise ValueError("`block` uses `into:`, not `to:`")
79
+ else:
80
+ if not self.to:
81
+ raise ValueError(f"`{self.kind}` needs `to:` (the destination path)")
82
+ if self.into:
83
+ raise ValueError(f"`{self.kind}` uses `to:`, not `into:`")
84
+ if self.mode:
85
+ try:
86
+ int(self.mode, 8)
87
+ except ValueError:
88
+ raise ValueError(f'mode {self.mode!r} is not octal, e.g. "600"') from None
89
+ return self
90
+
91
+ @property
92
+ def kind(self) -> str:
93
+ for kind, field_name in self._FIELDS.items():
94
+ if getattr(self, field_name):
95
+ return kind
96
+ raise AssertionError("validated above")
97
+
98
+ @property
99
+ def source(self) -> str:
100
+ return getattr(self, self._FIELDS[self.kind])
101
+
102
+ @property
103
+ def target(self) -> str:
104
+ return self.into if self.kind == "block" else self.to # type: ignore[return-value]
105
+
106
+
107
+ class Module(BaseModel):
108
+ """A unit of setup, and the thing the wizard offers as a choice.
109
+
110
+ `select: pick` means the wizard offers per-package checkboxes instead of
111
+ treating the module as atomic -- for genuine menus like editors, where
112
+ wanting half the module is normal.
113
+ """
114
+
115
+ model_config = ConfigDict(extra="forbid")
116
+
117
+ id: str
118
+ name: str
119
+ description: str = ""
120
+ always: bool = False
121
+ select: Literal["all", "pick"] = "all"
122
+ default: list[str] = Field(default_factory=list)
123
+ requires: list[str] = Field(default_factory=list)
124
+ packages: list[Package] = Field(default_factory=list)
125
+ files: list[FileSpec] = Field(default_factory=list)
126
+
127
+ @model_validator(mode="after")
128
+ def _check(self) -> Module:
129
+ names = {p.name for p in self.packages}
130
+ unknown = [d for d in self.default if d not in names]
131
+ if unknown:
132
+ raise ValueError(f"module {self.id!r}: default lists unknown packages {unknown}")
133
+ if self.select == "all" and self.default:
134
+ raise ValueError(f"module {self.id!r}: `default` only applies when select: pick")
135
+ return self
136
+
137
+ def selected_packages(self, picks: set[str] | None = None) -> list[Package]:
138
+ """Packages to install, honouring per-package picks for `select: pick`."""
139
+ if self.select == "all":
140
+ return list(self.packages)
141
+ chosen = picks if picks is not None else set(self.default)
142
+ return [p for p in self.packages if p.name in chosen]
143
+
144
+
145
+ class Catalog(BaseModel):
146
+ modules: dict[str, Module]
147
+ profiles: dict[str, list[str]]
148
+
149
+ def module(self, module_id: str) -> Module:
150
+ try:
151
+ return self.modules[module_id]
152
+ except KeyError:
153
+ raise ManifestError(f"unknown module {module_id!r}") from None
154
+
155
+ def resolve(
156
+ self,
157
+ profile: str | None = None,
158
+ only: list[str] | None = None,
159
+ *,
160
+ include_always: bool | None = None,
161
+ ) -> list[Module]:
162
+ """Profile (or explicit module list) -> ordered, dependency-closed modules.
163
+
164
+ `always` modules are folded in unconditionally; `--only` is the one case
165
+ where that is skipped, since it means "just this, I know what I want".
166
+ Applying a saved machine selection overrides that with
167
+ include_always=True, since machine.toml stores optional modules only.
168
+
169
+ An *empty* `only` is a selection, not the absence of one: a machine.toml
170
+ with `modules = []` resolves to the always-on modules and nothing else.
171
+ Falling back to the profile there would report "need a profile" on a
172
+ machine that is configured and names its profile in the file.
173
+ """
174
+ if only is not None:
175
+ wanted = list(only)
176
+ include_always = False if include_always is None else include_always
177
+ else:
178
+ if profile is None:
179
+ raise ManifestError("need a profile or an explicit module list")
180
+ if profile not in self.profiles:
181
+ known = ", ".join(sorted(self.profiles))
182
+ raise ManifestError(f"unknown profile {profile!r} (have: {known})")
183
+ entries = self.profiles[profile]
184
+ wanted = list(self.modules) if ALL in entries else list(entries)
185
+ include_always = True if include_always is None else include_always
186
+
187
+ for module_id in wanted:
188
+ self.module(module_id)
189
+
190
+ seen: set[str] = set()
191
+ ordered: list[str] = []
192
+
193
+ def visit(module_id: str, trail: tuple[str, ...] = ()) -> None:
194
+ if module_id in seen:
195
+ return
196
+ if module_id in trail:
197
+ cycle = " -> ".join([*trail, module_id])
198
+ raise ManifestError(f"circular requires: {cycle}")
199
+ for dep in self.module(module_id).requires:
200
+ visit(dep, (*trail, module_id))
201
+ seen.add(module_id)
202
+ ordered.append(module_id)
203
+
204
+ if include_always:
205
+ for module_id, module in self.modules.items():
206
+ if module.always:
207
+ visit(module_id)
208
+ for module_id in wanted:
209
+ visit(module_id)
210
+
211
+ return [self.modules[m] for m in ordered]
212
+
213
+ def unadopted(self, profile: str, selected: Iterable[str]) -> list[str]:
214
+ """Modules this machine could adopt but has not.
215
+
216
+ A hand-picked machine stores profile "custom", which is not a catalog
217
+ profile, and `state.py` falls back to the same string when the key is
218
+ missing. Treating an unknown profile as "the whole catalog" is what
219
+ keeps this check alive there -- gating it on a known profile name
220
+ disabled it on exactly the machines most likely to lag behind upstream.
221
+ """
222
+ candidates = (
223
+ {m.id for m in self.resolve(profile)} if profile in self.profiles else set(self.modules)
224
+ )
225
+ return sorted(candidates - set(selected))
226
+
227
+
228
+ def _resolve_dir(env_var: str, name: str, sentinel: str) -> Path:
229
+ """Locate a data dir: env override, then repo checkout, then packaged copy."""
230
+ override = os.environ.get(env_var)
231
+ if override:
232
+ path = Path(override).expanduser()
233
+ if not path.is_dir():
234
+ raise ManifestError(f"{env_var} points at {path}, which is not a directory")
235
+ return path
236
+
237
+ repo = Path.cwd() / name
238
+ if (repo / sentinel).exists():
239
+ return repo
240
+
241
+ packaged = Path(__file__).parent / "_data" / name
242
+ if (packaged / sentinel).exists():
243
+ return packaged
244
+
245
+ raise ManifestError(f"no {name}/ found; set {env_var} or run from the repo root")
246
+
247
+
248
+ def manifests_dir() -> Path:
249
+ return _resolve_dir("MACSETUP_MANIFESTS", "manifests", "profiles.yaml")
250
+
251
+
252
+ def files_dir() -> Path:
253
+ """Where `copy:`/`render:`/`block:` sources are resolved from."""
254
+ return _resolve_dir("MACSETUP_FILES", "files", "zsh")
255
+
256
+
257
+ def catalog_vars(root: Path | None = None) -> dict[str, str]:
258
+ """Default `render:` variables, declared by the catalog in vars.yaml.
259
+
260
+ Who you are is data, not code: the engine ships no name or email of its
261
+ own, so the same engine serves a public catalog and a private one. A
262
+ machine overrides individual values in the [vars] table of machine.toml.
263
+
264
+ Rendering uses StrictUndefined, so every variable a template references
265
+ must have a value here or in machine.toml -- a missing one is a hard error
266
+ rather than a silently empty `email = ` in a config file.
267
+ """
268
+ path = (root or manifests_dir()) / "vars.yaml"
269
+ if not path.is_file():
270
+ return {}
271
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
272
+ if not isinstance(loaded, dict) or not isinstance(loaded.get("vars", {}), dict):
273
+ raise ManifestError(f"{path} must hold a `vars:` mapping of names to values")
274
+ return {str(k): str(v) for k, v in (loaded.get("vars") or {}).items()}
275
+
276
+
277
+ def load_catalog(root: Path | None = None) -> Catalog:
278
+ root = root or manifests_dir()
279
+
280
+ profiles_file = root / "profiles.yaml"
281
+ if not profiles_file.is_file():
282
+ raise ManifestError(f"missing {profiles_file}")
283
+ profiles_doc = _read_yaml(profiles_file)
284
+ profiles = profiles_doc.get("profiles")
285
+ if not isinstance(profiles, dict):
286
+ raise ManifestError(f"{profiles_file}: expected a top-level `profiles:` mapping")
287
+
288
+ modules: dict[str, Module] = {}
289
+ for path in sorted((root / "modules").glob("*.yaml")):
290
+ try:
291
+ module = Module.model_validate(_read_yaml(path))
292
+ except Exception as exc:
293
+ raise ManifestError(f"{path.name}: {exc}") from exc
294
+ if module.id in modules:
295
+ raise ManifestError(f"{path.name}: duplicate module id {module.id!r}")
296
+ modules[module.id] = module
297
+
298
+ if not modules:
299
+ raise ManifestError(f"no module manifests found in {root / 'modules'}")
300
+
301
+ catalog = Catalog(modules=modules, profiles=profiles)
302
+
303
+ # Fail at load time rather than when someone selects the broken profile.
304
+ for name, entries in profiles.items():
305
+ for module_id in entries:
306
+ if module_id != ALL and module_id not in modules:
307
+ raise ManifestError(f"profile {name!r} references unknown module {module_id!r}")
308
+ for module in modules.values():
309
+ for dep in module.requires:
310
+ if dep not in modules:
311
+ raise ManifestError(f"module {module.id!r} requires unknown module {dep!r}")
312
+
313
+ return catalog
314
+
315
+
316
+ def _read_yaml(path: Path) -> dict[str, Any]:
317
+ with path.open(encoding="utf-8") as handle:
318
+ data = yaml.safe_load(handle)
319
+ if not isinstance(data, dict):
320
+ raise ManifestError(f"{path}: expected a YAML mapping")
321
+ return data
macsetup/planner.py ADDED
@@ -0,0 +1,141 @@
1
+ """Turn a module selection plus machine state into a reviewable plan.
2
+
3
+ Nothing here mutates anything. `plan` and `apply` share this code so the
4
+ preview is always exactly what apply will act on.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from . import files as files_mod
13
+ from .detect import System
14
+ from .manifest import FileSpec, Module, Package
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FileAction:
19
+ spec: FileSpec
20
+ outcome: files_mod.Outcome
21
+
22
+ @property
23
+ def pending(self) -> bool:
24
+ return self.outcome.pending
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ModulePlan:
29
+ module: Module
30
+ missing: list[Package] = field(default_factory=list)
31
+ satisfied: list[Package] = field(default_factory=list)
32
+ skipped: list[Package] = field(default_factory=list)
33
+ file_actions: list[FileAction] = field(default_factory=list)
34
+
35
+ @property
36
+ def pending_files(self) -> list[FileAction]:
37
+ return [a for a in self.file_actions if a.pending]
38
+
39
+ @property
40
+ def pending(self) -> bool:
41
+ return bool(self.missing) or bool(self.pending_files)
42
+
43
+ @property
44
+ def selected(self) -> list[Package]:
45
+ return [*self.missing, *self.satisfied]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Plan:
50
+ modules: list[ModulePlan] = field(default_factory=list)
51
+
52
+ @property
53
+ def pending(self) -> list[ModulePlan]:
54
+ return [m for m in self.modules if m.pending]
55
+
56
+ @property
57
+ def missing_count(self) -> int:
58
+ return sum(len(m.missing) for m in self.modules)
59
+
60
+ @property
61
+ def satisfied_count(self) -> int:
62
+ return sum(len(m.satisfied) for m in self.modules)
63
+
64
+ @property
65
+ def file_change_count(self) -> int:
66
+ return sum(len(m.pending_files) for m in self.modules)
67
+
68
+ @property
69
+ def converged(self) -> bool:
70
+ return self.missing_count == 0 and self.file_change_count == 0
71
+
72
+ def selected_packages(self) -> list[Package]:
73
+ return [p for m in self.modules for p in m.selected]
74
+
75
+
76
+ def _file_action(spec: FileSpec, root: Path, variables: dict[str, str]) -> FileAction:
77
+ source = root / spec.source
78
+ dest = files_mod.expand(spec.target)
79
+
80
+ if spec.kind == "copy":
81
+ outcome = files_mod.plan_copy(source, dest)
82
+ elif spec.kind == "render":
83
+ outcome = files_mod.plan_render(source, dest, variables)
84
+ else:
85
+ outcome = files_mod.plan_block(
86
+ dest,
87
+ source.read_text(encoding="utf-8"),
88
+ comment=spec.comment,
89
+ position=spec.position,
90
+ )
91
+ return FileAction(spec=spec, outcome=outcome)
92
+
93
+
94
+ def build_plan(
95
+ modules: list[Module],
96
+ system: System,
97
+ picks: dict[str, set[str]] | None = None,
98
+ *,
99
+ files_root: Path | None = None,
100
+ variables: dict[str, str] | None = None,
101
+ ) -> Plan:
102
+ picks = picks or {}
103
+ variables = variables or {}
104
+ plans: list[ModulePlan] = []
105
+
106
+ for module in modules:
107
+ chosen = module.selected_packages(picks.get(module.id))
108
+ chosen_names = {p.name for p in chosen}
109
+ missing = [p for p in chosen if not system.has(p.name, cask=p.cask)]
110
+ satisfied = [p for p in chosen if system.has(p.name, cask=p.cask)]
111
+ skipped = [p for p in module.packages if p.name not in chosen_names]
112
+
113
+ actions: list[FileAction] = []
114
+ if module.files and files_root is not None:
115
+ actions = [_file_action(spec, files_root, variables) for spec in module.files]
116
+
117
+ plans.append(
118
+ ModulePlan(
119
+ module=module,
120
+ missing=missing,
121
+ satisfied=satisfied,
122
+ skipped=skipped,
123
+ file_actions=actions,
124
+ )
125
+ )
126
+
127
+ return Plan(modules=plans)
128
+
129
+
130
+ def untracked(plan: Plan, system: System) -> tuple[list[str], list[str]]:
131
+ """Installed packages that no selected module accounts for.
132
+
133
+ Informational in Phase 1 -- this is what `macsetup prune` will eventually
134
+ offer to remove.
135
+ """
136
+ known_formulae = {p.name for p in plan.selected_packages() if not p.cask and p.mas is None}
137
+ known_casks = {p.name for p in plan.selected_packages() if p.cask}
138
+ return (
139
+ sorted(system.formulae - known_formulae),
140
+ sorted(system.casks - known_casks),
141
+ )
macsetup/runner.py ADDED
@@ -0,0 +1,71 @@
1
+ """The subprocess seam.
2
+
3
+ Every external command goes through a Runner. Production uses SubprocessRunner;
4
+ tests inject FakeRunner so planner logic can be exercised without touching the
5
+ machine running the tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass, field
13
+ from typing import Protocol
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Result:
18
+ code: int
19
+ stdout: str = ""
20
+ stderr: str = ""
21
+
22
+ @property
23
+ def ok(self) -> bool:
24
+ return self.code == 0
25
+
26
+ def lines(self) -> list[str]:
27
+ return [line for line in self.stdout.splitlines() if line.strip()]
28
+
29
+
30
+ class CommandError(RuntimeError):
31
+ def __init__(self, cmd: Sequence[str], result: Result) -> None:
32
+ self.cmd = list(cmd)
33
+ self.result = result
34
+ super().__init__(f"`{' '.join(cmd)}` exited {result.code}: {result.stderr.strip()}")
35
+
36
+
37
+ class Runner(Protocol):
38
+ def run(self, cmd: Sequence[str], *, capture: bool = True, check: bool = False) -> Result: ...
39
+
40
+
41
+ class SubprocessRunner:
42
+ """Runs commands for real.
43
+
44
+ capture=False streams straight to the terminal, which is what we want for
45
+ `brew bundle install` -- Homebrew's own progress output is better than
46
+ anything we would reconstruct from captured text.
47
+ """
48
+
49
+ def run(self, cmd: Sequence[str], *, capture: bool = True, check: bool = False) -> Result:
50
+ proc = subprocess.run(list(cmd), capture_output=capture, text=True)
51
+ result = Result(proc.returncode, proc.stdout or "", proc.stderr or "")
52
+ if check and not result.ok:
53
+ raise CommandError(cmd, result)
54
+ return result
55
+
56
+
57
+ @dataclass
58
+ class FakeRunner:
59
+ """Test double. Maps a joined command string to a canned Result."""
60
+
61
+ responses: dict[str, Result] = field(default_factory=dict)
62
+ calls: list[list[str]] = field(default_factory=list)
63
+ default: Result = Result(0)
64
+
65
+ def run(self, cmd: Sequence[str], *, capture: bool = True, check: bool = False) -> Result:
66
+ cmd = list(cmd)
67
+ self.calls.append(cmd)
68
+ result = self.responses.get(" ".join(cmd), self.default)
69
+ if check and not result.ok:
70
+ raise CommandError(cmd, result)
71
+ return result
macsetup/state.py ADDED
@@ -0,0 +1,102 @@
1
+ """Per-machine state.
2
+
3
+ Lives outside the repo, in ~/.config/macsetup/machine.toml. Identical manifests
4
+ in git plus a small local override file is what makes 5 machines tractable.
5
+
6
+ The module list is stored explicitly rather than derived from the profile name.
7
+ A module added to a profile upstream must not silently install itself on five
8
+ machines the next time they sync -- `macsetup configure` adopts it deliberately.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tomllib
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import tomli_w
20
+
21
+ from .manifest import catalog_vars
22
+
23
+
24
+ def config_dir() -> Path:
25
+ override = os.environ.get("MACSETUP_CONFIG")
26
+ if override:
27
+ return Path(override).expanduser()
28
+ xdg = os.environ.get("XDG_CONFIG_HOME")
29
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
30
+ return base / "macsetup"
31
+
32
+
33
+ def state_path() -> Path:
34
+ return config_dir() / "machine.toml"
35
+
36
+
37
+ def builtin_vars(machine: str) -> dict[str, str]:
38
+ """Variables every render gets, with or without a machine.toml.
39
+
40
+ Defaults come from the catalog's vars.yaml, not from this package: the
41
+ engine has no opinion about who you are. On top of those, two are always
42
+ supplied here --
43
+
44
+ `machine` the machine name, so one template can branch per machine
45
+ `home` an absolute path, for config values whose reader does not
46
+ tilde-expand. git expands ~ in core.hooksPath; a GUI client
47
+ carrying its own git implementation may not.
48
+ """
49
+ return {**catalog_vars(), "machine": machine, "home": str(Path.home())}
50
+
51
+
52
+ @dataclass
53
+ class MachineState:
54
+ machine: str
55
+ profile: str
56
+ modules: list[str] = field(default_factory=list)
57
+ picks: dict[str, list[str]] = field(default_factory=dict)
58
+ vars: dict[str, str] = field(default_factory=dict)
59
+
60
+ def template_vars(self) -> dict[str, str]:
61
+ return {**builtin_vars(self.machine), **self.vars}
62
+
63
+ def picks_as_sets(self) -> dict[str, set[str]]:
64
+ return {module_id: set(names) for module_id, names in self.picks.items()}
65
+
66
+ def to_toml(self) -> dict[str, Any]:
67
+ doc: dict[str, Any] = {
68
+ "machine": self.machine,
69
+ "profile": self.profile,
70
+ "modules": list(self.modules),
71
+ }
72
+ if self.picks:
73
+ doc["picks"] = {k: sorted(v) for k, v in self.picks.items() if v}
74
+ if self.vars:
75
+ doc["vars"] = dict(self.vars)
76
+ return doc
77
+
78
+
79
+ def load(path: Path | None = None) -> MachineState | None:
80
+ path = path or state_path()
81
+ if not path.is_file():
82
+ return None
83
+ with path.open("rb") as handle:
84
+ doc = tomllib.load(handle)
85
+ return MachineState(
86
+ machine=doc.get("machine", "unknown"),
87
+ profile=doc.get("profile", "custom"),
88
+ modules=list(doc.get("modules", [])),
89
+ picks={k: list(v) for k, v in (doc.get("picks") or {}).items()},
90
+ vars=dict(doc.get("vars") or {}),
91
+ )
92
+
93
+
94
+ def save(state: MachineState, path: Path | None = None) -> Path:
95
+ path = path or state_path()
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ header = (
98
+ "# macsetup machine state -- written by `macsetup init` / `configure`.\n"
99
+ "# Safe to edit by hand; `modules` is the authoritative selection.\n\n"
100
+ )
101
+ path.write_text(header + tomli_w.dumps(state.to_toml()), encoding="utf-8")
102
+ return path