fastws-cli 0.0.15__tar.gz → 0.0.16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/PKG-INFO +13 -2
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/README.md +12 -1
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/fastws/__init__.py +2 -1
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/fastws/core.py +52 -34
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/fastws/releases.py +2 -2
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/fastws_cli.egg-info/PKG-INFO +13 -2
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/tests/test_sync.py +53 -19
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/LICENSE +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/fastws_cli.egg-info/SOURCES.txt +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/fastws_cli.egg-info/dependency_links.txt +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/fastws_cli.egg-info/entry_points.txt +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/fastws_cli.egg-info/requires.txt +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/fastws_cli.egg-info/top_level.txt +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/pyproject.toml +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/setup.cfg +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/tests/test_build.py +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/tests/test_releases.py +0 -0
- {fastws_cli-0.0.15 → fastws_cli-0.0.16}/tests/test_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.16
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License: Apache-2.0
|
|
@@ -82,13 +82,24 @@ AnswerDotAI/fastcore
|
|
|
82
82
|
jph00/private ~/private
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
Select Python extras by attaching brackets to the repo name, before any checkout location:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
AnswerDotAI/fastws[dev]
|
|
89
|
+
owner/repo[dev,test] ~/somewhere
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
These select `[project.optional-dependencies]`, not `[dependency-groups]`. Sync uses the package name from `pyproject.toml`, so `AnswerDotAI/fastws[dev]` installs `fastws-cli[dev]` editably. For a checkout containing several discovered Python packages, the selection applies to each. uv handles the extras and their dependencies.
|
|
93
|
+
|
|
94
|
+
Extras from duplicate entries in `repos.txt` and `repos-local.txt` are combined. For example, a personal `AnswerDotAI/fastws[dev]` entry adds development extras to a shared `AnswerDotAI/fastws` entry. The lists are authoritative for those packages' extras: each sync updates existing workspace dependencies, including removing extras no longer selected. Put personal extra selections in `repos-local.txt`, not the generated `pyproject.toml`. Existing version constraints, markers, sources, and unrelated dependencies are preserved.
|
|
95
|
+
|
|
85
96
|
### Shared baseline and personal additions
|
|
86
97
|
|
|
87
98
|
Keep `repos.txt` tracked in the workspace Git repo. Put personal additions in a gitignored `repos-local.txt`, using the same format. Every command reads both lists. `ws-add` and automatic discovery append only to the local list. Edit the shared list deliberately when changing the team's baseline. Duplicate repo names are matched case-insensitively. Conflicting explicit locations, or two different repos targeting the same directory, cause errors.
|
|
88
99
|
|
|
89
100
|
`--repos-file myrepos.txt` uses `myrepos-local.txt` alongside it. Either list may be absent. Removing a repo from the baseline does not delete its checkout. The next sync discovers an existing root checkout as a personal addition. External checkouts also remain on disk; add them to the local list to keep managing them.
|
|
90
101
|
|
|
91
|
-
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it
|
|
102
|
+
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it while preserving personal settings except for the repo extras managed above. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
|
|
92
103
|
|
|
93
104
|
```gitignore
|
|
94
105
|
/repos-local.txt
|
|
@@ -57,13 +57,24 @@ AnswerDotAI/fastcore
|
|
|
57
57
|
jph00/private ~/private
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
Select Python extras by attaching brackets to the repo name, before any checkout location:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
AnswerDotAI/fastws[dev]
|
|
64
|
+
owner/repo[dev,test] ~/somewhere
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
These select `[project.optional-dependencies]`, not `[dependency-groups]`. Sync uses the package name from `pyproject.toml`, so `AnswerDotAI/fastws[dev]` installs `fastws-cli[dev]` editably. For a checkout containing several discovered Python packages, the selection applies to each. uv handles the extras and their dependencies.
|
|
68
|
+
|
|
69
|
+
Extras from duplicate entries in `repos.txt` and `repos-local.txt` are combined. For example, a personal `AnswerDotAI/fastws[dev]` entry adds development extras to a shared `AnswerDotAI/fastws` entry. The lists are authoritative for those packages' extras: each sync updates existing workspace dependencies, including removing extras no longer selected. Put personal extra selections in `repos-local.txt`, not the generated `pyproject.toml`. Existing version constraints, markers, sources, and unrelated dependencies are preserved.
|
|
70
|
+
|
|
60
71
|
### Shared baseline and personal additions
|
|
61
72
|
|
|
62
73
|
Keep `repos.txt` tracked in the workspace Git repo. Put personal additions in a gitignored `repos-local.txt`, using the same format. Every command reads both lists. `ws-add` and automatic discovery append only to the local list. Edit the shared list deliberately when changing the team's baseline. Duplicate repo names are matched case-insensitively. Conflicting explicit locations, or two different repos targeting the same directory, cause errors.
|
|
63
74
|
|
|
64
75
|
`--repos-file myrepos.txt` uses `myrepos-local.txt` alongside it. Either list may be absent. Removing a repo from the baseline does not delete its checkout. The next sync discovers an existing root checkout as a personal addition. External checkouts also remain on disk; add them to the local list to keep managing them.
|
|
65
76
|
|
|
66
|
-
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it
|
|
77
|
+
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it while preserving personal settings except for the repo extras managed above. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
|
|
67
78
|
|
|
68
79
|
```gitignore
|
|
69
80
|
/repos-local.txt
|
|
@@ -10,6 +10,8 @@ from fastcore.parallel import parallel_async_gen
|
|
|
10
10
|
from fastcore.script import call_parse
|
|
11
11
|
from fastgit import Git
|
|
12
12
|
from ghapi.core import dep_key, dep_closure
|
|
13
|
+
from packaging.requirements import Requirement
|
|
14
|
+
from packaging.utils import canonicalize_name
|
|
13
15
|
|
|
14
16
|
try: import tomllib
|
|
15
17
|
except ModuleNotFoundError: import tomli as tomllib
|
|
@@ -19,10 +21,12 @@ def _repo_lines(repos_file) -> list[str]:
|
|
|
19
21
|
if not p.exists(): raise SystemExit(f"File not found: {repos_file}")
|
|
20
22
|
return [line.strip() for line in p.read_text().splitlines() if line.strip() and not line.startswith("#")]
|
|
21
23
|
|
|
22
|
-
def _parse_repo_line(line: str) -> tuple[str, str|None]:
|
|
23
|
-
"Split
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
def _parse_repo_line(line: str) -> tuple[str, str|None, set[str]]:
|
|
25
|
+
"Split owner/repo[extra,...] and an optional checkout location."
|
|
26
|
+
m = re.fullmatch(r'([^\s\[\]]+)(?:\[([^\[\]]+)\])?(?:\s+(.+))?', line.strip())
|
|
27
|
+
if not m: raise SystemExit(f'Invalid repo entry: {line}')
|
|
28
|
+
repo, extras, loc = m.groups()
|
|
29
|
+
return repo, loc, {canonicalize_name(e.strip()) for e in extras.split(',')} if extras else set()
|
|
26
30
|
|
|
27
31
|
def _load_repos(repos_file: str = "repos.txt") -> list[str]:
|
|
28
32
|
return [_parse_repo_line(l)[0] for l in _repo_lines(repos_file)] if Path(repos_file).exists() else []
|
|
@@ -31,21 +35,23 @@ def _local_repos_path(repos_file) -> Path:
|
|
|
31
35
|
p = Path(repos_file)
|
|
32
36
|
return p.with_name(f'{p.stem}-local{p.suffix}')
|
|
33
37
|
|
|
34
|
-
def _load_repo_entries(repos_file, root: Path) -> list[tuple[str, Path]]:
|
|
35
|
-
"Shared and local entries,
|
|
38
|
+
def _load_repo_entries(repos_file, root: Path) -> list[tuple[str, Path, set[str]]]:
|
|
39
|
+
"Shared and local entries, with extras unioned by repo; conflicting checkout locations are errors."
|
|
36
40
|
res, locations = {}, {}
|
|
37
41
|
for p in (Path(repos_file), _local_repos_path(repos_file)):
|
|
38
42
|
if not p.exists(): continue
|
|
39
43
|
for line in _repo_lines(p):
|
|
40
|
-
repo, loc = _parse_repo_line(line)
|
|
44
|
+
repo, loc, extras = _parse_repo_line(line)
|
|
41
45
|
key = _repo_key(repo)
|
|
42
|
-
if key in res
|
|
46
|
+
if key in res:
|
|
47
|
+
res[key][2].update(extras)
|
|
48
|
+
if loc is None: continue
|
|
43
49
|
d = _resolve_path(root, Path(loc).expanduser()) if loc else root/_repo_dir(repo)
|
|
44
50
|
resolved = d.resolve()
|
|
45
51
|
if key in res and res[key][1].resolve() != resolved: raise SystemExit(f'Conflicting locations for {repo} in {p}')
|
|
46
52
|
if resolved in locations and locations[resolved] != key:
|
|
47
53
|
raise SystemExit(f'Checkout location {d} is shared by {repo} and {locations[resolved]}')
|
|
48
|
-
res.setdefault(key, (repo, d))
|
|
54
|
+
res.setdefault(key, (repo, d, extras))
|
|
49
55
|
locations[resolved] = key
|
|
50
56
|
return list(res.values())
|
|
51
57
|
|
|
@@ -57,7 +63,7 @@ def _resolve_path(root: Path, path: str) -> Path:
|
|
|
57
63
|
|
|
58
64
|
def _repo_key(repo: str) -> str: return repo.strip().rstrip("/").removesuffix(".git").casefold()
|
|
59
65
|
|
|
60
|
-
def _pkg_key(name: str) -> str: return name
|
|
66
|
+
def _pkg_key(name: str) -> str: return canonicalize_name(name)
|
|
61
67
|
|
|
62
68
|
def _fmt_toml_val(v) -> str:
|
|
63
69
|
if isinstance(v, bool): return "true" if v else "false"
|
|
@@ -161,10 +167,10 @@ def _remove_from_pyproject(pyproject_path: Path, names: list[str]) -> list[str]:
|
|
|
161
167
|
sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
|
|
162
168
|
deps = list(data.get("project", {}).get("dependencies", []))
|
|
163
169
|
targets = {_pkg_key(n) for n in names}
|
|
164
|
-
removed = sorted({_pkg_key(s) for s in sources if _pkg_key(s) in targets} | {dep_key(d) for d in deps if dep_key(d) in targets})
|
|
170
|
+
removed = sorted({_pkg_key(s) for s in sources if _pkg_key(s) in targets} | {_pkg_key(dep_key(d)) for d in deps if _pkg_key(dep_key(d)) in targets})
|
|
165
171
|
if not removed: return []
|
|
166
172
|
sources = {k:v for k,v in sources.items() if _pkg_key(k) not in targets}
|
|
167
|
-
deps = [d for d in deps if dep_key(d) not in targets]
|
|
173
|
+
deps = [d for d in deps if _pkg_key(dep_key(d)) not in targets]
|
|
168
174
|
content = _replace_table(content, "tool.uv.sources", "\n".join(f"{k} = {_fmt_source(v)}" for k,v in sources.items()))
|
|
169
175
|
content = _replace_project_dependencies(content, deps)
|
|
170
176
|
pyproject_path.write_text(content)
|
|
@@ -192,6 +198,10 @@ def _external_projects(root: Path, dirs: list[Path]) -> list[tuple[str,str]]:
|
|
|
192
198
|
if name := _read_pyproject_name(c/"pyproject.toml"): res.append((name, os.path.relpath(c, root)))
|
|
193
199
|
return res
|
|
194
200
|
|
|
201
|
+
def _repo_extras(root: Path, entries) -> dict[str, set[str]]:
|
|
202
|
+
"Package extras selected by the repo lists, including empty selections for removal on sync."
|
|
203
|
+
return {_pkg_key(name): extras for _,d,extras in entries for name,_ in _external_projects(root, [d])}
|
|
204
|
+
|
|
195
205
|
def _valid_project_dir(d: Path) -> bool: return (d/"pyproject.toml").exists() and bool(_read_pyproject_name(d/"pyproject.toml"))
|
|
196
206
|
|
|
197
207
|
def _non_python_project(d: Path) -> bool:
|
|
@@ -275,7 +285,7 @@ def _replace_project_dependencies(content: str, deps: list[str]) -> str:
|
|
|
275
285
|
if not (span := _table_span(content, "project")): raise SystemExit("Missing [project] table in pyproject.toml")
|
|
276
286
|
start,end = span
|
|
277
287
|
section = content[start:end]
|
|
278
|
-
dep_block = "dependencies = [\n" + "".join(f'
|
|
288
|
+
dep_block = "dependencies = [\n" + "".join(f' {json.dumps(dep)},\n' for dep in deps) + "]"
|
|
279
289
|
if m := re.search(r"(?m)^dependencies\s*=\s*\[", section):
|
|
280
290
|
arr_start = m.end()-1
|
|
281
291
|
arr_end = _find_array_end(section, arr_start)
|
|
@@ -298,29 +308,36 @@ members = ["./*"]
|
|
|
298
308
|
''')
|
|
299
309
|
return True
|
|
300
310
|
|
|
301
|
-
def _sync_ws_pyproject(pyproject_path: Path, template_path: Path, projects: list[str], externals: list[tuple] = None
|
|
311
|
+
def _sync_ws_pyproject(pyproject_path: Path, template_path: Path, projects: list[str], externals: list[tuple] = None,
|
|
312
|
+
extras: dict[str, set[str]] = None) -> list[str]:
|
|
302
313
|
if not pyproject_path.exists():
|
|
303
314
|
if template_path.exists(): shutil.copyfile(template_path, pyproject_path)
|
|
304
315
|
else: _init_ws_pyproject(pyproject_path)
|
|
305
|
-
content = pyproject_path.read_text()
|
|
316
|
+
original = content = pyproject_path.read_text()
|
|
306
317
|
data = tomllib.loads(content)
|
|
307
318
|
sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
|
|
308
319
|
source_keys = {_pkg_key(proj) for proj in sources}
|
|
309
320
|
missing = [proj for proj in projects if _pkg_key(proj) not in source_keys]
|
|
310
321
|
ext_missing = [(n,p) for n,p in (externals or []) if _pkg_key(n) not in source_keys]
|
|
311
|
-
if not missing and not ext_missing: return []
|
|
312
322
|
for proj in missing: sources[proj] = {"workspace": True}
|
|
313
323
|
for n,p in ext_missing: sources[n] = {"path": p, "editable": True}
|
|
314
324
|
deps = list(data.get("project", {}).get("dependencies", []))
|
|
315
|
-
dep_keys = {dep_key(dep) for dep in deps}
|
|
316
|
-
for proj in
|
|
325
|
+
dep_keys = {_pkg_key(dep_key(dep)) for dep in deps}
|
|
326
|
+
for proj in projects + [n for n,_ in (externals or [])]:
|
|
317
327
|
if _pkg_key(proj) in dep_keys: continue
|
|
318
328
|
deps.append(proj)
|
|
319
329
|
dep_keys.add(_pkg_key(proj))
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
330
|
+
for i,dep in enumerate(deps):
|
|
331
|
+
if (key := _pkg_key(dep_key(dep))) not in (extras or {}): continue
|
|
332
|
+
req = Requirement(dep)
|
|
333
|
+
if {canonicalize_name(e) for e in req.extras} == extras[key]: continue
|
|
334
|
+
req.extras = extras[key]
|
|
335
|
+
deps[i] = str(req)
|
|
336
|
+
if missing or ext_missing:
|
|
337
|
+
source_lines = "\n".join(f"{proj} = {_fmt_source(src)}" for proj,src in sources.items())
|
|
338
|
+
content = _replace_table(content, "tool.uv.sources", source_lines)
|
|
339
|
+
if deps != data.get("project", {}).get("dependencies", []): content = _replace_project_dependencies(content, deps)
|
|
340
|
+
if content != original: pyproject_path.write_text(content)
|
|
324
341
|
return missing + [n for n,_ in ext_missing]
|
|
325
342
|
|
|
326
343
|
def _editable_mapping(path: Path) -> dict[str,str]:
|
|
@@ -364,10 +381,10 @@ async def _clone_one(repo: str, d: Path) -> str|None:
|
|
|
364
381
|
except subprocess.CalledProcessError as e: return f"✗ {d.name}: {e.stderr.strip()}"
|
|
365
382
|
|
|
366
383
|
async def _clone_repos(entries, workers):
|
|
367
|
-
async def clone(e): return await _clone_one(
|
|
384
|
+
async def clone(e): return await _clone_one(e[0], e[1])
|
|
368
385
|
async for _, res in parallel_async_gen(clone, entries, n_workers=workers):
|
|
369
386
|
if res: print(res)
|
|
370
|
-
if missing := [str(d) for _,d in entries if not (d/'.git').exists()]:
|
|
387
|
+
if missing := [str(d) for _,d,_ in entries if not (d/'.git').exists()]:
|
|
371
388
|
raise SystemExit(f'Could not clone repos (missing Git checkouts): {", ".join(missing)}')
|
|
372
389
|
|
|
373
390
|
async def _pull_workspace(root: Path):
|
|
@@ -447,7 +464,7 @@ async def ws_pull(
|
|
|
447
464
|
workers: int = 64, # Number of parallel workers
|
|
448
465
|
):
|
|
449
466
|
"Pull updates for repos whose GitHub origin has moved (all repos when that can't be checked)."
|
|
450
|
-
dirs = [d for _,d in _load_repo_entries(repos_file, Path("."))]
|
|
467
|
+
dirs = [d for _,d,_ in _load_repo_entries(repos_file, Path("."))]
|
|
451
468
|
try: dirs = await _changed_dirs(dirs)
|
|
452
469
|
except Exception: pass
|
|
453
470
|
await _pull(dirs, workers)
|
|
@@ -458,7 +475,7 @@ def ws_status(
|
|
|
458
475
|
branches: bool = False, # Show unpushed commit details
|
|
459
476
|
):
|
|
460
477
|
"Show uncommitted changes and optionally unpushed commit details across repos."
|
|
461
|
-
for repo,d in _load_repo_entries(repos_file, Path(".")):
|
|
478
|
+
for repo,d,_ in _load_repo_entries(repos_file, Path(".")):
|
|
462
479
|
if not d.exists(): continue
|
|
463
480
|
g = Git(d)
|
|
464
481
|
if not g.exists: continue
|
|
@@ -479,7 +496,7 @@ def ws_branches(
|
|
|
479
496
|
expected: str = "main", # Expected branch name
|
|
480
497
|
):
|
|
481
498
|
"Check if all repos are on the expected branch."
|
|
482
|
-
for repo,d in _load_repo_entries(repos_file, Path(".")):
|
|
499
|
+
for repo,d,_ in _load_repo_entries(repos_file, Path(".")):
|
|
483
500
|
if not Path(d).exists():
|
|
484
501
|
print(f"⚠️ {d}: directory not found")
|
|
485
502
|
continue
|
|
@@ -514,7 +531,7 @@ def _build_projects(root: Path, repos_file: str, project: str = None) -> list[tu
|
|
|
514
531
|
"(name, dir) for every project ws-sync installs: workspace members plus external checkouts"
|
|
515
532
|
repos_path = _resolve_path(root, repos_file)
|
|
516
533
|
entries = _load_repo_entries(repos_path, root)
|
|
517
|
-
ext_dirs = [d for _,d in entries if d.resolve().parent != root.resolve()]
|
|
534
|
+
ext_dirs = [d for _,d,_ in entries if d.resolve().parent != root.resolve()]
|
|
518
535
|
res = [(name, d) for d in _ws_dirs(root) if (d/"pyproject.toml").exists() and (name := _read_pyproject_name(d/"pyproject.toml"))]
|
|
519
536
|
res += [(n, _resolve_path(root, p)) for n,p in _external_projects(root, ext_dirs)]
|
|
520
537
|
if project is None: return res
|
|
@@ -851,18 +868,19 @@ async def ws_sync(
|
|
|
851
868
|
|
|
852
869
|
if missing_repos := _update_repos_file(repos_path, repos): print(f"Added local repos: {', '.join(missing_repos)}")
|
|
853
870
|
entries = _load_repo_entries(repos_path, root)
|
|
854
|
-
ext_dirs = [d for _,d in entries if d.resolve().parent != root.resolve()]
|
|
855
|
-
dirs = [d for _,d in entries]
|
|
871
|
+
ext_dirs = [d for _,d,_ in entries if d.resolve().parent != root.resolve()]
|
|
872
|
+
dirs = [d for _,d,_ in entries]
|
|
856
873
|
try: dirs = await _changed_dirs(dirs)
|
|
857
874
|
except Exception: pass
|
|
858
875
|
await _pull(dirs, workers=workers)
|
|
859
876
|
|
|
860
877
|
if not pyproject_path.exists(): _sync_ws_pyproject(pyproject_path, template_path, [])
|
|
861
|
-
added_ex, removed_ex = _sync_ws_excludes(pyproject_path, root, {d.name for _,d in entries if d.resolve().parent == root.resolve()})
|
|
878
|
+
added_ex, removed_ex = _sync_ws_excludes(pyproject_path, root, {d.name for _,d,_ in entries if d.resolve().parent == root.resolve()})
|
|
862
879
|
if added_ex: print(f"Auto-excluded from the workspace: {', '.join(added_ex)}")
|
|
863
880
|
if removed_ex: print(f"No longer excluded from the workspace: {', '.join(removed_ex)}")
|
|
864
881
|
|
|
865
|
-
missing_projects = _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root), _external_projects(root, ext_dirs)
|
|
882
|
+
missing_projects = _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root), _external_projects(root, ext_dirs),
|
|
883
|
+
_repo_extras(root, entries))
|
|
866
884
|
if missing_projects: print(f"Added workspace projects: {', '.join(missing_projects)}")
|
|
867
885
|
|
|
868
886
|
wrapper_added = _sync_cargo_wrapper(root)
|
|
@@ -941,7 +959,7 @@ def _resolve_removal_target(root: Path, repo: str, repos_path: Path) -> str:
|
|
|
941
959
|
"Canonical owner/repo for `repo`, matching an existing folder name when `repo` isn't a valid spec."
|
|
942
960
|
if _is_repo_spec(repo): return _normalize_repo(repo)
|
|
943
961
|
if not (root/repo).is_dir(): return _normalize_repo(repo) # invalid spec and no such folder: raise
|
|
944
|
-
for r,d in _load_repo_entries(repos_path, root):
|
|
962
|
+
for r,d,_ in _load_repo_entries(repos_path, root):
|
|
945
963
|
if _repo_dir(r).casefold() == repo.casefold(): return r
|
|
946
964
|
if (root/repo/".git").exists():
|
|
947
965
|
url = Git(root/repo).remote("get-url", "origin", mute_errors=True)
|
|
@@ -980,7 +998,7 @@ def ws_remove(
|
|
|
980
998
|
if _repo_key(repo) in {_repo_key(r) for r in _load_repos(repos_path)}:
|
|
981
999
|
raise SystemExit(f'{repo} belongs to the shared baseline: edit {repos_path} deliberately to remove it')
|
|
982
1000
|
d = _resolve_repo_dir(root, repo)
|
|
983
|
-
for r,location in _load_repo_entries(repos_path, root):
|
|
1001
|
+
for r,location,_ in _load_repo_entries(repos_path, root):
|
|
984
1002
|
if _repo_key(r) == _repo_key(repo) and location.resolve() != d:
|
|
985
1003
|
raise SystemExit(f'Refusing to remove {repo}: custom checkout location {location}; manage it explicitly')
|
|
986
1004
|
repos_path = _local_repos_path(repos_path)
|
|
@@ -59,14 +59,14 @@ class ReleaseReport(list):
|
|
|
59
59
|
def _cwd_project(root: Path, repos_file: str = "repos.txt") -> str|None:
|
|
60
60
|
"Dir name of the workspace member checkout containing cwd (None at the root or outside every member)."
|
|
61
61
|
cwd = Path.cwd().resolve()
|
|
62
|
-
for _, d in _load_repo_entries(_resolve_path(root, repos_file), root):
|
|
62
|
+
for _, d, _ in _load_repo_entries(_resolve_path(root, repos_file), root):
|
|
63
63
|
if cwd.is_relative_to(d.resolve()): return d.name
|
|
64
64
|
return None
|
|
65
65
|
|
|
66
66
|
async def check_releases(project: str = None, skip=None, workspace: str = "", repos_file: str = "repos.txt", nodeps: bool = False) -> ReleaseReport:
|
|
67
67
|
"Sweep every workspace repo for unreleased commits; a `project` (given, or detected from cwd inside a member checkout) limits the sweep to its transitive dependency closure, or to itself alone with `nodeps`. `[tool.fastws].release_exclude` names repos to leave out (apps that deploy rather than release)."
|
|
68
68
|
root = _ws_root(workspace)
|
|
69
|
-
repos = [r for r,
|
|
69
|
+
repos = [r for r,_,_ in _load_repo_entries(_resolve_path(root, repos_file), root)]
|
|
70
70
|
excl = {e.casefold() for e in _fastws_cfg(root).get("release_exclude", [])}
|
|
71
71
|
repos = [r for r in repos if r.split("/")[-1].casefold() not in excl]
|
|
72
72
|
if project is None: project = _cwd_project(root, repos_file)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.16
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License: Apache-2.0
|
|
@@ -82,13 +82,24 @@ AnswerDotAI/fastcore
|
|
|
82
82
|
jph00/private ~/private
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
Select Python extras by attaching brackets to the repo name, before any checkout location:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
AnswerDotAI/fastws[dev]
|
|
89
|
+
owner/repo[dev,test] ~/somewhere
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
These select `[project.optional-dependencies]`, not `[dependency-groups]`. Sync uses the package name from `pyproject.toml`, so `AnswerDotAI/fastws[dev]` installs `fastws-cli[dev]` editably. For a checkout containing several discovered Python packages, the selection applies to each. uv handles the extras and their dependencies.
|
|
93
|
+
|
|
94
|
+
Extras from duplicate entries in `repos.txt` and `repos-local.txt` are combined. For example, a personal `AnswerDotAI/fastws[dev]` entry adds development extras to a shared `AnswerDotAI/fastws` entry. The lists are authoritative for those packages' extras: each sync updates existing workspace dependencies, including removing extras no longer selected. Put personal extra selections in `repos-local.txt`, not the generated `pyproject.toml`. Existing version constraints, markers, sources, and unrelated dependencies are preserved.
|
|
95
|
+
|
|
85
96
|
### Shared baseline and personal additions
|
|
86
97
|
|
|
87
98
|
Keep `repos.txt` tracked in the workspace Git repo. Put personal additions in a gitignored `repos-local.txt`, using the same format. Every command reads both lists. `ws-add` and automatic discovery append only to the local list. Edit the shared list deliberately when changing the team's baseline. Duplicate repo names are matched case-insensitively. Conflicting explicit locations, or two different repos targeting the same directory, cause errors.
|
|
88
99
|
|
|
89
100
|
`--repos-file myrepos.txt` uses `myrepos-local.txt` alongside it. Either list may be absent. Removing a repo from the baseline does not delete its checkout. The next sync discovers an existing root checkout as a personal addition. External checkouts also remain on disk; add them to the local list to keep managing them.
|
|
90
101
|
|
|
91
|
-
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it
|
|
102
|
+
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it while preserving personal settings except for the repo extras managed above. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
|
|
92
103
|
|
|
93
104
|
```gitignore
|
|
94
105
|
/repos-local.txt
|
|
@@ -40,40 +40,46 @@ def fake_uv(monkeypatch):
|
|
|
40
40
|
|
|
41
41
|
def test_repos_file_roundtrip(tmp_path):
|
|
42
42
|
repos_path = tmp_path/'repos.txt'
|
|
43
|
-
repos_path.write_text('# header\nAnswerDotAI/fastws\njph00/private ~/private\nfastai/fastai sub/dir\n')
|
|
43
|
+
repos_path.write_text('# header\nAnswerDotAI/fastws[dev]\njph00/private[docs] ~/private\nfastai/fastai sub/dir\n')
|
|
44
44
|
|
|
45
45
|
assert core._load_repos(repos_path) == ['AnswerDotAI/fastws', 'jph00/private', 'fastai/fastai']
|
|
46
46
|
assert core._load_repo_entries(repos_path, tmp_path) == [
|
|
47
|
-
('AnswerDotAI/fastws', tmp_path/'fastws'),
|
|
48
|
-
('jph00/private', core.Path('~/private').expanduser()),
|
|
49
|
-
('fastai/fastai', tmp_path/'sub'/'dir')]
|
|
47
|
+
('AnswerDotAI/fastws', tmp_path/'fastws', {'dev'}),
|
|
48
|
+
('jph00/private', core.Path('~/private').expanduser(), {'docs'}),
|
|
49
|
+
('fastai/fastai', tmp_path/'sub'/'dir', set())]
|
|
50
50
|
|
|
51
51
|
# discovery and ws-add write only extras, never change the shared baseline
|
|
52
52
|
original = repos_path.read_text()
|
|
53
53
|
local = tmp_path/'repos-local.txt'
|
|
54
|
-
added = core._update_repos_file(repos_path, ['answerdotai/fastws', 'jph00/private', 'fastai/new'])
|
|
55
|
-
assert added == ['fastai/new']
|
|
56
|
-
assert local.read_text() == 'fastai/new\n'
|
|
54
|
+
added = core._update_repos_file(repos_path, ['answerdotai/fastws', 'jph00/private', 'fastai/new[dev]'])
|
|
55
|
+
assert added == ['fastai/new[dev]']
|
|
56
|
+
assert local.read_text() == 'fastai/new[dev]\n'
|
|
57
57
|
assert repos_path.read_text() == original
|
|
58
|
-
assert core._load_repo_entries(repos_path, tmp_path)[-1] == ('fastai/new', tmp_path/'new')
|
|
58
|
+
assert core._load_repo_entries(repos_path, tmp_path)[-1] == ('fastai/new', tmp_path/'new', {'dev'})
|
|
59
59
|
|
|
60
60
|
# remove matches case-insensitively, preserves other lines (including comments), and reports absence
|
|
61
61
|
assert core._remove_from_repos_file(local, 'FASTAI/new') is True
|
|
62
62
|
assert core._remove_from_repos_file(repos_path, 'AnswerDotAI/gone') is False
|
|
63
|
-
assert repos_path.read_text() ==
|
|
63
|
+
assert repos_path.read_text() == original
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def test_repo_lists_deduplicate_and_reject_conflicting_locations(tmp_path):
|
|
67
67
|
base, local = tmp_path/'repos.txt', tmp_path/'repos-local.txt'
|
|
68
|
-
base.write_text('org/core\n')
|
|
69
|
-
local.write_text('ORG/core\norg/extra
|
|
70
|
-
assert core._load_repo_entries(base, tmp_path) == [
|
|
68
|
+
base.write_text('org/core[dev]\norg/extra[docs] ../external\n')
|
|
69
|
+
local.write_text('ORG/core[TEST, dev]\norg/extra[dev]\n')
|
|
70
|
+
assert core._load_repo_entries(base, tmp_path) == [
|
|
71
|
+
('org/core', tmp_path/'core', {'dev', 'test'}), ('org/extra', tmp_path/'../external', {'docs', 'dev'})]
|
|
71
72
|
local.write_text('org/core ../elsewhere\n')
|
|
72
73
|
with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
|
|
73
74
|
local.write_text('different/core\n')
|
|
74
75
|
with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
|
|
75
76
|
base.unlink()
|
|
76
|
-
assert core._load_repo_entries(base, tmp_path) == [('different/core', tmp_path/'core')]
|
|
77
|
+
assert core._load_repo_entries(base, tmp_path) == [('different/core', tmp_path/'core', set())]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize('entry', ['org/repo[dev', 'org/repo[dev]]', 'org/repo[]'])
|
|
81
|
+
def test_invalid_repo_extras(entry):
|
|
82
|
+
with pytest.raises(SystemExit, match='Invalid repo entry'): core._parse_repo_line(entry)
|
|
77
83
|
|
|
78
84
|
|
|
79
85
|
async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_path, monkeypatch, fake_uv):
|
|
@@ -87,21 +93,22 @@ async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_
|
|
|
87
93
|
for name in ('old', 'new', 'extra'):
|
|
88
94
|
d = tmp_path/name
|
|
89
95
|
d.mkdir()
|
|
90
|
-
(d/'pyproject.toml').write_text(f'[project]\nname = "{name}"\n')
|
|
96
|
+
(d/'pyproject.toml').write_text(f'[project]\nname = "{name}-pkg"\n[project.optional-dependencies]\ndev = []\ntest = []\n')
|
|
91
97
|
mk_repo(d, origin=origins/f'{name}.git')
|
|
92
98
|
seed.mkdir()
|
|
93
|
-
(seed/'repos.txt').write_text('org/old\n')
|
|
99
|
+
(seed/'repos.txt').write_text('org/old[dev]\n')
|
|
94
100
|
(seed/'.gitignore').write_text('*/\npyproject.toml\nrepos-local.txt\n')
|
|
95
101
|
(seed/'pyproject.tmpl').write_text('[project]\nname = "workspace"\ndependencies = []\n[tool.uv.workspace]\nmembers = ["./*"]\n')
|
|
96
102
|
g = mk_repo(seed, origin=origins/'workspace.git')
|
|
97
103
|
Git(tmp_path, raise_exc=True).clone(str(origins/'workspace.git'), str(root))
|
|
98
104
|
Git(root, raise_exc=True).clone('git@github.com:org/extra.git', str(root/'extra'))
|
|
105
|
+
(root/'repos-local.txt').write_text('org/old[test]\n')
|
|
99
106
|
|
|
100
107
|
await core.ws_sync(str(root))
|
|
101
108
|
assert (root/'old'/'pyproject.toml').exists()
|
|
102
|
-
assert (root/'repos.txt').read_text() == 'org/old\n'
|
|
103
|
-
assert (root/'repos-local.txt').read_text() == 'org/extra\n'
|
|
104
|
-
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old', 'extra'}
|
|
109
|
+
assert (root/'repos.txt').read_text() == 'org/old[dev]\n'
|
|
110
|
+
assert (root/'repos-local.txt').read_text() == 'org/old[test]\norg/extra\n'
|
|
111
|
+
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old-pkg[dev,test]', 'extra-pkg'}
|
|
105
112
|
|
|
106
113
|
(seed/'repos.txt').write_text('org/new\n')
|
|
107
114
|
g.commit('-a', m='new shared baseline')
|
|
@@ -111,7 +118,10 @@ async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_
|
|
|
111
118
|
assert (root/'old'/'pyproject.toml').exists()
|
|
112
119
|
assert (root/'repos.txt').read_text() == 'org/new\n'
|
|
113
120
|
assert set(core._load_repos(root/'repos-local.txt')) == {'org/extra', 'org/old'}
|
|
114
|
-
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old', 'new', 'extra'}
|
|
121
|
+
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old-pkg[test]', 'new-pkg', 'extra-pkg'}
|
|
122
|
+
(root/'repos-local.txt').write_text('org/extra\n')
|
|
123
|
+
await core.ws_sync(str(root))
|
|
124
|
+
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old-pkg', 'new-pkg', 'extra-pkg'}
|
|
115
125
|
|
|
116
126
|
(seed/'repos.txt').write_text('org/missing\n')
|
|
117
127
|
g.commit('-a', m='unavailable member')
|
|
@@ -162,6 +172,23 @@ def test_ws_pyproject_preserves_existing_entries(tmp_path):
|
|
|
162
172
|
assert 'alpha = { workspace = true }' in content
|
|
163
173
|
|
|
164
174
|
|
|
175
|
+
def test_sync_extras_preserves_constraints_markers_and_unrelated_settings(tmp_path):
|
|
176
|
+
pyproject = tmp_path/'pyproject.toml'
|
|
177
|
+
pyproject.write_text('[project]\nname = "ws"\ndependencies = [\'My_Pkg[old]>=1; python_version >= "3.10"\', "other[keep]"]\n'
|
|
178
|
+
'[tool.uv.sources]\nMy_Pkg = { path = "../somewhere", editable = true }\n')
|
|
179
|
+
for extras in ({'dev', 'test'}, set()):
|
|
180
|
+
core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['my-pkg'], extras={'my-pkg': extras})
|
|
181
|
+
data = core.tomllib.loads(pyproject.read_text())
|
|
182
|
+
dep, other = data['project']['dependencies']
|
|
183
|
+
req = core.Requirement(dep)
|
|
184
|
+
assert req.extras == extras and str(req.specifier) == '>=1' and str(req.marker) == 'python_version >= "3.10"'
|
|
185
|
+
assert other == 'other[keep]' and len(data['project']['dependencies']) == 2
|
|
186
|
+
assert data['tool']['uv']['sources'] == {'My_Pkg': {'path': '../somewhere', 'editable': True}}
|
|
187
|
+
before = pyproject.stat().st_mtime_ns
|
|
188
|
+
core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['my-pkg'], extras={'my-pkg': extras})
|
|
189
|
+
assert pyproject.stat().st_mtime_ns == before
|
|
190
|
+
|
|
191
|
+
|
|
165
192
|
def test_ws_excludes_generates_from_intent_and_auto(tmp_path):
|
|
166
193
|
pyproject = tmp_path/'pyproject.toml'
|
|
167
194
|
pyproject.write_text(
|
|
@@ -205,6 +232,13 @@ def test_external_projects_discovers_root_and_subdir_packages(tmp_path):
|
|
|
205
232
|
(multi/name/'pyproject.toml').write_text(f'[project]\nname = "{pkg}"\n')
|
|
206
233
|
|
|
207
234
|
assert core._external_projects(root, [single, multi, tmp_path/'missing']) == [('singlepkg', '../single'), ('tool1', '../multi/tool1')]
|
|
235
|
+
(root/'repos.txt').write_text(f'org/single[dev] {single}\norg/multi[docs] {multi}\n')
|
|
236
|
+
extras = core._repo_extras(root, core._load_repo_entries(root/'repos.txt', root))
|
|
237
|
+
assert extras == {'singlepkg': {'dev'}, 'tool1': {'docs'}}
|
|
238
|
+
core._sync_ws_pyproject(root/'pyproject.toml', root/'pyproject.tmpl', [], core._external_projects(root, [single, multi]), extras)
|
|
239
|
+
data = core.tomllib.loads((root/'pyproject.toml').read_text())
|
|
240
|
+
assert data['project']['dependencies'] == ['singlepkg[dev]', 'tool1[docs]']
|
|
241
|
+
assert data['tool']['uv']['sources']['tool1'] == {'path': '../multi/tool1', 'editable': True}
|
|
208
242
|
|
|
209
243
|
|
|
210
244
|
@pytest.mark.parametrize('manifest', ['pyproject.toml', 'Cargo.toml', 'package.json'])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|