agent-worktree-manager 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.
- agent_worktree_manager/__init__.py +3 -0
- agent_worktree_manager/__main__.py +5 -0
- agent_worktree_manager/agent_skill.py +49 -0
- agent_worktree_manager/cli.py +386 -0
- agent_worktree_manager/config.py +196 -0
- agent_worktree_manager/environments.py +385 -0
- agent_worktree_manager/errors.py +5 -0
- agent_worktree_manager/git.py +96 -0
- agent_worktree_manager/inspection.py +324 -0
- agent_worktree_manager/lifecycle.py +607 -0
- agent_worktree_manager/migration.py +125 -0
- agent_worktree_manager/process.py +63 -0
- agent_worktree_manager/registry.py +100 -0
- agent_worktree_manager/shell.py +123 -0
- agent_worktree_manager/skills/awm/SKILL.md +111 -0
- agent_worktree_manager/skills/awm/agents/openai.yaml +4 -0
- agent_worktree_manager/storage.py +85 -0
- agent_worktree_manager/tui.py +769 -0
- agent_worktree_manager-0.2.0.dist-info/METADATA +125 -0
- agent_worktree_manager-0.2.0.dist-info/RECORD +24 -0
- agent_worktree_manager-0.2.0.dist-info/WHEEL +5 -0
- agent_worktree_manager-0.2.0.dist-info/entry_points.txt +2 -0
- agent_worktree_manager-0.2.0.dist-info/licenses/LICENSE +29 -0
- agent_worktree_manager-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Export the bundled, agent-independent worktree skill."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .errors import AWMError
|
|
12
|
+
|
|
13
|
+
SKILL_FILES = ("SKILL.md", "agents/openai.yaml")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def bundled_files() -> dict[str, bytes]:
|
|
17
|
+
root = files("agent_worktree_manager").joinpath("skills", "awm")
|
|
18
|
+
return {name: root.joinpath(*name.split("/")).read_bytes() for name in SKILL_FILES}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def install_skill(destination: Path) -> Path:
|
|
22
|
+
destination = Path(os.path.abspath(destination.expanduser()))
|
|
23
|
+
content = bundled_files()
|
|
24
|
+
if destination.is_symlink():
|
|
25
|
+
raise AWMError(f"Skill destination must not be a symlink: {destination}")
|
|
26
|
+
if destination.exists():
|
|
27
|
+
if destination.is_dir() and all(
|
|
28
|
+
(destination / name).is_file()
|
|
29
|
+
and not (destination / name).is_symlink()
|
|
30
|
+
and (destination / name).read_bytes() == value
|
|
31
|
+
for name, value in content.items()
|
|
32
|
+
):
|
|
33
|
+
return destination
|
|
34
|
+
raise AWMError(
|
|
35
|
+
f"Skill destination already exists with different contents: {destination}; "
|
|
36
|
+
"review or move the existing skill before installing"
|
|
37
|
+
)
|
|
38
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
temporary = Path(tempfile.mkdtemp(prefix=".awm-skill-", dir=destination.parent))
|
|
40
|
+
try:
|
|
41
|
+
for name, value in content.items():
|
|
42
|
+
path = temporary / name
|
|
43
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
path.write_bytes(value)
|
|
45
|
+
temporary.rename(destination)
|
|
46
|
+
finally:
|
|
47
|
+
if temporary.exists():
|
|
48
|
+
shutil.rmtree(temporary)
|
|
49
|
+
return destination
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""The installed awm entry point. Mutations belong to shared services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__, environments, lifecycle, migration, registry
|
|
13
|
+
from .config import BACKENDS, initialize, load_project
|
|
14
|
+
from .errors import AWMError
|
|
15
|
+
from .git import check_repo, is_dirty
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def log(message: str) -> None:
|
|
19
|
+
print(f"[awm] {message}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def err(message: str) -> None:
|
|
23
|
+
print(f"[awm] ERROR: {message}", file=sys.stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def selected(opts):
|
|
27
|
+
return registry.select(root=getattr(opts, "root", None), project=getattr(opts, "project", None))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def confirmation(message: str, yes: bool) -> bool:
|
|
31
|
+
if yes:
|
|
32
|
+
return True
|
|
33
|
+
if not sys.stdin.isatty():
|
|
34
|
+
raise AWMError("Non-interactive deletion/import requires --yes; use --dry-run to preview")
|
|
35
|
+
try:
|
|
36
|
+
return input(message + " [y/N] ").strip().lower() in ("y", "yes")
|
|
37
|
+
except (EOFError, KeyboardInterrupt):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_plan(project, record: dict) -> None:
|
|
42
|
+
log(f"{project.name}/{record['name']} ({record['status']})")
|
|
43
|
+
for worktree in record["worktrees"]:
|
|
44
|
+
print(
|
|
45
|
+
f" worktree {worktree['alias']}[{worktree['branch'] or 'detached'}] {worktree['path']}"
|
|
46
|
+
)
|
|
47
|
+
for env in record["environments"]:
|
|
48
|
+
print(f" env {env['kind']} {env['path']}")
|
|
49
|
+
for editable in record["editables"]:
|
|
50
|
+
print(
|
|
51
|
+
f" editable {editable['name']} -> {editable['path']} ({'shared source' if editable['shared'] else 'isolated source'})"
|
|
52
|
+
)
|
|
53
|
+
if record.get("error"):
|
|
54
|
+
print(f" error: {record['error']}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def project_overview() -> list[dict]:
|
|
58
|
+
output = []
|
|
59
|
+
for entry in registry.entries():
|
|
60
|
+
try:
|
|
61
|
+
project = load_project(Path(entry["root"]))
|
|
62
|
+
if project.id != entry["id"]:
|
|
63
|
+
raise AWMError("Project identity changed; re-register this location")
|
|
64
|
+
output.append(lifecycle.project_summary(project))
|
|
65
|
+
except (AWMError, OSError) as exc:
|
|
66
|
+
output.append({**entry, "unavailable": str(exc)})
|
|
67
|
+
return output
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_projects(opts) -> int:
|
|
71
|
+
if opts.action == "add":
|
|
72
|
+
project = load_project(Path(opts.path))
|
|
73
|
+
if not opts.dry_run:
|
|
74
|
+
registry.register(project)
|
|
75
|
+
log(f"{'Would register' if opts.dry_run else 'Registered'} {project.name}: {project.id}")
|
|
76
|
+
elif opts.action == "remove":
|
|
77
|
+
entry = registry.find(opts.selector)
|
|
78
|
+
if not opts.dry_run:
|
|
79
|
+
registry.unregister(opts.selector)
|
|
80
|
+
log(
|
|
81
|
+
f"{'Would unregister' if opts.dry_run else 'Unregistered'} {entry['name']}; project resources are preserved"
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
items = project_overview()
|
|
85
|
+
if opts.json:
|
|
86
|
+
print(json.dumps({"schema_version": 1, "projects": items}, indent=2))
|
|
87
|
+
for item in [] if opts.json else items:
|
|
88
|
+
print(
|
|
89
|
+
f"{item['id']} {item['name']} {item.get('states', item.get('unavailable'))} {item['root']}"
|
|
90
|
+
)
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_init(opts) -> int:
|
|
95
|
+
if getattr(opts, "project", None):
|
|
96
|
+
raise AWMError("init takes --root, not --project")
|
|
97
|
+
project = initialize(
|
|
98
|
+
Path(getattr(opts, "root", None) or "."),
|
|
99
|
+
Path(opts.base),
|
|
100
|
+
opts.backend,
|
|
101
|
+
opts.name,
|
|
102
|
+
opts.repo,
|
|
103
|
+
dry_run=opts.dry_run,
|
|
104
|
+
)
|
|
105
|
+
if project:
|
|
106
|
+
registry.register(project)
|
|
107
|
+
log(f"Initialized {project.name}: {project.id}")
|
|
108
|
+
else:
|
|
109
|
+
log("Initialization preflight passed; no files written")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_create(opts) -> int:
|
|
114
|
+
project = selected(opts)
|
|
115
|
+
record = lifecycle.create(project, opts.name, opts.repo, opts.ref, dry_run=True)
|
|
116
|
+
print_plan(project, record)
|
|
117
|
+
if opts.dry_run:
|
|
118
|
+
log("Dry run; no resources created")
|
|
119
|
+
else:
|
|
120
|
+
log("Creating worktrees and reproducing the installed base…")
|
|
121
|
+
print_plan(project, lifecycle.create(project, opts.name, opts.repo, opts.ref))
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_import(opts) -> int:
|
|
126
|
+
project = selected(opts)
|
|
127
|
+
kwargs = dict(
|
|
128
|
+
legacy=opts.legacy,
|
|
129
|
+
venv_home=opts.venv_home,
|
|
130
|
+
base_env=opts.base_env,
|
|
131
|
+
conda_base_env=opts.conda_base_env,
|
|
132
|
+
)
|
|
133
|
+
record = migration.import_sandbox(
|
|
134
|
+
project, opts.name, opts.worktree, opts.env, dry_run=True, **kwargs
|
|
135
|
+
)
|
|
136
|
+
print_plan(project, record)
|
|
137
|
+
if opts.dry_run:
|
|
138
|
+
log("Dry run; ownership records unchanged")
|
|
139
|
+
elif confirmation("Adopt these resources for management by awm?", opts.yes):
|
|
140
|
+
migration.import_sandbox(project, opts.name, opts.worktree, opts.env, **kwargs)
|
|
141
|
+
log("Import complete")
|
|
142
|
+
else:
|
|
143
|
+
return 1
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_delete(opts) -> int:
|
|
148
|
+
project = selected(opts)
|
|
149
|
+
kwargs = dict(
|
|
150
|
+
force=opts.force,
|
|
151
|
+
keep_env=opts.keep_env,
|
|
152
|
+
keep_worktrees=opts.keep_worktrees,
|
|
153
|
+
delete_branch=opts.delete_branch,
|
|
154
|
+
)
|
|
155
|
+
records = lifecycle.delete(project, opts.names, dry_run=True, **kwargs)
|
|
156
|
+
for record in records:
|
|
157
|
+
print_plan(project, record)
|
|
158
|
+
log(
|
|
159
|
+
f"Delete worktrees: {not opts.keep_worktrees}; delete environments: {not opts.keep_env}; delete branches: {opts.delete_branch}; force: {opts.force}"
|
|
160
|
+
)
|
|
161
|
+
if opts.dry_run:
|
|
162
|
+
log("Dry run; nothing deleted")
|
|
163
|
+
elif confirmation(f"Delete selected resources in {project.name}?", opts.yes):
|
|
164
|
+
lifecycle.delete(project, opts.names, **kwargs)
|
|
165
|
+
log("Deletion complete")
|
|
166
|
+
else:
|
|
167
|
+
return 1
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def list_payload(project) -> dict:
|
|
172
|
+
records = []
|
|
173
|
+
for record in lifecycle.read_state(project)["sandboxes"].values():
|
|
174
|
+
summary = {
|
|
175
|
+
k: record[k]
|
|
176
|
+
for k in ("name", "status", "error", "worktrees", "environments", "editables")
|
|
177
|
+
}
|
|
178
|
+
summary["worktrees"] = []
|
|
179
|
+
for resource in record["worktrees"]:
|
|
180
|
+
path = Path(resource["path"])
|
|
181
|
+
try:
|
|
182
|
+
dirty = is_dirty(path) if path.exists() else None
|
|
183
|
+
except AWMError:
|
|
184
|
+
dirty = None
|
|
185
|
+
summary["worktrees"].append({**resource, "missing": not path.exists(), "dirty": dirty})
|
|
186
|
+
summary["environments"] = [
|
|
187
|
+
{**e, "missing": not Path(e["path"]).exists()} for e in record["environments"]
|
|
188
|
+
]
|
|
189
|
+
records.append(summary)
|
|
190
|
+
return {"id": project.id, "name": project.name, "root": str(project.root), "sandboxes": records}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cmd_list(opts) -> int:
|
|
194
|
+
projects = []
|
|
195
|
+
if opts.all:
|
|
196
|
+
if getattr(opts, "root", None) or getattr(opts, "project", None):
|
|
197
|
+
raise AWMError("--all cannot be combined with a project selector")
|
|
198
|
+
for entry in registry.entries():
|
|
199
|
+
try:
|
|
200
|
+
projects.append(list_payload(registry.select(project=entry["id"])))
|
|
201
|
+
except (AWMError, OSError) as exc:
|
|
202
|
+
projects.append({**entry, "unavailable": str(exc)})
|
|
203
|
+
else:
|
|
204
|
+
projects = [list_payload(selected(opts))]
|
|
205
|
+
if opts.json:
|
|
206
|
+
print(json.dumps({"schema_version": 1, "projects": projects}, indent=2))
|
|
207
|
+
else:
|
|
208
|
+
for project in projects:
|
|
209
|
+
print(f"{project['name']} ({project['id']}) {project['root']}")
|
|
210
|
+
if "unavailable" in project:
|
|
211
|
+
print(f" unavailable: {project['unavailable']}")
|
|
212
|
+
for record in project.get("sandboxes", []):
|
|
213
|
+
kinds = ", ".join(e["kind"] for e in record["environments"]) or "-"
|
|
214
|
+
worktrees = (
|
|
215
|
+
", ".join(
|
|
216
|
+
f"{w['alias']}[{w['branch'] or 'detached'}]{'*' if w['dirty'] else ''}{' (missing)' if w['missing'] else ''}"
|
|
217
|
+
for w in record["worktrees"]
|
|
218
|
+
)
|
|
219
|
+
or "-"
|
|
220
|
+
)
|
|
221
|
+
print(f" {record['name']} {record['status']} {kinds} {worktrees}")
|
|
222
|
+
if record["error"]:
|
|
223
|
+
print(f" {record['error']}")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_doctor(opts) -> int:
|
|
228
|
+
project = selected(opts)
|
|
229
|
+
for repo in project.repos.values():
|
|
230
|
+
check_repo(repo.path)
|
|
231
|
+
base = environments.validate_base(project.base, project.backend)
|
|
232
|
+
lifecycle.read_state(project)
|
|
233
|
+
for package in base["packages"]:
|
|
234
|
+
path = environments.editable_path(package)
|
|
235
|
+
if path and not path.exists():
|
|
236
|
+
raise AWMError(f"Missing editable source: {path}")
|
|
237
|
+
log(
|
|
238
|
+
f"{project.name}: Git checkouts and {project.backend} base are valid; Python {base['python']}, {len(base['packages'])} packages"
|
|
239
|
+
)
|
|
240
|
+
for name, recorded, declared, source in environments.stale_editables(base):
|
|
241
|
+
log(
|
|
242
|
+
f"Stale editable metadata: {name} records {recorded}, {source} declares {declared}; "
|
|
243
|
+
f"reinstall it into the base to reproduce that version in new sandboxes"
|
|
244
|
+
)
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_run(opts) -> int:
|
|
249
|
+
command = opts.args[1:] if opts.args[:1] == ["--"] else opts.args
|
|
250
|
+
return lifecycle.run_command(selected(opts), opts.name, command, opts.repo, opts.env)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cmd_skill(opts) -> int:
|
|
254
|
+
from .agent_skill import bundled_files, install_skill
|
|
255
|
+
|
|
256
|
+
if opts.install:
|
|
257
|
+
log(f"Skill installed at {install_skill(Path(opts.install))}")
|
|
258
|
+
else:
|
|
259
|
+
print(bundled_files()["SKILL.md"].decode("utf-8"), end="")
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_shell_init(opts) -> int:
|
|
264
|
+
from .shell import shell_init
|
|
265
|
+
|
|
266
|
+
print(shell_init(opts.shell), end="")
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_ui(opts) -> int:
|
|
271
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
272
|
+
raise AWMError("Interactive mode requires a TTY; use 'awm projects list' or 'awm list'")
|
|
273
|
+
from .tui import run_ui
|
|
274
|
+
|
|
275
|
+
return run_ui(opts)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
279
|
+
selectors = argparse.ArgumentParser(add_help=False)
|
|
280
|
+
group = selectors.add_mutually_exclusive_group()
|
|
281
|
+
group.add_argument("--root", default=argparse.SUPPRESS, help="project root containing awm.toml")
|
|
282
|
+
group.add_argument("--project", default=argparse.SUPPRESS, help="registered project name or ID")
|
|
283
|
+
parser = argparse.ArgumentParser(
|
|
284
|
+
prog="awm",
|
|
285
|
+
description="Manage project worktrees and isolated Python environments",
|
|
286
|
+
parents=[selectors],
|
|
287
|
+
)
|
|
288
|
+
parser.add_argument("--version", action="version", version=f"awm {__version__}")
|
|
289
|
+
parser.set_defaults(func=cmd_ui, envs=False)
|
|
290
|
+
commands = parser.add_subparsers(dest="command")
|
|
291
|
+
shell = commands.add_parser("shell-init", help="print current-shell integration for eval")
|
|
292
|
+
shell.add_argument("shell", choices=("bash", "zsh"))
|
|
293
|
+
shell.set_defaults(func=cmd_shell_init)
|
|
294
|
+
skill = commands.add_parser("skill", help="print or install the bundled coding-agent skill")
|
|
295
|
+
skill.add_argument("--install", metavar="PATH", help="copy the skill into this skill directory")
|
|
296
|
+
skill.set_defaults(func=cmd_skill)
|
|
297
|
+
init = commands.add_parser("init", parents=[selectors], help="configure and register a project")
|
|
298
|
+
init.add_argument("--base", required=True, help="existing base environment directory")
|
|
299
|
+
init.add_argument("--backend", choices=BACKENDS, default="venv")
|
|
300
|
+
init.add_argument("--name")
|
|
301
|
+
init.add_argument("--repo", action="append", default=[], metavar="ALIAS=PATH")
|
|
302
|
+
init.add_argument("--dry-run", action="store_true")
|
|
303
|
+
init.set_defaults(func=cmd_init)
|
|
304
|
+
create = commands.add_parser(
|
|
305
|
+
"create", parents=[selectors], help="create a sandbox from the installed base"
|
|
306
|
+
)
|
|
307
|
+
create.add_argument("name")
|
|
308
|
+
create.add_argument("--repo", action="append", default=[])
|
|
309
|
+
create.add_argument("--ref", default="HEAD")
|
|
310
|
+
create.add_argument("-n", "--dry-run", action="store_true")
|
|
311
|
+
create.set_defaults(func=cmd_create)
|
|
312
|
+
imp = commands.add_parser(
|
|
313
|
+
"import", parents=[selectors], help="explicitly adopt existing worktrees/environments"
|
|
314
|
+
)
|
|
315
|
+
imp.add_argument("name")
|
|
316
|
+
imp.add_argument("--worktree", action="append", default=[], metavar="ALIAS=PATH")
|
|
317
|
+
imp.add_argument("--env", action="append", default=[], metavar="BACKEND=PATH")
|
|
318
|
+
imp.add_argument("--legacy", action="store_true")
|
|
319
|
+
for flag in ("venv-home", "base-env", "conda-base-env"):
|
|
320
|
+
imp.add_argument(f"--{flag}")
|
|
321
|
+
imp.add_argument("-n", "--dry-run", action="store_true")
|
|
322
|
+
imp.add_argument("-y", "--yes", action="store_true")
|
|
323
|
+
imp.set_defaults(func=cmd_import)
|
|
324
|
+
delete = commands.add_parser(
|
|
325
|
+
"delete", parents=[selectors], help="delete owned sandbox resources"
|
|
326
|
+
)
|
|
327
|
+
delete.add_argument("names", nargs="+")
|
|
328
|
+
for short, long in (("-n", "--dry-run"), ("-y", "--yes"), ("-f", "--force")):
|
|
329
|
+
delete.add_argument(short, long, action="store_true")
|
|
330
|
+
for flag in ("keep-env", "keep-worktrees", "delete-branch"):
|
|
331
|
+
delete.add_argument(f"--{flag}", action="store_true")
|
|
332
|
+
delete.set_defaults(func=cmd_delete)
|
|
333
|
+
ls = commands.add_parser("list", parents=[selectors], help="list owned sandboxes")
|
|
334
|
+
ls.add_argument("--json", action="store_true")
|
|
335
|
+
ls.add_argument("--all", action="store_true")
|
|
336
|
+
ls.set_defaults(func=cmd_list)
|
|
337
|
+
doctor = commands.add_parser(
|
|
338
|
+
"doctor", parents=[selectors], help="check project prerequisites without changing them"
|
|
339
|
+
)
|
|
340
|
+
doctor.set_defaults(func=cmd_doctor)
|
|
341
|
+
run = commands.add_parser(
|
|
342
|
+
"run", parents=[selectors], help="run a command in a sandbox; options precede NAME"
|
|
343
|
+
)
|
|
344
|
+
run.add_argument("--repo")
|
|
345
|
+
run.add_argument("--env")
|
|
346
|
+
run.add_argument("name")
|
|
347
|
+
run.add_argument("args", nargs=argparse.REMAINDER)
|
|
348
|
+
run.set_defaults(func=cmd_run)
|
|
349
|
+
for command in ("ui", "envs"):
|
|
350
|
+
ui = commands.add_parser(command, parents=[selectors])
|
|
351
|
+
ui.add_argument("--envs", action="store_true", default=command == "envs")
|
|
352
|
+
ui.set_defaults(func=cmd_ui)
|
|
353
|
+
projects = commands.add_parser("projects", help="register or browse projects")
|
|
354
|
+
actions = projects.add_subparsers(dest="action", required=True)
|
|
355
|
+
for action in ("add", "remove", "list"):
|
|
356
|
+
p = actions.add_parser(action)
|
|
357
|
+
if action != "list":
|
|
358
|
+
p.add_argument("path" if action == "add" else "selector")
|
|
359
|
+
p.add_argument("--dry-run", action="store_true")
|
|
360
|
+
else:
|
|
361
|
+
p.add_argument("--json", action="store_true")
|
|
362
|
+
p.set_defaults(func=cmd_projects)
|
|
363
|
+
return parser
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def main(argv: list[str] | None = None) -> int:
|
|
367
|
+
previous = {}
|
|
368
|
+
|
|
369
|
+
def interrupted(signum, frame):
|
|
370
|
+
raise KeyboardInterrupt
|
|
371
|
+
|
|
372
|
+
if threading.current_thread() is threading.main_thread():
|
|
373
|
+
for signum in (signal.SIGTERM, signal.SIGHUP):
|
|
374
|
+
previous[signum] = signal.signal(signum, interrupted)
|
|
375
|
+
try:
|
|
376
|
+
opts = build_parser().parse_args(argv)
|
|
377
|
+
return opts.func(opts)
|
|
378
|
+
except (AWMError, OSError, ValueError) as exc:
|
|
379
|
+
err(str(exc))
|
|
380
|
+
return 1
|
|
381
|
+
except KeyboardInterrupt:
|
|
382
|
+
err("Interrupted")
|
|
383
|
+
return 130
|
|
384
|
+
finally:
|
|
385
|
+
for signum, handler in previous.items():
|
|
386
|
+
signal.signal(signum, handler)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Portable workspace configuration and per-machine settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tomllib
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from packaging.requirements import Requirement
|
|
14
|
+
from packaging.utils import canonicalize_name
|
|
15
|
+
|
|
16
|
+
from .errors import AWMError
|
|
17
|
+
from .git import check_repo
|
|
18
|
+
from .storage import atomic_text, lock
|
|
19
|
+
|
|
20
|
+
CONFIG_NAME = "awm.toml"
|
|
21
|
+
BACKENDS = ("venv", "uv", "conda")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def slug(value: str) -> str:
|
|
25
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}", value):
|
|
26
|
+
raise AWMError(
|
|
27
|
+
"Names must be 1–80 letters, digits, dots, underscores or dashes, starting with a letter or digit"
|
|
28
|
+
)
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_toml(path: Path) -> dict:
|
|
33
|
+
try:
|
|
34
|
+
value = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
except (OSError, ValueError) as exc:
|
|
36
|
+
raise AWMError(f"Cannot read {path}: {exc}") from exc
|
|
37
|
+
if value.get("schema_version") != 1:
|
|
38
|
+
raise AWMError(f"Unsupported configuration version: {path}")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def editable_target(target: str) -> tuple[str, tuple[str, ...]]:
|
|
43
|
+
subdir, separator, suffix = target.partition("[")
|
|
44
|
+
if not subdir or Path(subdir).is_absolute() or ".." in Path(subdir).parts:
|
|
45
|
+
raise ValueError(f"editable target must stay inside its repository: {target}")
|
|
46
|
+
if not separator:
|
|
47
|
+
return subdir, ()
|
|
48
|
+
requirement = Requirement("editable-target[" + suffix)
|
|
49
|
+
if not target.endswith("]") or requirement.marker or requirement.specifier or requirement.url:
|
|
50
|
+
raise ValueError(f"invalid editable target extras: {target}")
|
|
51
|
+
return subdir, tuple(sorted(canonicalize_name(extra) for extra in requirement.extras))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Repository:
|
|
56
|
+
alias: str
|
|
57
|
+
path: Path
|
|
58
|
+
targets: tuple[str, ...] = ()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Project:
|
|
63
|
+
root: Path
|
|
64
|
+
id: str
|
|
65
|
+
name: str
|
|
66
|
+
backend: str
|
|
67
|
+
base: Path
|
|
68
|
+
repos: dict[str, Repository]
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def local(self) -> Path:
|
|
72
|
+
path = self.root / ".awm"
|
|
73
|
+
if path.is_symlink():
|
|
74
|
+
raise AWMError(f"Project state directory must not be a symlink: {path}")
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_project(root: Path) -> Project:
|
|
79
|
+
root = root.expanduser().resolve()
|
|
80
|
+
if root.name == CONFIG_NAME and root.is_file():
|
|
81
|
+
root = root.parent
|
|
82
|
+
shared = read_toml(root / CONFIG_NAME)
|
|
83
|
+
if (root / ".awm").is_symlink():
|
|
84
|
+
raise AWMError("Project state directory must not be a symlink")
|
|
85
|
+
local = read_toml(root / ".awm" / "local.toml")
|
|
86
|
+
return parse_project(root, shared, local)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_project(root: Path, shared: dict, local: dict) -> Project:
|
|
90
|
+
try:
|
|
91
|
+
project_id = str(uuid.UUID(local["project_id"]))
|
|
92
|
+
name = shared["name"]
|
|
93
|
+
environment = local["environment"]
|
|
94
|
+
backend, base = environment["backend"], environment["base"]
|
|
95
|
+
if (
|
|
96
|
+
not isinstance(name, str)
|
|
97
|
+
or not name.strip()
|
|
98
|
+
or backend not in BACKENDS
|
|
99
|
+
or not isinstance(base, str)
|
|
100
|
+
):
|
|
101
|
+
raise ValueError("invalid project name or environment")
|
|
102
|
+
repos = {}
|
|
103
|
+
overrides = local.get("repositories", {})
|
|
104
|
+
if not isinstance(overrides, dict) or overrides.keys() - shared["repositories"].keys():
|
|
105
|
+
raise ValueError("local repository overrides must use configured aliases")
|
|
106
|
+
for alias, spec in shared["repositories"].items():
|
|
107
|
+
slug(alias)
|
|
108
|
+
spec = {**spec, **overrides.get(alias, {})}
|
|
109
|
+
targets = spec.get("targets", [])
|
|
110
|
+
if not isinstance(targets, list) or not all(isinstance(t, str) and t for t in targets):
|
|
111
|
+
raise ValueError(f"invalid editable targets for {alias}")
|
|
112
|
+
for target in targets:
|
|
113
|
+
editable_target(target)
|
|
114
|
+
repos[alias] = Repository(
|
|
115
|
+
alias, (root / Path(spec["path"]).expanduser()).resolve(), tuple(targets)
|
|
116
|
+
)
|
|
117
|
+
if not repos or len({r.path for r in repos.values()}) != len(repos):
|
|
118
|
+
raise ValueError("configure at least one repository, with distinct checkout paths")
|
|
119
|
+
return Project(
|
|
120
|
+
root, project_id, name, backend, (root / Path(base).expanduser()).resolve(), repos
|
|
121
|
+
)
|
|
122
|
+
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
|
123
|
+
raise AWMError(f"Invalid project configuration at {root}: {exc}") from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def discover_root(cwd: Path | None = None) -> Path:
|
|
127
|
+
path = (cwd or Path.cwd()).resolve()
|
|
128
|
+
for candidate in (path, *path.parents):
|
|
129
|
+
if (candidate / CONFIG_NAME).is_file():
|
|
130
|
+
return candidate
|
|
131
|
+
raise AWMError(
|
|
132
|
+
"No project selected; use --project, --root, or run inside an initialized project"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def initialize(
|
|
137
|
+
root: Path,
|
|
138
|
+
base: Path,
|
|
139
|
+
backend: str,
|
|
140
|
+
name: str | None,
|
|
141
|
+
repositories: list[str],
|
|
142
|
+
*,
|
|
143
|
+
dry_run: bool = False,
|
|
144
|
+
) -> Project | None:
|
|
145
|
+
root, base = root.expanduser().resolve(), base.expanduser().resolve()
|
|
146
|
+
if not root.is_dir():
|
|
147
|
+
raise AWMError(f"Project directory does not exist: {root}")
|
|
148
|
+
if not (base / "bin" / "python").is_file():
|
|
149
|
+
raise AWMError(f"Base has no Python interpreter: {base}")
|
|
150
|
+
if backend == "conda" and not (base / "conda-meta").is_dir():
|
|
151
|
+
raise AWMError(f"Not a conda environment: {base}")
|
|
152
|
+
if backend != "conda" and not (base / "pyvenv.cfg").is_file():
|
|
153
|
+
raise AWMError(f"Select a dedicated venv base, or use --backend conda: {base}")
|
|
154
|
+
if (root / ".awm").is_symlink():
|
|
155
|
+
raise AWMError("Project state directory must not be a symlink")
|
|
156
|
+
shared_path = root / CONFIG_NAME
|
|
157
|
+
if shared_path.exists():
|
|
158
|
+
if repositories or name:
|
|
159
|
+
raise AWMError(
|
|
160
|
+
"Configuration already exists; edit awm.toml to change its name/repositories"
|
|
161
|
+
)
|
|
162
|
+
shared = read_toml(shared_path)
|
|
163
|
+
text = None
|
|
164
|
+
else:
|
|
165
|
+
entries = repositories or ["repo=."]
|
|
166
|
+
text = f"schema_version = 1\nname = {json.dumps(name or root.name)}\n"
|
|
167
|
+
seen = set()
|
|
168
|
+
for entry in entries:
|
|
169
|
+
alias, sep, value = entry.partition("=")
|
|
170
|
+
if not sep or not value or slug(alias) in seen:
|
|
171
|
+
raise AWMError("Repositories use unique ALIAS=PATH entries")
|
|
172
|
+
seen.add(alias)
|
|
173
|
+
path = (root / Path(value).expanduser()).resolve()
|
|
174
|
+
check_repo(path)
|
|
175
|
+
text += f"\n[repositories.{json.dumps(alias)}]\npath = {json.dumps(os.path.relpath(path, root))}\ntargets = []\n"
|
|
176
|
+
if (root / ".awm" / "local.toml").exists():
|
|
177
|
+
raise AWMError("Project is already initialized; edit .awm/local.toml to change its base")
|
|
178
|
+
local_text = (
|
|
179
|
+
f'schema_version = 1\nproject_id = "{uuid.uuid4()}"\n\n[environment]\n'
|
|
180
|
+
f"backend = {json.dumps(backend)}\nbase = {json.dumps(str(base))}\n"
|
|
181
|
+
)
|
|
182
|
+
candidate = parse_project(
|
|
183
|
+
root, tomllib.loads(text) if text is not None else shared, tomllib.loads(local_text)
|
|
184
|
+
)
|
|
185
|
+
for repo in candidate.repos.values():
|
|
186
|
+
check_repo(repo.path)
|
|
187
|
+
if dry_run:
|
|
188
|
+
return None
|
|
189
|
+
with lock(root / ".awm" / "operation.lock"):
|
|
190
|
+
if (root / ".awm" / "local.toml").exists():
|
|
191
|
+
raise AWMError("Project was initialized by another process")
|
|
192
|
+
atomic_text(root / ".awm" / ".gitignore", "*\n")
|
|
193
|
+
if text is not None:
|
|
194
|
+
atomic_text(shared_path, text)
|
|
195
|
+
atomic_text(root / ".awm" / "local.toml", local_text)
|
|
196
|
+
return load_project(root)
|