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/__init__.py +1 -0
- macsetup/_data/files/git/gitconfig.j2 +17 -0
- macsetup/_data/files/shell/zshrc-block.zsh +6 -0
- macsetup/_data/files/zsh/00-homebrew.zsh +10 -0
- macsetup/_data/files/zsh/10-completion.zsh +22 -0
- macsetup/_data/files/zsh/cli.zsh +18 -0
- macsetup/_data/files/zsh/node.zsh +8 -0
- macsetup/_data/files/zsh/python.zsh +12 -0
- macsetup/_data/manifests/modules/cli.yaml +16 -0
- macsetup/_data/manifests/modules/core.yaml +7 -0
- macsetup/_data/manifests/modules/editors.yaml +16 -0
- macsetup/_data/manifests/modules/fonts.yaml +5 -0
- macsetup/_data/manifests/modules/git-identity.yaml +15 -0
- macsetup/_data/manifests/modules/lang-node.yaml +8 -0
- macsetup/_data/manifests/modules/lang-python.yaml +8 -0
- macsetup/_data/manifests/modules/shell.yaml +18 -0
- macsetup/_data/manifests/profiles.yaml +26 -0
- macsetup/_data/manifests/vars.yaml +10 -0
- macsetup/brewfile.py +59 -0
- macsetup/cli.py +481 -0
- macsetup/detect.py +53 -0
- macsetup/files.py +150 -0
- macsetup/manifest.py +321 -0
- macsetup/planner.py +141 -0
- macsetup/runner.py +71 -0
- macsetup/state.py +102 -0
- macsetup/wizard.py +169 -0
- macsetup-0.1.0.dist-info/METADATA +255 -0
- macsetup-0.1.0.dist-info/RECORD +32 -0
- macsetup-0.1.0.dist-info/WHEEL +4 -0
- macsetup-0.1.0.dist-info/entry_points.txt +2 -0
- macsetup-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|