fastws-cli 0.0.16__tar.gz → 0.0.18__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.16
3
+ Version: 0.0.18
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: fastcore>=2.2.6
13
+ Requires-Dist: fastcore>=2.2.28
14
14
  Requires-Dist: fastgit>=0.1.2
15
15
  Requires-Dist: ghapi>=2.0.6
16
16
  Requires-Dist: packaging
@@ -216,7 +216,7 @@ Each sync regenerates Cargo overrides in the workspace's `.cargo/config.toml`. `
216
216
 
217
217
  Do not commit a `Cargo.lock` generated under these patches. Its source-less local entries cannot be resolved on another machine.
218
218
 
219
- When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved.
219
+ When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved. If neither a wrapper nor `sccache` is present, sync warns with the install command (`cargo install sccache --locked`) and continues; rerun `ws-sync` after installing to enable it.
220
220
 
221
221
  Fastws discovers JavaScript packages directly under the workspace root. It reads each package's `workspaces` list for declared nested packages. These lists accept paths and globs. Fastws ignores undeclared nested packages. Lockfiles do not affect discovery. `[tool.fastws].exclude` matches paths relative to the workspace root. Fastws skips `node_modules` and directories whose names start with `.` or `_`.
222
222
 
@@ -261,15 +261,16 @@ ws-add ~/private # stays where it is, recorded in repos-local.txt with its loca
261
261
 
262
262
  ### `ws-remove`
263
263
 
264
- Delete a personal repo's clone, remove it from `repos-local.txt` and the workspace `pyproject.toml`, then run `uv sync`.
264
+ Delete one or more personal repo clones and remove them from `repos-local.txt` and the workspace `pyproject.toml`. Run `uv sync` once after processing all repos.
265
265
 
266
266
  The command refuses shared baseline members. Edit `repos.txt` deliberately to change the team's baseline. It also refuses external or custom checkout locations, which must be managed explicitly.
267
267
 
268
- Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command always asks for confirmation before deleting anything:
268
+ Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command validates all targets before changing anything. It asks for confirmation before deleting each checkout. Declining keeps that checkout but still removes its workspace metadata. Repeated targets are processed once:
269
269
 
270
270
  ```bash
271
271
  ws-remove owner/personal-project
272
272
  ws-remove personal-project # bare folder name also works if the directory exists
273
+ ws-remove owner/project-one project-two project-three
273
274
  ```
274
275
 
275
276
  ### `ws-releases`
@@ -191,7 +191,7 @@ Each sync regenerates Cargo overrides in the workspace's `.cargo/config.toml`. `
191
191
 
192
192
  Do not commit a `Cargo.lock` generated under these patches. Its source-less local entries cannot be resolved on another machine.
193
193
 
194
- When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved.
194
+ When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved. If neither a wrapper nor `sccache` is present, sync warns with the install command (`cargo install sccache --locked`) and continues; rerun `ws-sync` after installing to enable it.
195
195
 
196
196
  Fastws discovers JavaScript packages directly under the workspace root. It reads each package's `workspaces` list for declared nested packages. These lists accept paths and globs. Fastws ignores undeclared nested packages. Lockfiles do not affect discovery. `[tool.fastws].exclude` matches paths relative to the workspace root. Fastws skips `node_modules` and directories whose names start with `.` or `_`.
197
197
 
@@ -236,15 +236,16 @@ ws-add ~/private # stays where it is, recorded in repos-local.txt with its loca
236
236
 
237
237
  ### `ws-remove`
238
238
 
239
- Delete a personal repo's clone, remove it from `repos-local.txt` and the workspace `pyproject.toml`, then run `uv sync`.
239
+ Delete one or more personal repo clones and remove them from `repos-local.txt` and the workspace `pyproject.toml`. Run `uv sync` once after processing all repos.
240
240
 
241
241
  The command refuses shared baseline members. Edit `repos.txt` deliberately to change the team's baseline. It also refuses external or custom checkout locations, which must be managed explicitly.
242
242
 
243
- Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command always asks for confirmation before deleting anything:
243
+ Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command validates all targets before changing anything. It asks for confirmation before deleting each checkout. Declining keeps that checkout but still removes its workspace metadata. Repeated targets are processed once:
244
244
 
245
245
  ```bash
246
246
  ws-remove owner/personal-project
247
247
  ws-remove personal-project # bare folder name also works if the directory exists
248
+ ws-remove owner/project-one project-two project-three
248
249
  ```
249
250
 
250
251
  ### `ws-releases`
@@ -1,4 +1,6 @@
1
- __version__ = "0.0.16"
1
+ __version__ = "0.0.18"
2
+
3
+
2
4
 
3
5
 
4
6
 
@@ -694,11 +694,14 @@ def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
694
694
 
695
695
  def _sync_cargo_wrapper(root: Path) -> bool:
696
696
  "Add sccache to the generated Cargo config when installed, without overriding another wrapper"
697
- if not (wrapper := shutil.which("sccache")): return False
698
697
  config = root/".cargo"/"config.toml"
699
698
  content = config.read_text() if config.exists() else ""
700
699
  data = tomllib.loads(content) if content else {}
701
700
  if data.get("build", {}).get("rustc-wrapper"): return False
701
+ if not (wrapper := shutil.which("sccache")):
702
+ print("⚠️ sccache not found; Rust builds won't use compiler caching.\n"
703
+ "Install it with: cargo install sccache --locked\nThen rerun ws-sync to enable it.")
704
+ return False
702
705
  line = f'rustc-wrapper = {json.dumps(wrapper)}\n'
703
706
  if match := re.search(r"(?m)^\[build\][^\n]*\n", content): new = content[:match.end()] + line + content[match.end():]
704
707
  else: new = content.rstrip() + ("\n\n" if content.strip() else "") + "[build]\n" + line
@@ -839,7 +842,13 @@ def _npm_install(root: Path):
839
842
  def _sync_js(root: Path, members: list[Path]) -> list[Path]:
840
843
  "Install the JS workspace, run every native member's build script, and return those members. Cargo handles incremental compilation."
841
844
  tool = _fastws_cfg(root).get("js", "npm")
842
- if not shutil.which(tool): raise SystemExit(f"{tool} is not installed: install it, or set [tool.fastws].js to a package manager that is")
845
+ if not shutil.which(tool):
846
+ msg = f"{tool} is not installed: install it, or set [tool.fastws].js to a package manager that is"
847
+ if tool == "npm": msg += ("\n\nInstall Node.js and npm using nvm:\n"
848
+ "curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash\n"
849
+ "# Restart your terminal, then:\n"
850
+ "nvm install --lts")
851
+ raise SystemExit(msg)
843
852
  if tool == "npm": _npm_install(root)
844
853
  else: subprocess.run([tool, "install"], check=True, cwd=root)
845
854
  built = _native_js(members)
@@ -984,33 +993,41 @@ def _repo_safety_issues(d: Path) -> list[str]:
984
993
 
985
994
  @call_parse
986
995
  def ws_remove(
987
- repo: str, # Repo to remove, e.g. AnswerDotAI/fastws
996
+ repo: str, # Repo to remove, e.g. owner/repo or a local folder name
997
+ *repos: str, # Additional repos to remove
988
998
  workspace: str = "", # Workspace root; defaults to active venv parent when available
989
999
  repos_file: str = "repos.txt", # Repo list to update
990
1000
  pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
991
1001
  template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
992
1002
  ):
993
- "Remove a personal repo and its local metadata; shared baseline members cannot be removed."
1003
+ "Remove personal repos and their local metadata; shared baseline members cannot be removed."
994
1004
  root = _ws_root(workspace, repos_file, pyproject_file, template_file)
995
1005
  repos_path = _resolve_path(root, repos_file)
996
1006
  pyproject_path = _resolve_path(root, pyproject_file)
997
- repo = _resolve_removal_target(root, repo, repos_path)
998
- if _repo_key(repo) in {_repo_key(r) for r in _load_repos(repos_path)}:
999
- raise SystemExit(f'{repo} belongs to the shared baseline: edit {repos_path} deliberately to remove it')
1000
- d = _resolve_repo_dir(root, repo)
1001
- for r,location,_ in _load_repo_entries(repos_path, root):
1002
- if _repo_key(r) == _repo_key(repo) and location.resolve() != d:
1003
- raise SystemExit(f'Refusing to remove {repo}: custom checkout location {location}; manage it explicitly')
1004
- repos_path = _local_repos_path(repos_path)
1005
- name = _read_pyproject_name(d/"pyproject.toml") if (d/"pyproject.toml").exists() else None
1006
- names = [name] if name else [d.name]
1007
- in_repos = _repo_key(repo) in {_repo_key(r) for r in _load_repos(repos_path)}
1008
- if not d.exists() and not in_repos: raise SystemExit(f"Nothing to remove for {repo}")
1009
- if d.exists() and (issues := _repo_safety_issues(d)): raise SystemExit("Refusing to remove:\n" + "\n".join(f" - {i}" for i in issues))
1010
- _remove_from_repos_file(repos_path, repo)
1011
- _remove_from_pyproject(pyproject_path, names)
1012
- if d.exists():
1013
- try: ans = input(f"Remove directory {d}? [y/N] ")
1014
- except EOFError: ans = ""
1015
- if ans.strip().lower() in ("y", "yes"): shutil.rmtree(d)
1007
+ local_path = _local_repos_path(repos_path)
1008
+ baseline = {_repo_key(r) for r in _load_repos(repos_path)}
1009
+ local = {_repo_key(r) for r in _load_repos(local_path)}
1010
+ entries = _load_repo_entries(repos_path, root)
1011
+ targets = {}
1012
+ for repo in (repo, *repos):
1013
+ repo = _resolve_removal_target(root, repo, repos_path)
1014
+ key = _repo_key(repo)
1015
+ if key in baseline: raise SystemExit(f'{repo} belongs to the shared baseline: edit {repos_path} deliberately to remove it')
1016
+ d = _resolve_repo_dir(root, repo)
1017
+ for r,location,_ in entries:
1018
+ if _repo_key(r) == key and location.resolve() != d:
1019
+ raise SystemExit(f'Refusing to remove {repo}: custom checkout location {location}; manage it explicitly')
1020
+ if d in targets: continue
1021
+ if not d.exists() and key not in local: raise SystemExit(f"Nothing to remove for {repo}")
1022
+ if d.exists() and (issues := _repo_safety_issues(d)):
1023
+ raise SystemExit("Refusing to remove:\n" + "\n".join(f" - {i}" for i in issues))
1024
+ name = _read_pyproject_name(d/"pyproject.toml") if (d/"pyproject.toml").exists() else None
1025
+ targets[d] = repo, name or d.name
1026
+ for d,(repo,name) in targets.items():
1027
+ _remove_from_repos_file(local_path, repo)
1028
+ _remove_from_pyproject(pyproject_path, [name])
1029
+ if d.exists():
1030
+ try: ans = input(f"Remove directory {d}? [y/N] ")
1031
+ except EOFError: ans = ""
1032
+ if ans.strip().lower() in ("y", "yes"): shutil.rmtree(d)
1016
1033
  subprocess.run(["uv", "sync"], check=True, cwd=root)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.16
3
+ Version: 0.0.18
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: fastcore>=2.2.6
13
+ Requires-Dist: fastcore>=2.2.28
14
14
  Requires-Dist: fastgit>=0.1.2
15
15
  Requires-Dist: ghapi>=2.0.6
16
16
  Requires-Dist: packaging
@@ -216,7 +216,7 @@ Each sync regenerates Cargo overrides in the workspace's `.cargo/config.toml`. `
216
216
 
217
217
  Do not commit a `Cargo.lock` generated under these patches. Its source-less local entries cannot be resolved on another machine.
218
218
 
219
- When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved.
219
+ When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved. If neither a wrapper nor `sccache` is present, sync warns with the install command (`cargo install sccache --locked`) and continues; rerun `ws-sync` after installing to enable it.
220
220
 
221
221
  Fastws discovers JavaScript packages directly under the workspace root. It reads each package's `workspaces` list for declared nested packages. These lists accept paths and globs. Fastws ignores undeclared nested packages. Lockfiles do not affect discovery. `[tool.fastws].exclude` matches paths relative to the workspace root. Fastws skips `node_modules` and directories whose names start with `.` or `_`.
222
222
 
@@ -261,15 +261,16 @@ ws-add ~/private # stays where it is, recorded in repos-local.txt with its loca
261
261
 
262
262
  ### `ws-remove`
263
263
 
264
- Delete a personal repo's clone, remove it from `repos-local.txt` and the workspace `pyproject.toml`, then run `uv sync`.
264
+ Delete one or more personal repo clones and remove them from `repos-local.txt` and the workspace `pyproject.toml`. Run `uv sync` once after processing all repos.
265
265
 
266
266
  The command refuses shared baseline members. Edit `repos.txt` deliberately to change the team's baseline. It also refuses external or custom checkout locations, which must be managed explicitly.
267
267
 
268
- Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command always asks for confirmation before deleting anything:
268
+ Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command validates all targets before changing anything. It asks for confirmation before deleting each checkout. Declining keeps that checkout but still removes its workspace metadata. Repeated targets are processed once:
269
269
 
270
270
  ```bash
271
271
  ws-remove owner/personal-project
272
272
  ws-remove personal-project # bare folder name also works if the directory exists
273
+ ws-remove owner/project-one project-two project-three
273
274
  ```
274
275
 
275
276
  ### `ws-releases`
@@ -1,4 +1,4 @@
1
- fastcore>=2.2.6
1
+ fastcore>=2.2.28
2
2
  fastgit>=0.1.2
3
3
  ghapi>=2.0.6
4
4
  packaging
@@ -16,7 +16,7 @@ classifiers = [
16
16
  ]
17
17
 
18
18
  dependencies = [
19
- "fastcore>=2.2.6",
19
+ "fastcore>=2.2.28",
20
20
  "fastgit>=0.1.2",
21
21
  "ghapi>=2.0.6",
22
22
  "packaging",
@@ -0,0 +1,28 @@
1
+ from fastcore.basics import AttrDict
2
+
3
+ import fastws.releases as relmod
4
+
5
+
6
+ def _rel(tag): return AttrDict(tag_name=tag)
7
+
8
+
9
+ def test_newest_tag_by_version_not_publish_order():
10
+ assert relmod._newest_tag([_rel("v0.1.17"), _rel("v0.1.18"), _rel("v0.1.9")]) == "v0.1.18"
11
+ assert relmod._newest_tag([]) is None
12
+
13
+
14
+ def test_skip_pats_defaults_and_config(tmp_path):
15
+ (tmp_path/"pyproject.toml").write_text('[tool.fastws]\nrelease_skip = ["docs only"]\n')
16
+ pats = relmod._skip_pats("wip", root=tmp_path)
17
+ hits = lambda m: any(p.match(m) for p in pats)
18
+ assert all(hits(m) for m in ("bump", "docs only: fix typo", "wip checkpoint"))
19
+ assert not any(hits(m) for m in ("bumpy road ahead", "fixes #30"))
20
+
21
+
22
+ def test_release_report_repr():
23
+ rep = relmod.ReleaseReport([("mdhtml", ["fixes #30"]), ("solveit", None), ("fastcore", []), ("bad", ValueError("boom"))])
24
+ txt = repr(rep)
25
+ assert "mdhtml (1 unreleased):" in txt and " - fixes #30" in txt
26
+ assert "no releases: solveit" in txt
27
+ assert "up to date: fastcore" in txt
28
+ assert "bad: ERROR boom" in txt
@@ -1,12 +1,11 @@
1
1
  import json, os, pytest
2
- from types import SimpleNamespace
3
2
  from fastgit import Git
4
3
  import fastws.core as core
5
4
 
6
5
  os.environ.update(GIT_AUTHOR_NAME='fastws', GIT_AUTHOR_EMAIL='fastws@example.com',
7
6
  GIT_COMMITTER_NAME='fastws', GIT_COMMITTER_EMAIL='fastws@example.com')
8
7
 
9
- WS_META = '[project]\nname = "uvws"\ndependencies = [\n "repo1pkg",\n "keeppkg",\n]\n\n[tool.uv.sources]\nrepo1pkg = { workspace = true }\nkeeppkg = { workspace = true }\n'
8
+ WS_META = '[project]\nname = "uvws"\ndependencies = [\n "repo1pkg",\n "repo2pkg",\n "keeppkg",\n]\n\n[tool.uv.sources]\nrepo1pkg = { workspace = true }\nrepo2pkg = { workspace = true }\nkeeppkg = { workspace = true }\n'
10
9
 
11
10
 
12
11
  def mk_repo(d, origin=None):
@@ -25,19 +24,6 @@ def mk_repo(d, origin=None):
25
24
  return g
26
25
 
27
26
 
28
- @pytest.fixture
29
- def fake_uv(monkeypatch):
30
- "Intercept uv/cargo invocations (external tools stay out of tests); real git passes through"
31
- calls, real = [], core.subprocess.run
32
- def run(cmd, **kw):
33
- if cmd[0] in ('uv', 'cargo'):
34
- calls.append(cmd)
35
- return SimpleNamespace(returncode=0, stdout='', stderr='')
36
- return real(cmd, **kw)
37
- monkeypatch.setattr(core.subprocess, 'run', run)
38
- return calls
39
-
40
-
41
27
  def test_repos_file_roundtrip(tmp_path):
42
28
  repos_path = tmp_path/'repos.txt'
43
29
  repos_path.write_text('# header\nAnswerDotAI/fastws[dev]\njph00/private[docs] ~/private\nfastai/fastai sub/dir\n')
@@ -82,63 +68,6 @@ def test_invalid_repo_extras(entry):
82
68
  with pytest.raises(SystemExit, match='Invalid repo entry'): core._parse_repo_line(entry)
83
69
 
84
70
 
85
- async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_path, monkeypatch, fake_uv):
86
- origins, seed, root = tmp_path/'origins', tmp_path/'seed', tmp_path/'ws'
87
- config = tmp_path/'gitconfig'
88
- config.write_text(f'[init]\ndefaultBranch = main\n[url "{origins.as_uri()}/"]\ninsteadOf = git@github.com:org/\n')
89
- monkeypatch.setenv('GIT_CONFIG_GLOBAL', str(config))
90
- monkeypatch.setattr(core, '_parse_github_repo', lambda url: f'org/{core.Path(url).stem}' if str(origins) in url else None)
91
- async def offline_heads(refs): raise ValueError('No GitHub API in local Git test')
92
- monkeypatch.setattr(core, '_remote_heads', offline_heads)
93
- for name in ('old', 'new', 'extra'):
94
- d = tmp_path/name
95
- d.mkdir()
96
- (d/'pyproject.toml').write_text(f'[project]\nname = "{name}-pkg"\n[project.optional-dependencies]\ndev = []\ntest = []\n')
97
- mk_repo(d, origin=origins/f'{name}.git')
98
- seed.mkdir()
99
- (seed/'repos.txt').write_text('org/old[dev]\n')
100
- (seed/'.gitignore').write_text('*/\npyproject.toml\nrepos-local.txt\n')
101
- (seed/'pyproject.tmpl').write_text('[project]\nname = "workspace"\ndependencies = []\n[tool.uv.workspace]\nmembers = ["./*"]\n')
102
- g = mk_repo(seed, origin=origins/'workspace.git')
103
- Git(tmp_path, raise_exc=True).clone(str(origins/'workspace.git'), str(root))
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')
106
-
107
- await core.ws_sync(str(root))
108
- assert (root/'old'/'pyproject.toml').exists()
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'}
112
-
113
- (seed/'repos.txt').write_text('org/new\n')
114
- g.commit('-a', m='new shared baseline')
115
- g.push()
116
- await core.ws_sync(str(root))
117
- assert (root/'new'/'pyproject.toml').exists()
118
- assert (root/'old'/'pyproject.toml').exists()
119
- assert (root/'repos.txt').read_text() == 'org/new\n'
120
- assert set(core._load_repos(root/'repos-local.txt')) == {'org/extra', 'org/old'}
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'}
125
-
126
- (seed/'repos.txt').write_text('org/missing\n')
127
- g.commit('-a', m='unavailable member')
128
- g.push()
129
- fake_uv.clear()
130
- with pytest.raises(SystemExit, match='clone'): await core.ws_sync(str(root))
131
- assert not fake_uv
132
-
133
- (root/'repos.txt').write_text('local edits\n')
134
- (seed/'repos.txt').write_text('org/new\n')
135
- g.commit('-a', m='restore baseline')
136
- g.push()
137
- with pytest.raises(core.subprocess.CalledProcessError): await core.ws_sync(str(root))
138
- assert (root/'repos.txt').read_text() == 'local edits\n'
139
- assert not fake_uv
140
-
141
-
142
71
  def test_ws_pyproject_from_template_adds_projects(tmp_path):
143
72
  (tmp_path/'pyproject.tmpl').write_text('[project]\nname = "uvws"\ndependencies = [\n "ipython>=8.34.0",\n]\n\n[tool.uv.sources]\n\n')
144
73
  for name in ('alpha', 'beta'):
@@ -298,49 +227,26 @@ async def test_git_repo_resolution(tmp_path):
298
227
  assert sorted(await core._discover_ws_repos(tmp_path)) == ['AnswerDotAI/Proj', 'AnswerDotAI/crate']
299
228
 
300
229
 
301
- def test_ws_remove_workflow(tmp_path, monkeypatch, fake_uv):
230
+ def test_ws_remove_refuses_unsafe_repos(tmp_path):
302
231
  (tmp_path/'repos.txt').write_text('AnswerDotAI/keep\n')
303
232
  local = tmp_path/'repos-local.txt'
304
- local.write_text('AnswerDotAI/repo1\norg/external ../external\n')
233
+ local.write_text('AnswerDotAI/repo1\nAnswerDotAI/repo2\norg/external ../external\n')
305
234
  (tmp_path/'pyproject.toml').write_text(WS_META)
306
- repo = tmp_path/'repo1'
307
- repo.mkdir()
308
- (repo/'pyproject.toml').write_text('[project]\nname = "repo1pkg"\n')
309
- g = mk_repo(repo, origin=tmp_path/'origins'/'repo1')
310
- answer = ['y']
311
- monkeypatch.setattr('builtins.input', lambda *a: answer[0])
312
-
313
- with pytest.raises(SystemExit, match='baseline'): core.ws_remove('AnswerDotAI/keep', workspace=str(tmp_path))
314
- with pytest.raises(SystemExit, match='location'): core.ws_remove('org/external', workspace=str(tmp_path))
315
- assert 'org/external ../external' in local.read_text()
316
- assert (tmp_path/'repos.txt').read_text() == 'AnswerDotAI/keep\n'
317
- assert not fake_uv
318
-
319
- # a dirty tree refuses before any mutation
320
- (repo/'pyproject.toml').write_text('[project]\nname = "repo1pkg"\nversion = "1"\n')
321
- with pytest.raises(SystemExit): core.ws_remove('AnswerDotAI/repo1', workspace=str(tmp_path))
322
- assert repo.exists() and 'repo1' in local.read_text()
235
+ repo, repo2 = tmp_path/'repo1', tmp_path/'repo2'
236
+ for d in repo, repo2:
237
+ d.mkdir()
238
+ (d/'pyproject.toml').write_text(f'[project]\nname = "{d.name}pkg"\n')
239
+ g = mk_repo(d, origin=tmp_path/'origins'/d.name)
240
+ with pytest.raises(SystemExit, match='baseline'): core.ws_remove('repo1', 'AnswerDotAI/keep', workspace=str(tmp_path))
241
+ with pytest.raises(SystemExit, match='location'): core.ws_remove('repo1', 'org/external', workspace=str(tmp_path))
323
242
 
324
- # so does an unpushed commit on a clean tree
243
+ (repo2/'pyproject.toml').write_text('[project]\nname = "repo2pkg"\nversion = "1"\n')
244
+ with pytest.raises(SystemExit, match='uncommitted'): core.ws_remove('repo1', 'repo2', workspace=str(tmp_path))
325
245
  g.commit('-a', m='ahead')
326
- with pytest.raises(SystemExit): core.ws_remove('AnswerDotAI/repo1', workspace=str(tmp_path))
327
- assert repo.exists()
328
-
329
- # pushed and clean: answering 'n' keeps the directory but still removes the metadata and re-syncs
330
- g.push()
331
- answer[0] = 'n'
332
- core.ws_remove('AnswerDotAI/repo1', workspace=str(tmp_path))
333
- assert repo.exists()
334
- assert 'repo1' not in local.read_text()
335
- assert 'repo1pkg' not in (tmp_path/'pyproject.toml').read_text()
336
- assert ['uv', 'sync'] in fake_uv
337
-
338
- # answering 'y' (by folder name this time) also deletes the checkout
339
- local.write_text('AnswerDotAI/repo1\n')
340
- (tmp_path/'pyproject.toml').write_text(WS_META)
341
- answer[0] = 'y'
342
- core.ws_remove('repo1', workspace=str(tmp_path))
343
- assert not repo.exists()
246
+ with pytest.raises(SystemExit, match='unpushed'): core.ws_remove('repo1', 'repo2', workspace=str(tmp_path))
247
+ assert repo.exists() and repo2.exists()
248
+ assert local.read_text() == 'AnswerDotAI/repo1\nAnswerDotAI/repo2\norg/external ../external\n'
249
+ assert (tmp_path/'pyproject.toml').read_text() == WS_META
344
250
  assert (tmp_path/'repos.txt').read_text() == 'AnswerDotAI/keep\n'
345
251
 
346
252
 
@@ -410,31 +316,6 @@ def test_cargo_key_ignores_unused_patch_order(tmp_path):
410
316
  assert key.read_text() != first
411
317
 
412
318
 
413
- async def test_changed_dirs_pulls_only_moved_repos(tmp_path, monkeypatch):
414
- # ahead: a second clone pushes to the shared bare origin, so `ahead`'s origin/main falls behind
415
- ahead, current, weird = tmp_path/'ahead', tmp_path/'current', tmp_path/'weird'
416
- for d in (ahead, current):
417
- d.mkdir()
418
- g = mk_repo(d, origin=tmp_path/'origins'/d.name)
419
- g.remote('set-url', '--push', 'origin', str(tmp_path/'origins'/d.name))
420
- other = tmp_path/'other'
421
- Git(tmp_path, raise_exc=True).clone(str(tmp_path/'origins'/'ahead'), str(other))
422
- og = Git(other, raise_exc=True)
423
- (other/'g.txt').write_text('new')
424
- og.add('.')
425
- og.commit(m='advance')
426
- og.push()
427
- weird.mkdir()
428
- mk_repo(weird) # no origin remote: can't be checked, so it must be pulled
429
-
430
- async def fake_heads(refs): return [str(Git(tmp_path/'origins'/spec.split('/')[1], raise_exc=True).rev_parse(branch)) for spec, branch in refs]
431
- monkeypatch.setattr(core, '_remote_heads', fake_heads)
432
- monkeypatch.setattr(core, '_parse_github_repo', lambda url: f'o/{core.Path(url).name}' if 'origins' in url else None)
433
-
434
- changed = await core._changed_dirs([ahead, current, weird])
435
- assert set(changed) == {ahead, weird}
436
-
437
-
438
319
  def test_sync_cargo_patches_generates_and_preserves(tmp_path):
439
320
  (tmp_path/'.cargo').mkdir()
440
321
  config = tmp_path/'.cargo'/'config.toml'
@@ -475,25 +356,9 @@ def test_sync_cargo_patches_generates_and_preserves(tmp_path):
475
356
  assert core.tomllib.loads((bare/'.cargo'/'config.toml').read_text())['patch']['crates-io']['c']['path'] == str(bare/'c')
476
357
 
477
358
 
478
- def test_sync_cargo_wrapper(tmp_path, monkeypatch):
359
+ def test_sync_cargo_wrapper_preserves_existing(tmp_path):
479
360
  config = tmp_path/'.cargo'/'config.toml'
480
361
  config.parent.mkdir()
481
- config.write_text('[term]\nquiet = true\n')
482
- monkeypatch.setattr(core.shutil, 'which', lambda name: '/opt/homebrew/bin/sccache')
483
-
484
- assert core._sync_cargo_wrapper(tmp_path)
485
- data = core.tomllib.loads(config.read_text())
486
- assert data['build']['rustc-wrapper'] == '/opt/homebrew/bin/sccache'
487
- assert data['term']['quiet'] is True
488
- assert not core._sync_cargo_wrapper(tmp_path)
489
-
490
- (tmp_path/'crate').mkdir()
491
- (tmp_path/'crate'/'Cargo.toml').write_text('[package]\nname = "crate"\n')
492
- core._sync_cargo_patches(tmp_path)
493
- content = config.read_text()
494
- assert not core._sync_cargo_wrapper(tmp_path)
495
- assert core._sync_cargo_patches(tmp_path) == ([], []) and config.read_text() == content
496
-
497
362
  config.write_text('[build]\nrustc-wrapper = "other-cache"\n')
498
363
  assert not core._sync_cargo_wrapper(tmp_path)
499
364
  assert core.tomllib.loads(config.read_text())['build']['rustc-wrapper'] == 'other-cache'
@@ -524,54 +389,13 @@ def test_npm_dirs_honours_fastws_exclude(tmp_path):
524
389
  assert core._npm_dirs(tmp_path) == [tmp_path/'lib', tmp_path/'py', tmp_path/'py'/'wasm']
525
390
 
526
391
 
527
- def test_sync_ws_package_json_generates_and_preserves(tmp_path):
528
- pkg = tmp_path/'package.json'
529
- pkg.write_text('{\n "name": "ws",\n "private": true,\n "workspaces": ["../outside", "gone", "tools/*"]\n}\n')
530
- members = [tmp_path/'app', tmp_path/'crate'/'wasm']
531
-
532
- added, removed = core._sync_ws_package_json(tmp_path, members)
533
- data = json.loads(pkg.read_text())
534
- # kept: entries outside the root and globs; managed: existing dirs regenerated from discovery
535
- assert data['workspaces'] == ['../outside', 'tools/*', 'app', 'crate/wasm']
536
- assert data['name'] == 'ws' and data['private'] is True # other keys untouched
537
- assert added == ['app', 'crate/wasm'] and removed == ['gone']
538
-
539
- content = pkg.read_text()
540
- assert core._sync_ws_package_json(tmp_path, members) == ([], [])
541
- assert pkg.read_text() == content
542
-
543
- # a missing root package.json is created
544
- bare = tmp_path/'ws2'
545
- bare.mkdir()
546
- assert core._sync_ws_package_json(bare, [bare/'a']) == (['a'], [])
547
- assert json.loads((bare/'package.json').read_text()) == {'private': True, 'workspaces': ['a']}
548
-
549
-
550
- @pytest.mark.parametrize('workspaces', [{'packages': ['packages/*']}, {}])
551
- def test_sync_ws_package_json_rejects_object_form(tmp_path, workspaces):
552
- pkg = tmp_path/'package.json'
553
- content = json.dumps({'private': True, 'workspaces': workspaces}) + '\n'
554
- pkg.write_text(content)
555
-
556
- with pytest.raises(SystemExit, match='workspaces.*list') as exc: core._sync_ws_package_json(tmp_path, [tmp_path/'app'])
557
- assert str(pkg) in str(exc.value)
558
- assert pkg.read_text() == content
559
-
560
-
561
- def test_sync_ws_package_json_merges_shared_settings(tmp_path):
392
+ def test_sync_ws_package_json_preserves_local_and_merges_shared(tmp_path):
562
393
  pkg, shared = tmp_path/'package.json', tmp_path/'package.json.shared'
563
- pkg.write_text(json.dumps({'name': 'local', 'workspaces': ['app'], 'keywords': ['local'],
564
- 'allowScripts': {'wasm-pack@0.15.0': False, 'other': False}}))
565
- shared.write_text(json.dumps({'keywords': ['shared'], 'allowScripts': {'wasm-pack@0.15.0': True}}))
566
- assert core._sync_ws_package_json(tmp_path, [tmp_path/'app']) == ([], [])
567
- assert json.loads(pkg.read_text()) == {'name': 'local', 'workspaces': ['app'], 'keywords': ['shared'],
568
- 'allowScripts': {'wasm-pack@0.15.0': True, 'other': False}}
569
- content, mtime = pkg.read_text(), pkg.stat().st_mtime_ns
570
- core._sync_ws_package_json(tmp_path, [tmp_path/'app'])
571
- assert pkg.stat().st_mtime_ns == mtime
572
- shared.write_text('{}')
573
- core._sync_ws_package_json(tmp_path, [tmp_path/'app'])
574
- assert pkg.read_text() == content
394
+ pkg.write_text(json.dumps({'name': 'local', 'workspaces': ['../outside', 'gone', 'tools/*'], 'allowScripts': {'other': False}}))
395
+ shared.write_text('{"allowScripts": {"wasm-pack": true}}')
396
+ assert core._sync_ws_package_json(tmp_path, [tmp_path/'app']) == (['app'], ['gone'])
397
+ assert json.loads(pkg.read_text()) == {'name': 'local', 'workspaces': ['../outside', 'tools/*', 'app'],
398
+ 'allowScripts': {'other': False, 'wasm-pack': True}}
575
399
 
576
400
 
577
401
  def test_ws_excludes_treat_npm_only_dirs_like_cargo_only(tmp_path):
@@ -585,44 +409,7 @@ def test_ws_excludes_treat_npm_only_dirs_like_cargo_only(tmp_path):
585
409
  assert core._pending_dirs(tmp_path) == ['pending']
586
410
 
587
411
 
588
- def test_native_js_requires_rust_and_build_script(tmp_path):
589
- app, crate, wasm = [tmp_path/n for n in ('app', 'crate', 'wasm')]
590
- for d in (app, crate, wasm): d.mkdir()
591
- (app/'package.json').write_text('{"scripts": {"build": "vite build"}}')
592
- for d in (crate, wasm): (d/'Cargo.toml').write_text('')
593
- (crate/'package.json').write_text('{}')
594
- (wasm/'package.json').write_text('{"scripts": {"build": "cargo build"}}')
595
- assert core._native_js([app, crate, wasm]) == [wasm]
596
-
597
-
598
- def test_check_rustup_missing(monkeypatch):
599
- monkeypatch.setattr(core.shutil, 'which', lambda tool: None)
600
- with pytest.raises(SystemExit, match='Install it from https://rustup.rs'): core._check_rustup()
601
-
602
-
603
- @pytest.mark.parametrize('shadowed', [None, 'rustc', 'cargo'])
604
- def test_check_rustup_proxies(tmp_path, monkeypatch, shadowed):
605
- rustup = tmp_path/'rustup'
606
- rustup.touch()
607
- (tmp_path/'rustc').symlink_to(rustup)
608
- (tmp_path/'cargo').hardlink_to(rustup)
609
- other = tmp_path/'brew-rust'
610
- other.touch()
611
- monkeypatch.setattr(core.shutil, 'which', lambda tool: str(other if tool == shadowed else tmp_path/tool))
612
- if shadowed:
613
- with pytest.raises(SystemExit, match=f'{shadowed} resolves to') as err: core._check_rustup()
614
- assert 'Homebrew Rust can remain installed' in str(err.value)
615
- assert 'PATH' in str(err.value)
616
- else: core._check_rustup()
617
-
618
-
619
- async def test_sync_checks_rustup_before_installs(tmp_path, monkeypatch, fake_uv):
620
- (tmp_path/'repos.txt').write_text('')
621
- (tmp_path/'pyproject.toml').write_text('[project]\nname = "ws"\n[tool.uv.workspace]\nmembers = ["*"]\n')
622
- wasm = tmp_path/'wasm'
623
- wasm.mkdir()
624
- (wasm/'Cargo.toml').write_text('[package]\nname = "wasm"\n')
625
- (wasm/'package.json').write_text('{"name": "wasm", "scripts": {"build": "wasm-pack build"}}')
626
- monkeypatch.setattr(core.shutil, 'which', lambda tool: None)
627
- with pytest.raises(SystemExit, match='require rustup'): await core.ws_sync(str(tmp_path))
628
- assert not fake_uv
412
+ def test_check_rustup_rejects_shadowed_compiler(tmp_path, monkeypatch):
413
+ for tool in ('rustup', 'rustc'): (tmp_path/tool).touch(mode=0o755)
414
+ monkeypatch.setenv('PATH', str(tmp_path))
415
+ with pytest.raises(SystemExit, match='rustc resolves to'): core._check_rustup()
@@ -1,96 +0,0 @@
1
- import asyncio, pytest
2
-
3
- from fastcore.basics import AttrDict
4
-
5
- import fastws.releases as relmod
6
-
7
-
8
- def _rel(tag): return AttrDict(tag_name=tag)
9
-
10
-
11
- def test_newest_tag_by_version_not_publish_order():
12
- assert relmod._newest_tag([_rel("v0.1.17"), _rel("v0.1.18"), _rel("v0.1.9")]) == "v0.1.18"
13
- assert relmod._newest_tag([]) is None
14
-
15
-
16
- def test_skip_pats_defaults_and_config(tmp_path):
17
- (tmp_path/"pyproject.toml").write_text('[tool.fastws]\nrelease_skip = ["docs only"]\n', encoding="utf-8")
18
- pats = relmod._skip_pats("wip", root=tmp_path)
19
- hits = lambda m: any(p.match(m) for p in pats)
20
- assert hits("bump") and hits("Bump version to 0.2.2") and hits("update .gitignore")
21
- assert hits("docs only: fix typo") # workspace config
22
- assert hits("wip checkpoint") # per-call extra
23
- assert not hits("bumpy road ahead") # bump$ is anchored
24
- assert not hits("fixes #30")
25
- for m in (".gitignore", "gitignore", "docs", "doc", "clean", "CI", "meta", "ignore", "allowed_metadata_keys"): assert hits(m)
26
- for m in ("deps", "CI fixups", "document the API", "cleanup tests"): assert not hits(m) # deps IS release-worthy; anchored bare words only
27
-
28
-
29
- def test_release_report_repr():
30
- rep = relmod.ReleaseReport([("mdhtml", ["fixes #30"]), ("solveit", None), ("fastcore", []), ("bad", ValueError("boom"))])
31
- txt = repr(rep)
32
- assert "mdhtml (1 unreleased):" in txt and " - fixes #30" in txt
33
- assert "no releases: solveit" in txt
34
- assert "up to date: fastcore" in txt
35
- assert "bad: ERROR boom" in txt
36
-
37
-
38
- def test_release_exclude(tmp_path, monkeypatch):
39
- (tmp_path/"repos.txt").write_text("AnswerDotAI/libx\nAnswerDotAI/appy\n", encoding="utf-8")
40
- (tmp_path/"pyproject.toml").write_text('[tool.fastws]\nrelease_exclude = ["Appy"]\n', encoding="utf-8")
41
- checked = []
42
- async def fake(repo, skip=None): return checked.append(repo) or []
43
- monkeypatch.setattr(relmod, "check_release", fake)
44
-
45
- rep = asyncio.run(relmod.check_releases(workspace=str(tmp_path)))
46
-
47
- assert checked == ["AnswerDotAI/libx"] # appy excluded, case-insensitively
48
- assert [r for r, _ in rep] == ["libx"]
49
-
50
-
51
- def _member(tmp_path, dirname, pkg=None, deps=()):
52
- d = tmp_path/dirname
53
- d.mkdir()
54
- dep_s = ", ".join(f'"{x}"' for x in deps)
55
- (d/"pyproject.toml").write_text(f'[project]\nname = "{pkg or dirname}"\ndependencies = [{dep_s}]\n', encoding="utf-8")
56
-
57
-
58
- def test_check_releases_scoping(tmp_path, monkeypatch):
59
- (tmp_path/"repos.txt").write_text("AnswerDotAI/libx\nAnswerDotAI/appy\n", encoding="utf-8")
60
- (tmp_path/"repos-local.txt").write_text("AnswerDotAI/exty\n", encoding="utf-8")
61
- _member(tmp_path, "libx", pkg="libx-cli")
62
- _member(tmp_path, "appy", deps=["libx-cli"])
63
- _member(tmp_path, "exty")
64
- checked = []
65
- async def fake(repo, skip=None): return checked.append(repo) or []
66
- monkeypatch.setattr(relmod, "check_release", fake)
67
- run = lambda **kw: asyncio.run(relmod.check_releases(workspace=str(tmp_path), **kw))
68
-
69
- monkeypatch.chdir(tmp_path)
70
- run()
71
- assert checked == ["AnswerDotAI/libx", "AnswerDotAI/appy", "AnswerDotAI/exty"] # at root: full sweep
72
-
73
- checked.clear()
74
- (tmp_path/"appy"/"sub").mkdir()
75
- monkeypatch.chdir(tmp_path/"appy"/"sub")
76
- run()
77
- assert checked == ["AnswerDotAI/libx", "AnswerDotAI/appy"] # inside a member: its closure, dir<->package mapped
78
-
79
- checked.clear()
80
- run(nodeps=True)
81
- assert checked == ["AnswerDotAI/appy"] # nodeps: the repo alone
82
-
83
- checked.clear()
84
- monkeypatch.chdir(tmp_path)
85
- run(project="appy")
86
- assert checked == ["AnswerDotAI/libx", "AnswerDotAI/appy"] # explicit project: closure, as before
87
-
88
- checked.clear()
89
- run(project="libx")
90
- assert checked == ["AnswerDotAI/libx"] # explicit project by dir name maps to its package
91
-
92
- checked.clear()
93
- run(project="appy", nodeps=True)
94
- assert checked == ["AnswerDotAI/appy"]
95
-
96
- with pytest.raises(SystemExit): run(nodeps=True) # no project in play
File without changes
File without changes