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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.15
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 without replacing personal settings. 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):
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 without replacing personal settings. 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):
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
@@ -1,4 +1,5 @@
1
- __version__ = "0.0.15"
1
+ __version__ = "0.0.16"
2
+
2
3
 
3
4
 
4
5
 
@@ -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 a repos.txt line into its repo spec and optional checkout location"
24
- parts = line.split(None, 1)
25
- return parts[0], (parts[1].strip() or None) if len(parts) > 1 else None
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, deduplicated by repo; conflicting checkout locations are errors."
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 and loc is None: continue
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.casefold()
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' "{dep}",\n' for dep in deps) + "]"
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) -> list[str]:
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 missing + [n for n,_ in ext_missing]:
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
- source_lines = "\n".join(f"{proj} = {_fmt_source(src)}" for proj,src in sources.items())
321
- content = _replace_table(content, "tool.uv.sources", source_lines)
322
- content = _replace_project_dependencies(content, deps)
323
- pyproject_path.write_text(content)
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(*e)
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,d in _load_repo_entries(_resolve_path(root, repos_file), root)]
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.15
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 without replacing personal settings. 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):
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() == '# header\nAnswerDotAI/fastws\njph00/private ~/private\nfastai/fastai sub/dir\n'
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 ../external\n')
70
- assert core._load_repo_entries(base, tmp_path) == [('org/core', tmp_path/'core'), ('org/extra', tmp_path/'../external')]
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