fastws-cli 0.0.17__tar.gz → 0.0.19__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.17
3
+ Version: 0.0.19
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.29
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
@@ -208,7 +208,7 @@ By default, `ws-sync` uses the active venv's parent as the workspace root. It cr
208
208
 
209
209
  Project scanning respects `tool.uv.workspace.members` and `exclude`. If a member directory lacks `pyproject.toml`, such as a fresh empty clone, sync warns and skips the `uv sync` step.
210
210
 
211
- Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level directories without a valid Python project. It also excludes listed Rust or JavaScript projects without `pyproject.toml`. For a listed checkout with none of `pyproject.toml`, `Cargo.toml`, or `package.json`, sync warns and skips installation.
211
+ Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level directories without a valid Python project. It also excludes listed Rust or JavaScript projects without `pyproject.toml`. It also excludes listed project templates, whose `[project].name` still holds a `{placeholder}`, because uv cannot parse that name. For a listed checkout with none of `pyproject.toml`, `Cargo.toml`, or `package.json`, sync warns and skips installation.
212
212
 
213
213
  Existing globs, entries for missing directories, and entries for checkouts that are still not Python projects are retained. Use `exclude = [...]` under `[tool.fastws]` to specify exclusions the scan cannot infer, such as keeping a valid project out of the workspace. Adding members preserves hand-written `[tool.uv.sources]` entries, including path and Git sources.
214
214
 
@@ -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
 
@@ -183,7 +183,7 @@ By default, `ws-sync` uses the active venv's parent as the workspace root. It cr
183
183
 
184
184
  Project scanning respects `tool.uv.workspace.members` and `exclude`. If a member directory lacks `pyproject.toml`, such as a fresh empty clone, sync warns and skips the `uv sync` step.
185
185
 
186
- Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level directories without a valid Python project. It also excludes listed Rust or JavaScript projects without `pyproject.toml`. For a listed checkout with none of `pyproject.toml`, `Cargo.toml`, or `package.json`, sync warns and skips installation.
186
+ Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level directories without a valid Python project. It also excludes listed Rust or JavaScript projects without `pyproject.toml`. It also excludes listed project templates, whose `[project].name` still holds a `{placeholder}`, because uv cannot parse that name. For a listed checkout with none of `pyproject.toml`, `Cargo.toml`, or `package.json`, sync warns and skips installation.
187
187
 
188
188
  Existing globs, entries for missing directories, and entries for checkouts that are still not Python projects are retained. Use `exclude = [...]` under `[tool.fastws]` to specify exclusions the scan cannot infer, such as keeping a valid project out of the workspace. Adding members preserves hand-written `[tool.uv.sources]` entries, including path and Git sources.
189
189
 
@@ -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
 
@@ -1,4 +1,6 @@
1
- __version__ = "0.0.17"
1
+ __version__ = "0.0.19"
2
+
3
+
2
4
 
3
5
 
4
6
 
@@ -176,13 +176,21 @@ def _remove_from_pyproject(pyproject_path: Path, names: list[str]) -> list[str]:
176
176
  pyproject_path.write_text(content)
177
177
  return removed
178
178
 
179
- def _read_pyproject_name(path: Path) -> str|None:
179
+ def _pyproject_name(path: Path) -> str|None:
180
+ "`[project].name` as written, with any `{placeholder}` slots left in"
180
181
  try: data = tomllib.loads(path.read_text())
181
182
  except tomllib.TOMLDecodeError:
182
183
  print(f"Skipping invalid TOML: {path}")
183
184
  return None
184
185
  name = data.get("project", {}).get("name")
185
- return name if isinstance(name, str) and name and "{" not in name and "}" not in name else None
186
+ return name if isinstance(name, str) and name else None
187
+
188
+ def _is_placeholder(name: str|None) -> bool: return bool(name) and ("{" in name or "}" in name)
189
+
190
+ def _read_pyproject_name(path: Path) -> str|None:
191
+ "`[project].name`, or None when it is missing or still a template placeholder"
192
+ name = _pyproject_name(path)
193
+ return None if _is_placeholder(name) else name
186
194
 
187
195
  def _ws_projects(root: Path) -> list[str]:
188
196
  return [name for d in (o for o in _ws_dirs(root) if (o/"pyproject.toml").exists())
@@ -208,6 +216,10 @@ def _non_python_project(d: Path) -> bool:
208
216
  "A Rust or JS project without a Python layer is not a pending Python scaffold."
209
217
  return not (d/"pyproject.toml").exists() and any((d/f).exists() for f in ("Cargo.toml", "package.json"))
210
218
 
219
+ def _template_dir(d: Path) -> bool:
220
+ "A project template. Its `[project].name` still holds `{placeholder}` slots, so uv cannot parse it."
221
+ return (d/"pyproject.toml").exists() and _is_placeholder(_pyproject_name(d/"pyproject.toml"))
222
+
211
223
  def _pending_dirs(root: Path) -> list[str]:
212
224
  "uv workspace dirs that are not Python projects yet; sync stops rather than let uv fail on them"
213
225
  return [d.name for d in _ws_dirs(root) if not (d/"pyproject.toml").exists()]
@@ -218,7 +230,7 @@ def _sync_ws_excludes(pyproject_path: Path, root: Path, tracked: set[str]) -> tu
218
230
  Kept as-is: `[tool.fastws].exclude` entries (intent), globs, missing dirs, and tracked dirs
219
231
  (repos.txt checkouts) that are still not valid Python projects. Auto-managed: entries for other
220
232
  existing dirs are regenerated each sync, excluding dirs without a valid pyproject (tracked dirs
221
- only when they are Cargo-only crates or npm-only packages, since a tracked dir with none of those files is a pending member
233
+ only when they are Cargo-only crates, npm-only packages, or templates with a `{placeholder}` name, since a tracked dir with none of those files is a pending member
222
234
  awaiting scaffolding) and un-excluding dirs that gained one; deliberately excluding a real
223
235
  project takes a `[tool.fastws]` entry."""
224
236
  if not pyproject_path.exists(): return [], []
@@ -231,7 +243,7 @@ def _sync_ws_excludes(pyproject_path: Path, root: Path, tracked: set[str]) -> tu
231
243
  kept = [e for e in cur if e in intent or any(c in e for c in "*?[") or not (root/e).is_dir() or (e in tracked and not _valid_project_dir(root/e))]
232
244
  kept += [e for e in intent if e not in kept]
233
245
  auto = [d.name for d in _project_dirs(root, exclude=kept) if not d.name.startswith(".") and any(_matches_ws(d.name, m) for m in members)
234
- and (d.name not in tracked or _non_python_project(d)) and not _valid_project_dir(d)]
246
+ and (d.name not in tracked or _non_python_project(d) or _template_dir(d)) and not _valid_project_dir(d)]
235
247
  survivors = set(kept) | set(auto)
236
248
  new = [e for e in cur if e in survivors] + [e for e in kept + auto if e not in cur]
237
249
  if new == cur: return [], []
@@ -694,11 +706,14 @@ def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
694
706
 
695
707
  def _sync_cargo_wrapper(root: Path) -> bool:
696
708
  "Add sccache to the generated Cargo config when installed, without overriding another wrapper"
697
- if not (wrapper := shutil.which("sccache")): return False
698
709
  config = root/".cargo"/"config.toml"
699
710
  content = config.read_text() if config.exists() else ""
700
711
  data = tomllib.loads(content) if content else {}
701
712
  if data.get("build", {}).get("rustc-wrapper"): return False
713
+ if not (wrapper := shutil.which("sccache")):
714
+ print("⚠️ sccache not found; Rust builds won't use compiler caching.\n"
715
+ "Install it with: cargo install sccache --locked\nThen rerun ws-sync to enable it.")
716
+ return False
702
717
  line = f'rustc-wrapper = {json.dumps(wrapper)}\n'
703
718
  if match := re.search(r"(?m)^\[build\][^\n]*\n", content): new = content[:match.end()] + line + content[match.end():]
704
719
  else: new = content.rstrip() + ("\n\n" if content.strip() else "") + "[build]\n" + line
@@ -839,7 +854,13 @@ def _npm_install(root: Path):
839
854
  def _sync_js(root: Path, members: list[Path]) -> list[Path]:
840
855
  "Install the JS workspace, run every native member's build script, and return those members. Cargo handles incremental compilation."
841
856
  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")
857
+ if not shutil.which(tool):
858
+ msg = f"{tool} is not installed: install it, or set [tool.fastws].js to a package manager that is"
859
+ if tool == "npm": msg += ("\n\nInstall Node.js and npm using nvm:\n"
860
+ "curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash\n"
861
+ "# Restart your terminal, then:\n"
862
+ "nvm install --lts")
863
+ raise SystemExit(msg)
843
864
  if tool == "npm": _npm_install(root)
844
865
  else: subprocess.run([tool, "install"], check=True, cwd=root)
845
866
  built = _native_js(members)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.17
3
+ Version: 0.0.19
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.29
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
@@ -208,7 +208,7 @@ By default, `ws-sync` uses the active venv's parent as the workspace root. It cr
208
208
 
209
209
  Project scanning respects `tool.uv.workspace.members` and `exclude`. If a member directory lacks `pyproject.toml`, such as a fresh empty clone, sync warns and skips the `uv sync` step.
210
210
 
211
- Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level directories without a valid Python project. It also excludes listed Rust or JavaScript projects without `pyproject.toml`. For a listed checkout with none of `pyproject.toml`, `Cargo.toml`, or `package.json`, sync warns and skips installation.
211
+ Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level directories without a valid Python project. It also excludes listed Rust or JavaScript projects without `pyproject.toml`. It also excludes listed project templates, whose `[project].name` still holds a `{placeholder}`, because uv cannot parse that name. For a listed checkout with none of `pyproject.toml`, `Cargo.toml`, or `package.json`, sync warns and skips installation.
212
212
 
213
213
  Existing globs, entries for missing directories, and entries for checkouts that are still not Python projects are retained. Use `exclude = [...]` under `[tool.fastws]` to specify exclusions the scan cannot infer, such as keeping a valid project out of the workspace. Adding members preserves hand-written `[tool.uv.sources]` entries, including path and Git sources.
214
214
 
@@ -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
 
@@ -1,4 +1,4 @@
1
- fastcore>=2.2.29
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.29",
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,5 +1,4 @@
1
- import json, os, runpy, shutil, sys, pytest
2
- from types import SimpleNamespace
1
+ import json, os, pytest
3
2
  from fastgit import Git
4
3
  import fastws.core as core
5
4
 
@@ -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'):
@@ -194,19 +123,19 @@ def test_ws_excludes_generates_from_intent_and_auto(tmp_path):
194
123
  pyproject.write_text(
195
124
  '[project]\nname = "uvws"\n\n[tool.uv.workspace]\nmembers = ["./*"]\n'
196
125
  'exclude = ["_*", "tmp", "example", "stale", "pending"]\n\n[tool.fastws]\nexclude = ["wanted-out"]\n')
197
- for name in ('junk', 'fresh-clone', 'realpkg', 'tmpl', 'example', 'stale', 'wanted-out', 'pending', 'rustcrate'): (tmp_path/name).mkdir()
126
+ for name in ('junk', 'fresh-clone', 'realpkg', 'tmpl', 'tracked-tmpl', 'example', 'stale', 'wanted-out', 'pending', 'rustcrate'): (tmp_path/name).mkdir()
198
127
  (tmp_path/'realpkg'/'pyproject.toml').write_text('[project]\nname = "realpkg"\n')
199
- (tmp_path/'tmpl'/'pyproject.toml').write_text('[project]\nname = "{repo}"\n')
128
+ for name in ('tmpl', 'tracked-tmpl'): (tmp_path/name/'pyproject.toml').write_text('[project]\nname = "{repo}"\n')
200
129
  (tmp_path/'rustcrate'/'Cargo.toml').write_text('[package]\nname = "rustcrate"\n')
201
130
  for name in ('example', 'stale', 'wanted-out'): (tmp_path/name/'pyproject.toml').write_text(f'[project]\nname = "{name}"\n')
202
131
 
203
- tracked = {'fresh-clone', 'example', 'pending', 'rustcrate'}
132
+ tracked = {'fresh-clone', 'example', 'pending', 'rustcrate', 'tracked-tmpl'}
204
133
  added, removed = core._sync_ws_excludes(pyproject, tmp_path, tracked)
205
134
  data = core.tomllib.loads(pyproject.read_text())
206
135
 
207
- # kept: glob, missing dir, tracked non-project; auto: junk, placeholder tmpl, tracked Cargo-only crate; intent: wanted-out; dropped: valid projects, tracked or not
208
- assert set(data['tool']['uv']['workspace']['exclude']) == {'_*', 'tmp', 'pending', 'wanted-out', 'junk', 'tmpl', 'rustcrate'}
209
- assert set(added) == {'wanted-out', 'junk', 'tmpl', 'rustcrate'}
136
+ # kept: glob, missing dir, tracked non-project; auto: junk, placeholder templates tracked or not, tracked Cargo-only crate; intent: wanted-out; dropped: valid projects, tracked or not
137
+ assert set(data['tool']['uv']['workspace']['exclude']) == {'_*', 'tmp', 'pending', 'wanted-out', 'junk', 'tmpl', 'tracked-tmpl', 'rustcrate'}
138
+ assert set(added) == {'wanted-out', 'junk', 'tmpl', 'tracked-tmpl', 'rustcrate'}
210
139
  assert set(removed) == {'example', 'stale'}
211
140
  assert data['tool']['fastws']['exclude'] == ['wanted-out'] # fastws table untouched
212
141
 
@@ -298,7 +227,7 @@ 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
233
  local.write_text('AnswerDotAI/repo1\nAnswerDotAI/repo2\norg/external ../external\n')
@@ -308,43 +237,17 @@ def test_ws_remove_workflow(tmp_path, monkeypatch, fake_uv):
308
237
  d.mkdir()
309
238
  (d/'pyproject.toml').write_text(f'[project]\nname = "{d.name}pkg"\n')
310
239
  g = mk_repo(d, origin=tmp_path/'origins'/d.name)
311
- answer = ['n', 'y', 'y']
312
- monkeypatch.setattr('builtins.input', lambda *a: answer.pop(0))
313
-
314
240
  with pytest.raises(SystemExit, match='baseline'): core.ws_remove('repo1', 'AnswerDotAI/keep', workspace=str(tmp_path))
315
241
  with pytest.raises(SystemExit, match='location'): core.ws_remove('repo1', 'org/external', workspace=str(tmp_path))
316
- assert 'org/external ../external' in local.read_text()
317
- assert (tmp_path/'repos.txt').read_text() == 'AnswerDotAI/keep\n'
318
- assert not fake_uv
319
242
 
320
- # a dirty tree refuses before any mutation
321
243
  (repo2/'pyproject.toml').write_text('[project]\nname = "repo2pkg"\nversion = "1"\n')
322
- with pytest.raises(SystemExit): core.ws_remove('AnswerDotAI/repo1', 'repo2', workspace=str(tmp_path))
323
- assert repo.exists() and repo2.exists() and 'repo1' in local.read_text() and 'repo2' in local.read_text()
324
- assert (tmp_path/'pyproject.toml').read_text() == WS_META
325
-
326
- # so does an unpushed commit on a clean tree
244
+ with pytest.raises(SystemExit, match='uncommitted'): core.ws_remove('repo1', 'repo2', workspace=str(tmp_path))
327
245
  g.commit('-a', m='ahead')
328
- with pytest.raises(SystemExit): core.ws_remove('AnswerDotAI/repo1', 'repo2', workspace=str(tmp_path))
246
+ with pytest.raises(SystemExit, match='unpushed'): core.ws_remove('repo1', 'repo2', workspace=str(tmp_path))
329
247
  assert repo.exists() and repo2.exists()
330
-
331
- # Each checkout has its own prompt; duplicate targets are removed once, with one sync for the batch.
332
- g.push()
333
- monkeypatch.setattr(sys, 'argv', ['ws-remove', 'AnswerDotAI/repo1', 'repo2', 'repo1', '--workspace', str(tmp_path)])
334
- with pytest.raises(SystemExit) as exc: runpy.run_path(shutil.which('ws-remove'), run_name='__main__')
335
- assert exc.value.code in (None, 0)
336
- assert repo.exists() and not repo2.exists()
337
- assert 'repo1' not in local.read_text() and 'repo2' not in local.read_text()
338
- assert 'repo1pkg' not in (content := (tmp_path/'pyproject.toml').read_text()) and 'repo2pkg' not in content
339
- assert 'keeppkg' in content and fake_uv == [['uv', 'sync']]
340
-
341
- # answering 'y' (by folder name this time) also deletes the checkout
342
- local.write_text('AnswerDotAI/repo1\n')
343
- (tmp_path/'pyproject.toml').write_text(WS_META)
344
- core.ws_remove('repo1', workspace=str(tmp_path))
345
- assert not repo.exists()
248
+ assert local.read_text() == 'AnswerDotAI/repo1\nAnswerDotAI/repo2\norg/external ../external\n'
249
+ assert (tmp_path/'pyproject.toml').read_text() == WS_META
346
250
  assert (tmp_path/'repos.txt').read_text() == 'AnswerDotAI/keep\n'
347
- assert not answer
348
251
 
349
252
 
350
253
  def test_upgrade_stamp(tmp_path):
@@ -413,31 +316,6 @@ def test_cargo_key_ignores_unused_patch_order(tmp_path):
413
316
  assert key.read_text() != first
414
317
 
415
318
 
416
- async def test_changed_dirs_pulls_only_moved_repos(tmp_path, monkeypatch):
417
- # ahead: a second clone pushes to the shared bare origin, so `ahead`'s origin/main falls behind
418
- ahead, current, weird = tmp_path/'ahead', tmp_path/'current', tmp_path/'weird'
419
- for d in (ahead, current):
420
- d.mkdir()
421
- g = mk_repo(d, origin=tmp_path/'origins'/d.name)
422
- g.remote('set-url', '--push', 'origin', str(tmp_path/'origins'/d.name))
423
- other = tmp_path/'other'
424
- Git(tmp_path, raise_exc=True).clone(str(tmp_path/'origins'/'ahead'), str(other))
425
- og = Git(other, raise_exc=True)
426
- (other/'g.txt').write_text('new')
427
- og.add('.')
428
- og.commit(m='advance')
429
- og.push()
430
- weird.mkdir()
431
- mk_repo(weird) # no origin remote: can't be checked, so it must be pulled
432
-
433
- 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]
434
- monkeypatch.setattr(core, '_remote_heads', fake_heads)
435
- monkeypatch.setattr(core, '_parse_github_repo', lambda url: f'o/{core.Path(url).name}' if 'origins' in url else None)
436
-
437
- changed = await core._changed_dirs([ahead, current, weird])
438
- assert set(changed) == {ahead, weird}
439
-
440
-
441
319
  def test_sync_cargo_patches_generates_and_preserves(tmp_path):
442
320
  (tmp_path/'.cargo').mkdir()
443
321
  config = tmp_path/'.cargo'/'config.toml'
@@ -478,25 +356,9 @@ def test_sync_cargo_patches_generates_and_preserves(tmp_path):
478
356
  assert core.tomllib.loads((bare/'.cargo'/'config.toml').read_text())['patch']['crates-io']['c']['path'] == str(bare/'c')
479
357
 
480
358
 
481
- def test_sync_cargo_wrapper(tmp_path, monkeypatch):
359
+ def test_sync_cargo_wrapper_preserves_existing(tmp_path):
482
360
  config = tmp_path/'.cargo'/'config.toml'
483
361
  config.parent.mkdir()
484
- config.write_text('[term]\nquiet = true\n')
485
- monkeypatch.setattr(core.shutil, 'which', lambda name: '/opt/homebrew/bin/sccache')
486
-
487
- assert core._sync_cargo_wrapper(tmp_path)
488
- data = core.tomllib.loads(config.read_text())
489
- assert data['build']['rustc-wrapper'] == '/opt/homebrew/bin/sccache'
490
- assert data['term']['quiet'] is True
491
- assert not core._sync_cargo_wrapper(tmp_path)
492
-
493
- (tmp_path/'crate').mkdir()
494
- (tmp_path/'crate'/'Cargo.toml').write_text('[package]\nname = "crate"\n')
495
- core._sync_cargo_patches(tmp_path)
496
- content = config.read_text()
497
- assert not core._sync_cargo_wrapper(tmp_path)
498
- assert core._sync_cargo_patches(tmp_path) == ([], []) and config.read_text() == content
499
-
500
362
  config.write_text('[build]\nrustc-wrapper = "other-cache"\n')
501
363
  assert not core._sync_cargo_wrapper(tmp_path)
502
364
  assert core.tomllib.loads(config.read_text())['build']['rustc-wrapper'] == 'other-cache'
@@ -527,54 +389,13 @@ def test_npm_dirs_honours_fastws_exclude(tmp_path):
527
389
  assert core._npm_dirs(tmp_path) == [tmp_path/'lib', tmp_path/'py', tmp_path/'py'/'wasm']
528
390
 
529
391
 
530
- def test_sync_ws_package_json_generates_and_preserves(tmp_path):
531
- pkg = tmp_path/'package.json'
532
- pkg.write_text('{\n "name": "ws",\n "private": true,\n "workspaces": ["../outside", "gone", "tools/*"]\n}\n')
533
- members = [tmp_path/'app', tmp_path/'crate'/'wasm']
534
-
535
- added, removed = core._sync_ws_package_json(tmp_path, members)
536
- data = json.loads(pkg.read_text())
537
- # kept: entries outside the root and globs; managed: existing dirs regenerated from discovery
538
- assert data['workspaces'] == ['../outside', 'tools/*', 'app', 'crate/wasm']
539
- assert data['name'] == 'ws' and data['private'] is True # other keys untouched
540
- assert added == ['app', 'crate/wasm'] and removed == ['gone']
541
-
542
- content = pkg.read_text()
543
- assert core._sync_ws_package_json(tmp_path, members) == ([], [])
544
- assert pkg.read_text() == content
545
-
546
- # a missing root package.json is created
547
- bare = tmp_path/'ws2'
548
- bare.mkdir()
549
- assert core._sync_ws_package_json(bare, [bare/'a']) == (['a'], [])
550
- assert json.loads((bare/'package.json').read_text()) == {'private': True, 'workspaces': ['a']}
551
-
552
-
553
- @pytest.mark.parametrize('workspaces', [{'packages': ['packages/*']}, {}])
554
- def test_sync_ws_package_json_rejects_object_form(tmp_path, workspaces):
555
- pkg = tmp_path/'package.json'
556
- content = json.dumps({'private': True, 'workspaces': workspaces}) + '\n'
557
- pkg.write_text(content)
558
-
559
- with pytest.raises(SystemExit, match='workspaces.*list') as exc: core._sync_ws_package_json(tmp_path, [tmp_path/'app'])
560
- assert str(pkg) in str(exc.value)
561
- assert pkg.read_text() == content
562
-
563
-
564
- 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):
565
393
  pkg, shared = tmp_path/'package.json', tmp_path/'package.json.shared'
566
- pkg.write_text(json.dumps({'name': 'local', 'workspaces': ['app'], 'keywords': ['local'],
567
- 'allowScripts': {'wasm-pack@0.15.0': False, 'other': False}}))
568
- shared.write_text(json.dumps({'keywords': ['shared'], 'allowScripts': {'wasm-pack@0.15.0': True}}))
569
- assert core._sync_ws_package_json(tmp_path, [tmp_path/'app']) == ([], [])
570
- assert json.loads(pkg.read_text()) == {'name': 'local', 'workspaces': ['app'], 'keywords': ['shared'],
571
- 'allowScripts': {'wasm-pack@0.15.0': True, 'other': False}}
572
- content, mtime = pkg.read_text(), pkg.stat().st_mtime_ns
573
- core._sync_ws_package_json(tmp_path, [tmp_path/'app'])
574
- assert pkg.stat().st_mtime_ns == mtime
575
- shared.write_text('{}')
576
- core._sync_ws_package_json(tmp_path, [tmp_path/'app'])
577
- 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}}
578
399
 
579
400
 
580
401
  def test_ws_excludes_treat_npm_only_dirs_like_cargo_only(tmp_path):
@@ -588,44 +409,7 @@ def test_ws_excludes_treat_npm_only_dirs_like_cargo_only(tmp_path):
588
409
  assert core._pending_dirs(tmp_path) == ['pending']
589
410
 
590
411
 
591
- def test_native_js_requires_rust_and_build_script(tmp_path):
592
- app, crate, wasm = [tmp_path/n for n in ('app', 'crate', 'wasm')]
593
- for d in (app, crate, wasm): d.mkdir()
594
- (app/'package.json').write_text('{"scripts": {"build": "vite build"}}')
595
- for d in (crate, wasm): (d/'Cargo.toml').write_text('')
596
- (crate/'package.json').write_text('{}')
597
- (wasm/'package.json').write_text('{"scripts": {"build": "cargo build"}}')
598
- assert core._native_js([app, crate, wasm]) == [wasm]
599
-
600
-
601
- def test_check_rustup_missing(monkeypatch):
602
- monkeypatch.setattr(core.shutil, 'which', lambda tool: None)
603
- with pytest.raises(SystemExit, match='Install it from https://rustup.rs'): core._check_rustup()
604
-
605
-
606
- @pytest.mark.parametrize('shadowed', [None, 'rustc', 'cargo'])
607
- def test_check_rustup_proxies(tmp_path, monkeypatch, shadowed):
608
- rustup = tmp_path/'rustup'
609
- rustup.touch()
610
- (tmp_path/'rustc').symlink_to(rustup)
611
- (tmp_path/'cargo').hardlink_to(rustup)
612
- other = tmp_path/'brew-rust'
613
- other.touch()
614
- monkeypatch.setattr(core.shutil, 'which', lambda tool: str(other if tool == shadowed else tmp_path/tool))
615
- if shadowed:
616
- with pytest.raises(SystemExit, match=f'{shadowed} resolves to') as err: core._check_rustup()
617
- assert 'Homebrew Rust can remain installed' in str(err.value)
618
- assert 'PATH' in str(err.value)
619
- else: core._check_rustup()
620
-
621
-
622
- async def test_sync_checks_rustup_before_installs(tmp_path, monkeypatch, fake_uv):
623
- (tmp_path/'repos.txt').write_text('')
624
- (tmp_path/'pyproject.toml').write_text('[project]\nname = "ws"\n[tool.uv.workspace]\nmembers = ["*"]\n')
625
- wasm = tmp_path/'wasm'
626
- wasm.mkdir()
627
- (wasm/'Cargo.toml').write_text('[package]\nname = "wasm"\n')
628
- (wasm/'package.json').write_text('{"name": "wasm", "scripts": {"build": "wasm-pack build"}}')
629
- monkeypatch.setattr(core.shutil, 'which', lambda tool: None)
630
- with pytest.raises(SystemExit, match='require rustup'): await core.ws_sync(str(tmp_path))
631
- 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