fastws-cli 0.0.14__tar.gz → 0.0.16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/PKG-INFO +31 -3
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/README.md +30 -2
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/fastws/__init__.py +3 -1
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/fastws/core.py +167 -56
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/fastws/releases.py +3 -12
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/fastws_cli.egg-info/PKG-INFO +31 -3
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/tests/test_sync.py +192 -20
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/LICENSE +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/fastws_cli.egg-info/SOURCES.txt +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/fastws_cli.egg-info/dependency_links.txt +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/fastws_cli.egg-info/entry_points.txt +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/fastws_cli.egg-info/requires.txt +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/fastws_cli.egg-info/top_level.txt +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/pyproject.toml +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/setup.cfg +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/tests/test_build.py +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/tests/test_releases.py +0 -0
- {fastws_cli-0.0.14 → fastws_cli-0.0.16}/tests/test_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.16
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License: Apache-2.0
|
|
@@ -82,17 +82,31 @@ AnswerDotAI/fastcore
|
|
|
82
82
|
jph00/private ~/private
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
Select Python extras by attaching brackets to the repo name, before any checkout location:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
AnswerDotAI/fastws[dev]
|
|
89
|
+
owner/repo[dev,test] ~/somewhere
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
These select `[project.optional-dependencies]`, not `[dependency-groups]`. Sync uses the package name from `pyproject.toml`, so `AnswerDotAI/fastws[dev]` installs `fastws-cli[dev]` editably. For a checkout containing several discovered Python packages, the selection applies to each. uv handles the extras and their dependencies.
|
|
93
|
+
|
|
94
|
+
Extras from duplicate entries in `repos.txt` and `repos-local.txt` are combined. For example, a personal `AnswerDotAI/fastws[dev]` entry adds development extras to a shared `AnswerDotAI/fastws` entry. The lists are authoritative for those packages' extras: each sync updates existing workspace dependencies, including removing extras no longer selected. Put personal extra selections in `repos-local.txt`, not the generated `pyproject.toml`. Existing version constraints, markers, sources, and unrelated dependencies are preserved.
|
|
95
|
+
|
|
85
96
|
### Shared baseline and personal additions
|
|
86
97
|
|
|
87
98
|
Keep `repos.txt` tracked in the workspace Git repo. Put personal additions in a gitignored `repos-local.txt`, using the same format. Every command reads both lists. `ws-add` and automatic discovery append only to the local list. Edit the shared list deliberately when changing the team's baseline. Duplicate repo names are matched case-insensitively. Conflicting explicit locations, or two different repos targeting the same directory, cause errors.
|
|
88
99
|
|
|
89
100
|
`--repos-file myrepos.txt` uses `myrepos-local.txt` alongside it. Either list may be absent. Removing a repo from the baseline does not delete its checkout. The next sync discovers an existing root checkout as a personal addition. External checkouts also remain on disk; add them to the local list to keep managing them.
|
|
90
101
|
|
|
91
|
-
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it
|
|
102
|
+
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it while preserving personal settings except for the repo extras managed above. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
|
|
92
103
|
|
|
93
104
|
```gitignore
|
|
94
105
|
/repos-local.txt
|
|
95
106
|
/pyproject.toml
|
|
107
|
+
/package.json
|
|
108
|
+
/package-lock.json
|
|
109
|
+
/node_modules/
|
|
96
110
|
/pyrightconfig.json
|
|
97
111
|
/uv.lock
|
|
98
112
|
/.cargo/
|
|
@@ -194,7 +208,7 @@ By default, `ws-sync` uses the active venv's parent as the workspace root. It cr
|
|
|
194
208
|
|
|
195
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.
|
|
196
210
|
|
|
197
|
-
|
|
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.
|
|
198
212
|
|
|
199
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.
|
|
200
214
|
|
|
@@ -204,6 +218,20 @@ Do not commit a `Cargo.lock` generated under these patches. Its source-less loca
|
|
|
204
218
|
|
|
205
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.
|
|
206
220
|
|
|
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
|
+
|
|
223
|
+
Each sync updates `workspaces` in the root `package.json`. It creates the file when needed. It preserves external paths, globs, and unrelated settings. It reports added and removed packages.
|
|
224
|
+
|
|
225
|
+
An optional tracked `package.json.shared` is merged into the root `package.json` on every sync. Object-valued fields merge one level deep, shared values win conflicts, and arrays replace rather than concatenate. Unrelated local settings remain; removing a shared key does not delete its local value. Workspace discovery still manages `workspaces`. Use this file for shared policy such as version-pinned npm `allowScripts` approvals.
|
|
226
|
+
|
|
227
|
+
After `uv sync`, fastws runs `<tool> install` at the workspace root. It then runs `<tool> run build` in each JavaScript package containing both `Cargo.toml` and a `build` script. Cargo handles incremental compilation. Other build steps still run.
|
|
228
|
+
|
|
229
|
+
For npm, sync hides routine install summaries and build-command banners. Install warnings, audit findings, unreviewed install scripts, and failure diagnostics remain visible. Build scripts retain their own output; configure their tools to suppress routine progress. Other package managers retain their normal output.
|
|
230
|
+
|
|
231
|
+
When Rust-backed JavaScript builds are present, fastws checks before dependency installation that `rustc` and `cargo` on `PATH` are rustup's proxies. Install rustup from https://rustup.rs and select a stable toolchain with `rustup default stable` if not already configured. If another Rust installation shadows rustup, put its bin directory first on `PATH` after Homebrew's shell setup: `export PATH="$HOME/.cargo/bin:$PATH"` (use `$CARGO_HOME/bin` for a custom Cargo home), then open a new shell. Homebrew Rust can remain installed for Homebrew's own use. Fastws reports missing or shadowed rustup without changing your shell or installing a different toolchain. Packages using wasm-pack can then install their WASM target and matching binding tools automatically as part of their build.
|
|
232
|
+
|
|
233
|
+
The package manager defaults to `npm`. Set `js` under `[tool.fastws]` in the workspace `pyproject.toml` to choose another executable, such as `js = "bun"`. Fastws requires a list for `workspaces` in `package.json`, following the npm format. It stops if it cannot find the selected executable.
|
|
234
|
+
|
|
207
235
|
At most once per day, sync upgrades dependencies with `uv sync -U` and a parallel `cargo update` in every member with a `Cargo.toml`. It prints the changes and records the run in a stamp file inside the workspace's `.git` directory. Pass `--upgrade` to force an upgrade regardless of the last run time.
|
|
208
236
|
|
|
209
237
|
Before every uv sync, `ws-sync` writes `.git/fastws-cargo-key` for each member crate. The key hashes `Cargo.lock` contents and the workspace Cargo patch configuration. For Git dependencies redirected to local paths by `[patch."<url>"]`, it also hashes each patched crate's `Cargo.toml`, `build.rs`, and `src` tree, recursively. The file is rewritten only when that content changes, so projects can use `{ file = ".git/fastws-cargo-key" }` in `tool.uv.cache-keys` without rebuilding after a timestamp-only `Cargo.lock` write.
|
|
@@ -57,17 +57,31 @@ AnswerDotAI/fastcore
|
|
|
57
57
|
jph00/private ~/private
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
Select Python extras by attaching brackets to the repo name, before any checkout location:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
AnswerDotAI/fastws[dev]
|
|
64
|
+
owner/repo[dev,test] ~/somewhere
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
These select `[project.optional-dependencies]`, not `[dependency-groups]`. Sync uses the package name from `pyproject.toml`, so `AnswerDotAI/fastws[dev]` installs `fastws-cli[dev]` editably. For a checkout containing several discovered Python packages, the selection applies to each. uv handles the extras and their dependencies.
|
|
68
|
+
|
|
69
|
+
Extras from duplicate entries in `repos.txt` and `repos-local.txt` are combined. For example, a personal `AnswerDotAI/fastws[dev]` entry adds development extras to a shared `AnswerDotAI/fastws` entry. The lists are authoritative for those packages' extras: each sync updates existing workspace dependencies, including removing extras no longer selected. Put personal extra selections in `repos-local.txt`, not the generated `pyproject.toml`. Existing version constraints, markers, sources, and unrelated dependencies are preserved.
|
|
70
|
+
|
|
60
71
|
### Shared baseline and personal additions
|
|
61
72
|
|
|
62
73
|
Keep `repos.txt` tracked in the workspace Git repo. Put personal additions in a gitignored `repos-local.txt`, using the same format. Every command reads both lists. `ws-add` and automatic discovery append only to the local list. Edit the shared list deliberately when changing the team's baseline. Duplicate repo names are matched case-insensitively. Conflicting explicit locations, or two different repos targeting the same directory, cause errors.
|
|
63
74
|
|
|
64
75
|
`--repos-file myrepos.txt` uses `myrepos-local.txt` alongside it. Either list may be absent. Removing a repo from the baseline does not delete its checkout. The next sync discovers an existing root checkout as a personal addition. External checkouts also remain on disk; add them to the local list to keep managing them.
|
|
65
76
|
|
|
66
|
-
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it
|
|
77
|
+
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it while preserving personal settings except for the repo extras managed above. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
|
|
67
78
|
|
|
68
79
|
```gitignore
|
|
69
80
|
/repos-local.txt
|
|
70
81
|
/pyproject.toml
|
|
82
|
+
/package.json
|
|
83
|
+
/package-lock.json
|
|
84
|
+
/node_modules/
|
|
71
85
|
/pyrightconfig.json
|
|
72
86
|
/uv.lock
|
|
73
87
|
/.cargo/
|
|
@@ -169,7 +183,7 @@ By default, `ws-sync` uses the active venv's parent as the workspace root. It cr
|
|
|
169
183
|
|
|
170
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.
|
|
171
185
|
|
|
172
|
-
|
|
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.
|
|
173
187
|
|
|
174
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.
|
|
175
189
|
|
|
@@ -179,6 +193,20 @@ Do not commit a `Cargo.lock` generated under these patches. Its source-less loca
|
|
|
179
193
|
|
|
180
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.
|
|
181
195
|
|
|
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
|
+
|
|
198
|
+
Each sync updates `workspaces` in the root `package.json`. It creates the file when needed. It preserves external paths, globs, and unrelated settings. It reports added and removed packages.
|
|
199
|
+
|
|
200
|
+
An optional tracked `package.json.shared` is merged into the root `package.json` on every sync. Object-valued fields merge one level deep, shared values win conflicts, and arrays replace rather than concatenate. Unrelated local settings remain; removing a shared key does not delete its local value. Workspace discovery still manages `workspaces`. Use this file for shared policy such as version-pinned npm `allowScripts` approvals.
|
|
201
|
+
|
|
202
|
+
After `uv sync`, fastws runs `<tool> install` at the workspace root. It then runs `<tool> run build` in each JavaScript package containing both `Cargo.toml` and a `build` script. Cargo handles incremental compilation. Other build steps still run.
|
|
203
|
+
|
|
204
|
+
For npm, sync hides routine install summaries and build-command banners. Install warnings, audit findings, unreviewed install scripts, and failure diagnostics remain visible. Build scripts retain their own output; configure their tools to suppress routine progress. Other package managers retain their normal output.
|
|
205
|
+
|
|
206
|
+
When Rust-backed JavaScript builds are present, fastws checks before dependency installation that `rustc` and `cargo` on `PATH` are rustup's proxies. Install rustup from https://rustup.rs and select a stable toolchain with `rustup default stable` if not already configured. If another Rust installation shadows rustup, put its bin directory first on `PATH` after Homebrew's shell setup: `export PATH="$HOME/.cargo/bin:$PATH"` (use `$CARGO_HOME/bin` for a custom Cargo home), then open a new shell. Homebrew Rust can remain installed for Homebrew's own use. Fastws reports missing or shadowed rustup without changing your shell or installing a different toolchain. Packages using wasm-pack can then install their WASM target and matching binding tools automatically as part of their build.
|
|
207
|
+
|
|
208
|
+
The package manager defaults to `npm`. Set `js` under `[tool.fastws]` in the workspace `pyproject.toml` to choose another executable, such as `js = "bun"`. Fastws requires a list for `workspaces` in `package.json`, following the npm format. It stops if it cannot find the selected executable.
|
|
209
|
+
|
|
182
210
|
At most once per day, sync upgrades dependencies with `uv sync -U` and a parallel `cargo update` in every member with a `Cargo.toml`. It prints the changes and records the run in a stamp file inside the workspace's `.git` directory. Pass `--upgrade` to force an upgrade regardless of the last run time.
|
|
183
211
|
|
|
184
212
|
Before every uv sync, `ws-sync` writes `.git/fastws-cargo-key` for each member crate. The key hashes `Cargo.lock` contents and the workspace Cargo patch configuration. For Git dependencies redirected to local paths by `[patch."<url>"]`, it also hashes each patched crate's `Cargo.toml`, `build.rs`, and `src` tree, recursively. The file is rewritten only when that content changes, so projects can use `{ file = ".git/fastws-cargo-key" }` in `tool.uv.cache-keys` without rebuilding after a timestamp-only `Cargo.lock` write.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
__all__ = ["ws_setup", "ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_build", "ws_sync", "ws_add", "ws_remove"]
|
|
4
4
|
|
|
5
|
-
import ast, fnmatch, hashlib, json, os, re, shlex, shutil, subprocess, sys, time
|
|
5
|
+
import ast, fnmatch, glob, hashlib, json, os, re, shlex, shutil, subprocess, sys, time
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from concurrent.futures import ThreadPoolExecutor
|
|
8
8
|
|
|
@@ -10,6 +10,8 @@ from fastcore.parallel import parallel_async_gen
|
|
|
10
10
|
from fastcore.script import call_parse
|
|
11
11
|
from fastgit import Git
|
|
12
12
|
from ghapi.core import dep_key, dep_closure
|
|
13
|
+
from packaging.requirements import Requirement
|
|
14
|
+
from packaging.utils import canonicalize_name
|
|
13
15
|
|
|
14
16
|
try: import tomllib
|
|
15
17
|
except ModuleNotFoundError: import tomli as tomllib
|
|
@@ -19,10 +21,12 @@ def _repo_lines(repos_file) -> list[str]:
|
|
|
19
21
|
if not p.exists(): raise SystemExit(f"File not found: {repos_file}")
|
|
20
22
|
return [line.strip() for line in p.read_text().splitlines() if line.strip() and not line.startswith("#")]
|
|
21
23
|
|
|
22
|
-
def _parse_repo_line(line: str) -> tuple[str, str|None]:
|
|
23
|
-
"Split
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
def _parse_repo_line(line: str) -> tuple[str, str|None, set[str]]:
|
|
25
|
+
"Split owner/repo[extra,...] and an optional checkout location."
|
|
26
|
+
m = re.fullmatch(r'([^\s\[\]]+)(?:\[([^\[\]]+)\])?(?:\s+(.+))?', line.strip())
|
|
27
|
+
if not m: raise SystemExit(f'Invalid repo entry: {line}')
|
|
28
|
+
repo, extras, loc = m.groups()
|
|
29
|
+
return repo, loc, {canonicalize_name(e.strip()) for e in extras.split(',')} if extras else set()
|
|
26
30
|
|
|
27
31
|
def _load_repos(repos_file: str = "repos.txt") -> list[str]:
|
|
28
32
|
return [_parse_repo_line(l)[0] for l in _repo_lines(repos_file)] if Path(repos_file).exists() else []
|
|
@@ -31,21 +35,23 @@ def _local_repos_path(repos_file) -> Path:
|
|
|
31
35
|
p = Path(repos_file)
|
|
32
36
|
return p.with_name(f'{p.stem}-local{p.suffix}')
|
|
33
37
|
|
|
34
|
-
def _load_repo_entries(repos_file, root: Path) -> list[tuple[str, Path]]:
|
|
35
|
-
"Shared and local entries,
|
|
38
|
+
def _load_repo_entries(repos_file, root: Path) -> list[tuple[str, Path, set[str]]]:
|
|
39
|
+
"Shared and local entries, with extras unioned by repo; conflicting checkout locations are errors."
|
|
36
40
|
res, locations = {}, {}
|
|
37
41
|
for p in (Path(repos_file), _local_repos_path(repos_file)):
|
|
38
42
|
if not p.exists(): continue
|
|
39
43
|
for line in _repo_lines(p):
|
|
40
|
-
repo, loc = _parse_repo_line(line)
|
|
44
|
+
repo, loc, extras = _parse_repo_line(line)
|
|
41
45
|
key = _repo_key(repo)
|
|
42
|
-
if key in res
|
|
46
|
+
if key in res:
|
|
47
|
+
res[key][2].update(extras)
|
|
48
|
+
if loc is None: continue
|
|
43
49
|
d = _resolve_path(root, Path(loc).expanduser()) if loc else root/_repo_dir(repo)
|
|
44
50
|
resolved = d.resolve()
|
|
45
51
|
if key in res and res[key][1].resolve() != resolved: raise SystemExit(f'Conflicting locations for {repo} in {p}')
|
|
46
52
|
if resolved in locations and locations[resolved] != key:
|
|
47
53
|
raise SystemExit(f'Checkout location {d} is shared by {repo} and {locations[resolved]}')
|
|
48
|
-
res.setdefault(key, (repo, d))
|
|
54
|
+
res.setdefault(key, (repo, d, extras))
|
|
49
55
|
locations[resolved] = key
|
|
50
56
|
return list(res.values())
|
|
51
57
|
|
|
@@ -57,7 +63,7 @@ def _resolve_path(root: Path, path: str) -> Path:
|
|
|
57
63
|
|
|
58
64
|
def _repo_key(repo: str) -> str: return repo.strip().rstrip("/").removesuffix(".git").casefold()
|
|
59
65
|
|
|
60
|
-
def _pkg_key(name: str) -> str: return name
|
|
66
|
+
def _pkg_key(name: str) -> str: return canonicalize_name(name)
|
|
61
67
|
|
|
62
68
|
def _fmt_toml_val(v) -> str:
|
|
63
69
|
if isinstance(v, bool): return "true" if v else "false"
|
|
@@ -98,16 +104,25 @@ def _ws_cfg(root: Path):
|
|
|
98
104
|
exclude = ws.get("exclude") or []
|
|
99
105
|
return members, exclude
|
|
100
106
|
|
|
107
|
+
def _fastws_cfg(root: Path) -> dict:
|
|
108
|
+
"The `[tool.fastws]` table from the workspace root pyproject.toml (empty when absent)."
|
|
109
|
+
pyproj = root/"pyproject.toml"
|
|
110
|
+
if not pyproj.exists(): return {}
|
|
111
|
+
return tomllib.loads(pyproj.read_text(encoding="utf-8")).get("tool", {}).get("fastws", {})
|
|
112
|
+
|
|
101
113
|
def _matches_ws(name: str, pattern: str) -> bool:
|
|
102
114
|
pattern = pattern.strip()
|
|
103
115
|
return any(fnmatch.fnmatch(candidate, normalized) for candidate in (name, f"./{name}") for normalized in (pattern, pattern.removeprefix("./")))
|
|
104
116
|
|
|
105
|
-
def
|
|
106
|
-
|
|
117
|
+
def _project_dirs(root: Path, manifest="", members=("*",), exclude=()) -> list[Path]:
|
|
118
|
+
"Expand member patterns into unique directories, optionally requiring a manifest; exclusions are relative to root."
|
|
119
|
+
dirs = dict.fromkeys(d for pat in members for d in sorted(root.glob(pat)))
|
|
120
|
+
return [d for d in dirs if d.is_dir() and (not manifest or (d/manifest).is_file())
|
|
121
|
+
and not any(_matches_ws(os.path.relpath(d, root), e) for e in exclude)]
|
|
107
122
|
|
|
108
123
|
def _ws_dirs(root: Path) -> list[Path]:
|
|
109
124
|
members, exclude = _ws_cfg(root)
|
|
110
|
-
return [d for d in
|
|
125
|
+
return [d for d in _project_dirs(root, exclude=exclude) if not d.name.startswith(".") and any(_matches_ws(d.name, o) for o in members)]
|
|
111
126
|
|
|
112
127
|
def _root_git_dirs(root: Path) -> list[Path]:
|
|
113
128
|
"Every git checkout directly under `root`, whatever the uv workspace config; `_`-prefixed dirs are private"
|
|
@@ -152,10 +167,10 @@ def _remove_from_pyproject(pyproject_path: Path, names: list[str]) -> list[str]:
|
|
|
152
167
|
sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
|
|
153
168
|
deps = list(data.get("project", {}).get("dependencies", []))
|
|
154
169
|
targets = {_pkg_key(n) for n in names}
|
|
155
|
-
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})
|
|
156
171
|
if not removed: return []
|
|
157
172
|
sources = {k:v for k,v in sources.items() if _pkg_key(k) not in targets}
|
|
158
|
-
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]
|
|
159
174
|
content = _replace_table(content, "tool.uv.sources", "\n".join(f"{k} = {_fmt_source(v)}" for k,v in sources.items()))
|
|
160
175
|
content = _replace_project_dependencies(content, deps)
|
|
161
176
|
pyproject_path.write_text(content)
|
|
@@ -178,16 +193,24 @@ def _external_projects(root: Path, dirs: list[Path]) -> list[tuple[str,str]]:
|
|
|
178
193
|
res = []
|
|
179
194
|
for d in dirs:
|
|
180
195
|
if not d.is_dir(): continue
|
|
181
|
-
cands = [d] if (d/"pyproject.toml").exists() else
|
|
196
|
+
cands = [d] if (d/"pyproject.toml").exists() else [p for p in _project_dirs(d, "pyproject.toml") if not p.name.startswith((".", "_"))]
|
|
182
197
|
for c in cands:
|
|
183
198
|
if name := _read_pyproject_name(c/"pyproject.toml"): res.append((name, os.path.relpath(c, root)))
|
|
184
199
|
return res
|
|
185
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
|
+
|
|
186
205
|
def _valid_project_dir(d: Path) -> bool: return (d/"pyproject.toml").exists() and bool(_read_pyproject_name(d/"pyproject.toml"))
|
|
187
206
|
|
|
188
|
-
def
|
|
189
|
-
"A Rust
|
|
190
|
-
return (d/"
|
|
207
|
+
def _non_python_project(d: Path) -> bool:
|
|
208
|
+
"A Rust or JS project without a Python layer is not a pending Python scaffold."
|
|
209
|
+
return not (d/"pyproject.toml").exists() and any((d/f).exists() for f in ("Cargo.toml", "package.json"))
|
|
210
|
+
|
|
211
|
+
def _pending_dirs(root: Path) -> list[str]:
|
|
212
|
+
"uv workspace dirs that are not Python projects yet; sync stops rather than let uv fail on them"
|
|
213
|
+
return [d.name for d in _ws_dirs(root) if not (d/"pyproject.toml").exists()]
|
|
191
214
|
|
|
192
215
|
def _sync_ws_excludes(pyproject_path: Path, root: Path, tracked: set[str]) -> tuple[list[str], list[str]]:
|
|
193
216
|
"""Regenerate `tool.uv.workspace.exclude` and return (added, removed).
|
|
@@ -195,7 +218,7 @@ def _sync_ws_excludes(pyproject_path: Path, root: Path, tracked: set[str]) -> tu
|
|
|
195
218
|
Kept as-is: `[tool.fastws].exclude` entries (intent), globs, missing dirs, and tracked dirs
|
|
196
219
|
(repos.txt checkouts) that are still not valid Python projects. Auto-managed: entries for other
|
|
197
220
|
existing dirs are regenerated each sync, excluding dirs without a valid pyproject (tracked dirs
|
|
198
|
-
only when they are Cargo-only crates, since a tracked dir with
|
|
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
|
|
199
222
|
awaiting scaffolding) and un-excluding dirs that gained one; deliberately excluding a real
|
|
200
223
|
project takes a `[tool.fastws]` entry."""
|
|
201
224
|
if not pyproject_path.exists(): return [], []
|
|
@@ -207,10 +230,8 @@ def _sync_ws_excludes(pyproject_path: Path, root: Path, tracked: set[str]) -> tu
|
|
|
207
230
|
intent = [e for e in data.get("tool", {}).get("fastws", {}).get("exclude", []) if isinstance(e, str)]
|
|
208
231
|
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))]
|
|
209
232
|
kept += [e for e in intent if e not in kept]
|
|
210
|
-
auto = [d.name for d in
|
|
211
|
-
|
|
212
|
-
and not any(_matches_ws(d.name, e) for e in kept)
|
|
213
|
-
and (d.name not in tracked or _cargo_only(d)) and not _valid_project_dir(d)]
|
|
233
|
+
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)]
|
|
214
235
|
survivors = set(kept) | set(auto)
|
|
215
236
|
new = [e for e in cur if e in survivors] + [e for e in kept + auto if e not in cur]
|
|
216
237
|
if new == cur: return [], []
|
|
@@ -264,7 +285,7 @@ def _replace_project_dependencies(content: str, deps: list[str]) -> str:
|
|
|
264
285
|
if not (span := _table_span(content, "project")): raise SystemExit("Missing [project] table in pyproject.toml")
|
|
265
286
|
start,end = span
|
|
266
287
|
section = content[start:end]
|
|
267
|
-
dep_block = "dependencies = [\n" + "".join(f'
|
|
288
|
+
dep_block = "dependencies = [\n" + "".join(f' {json.dumps(dep)},\n' for dep in deps) + "]"
|
|
268
289
|
if m := re.search(r"(?m)^dependencies\s*=\s*\[", section):
|
|
269
290
|
arr_start = m.end()-1
|
|
270
291
|
arr_end = _find_array_end(section, arr_start)
|
|
@@ -287,29 +308,36 @@ members = ["./*"]
|
|
|
287
308
|
''')
|
|
288
309
|
return True
|
|
289
310
|
|
|
290
|
-
def _sync_ws_pyproject(pyproject_path: Path, template_path: Path, projects: list[str], externals: list[tuple] = None
|
|
311
|
+
def _sync_ws_pyproject(pyproject_path: Path, template_path: Path, projects: list[str], externals: list[tuple] = None,
|
|
312
|
+
extras: dict[str, set[str]] = None) -> list[str]:
|
|
291
313
|
if not pyproject_path.exists():
|
|
292
314
|
if template_path.exists(): shutil.copyfile(template_path, pyproject_path)
|
|
293
315
|
else: _init_ws_pyproject(pyproject_path)
|
|
294
|
-
content = pyproject_path.read_text()
|
|
316
|
+
original = content = pyproject_path.read_text()
|
|
295
317
|
data = tomllib.loads(content)
|
|
296
318
|
sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
|
|
297
319
|
source_keys = {_pkg_key(proj) for proj in sources}
|
|
298
320
|
missing = [proj for proj in projects if _pkg_key(proj) not in source_keys]
|
|
299
321
|
ext_missing = [(n,p) for n,p in (externals or []) if _pkg_key(n) not in source_keys]
|
|
300
|
-
if not missing and not ext_missing: return []
|
|
301
322
|
for proj in missing: sources[proj] = {"workspace": True}
|
|
302
323
|
for n,p in ext_missing: sources[n] = {"path": p, "editable": True}
|
|
303
324
|
deps = list(data.get("project", {}).get("dependencies", []))
|
|
304
|
-
dep_keys = {dep_key(dep) for dep in deps}
|
|
305
|
-
for proj in
|
|
325
|
+
dep_keys = {_pkg_key(dep_key(dep)) for dep in deps}
|
|
326
|
+
for proj in projects + [n for n,_ in (externals or [])]:
|
|
306
327
|
if _pkg_key(proj) in dep_keys: continue
|
|
307
328
|
deps.append(proj)
|
|
308
329
|
dep_keys.add(_pkg_key(proj))
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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)
|
|
313
341
|
return missing + [n for n,_ in ext_missing]
|
|
314
342
|
|
|
315
343
|
def _editable_mapping(path: Path) -> dict[str,str]:
|
|
@@ -353,10 +381,10 @@ async def _clone_one(repo: str, d: Path) -> str|None:
|
|
|
353
381
|
except subprocess.CalledProcessError as e: return f"✗ {d.name}: {e.stderr.strip()}"
|
|
354
382
|
|
|
355
383
|
async def _clone_repos(entries, workers):
|
|
356
|
-
async def clone(e): return await _clone_one(
|
|
384
|
+
async def clone(e): return await _clone_one(e[0], e[1])
|
|
357
385
|
async for _, res in parallel_async_gen(clone, entries, n_workers=workers):
|
|
358
386
|
if res: print(res)
|
|
359
|
-
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()]:
|
|
360
388
|
raise SystemExit(f'Could not clone repos (missing Git checkouts): {", ".join(missing)}')
|
|
361
389
|
|
|
362
390
|
async def _pull_workspace(root: Path):
|
|
@@ -436,7 +464,7 @@ async def ws_pull(
|
|
|
436
464
|
workers: int = 64, # Number of parallel workers
|
|
437
465
|
):
|
|
438
466
|
"Pull updates for repos whose GitHub origin has moved (all repos when that can't be checked)."
|
|
439
|
-
dirs = [d for _,d in _load_repo_entries(repos_file, Path("."))]
|
|
467
|
+
dirs = [d for _,d,_ in _load_repo_entries(repos_file, Path("."))]
|
|
440
468
|
try: dirs = await _changed_dirs(dirs)
|
|
441
469
|
except Exception: pass
|
|
442
470
|
await _pull(dirs, workers)
|
|
@@ -447,7 +475,7 @@ def ws_status(
|
|
|
447
475
|
branches: bool = False, # Show unpushed commit details
|
|
448
476
|
):
|
|
449
477
|
"Show uncommitted changes and optionally unpushed commit details across repos."
|
|
450
|
-
for repo,d in _load_repo_entries(repos_file, Path(".")):
|
|
478
|
+
for repo,d,_ in _load_repo_entries(repos_file, Path(".")):
|
|
451
479
|
if not d.exists(): continue
|
|
452
480
|
g = Git(d)
|
|
453
481
|
if not g.exists: continue
|
|
@@ -468,7 +496,7 @@ def ws_branches(
|
|
|
468
496
|
expected: str = "main", # Expected branch name
|
|
469
497
|
):
|
|
470
498
|
"Check if all repos are on the expected branch."
|
|
471
|
-
for repo,d in _load_repo_entries(repos_file, Path(".")):
|
|
499
|
+
for repo,d,_ in _load_repo_entries(repos_file, Path(".")):
|
|
472
500
|
if not Path(d).exists():
|
|
473
501
|
print(f"⚠️ {d}: directory not found")
|
|
474
502
|
continue
|
|
@@ -503,7 +531,7 @@ def _build_projects(root: Path, repos_file: str, project: str = None) -> list[tu
|
|
|
503
531
|
"(name, dir) for every project ws-sync installs: workspace members plus external checkouts"
|
|
504
532
|
repos_path = _resolve_path(root, repos_file)
|
|
505
533
|
entries = _load_repo_entries(repos_path, root)
|
|
506
|
-
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()]
|
|
507
535
|
res = [(name, d) for d in _ws_dirs(root) if (d/"pyproject.toml").exists() and (name := _read_pyproject_name(d/"pyproject.toml"))]
|
|
508
536
|
res += [(n, _resolve_path(root, p)) for n,p in _external_projects(root, ext_dirs)]
|
|
509
537
|
if project is None: return res
|
|
@@ -569,7 +597,7 @@ _CARGO_KEY = Path(".git/fastws-cargo-key")
|
|
|
569
597
|
|
|
570
598
|
def _crate_dirs(root: Path) -> list[Path]:
|
|
571
599
|
"Root dirs containing a Cargo.toml: the crate view of the workspace, independent of uv membership"
|
|
572
|
-
return [d for d in
|
|
600
|
+
return [d for d in _project_dirs(root, "Cargo.toml") if not d.name.startswith((".", "_"))]
|
|
573
601
|
|
|
574
602
|
def _cargo_patches(root: Path):
|
|
575
603
|
"Local Cargo patches keyed by normalized Git URL and package name, plus their config file."
|
|
@@ -589,12 +617,10 @@ def _crate_pkgs(d: Path):
|
|
|
589
617
|
try: data = tomllib.loads((d/"Cargo.toml").read_text())
|
|
590
618
|
except tomllib.TOMLDecodeError: return
|
|
591
619
|
if name := data.get("package", {}).get("name"): yield name, d
|
|
592
|
-
for
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
except tomllib.TOMLDecodeError: continue
|
|
597
|
-
if name := sub.get("package", {}).get("name"): yield name, m
|
|
620
|
+
for m in _project_dirs(d, "Cargo.toml", data.get("workspace", {}).get("members", [])):
|
|
621
|
+
try: sub = tomllib.loads((m/"Cargo.toml").read_text())
|
|
622
|
+
except tomllib.TOMLDecodeError: continue
|
|
623
|
+
if name := sub.get("package", {}).get("name"): yield name, m
|
|
598
624
|
|
|
599
625
|
def _local_crates(root: Path) -> dict[str, Path]:
|
|
600
626
|
"Package name -> dir for every crate under `root`, nested cargo workspace members included"
|
|
@@ -623,6 +649,10 @@ def _strip_patch_tables(content: str) -> str:
|
|
|
623
649
|
content = content[:m.start()] + content[end:]
|
|
624
650
|
return content
|
|
625
651
|
|
|
652
|
+
def _entry_changes(current, new):
|
|
653
|
+
"Added and removed keys, in the order supplied."
|
|
654
|
+
return [k for k in new if k not in current], [k for k in current if k not in new]
|
|
655
|
+
|
|
626
656
|
def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
|
|
627
657
|
"""Regenerate `[patch]` entries in the workspace `.cargo/config.toml` and return (added, removed).
|
|
628
658
|
|
|
@@ -659,7 +689,8 @@ def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
|
|
|
659
689
|
config.write_text(new)
|
|
660
690
|
before = {n for entries in old.values() for n in entries}
|
|
661
691
|
after = {n for entries in tables.values() for n in entries}
|
|
662
|
-
|
|
692
|
+
added, removed = _entry_changes(before, after)
|
|
693
|
+
return sorted(added), sorted(removed)
|
|
663
694
|
|
|
664
695
|
def _sync_cargo_wrapper(root: Path) -> bool:
|
|
665
696
|
"Add sccache to the generated Cargo config when installed, without overriding another wrapper"
|
|
@@ -744,6 +775,78 @@ def _cargo_update(root: Path, workers: int = 16):
|
|
|
744
775
|
elif lines := [l for l in out.splitlines() if l.lstrip().startswith(("Updating ", "Adding ", "Removing ")) and "crates.io index" not in l]:
|
|
745
776
|
print(f"{name}:\n" + "\n".join(lines))
|
|
746
777
|
|
|
778
|
+
def _npm_dirs(root: Path) -> list[Path]:
|
|
779
|
+
"Find root JavaScript packages and their declared workspace members."
|
|
780
|
+
exclude = _fastws_cfg(root).get("exclude", [])
|
|
781
|
+
def ok(d): return not d.name.startswith((".", "_")) and d.name != "node_modules"
|
|
782
|
+
res = []
|
|
783
|
+
for d in _project_dirs(root, "package.json", exclude=exclude):
|
|
784
|
+
if not ok(d): continue
|
|
785
|
+
data = json.loads((d/"package.json").read_text())
|
|
786
|
+
members = [glob.escape(d.name)] + [f"{glob.escape(d.name)}/{p}" for p in data.get("workspaces", [])]
|
|
787
|
+
res += _project_dirs(root, "package.json", members, exclude)
|
|
788
|
+
return list(dict.fromkeys(d for d in res if ok(d)))
|
|
789
|
+
|
|
790
|
+
def _sync_ws_package_json(root: Path, members: list[Path]) -> tuple[list[str], list[str]]:
|
|
791
|
+
"Merge shared settings and update workspaces, preserving external paths and globs; return (added, removed)."
|
|
792
|
+
path = root/"package.json"
|
|
793
|
+
data = json.loads(path.read_text()) if path.exists() else {"private": True}
|
|
794
|
+
before = json.dumps(data)
|
|
795
|
+
shared = root/"package.json.shared"
|
|
796
|
+
if shared.exists():
|
|
797
|
+
for k,v in json.loads(shared.read_text()).items():
|
|
798
|
+
data[k] = data[k] | v if isinstance(v, dict) and isinstance(data.get(k), dict) else v
|
|
799
|
+
cur = data.get("workspaces", [])
|
|
800
|
+
if not isinstance(cur, list): raise SystemExit(f"{path}: fastws requires `workspaces` to be a list of package paths.")
|
|
801
|
+
kept = [e for e in cur if any(c in e for c in "*?[") or not (root/e).resolve().is_relative_to(root.resolve())]
|
|
802
|
+
new = kept + [e for e in (os.path.relpath(d, root) for d in members) if e not in kept]
|
|
803
|
+
if new != cur: data["workspaces"] = new
|
|
804
|
+
if json.dumps(data) == before: return [], []
|
|
805
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
806
|
+
return _entry_changes(cur, new)
|
|
807
|
+
|
|
808
|
+
def _native_js(members: list[Path]) -> list[Path]:
|
|
809
|
+
"JavaScript packages with a Rust manifest and a build script."
|
|
810
|
+
return [d for d in members if (d/"Cargo.toml").exists()
|
|
811
|
+
and json.loads((d/"package.json").read_text()).get("scripts", {}).get("build")]
|
|
812
|
+
|
|
813
|
+
def _check_rustup():
|
|
814
|
+
"Require rustup's compiler proxies for automatic WASM target installation."
|
|
815
|
+
if not (rustup := shutil.which("rustup")):
|
|
816
|
+
raise SystemExit("Rust-backed JavaScript builds require rustup. Install it from https://rustup.rs, "
|
|
817
|
+
"select a stable toolchain with `rustup default stable`, then reopen your shell and rerun ws-sync.")
|
|
818
|
+
for tool in "rustc","cargo":
|
|
819
|
+
path = shutil.which(tool)
|
|
820
|
+
if path and os.path.samefile(path, rustup): continue
|
|
821
|
+
raise SystemExit(f"Rust-backed JavaScript builds require rustup-managed Rust, but {tool} resolves to {path or 'nothing'}. "
|
|
822
|
+
"Put rustup's bin directory first on PATH after Homebrew or other toolchain setup in your shell profile "
|
|
823
|
+
'(normally: export PATH="$HOME/.cargo/bin:$PATH"; use $CARGO_HOME/bin when customized). '
|
|
824
|
+
"Homebrew Rust can remain installed. Open a new shell and rerun ws-sync.")
|
|
825
|
+
|
|
826
|
+
def _npm_install(root: Path):
|
|
827
|
+
"Hide npm's install summary, but retain warnings, audit findings, and failure diagnostics."
|
|
828
|
+
try: res = subprocess.run(["npm", "install", "--json"], check=True, cwd=root, stdout=subprocess.PIPE, text=True)
|
|
829
|
+
except subprocess.CalledProcessError as e:
|
|
830
|
+
if e.stdout: print(e.stdout, file=sys.stderr, end="")
|
|
831
|
+
raise
|
|
832
|
+
try: data = json.loads(res.stdout)
|
|
833
|
+
except json.JSONDecodeError:
|
|
834
|
+
print(res.stdout, file=sys.stderr, end="")
|
|
835
|
+
raise
|
|
836
|
+
if data.get("audit", {}).get("vulnerabilities", {}).get("total") or data.get("unreviewedScripts"):
|
|
837
|
+
print(f"npm install findings ({root}):\n{res.stdout}", file=sys.stderr, end="")
|
|
838
|
+
|
|
839
|
+
def _sync_js(root: Path, members: list[Path]) -> list[Path]:
|
|
840
|
+
"Install the JS workspace, run every native member's build script, and return those members. Cargo handles incremental compilation."
|
|
841
|
+
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")
|
|
843
|
+
if tool == "npm": _npm_install(root)
|
|
844
|
+
else: subprocess.run([tool, "install"], check=True, cwd=root)
|
|
845
|
+
built = _native_js(members)
|
|
846
|
+
cmd = [tool, "run", "--silent", "build"] if tool == "npm" else [tool, "run", "build"]
|
|
847
|
+
for d in built: subprocess.run(cmd, check=True, cwd=d)
|
|
848
|
+
return built
|
|
849
|
+
|
|
747
850
|
@call_parse
|
|
748
851
|
async def ws_sync(
|
|
749
852
|
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
@@ -765,18 +868,19 @@ async def ws_sync(
|
|
|
765
868
|
|
|
766
869
|
if missing_repos := _update_repos_file(repos_path, repos): print(f"Added local repos: {', '.join(missing_repos)}")
|
|
767
870
|
entries = _load_repo_entries(repos_path, root)
|
|
768
|
-
ext_dirs = [d for _,d in entries if d.resolve().parent != root.resolve()]
|
|
769
|
-
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]
|
|
770
873
|
try: dirs = await _changed_dirs(dirs)
|
|
771
874
|
except Exception: pass
|
|
772
875
|
await _pull(dirs, workers=workers)
|
|
773
876
|
|
|
774
877
|
if not pyproject_path.exists(): _sync_ws_pyproject(pyproject_path, template_path, [])
|
|
775
|
-
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()})
|
|
776
879
|
if added_ex: print(f"Auto-excluded from the workspace: {', '.join(added_ex)}")
|
|
777
880
|
if removed_ex: print(f"No longer excluded from the workspace: {', '.join(removed_ex)}")
|
|
778
881
|
|
|
779
|
-
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))
|
|
780
884
|
if missing_projects: print(f"Added workspace projects: {', '.join(missing_projects)}")
|
|
781
885
|
|
|
782
886
|
wrapper_added = _sync_cargo_wrapper(root)
|
|
@@ -785,7 +889,13 @@ async def ws_sync(
|
|
|
785
889
|
if removed_p: print(f"Cargo patches removed: {', '.join(removed_p)}")
|
|
786
890
|
if wrapper_added: print("Cargo builds now use sccache")
|
|
787
891
|
|
|
788
|
-
|
|
892
|
+
js_members = _npm_dirs(root)
|
|
893
|
+
if _native_js(js_members): _check_rustup()
|
|
894
|
+
added_j, removed_j = _sync_ws_package_json(root, js_members)
|
|
895
|
+
if added_j: print(f"JS workspace packages added: {', '.join(added_j)}")
|
|
896
|
+
if removed_j: print(f"JS workspace packages removed: {', '.join(removed_j)}")
|
|
897
|
+
|
|
898
|
+
if bad := _pending_dirs(root):
|
|
789
899
|
print(f"⚠️ Skipping uv sync, not Python projects yet (scaffold with e.g. nbdev-new or ship-new, or remove): {', '.join(bad)}")
|
|
790
900
|
return
|
|
791
901
|
up = upgrade or _should_upgrade(root)
|
|
@@ -793,6 +903,7 @@ async def ws_sync(
|
|
|
793
903
|
_sync_cargo_keys(root)
|
|
794
904
|
subprocess.run(["uv", "sync", "-U"] if up else ["uv", "sync"], check=True, cwd=root)
|
|
795
905
|
if up: _upgrade_stamp(root).touch()
|
|
906
|
+
if js_members: _sync_js(root, js_members)
|
|
796
907
|
|
|
797
908
|
@call_parse
|
|
798
909
|
async def ws_add(
|
|
@@ -848,7 +959,7 @@ def _resolve_removal_target(root: Path, repo: str, repos_path: Path) -> str:
|
|
|
848
959
|
"Canonical owner/repo for `repo`, matching an existing folder name when `repo` isn't a valid spec."
|
|
849
960
|
if _is_repo_spec(repo): return _normalize_repo(repo)
|
|
850
961
|
if not (root/repo).is_dir(): return _normalize_repo(repo) # invalid spec and no such folder: raise
|
|
851
|
-
for r,d in _load_repo_entries(repos_path, root):
|
|
962
|
+
for r,d,_ in _load_repo_entries(repos_path, root):
|
|
852
963
|
if _repo_dir(r).casefold() == repo.casefold(): return r
|
|
853
964
|
if (root/repo/".git").exists():
|
|
854
965
|
url = Git(root/repo).remote("get-url", "origin", mute_errors=True)
|
|
@@ -887,7 +998,7 @@ def ws_remove(
|
|
|
887
998
|
if _repo_key(repo) in {_repo_key(r) for r in _load_repos(repos_path)}:
|
|
888
999
|
raise SystemExit(f'{repo} belongs to the shared baseline: edit {repos_path} deliberately to remove it')
|
|
889
1000
|
d = _resolve_repo_dir(root, repo)
|
|
890
|
-
for r,location in _load_repo_entries(repos_path, root):
|
|
1001
|
+
for r,location,_ in _load_repo_entries(repos_path, root):
|
|
891
1002
|
if _repo_key(r) == _repo_key(repo) and location.resolve() != d:
|
|
892
1003
|
raise SystemExit(f'Refusing to remove {repo}: custom checkout location {location}; manage it explicitly')
|
|
893
1004
|
repos_path = _local_repos_path(repos_path)
|
|
@@ -10,21 +10,12 @@ from fastcore.parallel import parallel_async
|
|
|
10
10
|
from ghapi.core import APIError, GhApi, dep_closure, local_dep_graph
|
|
11
11
|
from packaging.version import InvalidVersion, Version
|
|
12
12
|
|
|
13
|
-
from .core import _load_repo_entries, _resolve_path, _ws_root
|
|
14
|
-
|
|
15
|
-
try: import tomllib
|
|
16
|
-
except ModuleNotFoundError: import tomli as tomllib
|
|
13
|
+
from .core import _fastws_cfg, _load_repo_entries, _resolve_path, _ws_root
|
|
17
14
|
|
|
18
15
|
# Start-of-message regexes for commits that need no release (curation noise, CI config, docs regen)
|
|
19
16
|
DEFAULT_SKIP = ["bump$", "Bump version to ", "meta", "auto", "README", "regen readme", "nbdev regen",
|
|
20
17
|
"update .gitignore", r"\.?gitignore$", "workflow", "chkstyle skip", "tests$", "md$", "docs?$", "clean$", "CI$", "ignore$", "allowed_metadata_keys$"]
|
|
21
18
|
|
|
22
|
-
def _fastws_cfg(root: Path) -> dict:
|
|
23
|
-
"The `[tool.fastws]` table from the workspace root pyproject.toml (empty when absent)."
|
|
24
|
-
pyproj = root/"pyproject.toml"
|
|
25
|
-
if not pyproj.exists(): return {}
|
|
26
|
-
return tomllib.loads(pyproj.read_text(encoding="utf-8")).get("tool", {}).get("fastws", {})
|
|
27
|
-
|
|
28
19
|
def _skip_pats(skip=None, root: Path|None = None) -> list[re.Pattern]:
|
|
29
20
|
"Compiled skip patterns: defaults + `[tool.fastws].release_skip` from the workspace pyproject + `skip`."
|
|
30
21
|
pats = list(DEFAULT_SKIP)
|
|
@@ -68,14 +59,14 @@ class ReleaseReport(list):
|
|
|
68
59
|
def _cwd_project(root: Path, repos_file: str = "repos.txt") -> str|None:
|
|
69
60
|
"Dir name of the workspace member checkout containing cwd (None at the root or outside every member)."
|
|
70
61
|
cwd = Path.cwd().resolve()
|
|
71
|
-
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):
|
|
72
63
|
if cwd.is_relative_to(d.resolve()): return d.name
|
|
73
64
|
return None
|
|
74
65
|
|
|
75
66
|
async def check_releases(project: str = None, skip=None, workspace: str = "", repos_file: str = "repos.txt", nodeps: bool = False) -> ReleaseReport:
|
|
76
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)."
|
|
77
68
|
root = _ws_root(workspace)
|
|
78
|
-
repos = [r for r,
|
|
69
|
+
repos = [r for r,_,_ in _load_repo_entries(_resolve_path(root, repos_file), root)]
|
|
79
70
|
excl = {e.casefold() for e in _fastws_cfg(root).get("release_exclude", [])}
|
|
80
71
|
repos = [r for r in repos if r.split("/")[-1].casefold() not in excl]
|
|
81
72
|
if project is None: project = _cwd_project(root, repos_file)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.16
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License: Apache-2.0
|
|
@@ -82,17 +82,31 @@ AnswerDotAI/fastcore
|
|
|
82
82
|
jph00/private ~/private
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
Select Python extras by attaching brackets to the repo name, before any checkout location:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
AnswerDotAI/fastws[dev]
|
|
89
|
+
owner/repo[dev,test] ~/somewhere
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
These select `[project.optional-dependencies]`, not `[dependency-groups]`. Sync uses the package name from `pyproject.toml`, so `AnswerDotAI/fastws[dev]` installs `fastws-cli[dev]` editably. For a checkout containing several discovered Python packages, the selection applies to each. uv handles the extras and their dependencies.
|
|
93
|
+
|
|
94
|
+
Extras from duplicate entries in `repos.txt` and `repos-local.txt` are combined. For example, a personal `AnswerDotAI/fastws[dev]` entry adds development extras to a shared `AnswerDotAI/fastws` entry. The lists are authoritative for those packages' extras: each sync updates existing workspace dependencies, including removing extras no longer selected. Put personal extra selections in `repos-local.txt`, not the generated `pyproject.toml`. Existing version constraints, markers, sources, and unrelated dependencies are preserved.
|
|
95
|
+
|
|
85
96
|
### Shared baseline and personal additions
|
|
86
97
|
|
|
87
98
|
Keep `repos.txt` tracked in the workspace Git repo. Put personal additions in a gitignored `repos-local.txt`, using the same format. Every command reads both lists. `ws-add` and automatic discovery append only to the local list. Edit the shared list deliberately when changing the team's baseline. Duplicate repo names are matched case-insensitively. Conflicting explicit locations, or two different repos targeting the same directory, cause errors.
|
|
88
99
|
|
|
89
100
|
`--repos-file myrepos.txt` uses `myrepos-local.txt` alongside it. Either list may be absent. Removing a repo from the baseline does not delete its checkout. The next sync discovers an existing root checkout as a personal addition. External checkouts also remain on disk; add them to the local list to keep managing them.
|
|
90
101
|
|
|
91
|
-
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it
|
|
102
|
+
The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it while preserving personal settings except for the repo extras managed above. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
|
|
92
103
|
|
|
93
104
|
```gitignore
|
|
94
105
|
/repos-local.txt
|
|
95
106
|
/pyproject.toml
|
|
107
|
+
/package.json
|
|
108
|
+
/package-lock.json
|
|
109
|
+
/node_modules/
|
|
96
110
|
/pyrightconfig.json
|
|
97
111
|
/uv.lock
|
|
98
112
|
/.cargo/
|
|
@@ -194,7 +208,7 @@ By default, `ws-sync` uses the active venv's parent as the workspace root. It cr
|
|
|
194
208
|
|
|
195
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.
|
|
196
210
|
|
|
197
|
-
|
|
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.
|
|
198
212
|
|
|
199
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.
|
|
200
214
|
|
|
@@ -204,6 +218,20 @@ Do not commit a `Cargo.lock` generated under these patches. Its source-less loca
|
|
|
204
218
|
|
|
205
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.
|
|
206
220
|
|
|
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
|
+
|
|
223
|
+
Each sync updates `workspaces` in the root `package.json`. It creates the file when needed. It preserves external paths, globs, and unrelated settings. It reports added and removed packages.
|
|
224
|
+
|
|
225
|
+
An optional tracked `package.json.shared` is merged into the root `package.json` on every sync. Object-valued fields merge one level deep, shared values win conflicts, and arrays replace rather than concatenate. Unrelated local settings remain; removing a shared key does not delete its local value. Workspace discovery still manages `workspaces`. Use this file for shared policy such as version-pinned npm `allowScripts` approvals.
|
|
226
|
+
|
|
227
|
+
After `uv sync`, fastws runs `<tool> install` at the workspace root. It then runs `<tool> run build` in each JavaScript package containing both `Cargo.toml` and a `build` script. Cargo handles incremental compilation. Other build steps still run.
|
|
228
|
+
|
|
229
|
+
For npm, sync hides routine install summaries and build-command banners. Install warnings, audit findings, unreviewed install scripts, and failure diagnostics remain visible. Build scripts retain their own output; configure their tools to suppress routine progress. Other package managers retain their normal output.
|
|
230
|
+
|
|
231
|
+
When Rust-backed JavaScript builds are present, fastws checks before dependency installation that `rustc` and `cargo` on `PATH` are rustup's proxies. Install rustup from https://rustup.rs and select a stable toolchain with `rustup default stable` if not already configured. If another Rust installation shadows rustup, put its bin directory first on `PATH` after Homebrew's shell setup: `export PATH="$HOME/.cargo/bin:$PATH"` (use `$CARGO_HOME/bin` for a custom Cargo home), then open a new shell. Homebrew Rust can remain installed for Homebrew's own use. Fastws reports missing or shadowed rustup without changing your shell or installing a different toolchain. Packages using wasm-pack can then install their WASM target and matching binding tools automatically as part of their build.
|
|
232
|
+
|
|
233
|
+
The package manager defaults to `npm`. Set `js` under `[tool.fastws]` in the workspace `pyproject.toml` to choose another executable, such as `js = "bun"`. Fastws requires a list for `workspaces` in `package.json`, following the npm format. It stops if it cannot find the selected executable.
|
|
234
|
+
|
|
207
235
|
At most once per day, sync upgrades dependencies with `uv sync -U` and a parallel `cargo update` in every member with a `Cargo.toml`. It prints the changes and records the run in a stamp file inside the workspace's `.git` directory. Pass `--upgrade` to force an upgrade regardless of the last run time.
|
|
208
236
|
|
|
209
237
|
Before every uv sync, `ws-sync` writes `.git/fastws-cargo-key` for each member crate. The key hashes `Cargo.lock` contents and the workspace Cargo patch configuration. For Git dependencies redirected to local paths by `[patch."<url>"]`, it also hashes each patched crate's `Cargo.toml`, `build.rs`, and `src` tree, recursively. The file is rewritten only when that content changes, so projects can use `{ file = ".git/fastws-cargo-key" }` in `tool.uv.cache-keys` without rebuilding after a timestamp-only `Cargo.lock` write.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import os, pytest
|
|
1
|
+
import json, os, pytest
|
|
2
2
|
from types import SimpleNamespace
|
|
3
3
|
from fastgit import Git
|
|
4
4
|
import fastws.core as core
|
|
@@ -40,40 +40,46 @@ def fake_uv(monkeypatch):
|
|
|
40
40
|
|
|
41
41
|
def test_repos_file_roundtrip(tmp_path):
|
|
42
42
|
repos_path = tmp_path/'repos.txt'
|
|
43
|
-
repos_path.write_text('# header\nAnswerDotAI/fastws\njph00/private ~/private\nfastai/fastai sub/dir\n')
|
|
43
|
+
repos_path.write_text('# header\nAnswerDotAI/fastws[dev]\njph00/private[docs] ~/private\nfastai/fastai sub/dir\n')
|
|
44
44
|
|
|
45
45
|
assert core._load_repos(repos_path) == ['AnswerDotAI/fastws', 'jph00/private', 'fastai/fastai']
|
|
46
46
|
assert core._load_repo_entries(repos_path, tmp_path) == [
|
|
47
|
-
('AnswerDotAI/fastws', tmp_path/'fastws'),
|
|
48
|
-
('jph00/private', core.Path('~/private').expanduser()),
|
|
49
|
-
('fastai/fastai', tmp_path/'sub'/'dir')]
|
|
47
|
+
('AnswerDotAI/fastws', tmp_path/'fastws', {'dev'}),
|
|
48
|
+
('jph00/private', core.Path('~/private').expanduser(), {'docs'}),
|
|
49
|
+
('fastai/fastai', tmp_path/'sub'/'dir', set())]
|
|
50
50
|
|
|
51
51
|
# discovery and ws-add write only extras, never change the shared baseline
|
|
52
52
|
original = repos_path.read_text()
|
|
53
53
|
local = tmp_path/'repos-local.txt'
|
|
54
|
-
added = core._update_repos_file(repos_path, ['answerdotai/fastws', 'jph00/private', 'fastai/new'])
|
|
55
|
-
assert added == ['fastai/new']
|
|
56
|
-
assert local.read_text() == 'fastai/new\n'
|
|
54
|
+
added = core._update_repos_file(repos_path, ['answerdotai/fastws', 'jph00/private', 'fastai/new[dev]'])
|
|
55
|
+
assert added == ['fastai/new[dev]']
|
|
56
|
+
assert local.read_text() == 'fastai/new[dev]\n'
|
|
57
57
|
assert repos_path.read_text() == original
|
|
58
|
-
assert core._load_repo_entries(repos_path, tmp_path)[-1] == ('fastai/new', tmp_path/'new')
|
|
58
|
+
assert core._load_repo_entries(repos_path, tmp_path)[-1] == ('fastai/new', tmp_path/'new', {'dev'})
|
|
59
59
|
|
|
60
60
|
# remove matches case-insensitively, preserves other lines (including comments), and reports absence
|
|
61
61
|
assert core._remove_from_repos_file(local, 'FASTAI/new') is True
|
|
62
62
|
assert core._remove_from_repos_file(repos_path, 'AnswerDotAI/gone') is False
|
|
63
|
-
assert repos_path.read_text() ==
|
|
63
|
+
assert repos_path.read_text() == original
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def test_repo_lists_deduplicate_and_reject_conflicting_locations(tmp_path):
|
|
67
67
|
base, local = tmp_path/'repos.txt', tmp_path/'repos-local.txt'
|
|
68
|
-
base.write_text('org/core\n')
|
|
69
|
-
local.write_text('ORG/core\norg/extra
|
|
70
|
-
assert core._load_repo_entries(base, tmp_path) == [
|
|
68
|
+
base.write_text('org/core[dev]\norg/extra[docs] ../external\n')
|
|
69
|
+
local.write_text('ORG/core[TEST, dev]\norg/extra[dev]\n')
|
|
70
|
+
assert core._load_repo_entries(base, tmp_path) == [
|
|
71
|
+
('org/core', tmp_path/'core', {'dev', 'test'}), ('org/extra', tmp_path/'../external', {'docs', 'dev'})]
|
|
71
72
|
local.write_text('org/core ../elsewhere\n')
|
|
72
73
|
with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
|
|
73
74
|
local.write_text('different/core\n')
|
|
74
75
|
with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
|
|
75
76
|
base.unlink()
|
|
76
|
-
assert core._load_repo_entries(base, tmp_path) == [('different/core', tmp_path/'core')]
|
|
77
|
+
assert core._load_repo_entries(base, tmp_path) == [('different/core', tmp_path/'core', set())]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize('entry', ['org/repo[dev', 'org/repo[dev]]', 'org/repo[]'])
|
|
81
|
+
def test_invalid_repo_extras(entry):
|
|
82
|
+
with pytest.raises(SystemExit, match='Invalid repo entry'): core._parse_repo_line(entry)
|
|
77
83
|
|
|
78
84
|
|
|
79
85
|
async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_path, monkeypatch, fake_uv):
|
|
@@ -87,21 +93,22 @@ async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_
|
|
|
87
93
|
for name in ('old', 'new', 'extra'):
|
|
88
94
|
d = tmp_path/name
|
|
89
95
|
d.mkdir()
|
|
90
|
-
(d/'pyproject.toml').write_text(f'[project]\nname = "{name}"\n')
|
|
96
|
+
(d/'pyproject.toml').write_text(f'[project]\nname = "{name}-pkg"\n[project.optional-dependencies]\ndev = []\ntest = []\n')
|
|
91
97
|
mk_repo(d, origin=origins/f'{name}.git')
|
|
92
98
|
seed.mkdir()
|
|
93
|
-
(seed/'repos.txt').write_text('org/old\n')
|
|
99
|
+
(seed/'repos.txt').write_text('org/old[dev]\n')
|
|
94
100
|
(seed/'.gitignore').write_text('*/\npyproject.toml\nrepos-local.txt\n')
|
|
95
101
|
(seed/'pyproject.tmpl').write_text('[project]\nname = "workspace"\ndependencies = []\n[tool.uv.workspace]\nmembers = ["./*"]\n')
|
|
96
102
|
g = mk_repo(seed, origin=origins/'workspace.git')
|
|
97
103
|
Git(tmp_path, raise_exc=True).clone(str(origins/'workspace.git'), str(root))
|
|
98
104
|
Git(root, raise_exc=True).clone('git@github.com:org/extra.git', str(root/'extra'))
|
|
105
|
+
(root/'repos-local.txt').write_text('org/old[test]\n')
|
|
99
106
|
|
|
100
107
|
await core.ws_sync(str(root))
|
|
101
108
|
assert (root/'old'/'pyproject.toml').exists()
|
|
102
|
-
assert (root/'repos.txt').read_text() == 'org/old\n'
|
|
103
|
-
assert (root/'repos-local.txt').read_text() == 'org/extra\n'
|
|
104
|
-
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old', 'extra'}
|
|
109
|
+
assert (root/'repos.txt').read_text() == 'org/old[dev]\n'
|
|
110
|
+
assert (root/'repos-local.txt').read_text() == 'org/old[test]\norg/extra\n'
|
|
111
|
+
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old-pkg[dev,test]', 'extra-pkg'}
|
|
105
112
|
|
|
106
113
|
(seed/'repos.txt').write_text('org/new\n')
|
|
107
114
|
g.commit('-a', m='new shared baseline')
|
|
@@ -111,7 +118,10 @@ async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_
|
|
|
111
118
|
assert (root/'old'/'pyproject.toml').exists()
|
|
112
119
|
assert (root/'repos.txt').read_text() == 'org/new\n'
|
|
113
120
|
assert set(core._load_repos(root/'repos-local.txt')) == {'org/extra', 'org/old'}
|
|
114
|
-
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old', 'new', 'extra'}
|
|
121
|
+
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old-pkg[test]', 'new-pkg', 'extra-pkg'}
|
|
122
|
+
(root/'repos-local.txt').write_text('org/extra\n')
|
|
123
|
+
await core.ws_sync(str(root))
|
|
124
|
+
assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old-pkg', 'new-pkg', 'extra-pkg'}
|
|
115
125
|
|
|
116
126
|
(seed/'repos.txt').write_text('org/missing\n')
|
|
117
127
|
g.commit('-a', m='unavailable member')
|
|
@@ -162,6 +172,23 @@ def test_ws_pyproject_preserves_existing_entries(tmp_path):
|
|
|
162
172
|
assert 'alpha = { workspace = true }' in content
|
|
163
173
|
|
|
164
174
|
|
|
175
|
+
def test_sync_extras_preserves_constraints_markers_and_unrelated_settings(tmp_path):
|
|
176
|
+
pyproject = tmp_path/'pyproject.toml'
|
|
177
|
+
pyproject.write_text('[project]\nname = "ws"\ndependencies = [\'My_Pkg[old]>=1; python_version >= "3.10"\', "other[keep]"]\n'
|
|
178
|
+
'[tool.uv.sources]\nMy_Pkg = { path = "../somewhere", editable = true }\n')
|
|
179
|
+
for extras in ({'dev', 'test'}, set()):
|
|
180
|
+
core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['my-pkg'], extras={'my-pkg': extras})
|
|
181
|
+
data = core.tomllib.loads(pyproject.read_text())
|
|
182
|
+
dep, other = data['project']['dependencies']
|
|
183
|
+
req = core.Requirement(dep)
|
|
184
|
+
assert req.extras == extras and str(req.specifier) == '>=1' and str(req.marker) == 'python_version >= "3.10"'
|
|
185
|
+
assert other == 'other[keep]' and len(data['project']['dependencies']) == 2
|
|
186
|
+
assert data['tool']['uv']['sources'] == {'My_Pkg': {'path': '../somewhere', 'editable': True}}
|
|
187
|
+
before = pyproject.stat().st_mtime_ns
|
|
188
|
+
core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['my-pkg'], extras={'my-pkg': extras})
|
|
189
|
+
assert pyproject.stat().st_mtime_ns == before
|
|
190
|
+
|
|
191
|
+
|
|
165
192
|
def test_ws_excludes_generates_from_intent_and_auto(tmp_path):
|
|
166
193
|
pyproject = tmp_path/'pyproject.toml'
|
|
167
194
|
pyproject.write_text(
|
|
@@ -205,6 +232,22 @@ 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}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pytest.mark.parametrize('manifest', ['pyproject.toml', 'Cargo.toml', 'package.json'])
|
|
245
|
+
def test_project_dirs_expands_patterns_and_filters_manifests(tmp_path, manifest):
|
|
246
|
+
for name in ('a', 'b', 'empty'): (tmp_path/'pkgs'/name).mkdir(parents=True)
|
|
247
|
+
for name in ('a', 'b'): (tmp_path/'pkgs'/name/manifest).write_text('')
|
|
248
|
+
members, exclude = ['pkgs/*', 'pkgs/a'], ['./pkgs/b']
|
|
249
|
+
assert core._project_dirs(tmp_path, manifest, members, exclude) == [tmp_path/'pkgs'/'a']
|
|
250
|
+
assert core._project_dirs(tmp_path, members=members, exclude=exclude) == [tmp_path/'pkgs'/'a', tmp_path/'pkgs'/'empty']
|
|
208
251
|
|
|
209
252
|
|
|
210
253
|
def test_ws_projects_skip_excluded_dirs_and_template_names(tmp_path):
|
|
@@ -454,3 +497,132 @@ def test_sync_cargo_wrapper(tmp_path, monkeypatch):
|
|
|
454
497
|
config.write_text('[build]\nrustc-wrapper = "other-cache"\n')
|
|
455
498
|
assert not core._sync_cargo_wrapper(tmp_path)
|
|
456
499
|
assert core.tomllib.loads(config.read_text())['build']['rustc-wrapper'] == 'other-cache'
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_npm_dirs_discovers_root_and_declared_packages(tmp_path):
|
|
503
|
+
for name in ('app', 'crate', 'crate/wasm', 'crate/frontend', 'crate/docs', '_scratch', 'node_modules', 'crate/node_modules'):
|
|
504
|
+
d = tmp_path/name
|
|
505
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
506
|
+
(d/'package.json').write_text('{}')
|
|
507
|
+
(tmp_path/'app'/'package-lock.json').write_text('{}')
|
|
508
|
+
(tmp_path/'crate'/'package.json').write_text('{"private": true, "workspaces": ["wasm", "front*", "node_modules", "wasm", "missing"]}')
|
|
509
|
+
(tmp_path/'crate'/'frontend'/'bun.lock').write_text('') # lockfiles do not override explicit membership
|
|
510
|
+
|
|
511
|
+
assert core._npm_dirs(tmp_path) == [tmp_path/'app', tmp_path/'crate', tmp_path/'crate'/'wasm', tmp_path/'crate'/'frontend']
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def test_npm_dirs_honours_fastws_exclude(tmp_path):
|
|
515
|
+
(tmp_path/'pyproject.toml').write_text('[tool.fastws]\nexclude = ["app", "tool*", "py/examples"]\n')
|
|
516
|
+
for name in ('app', 'lib', 'tools'):
|
|
517
|
+
(tmp_path/name).mkdir()
|
|
518
|
+
(tmp_path/name/'package.json').write_text('{}')
|
|
519
|
+
for name in ('examples', 'wasm'):
|
|
520
|
+
(tmp_path/'py'/name).mkdir(parents=True)
|
|
521
|
+
(tmp_path/'py'/name/'package.json').write_text('{}') # examples is excluded by its root-relative path
|
|
522
|
+
(tmp_path/'py'/'package.json').write_text('{"private": true, "workspaces": ["*"]}')
|
|
523
|
+
|
|
524
|
+
assert core._npm_dirs(tmp_path) == [tmp_path/'lib', tmp_path/'py', tmp_path/'py'/'wasm']
|
|
525
|
+
|
|
526
|
+
|
|
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):
|
|
562
|
+
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
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def test_ws_excludes_treat_npm_only_dirs_like_cargo_only(tmp_path):
|
|
578
|
+
pyproject = tmp_path/'pyproject.toml'
|
|
579
|
+
pyproject.write_text('[project]\nname = "uvws"\n\n[tool.uv.workspace]\nmembers = ["./*"]\nexclude = []\n')
|
|
580
|
+
for name in ('app', 'pending'): (tmp_path/name).mkdir()
|
|
581
|
+
(tmp_path/'app'/'package.json').write_text('{}')
|
|
582
|
+
|
|
583
|
+
# a tracked npm-only checkout is a valid JS project: excluded from uv, never pending; an empty tracked dir still awaits scaffolding
|
|
584
|
+
assert core._sync_ws_excludes(pyproject, tmp_path, {'app', 'pending'}) == (['app'], [])
|
|
585
|
+
assert core._pending_dirs(tmp_path) == ['pending']
|
|
586
|
+
|
|
587
|
+
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|