fastws-cli 0.0.19__tar.gz → 0.0.20__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,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.19
3
+ Version: 0.0.20
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
- License: Apache-2.0
6
+ License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/AnswerDotAI/fastws
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  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.28
13
+ Requires-Dist: fastcore>=2.2.33
14
14
  Requires-Dist: fastgit>=0.1.2
15
15
  Requires-Dist: ghapi>=2.0.6
16
16
  Requires-Dist: packaging
@@ -195,6 +195,34 @@ ws-build --project solveit # only this package and its transitive workspace dep
195
195
 
196
196
  `--project` selects by package name and follows declared runtime and build dependencies, including workspace packages in external checkouts. Other packages in the output directory are left untouched. Without it, the whole workspace is built as before.
197
197
 
198
+ ### `cargo develop`
199
+
200
+ Rebuild a mixed Rust/Python project's extension and replace it in the editable source package:
201
+
202
+ ```bash
203
+ cargo develop
204
+ cargo develop --path /path/to/project
205
+ ```
206
+
207
+ Run from the project root, or give its directory with `--path`. The command reads `python-source`, `module-name`, `manifest-path` and features from `[tool.maturin]`. It always builds with Cargo's `test` profile, matching bare `cargo test`. The project must produce a `cdylib` and set `python-source`. To use release settings without command-line flags, add `[profile.test]` with `inherits = "release"` to `Cargo.toml`. Keep incremental compilation enabled for the local crate in that profile.
208
+
209
+ Make the initial editable installation through `ws-sync` or maturin. Repeat that installation when package metadata or entry points change. Running Python processes keep the extension they loaded.
210
+
211
+ Cargo must already build a loadable Python extension. For PyO3 extension-module builds on macOS, configure the linker in `build.rs` with `pyo3_build_config::add_extension_module_link_args()`. This command does not inject maturin's extra compiler flags.
212
+
213
+ To share the library build with `cargo test`, also produce an `rlib` and use the same features for both commands. Rust unit tests compile a separate library with `cfg(test)`; integration tests reuse the ordinary library. `cargo develop` does not change the project's Cargo configuration or its test layout.
214
+
215
+ Set `native-binaries = true` under `[tool.fastws]` to build and install the package's Cargo binary targets into the active venv alongside its extension. Cargo reports the executable paths; there is no separate list of binary names.
216
+
217
+ For wheels containing these binaries, use `fastws.build_backend` as the build backend and include `fastws-cli>=0.0.20` alongside maturin in `[build-system].requires`. Set `[tool.maturin].data` to the wheel-data directory. The backend stages Python-free binaries in its `scripts/` subdirectory, then delegates packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries.
218
+
219
+ Direct maturin CLI builds bypass the Python build backend. Stage the binaries first with the shared command:
220
+
221
+ ```bash
222
+ cargo stage --profile dist
223
+ maturin build --profile dist
224
+ ```
225
+
198
226
  ### `ws-sync`
199
227
 
200
228
  Syncing proceeds in this order:
@@ -224,7 +252,9 @@ Each sync updates `workspaces` in the root `package.json`. It creates the file w
224
252
 
225
253
  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
254
 
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.
255
+ After `uv sync`, fastws runs `cargo develop` in each mixed Rust/Python project. Cargo skips unchanged compilation inputs. These projects should keep only packaging inputs in `tool.uv.cache-keys`; Rust source changes are handled by Cargo rather than triggering a separate maturin editable build. Maturin packages with `bindings = "bin"` keep their existing uv installation path.
256
+
257
+ Fastws then runs `<tool> install` at the workspace root. It 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
258
 
229
259
  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
260
 
@@ -311,4 +341,4 @@ await check_release('mdhtml') # one repo: list of unreleased commit summarie
311
341
 
312
342
  ## Tests
313
343
 
314
- Run `pytest -q` for the regular suite. `pytest -q tests/test_setup.py -m slow` also builds the current package and bootstraps a fresh environment with real Git and uv; it may fetch package dependencies. Run `chkstyle fastws tests` after edits.
344
+ Run `pytest -q`. Keep tests concise and focused on substantive logic. Do not test policy choices, settings, constants, or orchestration. Do not add tests merely because code changed.
@@ -170,6 +170,34 @@ ws-build --project solveit # only this package and its transitive workspace dep
170
170
 
171
171
  `--project` selects by package name and follows declared runtime and build dependencies, including workspace packages in external checkouts. Other packages in the output directory are left untouched. Without it, the whole workspace is built as before.
172
172
 
173
+ ### `cargo develop`
174
+
175
+ Rebuild a mixed Rust/Python project's extension and replace it in the editable source package:
176
+
177
+ ```bash
178
+ cargo develop
179
+ cargo develop --path /path/to/project
180
+ ```
181
+
182
+ Run from the project root, or give its directory with `--path`. The command reads `python-source`, `module-name`, `manifest-path` and features from `[tool.maturin]`. It always builds with Cargo's `test` profile, matching bare `cargo test`. The project must produce a `cdylib` and set `python-source`. To use release settings without command-line flags, add `[profile.test]` with `inherits = "release"` to `Cargo.toml`. Keep incremental compilation enabled for the local crate in that profile.
183
+
184
+ Make the initial editable installation through `ws-sync` or maturin. Repeat that installation when package metadata or entry points change. Running Python processes keep the extension they loaded.
185
+
186
+ Cargo must already build a loadable Python extension. For PyO3 extension-module builds on macOS, configure the linker in `build.rs` with `pyo3_build_config::add_extension_module_link_args()`. This command does not inject maturin's extra compiler flags.
187
+
188
+ To share the library build with `cargo test`, also produce an `rlib` and use the same features for both commands. Rust unit tests compile a separate library with `cfg(test)`; integration tests reuse the ordinary library. `cargo develop` does not change the project's Cargo configuration or its test layout.
189
+
190
+ Set `native-binaries = true` under `[tool.fastws]` to build and install the package's Cargo binary targets into the active venv alongside its extension. Cargo reports the executable paths; there is no separate list of binary names.
191
+
192
+ For wheels containing these binaries, use `fastws.build_backend` as the build backend and include `fastws-cli>=0.0.20` alongside maturin in `[build-system].requires`. Set `[tool.maturin].data` to the wheel-data directory. The backend stages Python-free binaries in its `scripts/` subdirectory, then delegates packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries.
193
+
194
+ Direct maturin CLI builds bypass the Python build backend. Stage the binaries first with the shared command:
195
+
196
+ ```bash
197
+ cargo stage --profile dist
198
+ maturin build --profile dist
199
+ ```
200
+
173
201
  ### `ws-sync`
174
202
 
175
203
  Syncing proceeds in this order:
@@ -199,7 +227,9 @@ Each sync updates `workspaces` in the root `package.json`. It creates the file w
199
227
 
200
228
  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
229
 
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.
230
+ After `uv sync`, fastws runs `cargo develop` in each mixed Rust/Python project. Cargo skips unchanged compilation inputs. These projects should keep only packaging inputs in `tool.uv.cache-keys`; Rust source changes are handled by Cargo rather than triggering a separate maturin editable build. Maturin packages with `bindings = "bin"` keep their existing uv installation path.
231
+
232
+ Fastws then runs `<tool> install` at the workspace root. It 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
233
 
204
234
  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
235
 
@@ -286,4 +316,4 @@ await check_release('mdhtml') # one repo: list of unreleased commit summarie
286
316
 
287
317
  ## Tests
288
318
 
289
- Run `pytest -q` for the regular suite. `pytest -q tests/test_setup.py -m slow` also builds the current package and bootstraps a fresh environment with real Git and uv; it may fetch package dependencies. Run `chkstyle fastws tests` after edits.
319
+ Run `pytest -q`. Keep tests concise and focused on substantive logic. Do not test policy choices, settings, constants, or orchestration. Do not add tests merely because code changed.
@@ -1,4 +1,5 @@
1
- __version__ = "0.0.19"
1
+ __version__ = "0.0.20"
2
+
2
3
 
3
4
 
4
5
 
@@ -0,0 +1,38 @@
1
+ r"Maturin build backend that includes Cargo binaries in Python wheels."
2
+ import argparse
3
+ from pathlib import Path
4
+ import maturin
5
+ from .cargo import settings, stage_binaries
6
+
7
+
8
+ def _stage(config_settings):
9
+ parser = argparse.ArgumentParser(add_help=False)
10
+ parser.add_argument('--profile')
11
+ parser.add_argument('--release', action='store_true')
12
+ parser.add_argument('--target')
13
+ args, _ = parser.parse_known_args(maturin.get_maturin_pep517_args(config_settings))
14
+ root = Path.cwd()
15
+ profile = args.profile or ('release' if args.release else settings(root)['maturin'].get('profile', 'release'))
16
+ stage_binaries(root, profile, args.target)
17
+
18
+
19
+ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
20
+ _stage(config_settings)
21
+ return maturin.build_wheel(wheel_directory, config_settings, metadata_directory)
22
+
23
+
24
+ def build_editable(wheel_directory, config_settings=None, metadata_directory=None):
25
+ _stage(config_settings)
26
+ return maturin.build_editable(wheel_directory, config_settings, metadata_directory)
27
+
28
+
29
+ def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
30
+ (Path.cwd()/settings(Path.cwd())['maturin']['data']).mkdir(parents=True, exist_ok=True)
31
+ return maturin.prepare_metadata_for_build_wheel(metadata_directory, config_settings)
32
+
33
+
34
+ prepare_metadata_for_build_editable = prepare_metadata_for_build_wheel
35
+ build_sdist = maturin.build_sdist
36
+ get_requires_for_build_wheel = maturin.get_requires_for_build_wheel
37
+ get_requires_for_build_editable = maturin.get_requires_for_build_editable
38
+ get_requires_for_build_sdist = maturin.get_requires_for_build_sdist
@@ -0,0 +1,67 @@
1
+ r"Build and install Cargo artifacts for mixed Rust/Python projects."
2
+ import json, os, shutil, subprocess, sys, sysconfig
3
+ from pathlib import Path
4
+
5
+ try: import tomllib
6
+ except ModuleNotFoundError: import tomli as tomllib
7
+
8
+
9
+ def settings(root):
10
+ "Read the project's tool configuration."
11
+ return tomllib.loads((root/'pyproject.toml').read_text()).get('tool', {})
12
+
13
+
14
+ def build(root, *, library=True, binaries=False, profile=None, target=None, no_default_features=False):
15
+ "Build with Cargo and return this package's compiler artifacts."
16
+ cfg = settings(root)['maturin']
17
+ manifest = (root/'Cargo.toml').resolve()
18
+ binding = (root/cfg.get('manifest-path', 'Cargo.toml')).resolve()
19
+ cmd = ['cargo', 'build', '--manifest-path', str(manifest), '--message-format=json-render-diagnostics']
20
+ if library: cmd.append('--lib')
21
+ if binaries: cmd.append('--bins')
22
+ if profile := profile or cfg.get('profile'): cmd += ['--profile', profile]
23
+ if target: cmd += ['--target', target]
24
+ if no_default_features: cmd.append('--no-default-features')
25
+ else:
26
+ if features := cfg.get('features'): cmd += ['--features', ','.join(features)]
27
+ for flag in ('all-features', 'no-default-features'):
28
+ if cfg.get(flag): cmd.append('--' + flag)
29
+ out = subprocess.run(cmd, cwd=root, stdout=subprocess.PIPE, text=True, check=True).stdout
30
+ messages = [json.loads(line) for line in out.splitlines()]
31
+ return [m for m in messages if m.get('reason') == 'compiler-artifact' and Path(m['manifest_path']) in (manifest, binding)]
32
+
33
+
34
+ def replace(source, destination):
35
+ "Replace an installed artifact without changing the file held by running processes."
36
+ destination.parent.mkdir(parents=True, exist_ok=True)
37
+ tmp = destination.with_name(destination.name + '.tmp')
38
+ shutil.copy2(source, tmp)
39
+ os.replace(tmp, destination)
40
+ return destination
41
+
42
+
43
+ def develop(root):
44
+ "Install a Python extension and opted-in binaries from one Cargo build."
45
+ tool = settings(root)
46
+ cfg = tool['maturin']
47
+ if 'python-source' not in cfg: raise SystemExit(r'cargo develop requires tool.maturin.python-source')
48
+ binaries = tool.get('fastws', {}).get('native-binaries', False)
49
+ artifacts = build(root, binaries=binaries, profile='test')
50
+ artifact, = [m for m in artifacts if 'cdylib' in m['target']['crate_types']]
51
+ lib, = [f for f in artifact['filenames'] if Path(f).suffix in ('.dylib', '.so', '.dll')]
52
+ module = cfg.get('module-name', artifact['target']['name'])
53
+ dest = root/cfg['python-source']/Path(*module.split('.'))
54
+ paths = [replace(lib, dest.with_name(dest.name + sysconfig.get_config_var('EXT_SUFFIX')))]
55
+ if binaries:
56
+ env = Path(os.environ.get('VIRTUAL_ENV', sys.prefix))
57
+ scripts = env/('Scripts' if os.name == 'nt' else 'bin')
58
+ paths += [replace(m['executable'], scripts/Path(m['executable']).name) for m in artifacts if m.get('executable')]
59
+ return paths
60
+
61
+
62
+ def stage_binaries(root, profile=None, target=None):
63
+ "Stage Python-free Cargo binaries in maturin's wheel-data directory."
64
+ cfg = settings(root)['maturin']
65
+ dest = root/cfg['data']/'scripts'
66
+ artifacts = build(root, library=False, binaries=True, profile=profile, target=target, no_default_features=True)
67
+ return [replace(m['executable'], dest/Path(m['executable']).name) for m in artifacts if m.get('executable')]
@@ -1,6 +1,6 @@
1
1
  "Fast workspace tools for multi-repo management."
2
2
 
3
- __all__ = ["ws_setup", "ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_build", "ws_sync", "ws_add", "ws_remove"]
3
+ __all__ = ["ws_setup", "ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_build", "ws_sync", "ws_add", "ws_remove", "cargo_develop", "cargo_stage"]
4
4
 
5
5
  import ast, fnmatch, glob, hashlib, json, os, re, shlex, shutil, subprocess, sys, time
6
6
  from pathlib import Path
@@ -557,6 +557,26 @@ def _build_projects(root: Path, repos_file: str, project: str = None) -> list[tu
557
557
  dirs = dep_closure(key, graph)
558
558
  return [(n,d) for n,d in res if d in dirs]
559
559
 
560
+ @call_parse(pos=['command'])
561
+ def cargo_develop(
562
+ command: str = 'develop', # Subcommand name supplied by Cargo
563
+ path: str = '.', # Rust/Python project directory
564
+ ):
565
+ r"Build and install a mixed Rust/Python project's extension and opted-in binaries."
566
+ from .cargo import develop
567
+ for dest in develop(Path(path).resolve()): print(f'Installed {dest}')
568
+
569
+ @call_parse(pos=['command'])
570
+ def cargo_stage(
571
+ command: str = 'stage', # Subcommand name supplied by Cargo
572
+ path: str = '.', # Rust/Python project directory
573
+ profile: str = '', # Cargo profile; defaults to the maturin profile or dev
574
+ target: str = '', # Cargo target triple
575
+ ):
576
+ r"Stage Python-free native binaries for maturin wheel builds."
577
+ from .cargo import stage_binaries
578
+ for dest in stage_binaries(Path(path).resolve(), profile or None, target or None): print(f'Staged {dest}')
579
+
560
580
  @call_parse
561
581
  def ws_build(
562
582
  workspace: str = "", # Workspace root; defaults to active venv parent when available
@@ -923,6 +943,9 @@ async def ws_sync(
923
943
  if up: _cargo_update(root, workers=workers)
924
944
  _sync_cargo_keys(root)
925
945
  subprocess.run(["uv", "sync", "-U"] if up else ["uv", "sync"], check=True, cwd=root)
946
+ for _,d in _build_projects(root, repos_file):
947
+ cfg = tomllib.loads((d/'pyproject.toml').read_text()).get('tool', {}).get('maturin', {})
948
+ if 'python-source' in cfg and cfg.get('bindings') != 'bin': cargo_develop(path=str(d))
926
949
  if up: _upgrade_stamp(root).touch()
927
950
  if js_members: _sync_js(root, js_members)
928
951
 
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.19
3
+ Version: 0.0.20
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
- License: Apache-2.0
6
+ License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/AnswerDotAI/fastws
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  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.28
13
+ Requires-Dist: fastcore>=2.2.33
14
14
  Requires-Dist: fastgit>=0.1.2
15
15
  Requires-Dist: ghapi>=2.0.6
16
16
  Requires-Dist: packaging
@@ -195,6 +195,34 @@ ws-build --project solveit # only this package and its transitive workspace dep
195
195
 
196
196
  `--project` selects by package name and follows declared runtime and build dependencies, including workspace packages in external checkouts. Other packages in the output directory are left untouched. Without it, the whole workspace is built as before.
197
197
 
198
+ ### `cargo develop`
199
+
200
+ Rebuild a mixed Rust/Python project's extension and replace it in the editable source package:
201
+
202
+ ```bash
203
+ cargo develop
204
+ cargo develop --path /path/to/project
205
+ ```
206
+
207
+ Run from the project root, or give its directory with `--path`. The command reads `python-source`, `module-name`, `manifest-path` and features from `[tool.maturin]`. It always builds with Cargo's `test` profile, matching bare `cargo test`. The project must produce a `cdylib` and set `python-source`. To use release settings without command-line flags, add `[profile.test]` with `inherits = "release"` to `Cargo.toml`. Keep incremental compilation enabled for the local crate in that profile.
208
+
209
+ Make the initial editable installation through `ws-sync` or maturin. Repeat that installation when package metadata or entry points change. Running Python processes keep the extension they loaded.
210
+
211
+ Cargo must already build a loadable Python extension. For PyO3 extension-module builds on macOS, configure the linker in `build.rs` with `pyo3_build_config::add_extension_module_link_args()`. This command does not inject maturin's extra compiler flags.
212
+
213
+ To share the library build with `cargo test`, also produce an `rlib` and use the same features for both commands. Rust unit tests compile a separate library with `cfg(test)`; integration tests reuse the ordinary library. `cargo develop` does not change the project's Cargo configuration or its test layout.
214
+
215
+ Set `native-binaries = true` under `[tool.fastws]` to build and install the package's Cargo binary targets into the active venv alongside its extension. Cargo reports the executable paths; there is no separate list of binary names.
216
+
217
+ For wheels containing these binaries, use `fastws.build_backend` as the build backend and include `fastws-cli>=0.0.20` alongside maturin in `[build-system].requires`. Set `[tool.maturin].data` to the wheel-data directory. The backend stages Python-free binaries in its `scripts/` subdirectory, then delegates packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries.
218
+
219
+ Direct maturin CLI builds bypass the Python build backend. Stage the binaries first with the shared command:
220
+
221
+ ```bash
222
+ cargo stage --profile dist
223
+ maturin build --profile dist
224
+ ```
225
+
198
226
  ### `ws-sync`
199
227
 
200
228
  Syncing proceeds in this order:
@@ -224,7 +252,9 @@ Each sync updates `workspaces` in the root `package.json`. It creates the file w
224
252
 
225
253
  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
254
 
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.
255
+ After `uv sync`, fastws runs `cargo develop` in each mixed Rust/Python project. Cargo skips unchanged compilation inputs. These projects should keep only packaging inputs in `tool.uv.cache-keys`; Rust source changes are handled by Cargo rather than triggering a separate maturin editable build. Maturin packages with `bindings = "bin"` keep their existing uv installation path.
256
+
257
+ Fastws then runs `<tool> install` at the workspace root. It 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
258
 
229
259
  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
260
 
@@ -311,4 +341,4 @@ await check_release('mdhtml') # one repo: list of unreleased commit summarie
311
341
 
312
342
  ## Tests
313
343
 
314
- Run `pytest -q` for the regular suite. `pytest -q tests/test_setup.py -m slow` also builds the current package and bootstraps a fresh environment with real Git and uv; it may fetch package dependencies. Run `chkstyle fastws tests` after edits.
344
+ Run `pytest -q`. Keep tests concise and focused on substantive logic. Do not test policy choices, settings, constants, or orchestration. Do not add tests merely because code changed.
@@ -2,6 +2,8 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  fastws/__init__.py
5
+ fastws/build_backend.py
6
+ fastws/cargo.py
5
7
  fastws/core.py
6
8
  fastws/releases.py
7
9
  fastws_cli.egg-info/PKG-INFO
@@ -1,4 +1,6 @@
1
1
  [console_scripts]
2
+ cargo-develop = fastws.core:cargo_develop
3
+ cargo-stage = fastws.core:cargo_stage
2
4
  ws-add = fastws.core:ws_add
3
5
  ws-branches = fastws.core:ws_branches
4
6
  ws-build = fastws.core:ws_build
@@ -1,4 +1,4 @@
1
- fastcore>=2.2.28
1
+ fastcore>=2.2.33
2
2
  fastgit>=0.1.2
3
3
  ghapi>=2.0.6
4
4
  packaging
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["setuptools>=68", "wheel"]
2
+ requires = ["setuptools>=77"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  description = "Fast workspace tools for multi-repo management"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
- license = { text = "Apache-2.0" }
11
+ license = "Apache-2.0"
12
12
  authors = [{ name = "Jeremy Howard" }]
13
13
  classifiers = [
14
14
  "Programming Language :: Python :: 3",
@@ -16,7 +16,7 @@ classifiers = [
16
16
  ]
17
17
 
18
18
  dependencies = [
19
- "fastcore>=2.2.28",
19
+ "fastcore>=2.2.33",
20
20
  "fastgit>=0.1.2",
21
21
  "ghapi>=2.0.6",
22
22
  "packaging",
@@ -33,6 +33,8 @@ dev = [
33
33
  ]
34
34
 
35
35
  [project.scripts]
36
+ cargo-develop = "fastws.core:cargo_develop"
37
+ cargo-stage = "fastws.core:cargo_stage"
36
38
  ws-setup = "fastws.core:ws_setup"
37
39
  ws-clone = "fastws.core:ws_clone"
38
40
  ws-pull = "fastws.core:ws_pull"
@@ -47,10 +49,6 @@ ws-releases = "fastws.releases:ws_releases"
47
49
  [project.urls]
48
50
  Homepage = "https://github.com/AnswerDotAI/fastws"
49
51
 
50
- [tool.pytest.ini_options]
51
- asyncio_mode = "auto"
52
- markers = ["slow: builds and installs a fresh workspace; may fetch package dependencies"]
53
- addopts = "-m 'not slow'"
54
52
 
55
53
  [tool.setuptools.dynamic]
56
54
  version = { attr = "fastws.__version__" }
@@ -0,0 +1,25 @@
1
+ import pytest
2
+ import fastws.core as core
3
+
4
+
5
+ def _mk_proj(root, name, extra=''):
6
+ d = root/name
7
+ d.mkdir()
8
+ (d/'pyproject.toml').write_text(fr'''[project]
9
+ name = "{name}"
10
+ {extra}
11
+ ''')
12
+ return d
13
+
14
+
15
+ def test_build_dependency_selection(tmp_path):
16
+ root = tmp_path/'ws'
17
+ root.mkdir()
18
+ _mk_proj(root, 'app', r'''dependencies = ["My.Lib>=1"]''')
19
+ _mk_proj(root, 'my-lib', r'''[build-system]
20
+ requires = ["external"]''')
21
+ external = _mk_proj(tmp_path, 'external', r'''dependencies = ["app", "published-only"]''')
22
+ _mk_proj(root, 'unrelated')
23
+ (root/'repos-local.txt').write_text(f'owner/external {external}')
24
+ assert {n for n,d in core._build_projects(root, 'repos.txt', 'APP')} == {'app', 'my-lib', 'external'}
25
+ with pytest.raises(SystemExit, match='No workspace project'): core._build_projects(root, 'repos.txt', 'missing')
@@ -0,0 +1,15 @@
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
+
15
+
@@ -0,0 +1,15 @@
1
+ import pytest
2
+ import fastws.core as core
3
+
4
+
5
+ def test_setup_preserves_existing_destinations(tmp_path):
6
+ marker = tmp_path/'keep.txt'
7
+ marker.write_text('personal work')
8
+ with pytest.raises(SystemExit, match='exists'): core.ws_setup('org/base', str(tmp_path))
9
+ assert marker.read_text() == 'personal work'
10
+ link = tmp_path/'link'
11
+ link.symlink_to(tmp_path/'missing')
12
+ with pytest.raises(SystemExit, match='exists'): core.ws_setup('org/base', str(link))
13
+ assert link.is_symlink() and not (tmp_path/'missing').exists()
14
+
15
+
@@ -0,0 +1,182 @@
1
+ import json, os, pytest
2
+ from fastgit import Git
3
+ import fastws.core as core
4
+
5
+ os.environ.update(GIT_AUTHOR_NAME='fastws', GIT_AUTHOR_EMAIL='fastws@example.com',
6
+ GIT_COMMITTER_NAME='fastws', GIT_COMMITTER_EMAIL='fastws@example.com')
7
+
8
+ WS_META = '[project]\nname = "uvws"\ndependencies = [\n "repo1pkg",\n "repo2pkg",\n "keeppkg",\n]\n\n[tool.uv.sources]\nrepo1pkg = { workspace = true }\nrepo2pkg = { workspace = true }\nkeeppkg = { workspace = true }\n'
9
+
10
+
11
+ def mk_repo(d, origin=None):
12
+ "Real git repo at `d`, committing any existing files; with `origin`, a bare remote with main pushed"
13
+ d.mkdir(exist_ok=True)
14
+ g = Git(d, raise_exc=True)
15
+ g.init(b='main')
16
+ if not [p for p in d.iterdir() if p.name != '.git']: (d/'f.txt').write_text('x')
17
+ g.add('.')
18
+ g.commit(m='init')
19
+ if origin:
20
+ origin.mkdir(parents=True)
21
+ Git(origin, raise_exc=True).init(bare=True)
22
+ g.remote('add', 'origin', str(origin))
23
+ g.push('-u', 'origin', 'main')
24
+ return g
25
+
26
+
27
+ def test_repos_file_roundtrip(tmp_path):
28
+ repos_path = tmp_path/'repos.txt'
29
+ repos_path.write_text('# header\nAnswerDotAI/fastws[dev]\njph00/private[docs] ~/private\nfastai/fastai sub/dir\n')
30
+
31
+ assert core._load_repo_entries(repos_path, tmp_path) == [
32
+ ('AnswerDotAI/fastws', tmp_path/'fastws', {'dev'}),
33
+ ('jph00/private', core.Path('~/private').expanduser(), {'docs'}),
34
+ ('fastai/fastai', tmp_path/'sub'/'dir', set())]
35
+
36
+ # discovery and ws-add write only extras, never change the shared baseline
37
+ original = repos_path.read_text()
38
+ local = tmp_path/'repos-local.txt'
39
+ added = core._update_repos_file(repos_path, ['answerdotai/fastws', 'jph00/private', 'fastai/new[dev]'])
40
+ assert added == ['fastai/new[dev]']
41
+ assert local.read_text() == 'fastai/new[dev]\n'
42
+ assert repos_path.read_text() == original
43
+ assert core._load_repo_entries(repos_path, tmp_path)[-1] == ('fastai/new', tmp_path/'new', {'dev'})
44
+
45
+ # remove matches case-insensitively, preserves other lines (including comments), and reports absence
46
+ assert core._remove_from_repos_file(local, 'FASTAI/new') is True
47
+ assert core._remove_from_repos_file(repos_path, 'AnswerDotAI/gone') is False
48
+ assert repos_path.read_text() == original
49
+
50
+
51
+ def test_repo_lists_deduplicate_and_reject_conflicting_locations(tmp_path):
52
+ base, local = tmp_path/'repos.txt', tmp_path/'repos-local.txt'
53
+ base.write_text('org/core[dev]\norg/extra[docs] ../external\n')
54
+ local.write_text('ORG/core[TEST, dev]\norg/extra[dev]\n')
55
+ assert core._load_repo_entries(base, tmp_path) == [
56
+ ('org/core', tmp_path/'core', {'dev', 'test'}), ('org/extra', tmp_path/'../external', {'docs', 'dev'})]
57
+ local.write_text('org/core ../elsewhere\n')
58
+ with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
59
+ local.write_text('different/core\n')
60
+ with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
61
+ base.unlink()
62
+ assert core._load_repo_entries(base, tmp_path) == [('different/core', tmp_path/'core', set())]
63
+
64
+
65
+ @pytest.mark.parametrize('entry', ['org/repo[dev', 'org/repo[dev]]', 'org/repo[]'])
66
+ def test_invalid_repo_extras(entry):
67
+ with pytest.raises(SystemExit, match='Invalid repo entry'): core._parse_repo_line(entry)
68
+
69
+
70
+ def test_sync_extras_preserves_constraints_markers_and_unrelated_settings(tmp_path):
71
+ pyproject = tmp_path/'pyproject.toml'
72
+ pyproject.write_text('[project]\nname = "ws"\ndependencies = [\'My_Pkg[old]>=1; python_version >= "3.10"\', "other[keep]"]\n'
73
+ '[tool.uv.sources]\nMy_Pkg = { path = "../somewhere", editable = true }\n')
74
+ for extras in ({'dev', 'test'}, set()):
75
+ core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['my-pkg'], extras={'my-pkg': extras})
76
+ data = core.tomllib.loads(pyproject.read_text())
77
+ dep, other = data['project']['dependencies']
78
+ req = core.Requirement(dep)
79
+ assert req.extras == extras and str(req.specifier) == '>=1' and str(req.marker) == 'python_version >= "3.10"'
80
+ assert other == 'other[keep]' and len(data['project']['dependencies']) == 2
81
+ assert data['tool']['uv']['sources'] == {'My_Pkg': {'path': '../somewhere', 'editable': True}}
82
+
83
+
84
+ def test_remove_from_pyproject(tmp_path):
85
+ pyproject = tmp_path/'pyproject.toml'
86
+ pyproject.write_text('[project]\nname = "uvws"\ndependencies = [\n "alpha",\n "mytool",\n]\n\n[tool.uv.sources]\nalpha = { workspace = true }\nmytool = { path = "../private/mytool", editable = true }\n')
87
+
88
+ assert core._remove_from_pyproject(pyproject, ['Alpha']) == ['alpha'] # case-insensitive
89
+ content = pyproject.read_text()
90
+ assert 'alpha' not in content
91
+ assert 'mytool = { path = "../private/mytool", editable = true }' in content # path sources survive
92
+
93
+
94
+ def test_ws_remove_refuses_unsafe_repos(tmp_path):
95
+ (tmp_path/'repos.txt').write_text('AnswerDotAI/keep\n')
96
+ local = tmp_path/'repos-local.txt'
97
+ local.write_text('AnswerDotAI/repo1\nAnswerDotAI/repo2\norg/external ../external\n')
98
+ (tmp_path/'pyproject.toml').write_text(WS_META)
99
+ repo, repo2 = tmp_path/'repo1', tmp_path/'repo2'
100
+ for d in repo, repo2:
101
+ d.mkdir()
102
+ (d/'pyproject.toml').write_text(f'[project]\nname = "{d.name}pkg"\n')
103
+ g = mk_repo(d, origin=tmp_path/'origins'/d.name)
104
+ with pytest.raises(SystemExit, match='baseline'): core.ws_remove('repo1', 'AnswerDotAI/keep', workspace=str(tmp_path))
105
+ with pytest.raises(SystemExit, match='location'): core.ws_remove('repo1', 'org/external', workspace=str(tmp_path))
106
+
107
+ (repo2/'pyproject.toml').write_text('[project]\nname = "repo2pkg"\nversion = "1"\n')
108
+ with pytest.raises(SystemExit, match='uncommitted'): core.ws_remove('repo1', 'repo2', workspace=str(tmp_path))
109
+ g.commit('-a', m='ahead')
110
+ with pytest.raises(SystemExit, match='unpushed'): core.ws_remove('repo1', 'repo2', workspace=str(tmp_path))
111
+ assert repo.exists() and repo2.exists()
112
+ assert local.read_text() == 'AnswerDotAI/repo1\nAnswerDotAI/repo2\norg/external ../external\n'
113
+ assert (tmp_path/'pyproject.toml').read_text() == WS_META
114
+ assert (tmp_path/'repos.txt').read_text() == 'AnswerDotAI/keep\n'
115
+
116
+
117
+ def test_cargo_key_hashes_lock_and_patched_dependency_contents(tmp_path):
118
+ crate, dep = tmp_path/'crate', tmp_path/'dep'
119
+ for d in crate, dep: (d/'src').mkdir(parents=True)
120
+ (crate/'Cargo.toml').write_text(r'''[dependencies]
121
+ dep = { git = "https://example.com/dep" }
122
+ ''')
123
+ (dep/'Cargo.toml').touch()
124
+ source, lock = dep/'src'/'lib.rs', crate/'Cargo.lock'
125
+ source.write_text('original')
126
+ lock.write_text('first lock')
127
+ patches = {('https://example.com/dep', 'dep'): dep}
128
+ first = core._cargo_key(crate, patches, None)
129
+ lock.write_text('second lock')
130
+ second = core._cargo_key(crate, patches, None)
131
+ assert second != first
132
+ source.write_text('changed')
133
+ assert core._cargo_key(crate, patches, None) != second
134
+
135
+
136
+ def test_cargo_lock_ignores_unused_patches(tmp_path):
137
+ lock = tmp_path/'Cargo.lock'
138
+ header = r'''version = 4
139
+ [[package]]
140
+ name = "crate"
141
+ '''
142
+ lock.write_text(header + r'''[[patch.unused]]
143
+ name = "unused"
144
+ ''')
145
+ assert core._cargo_lock_content(lock) == header.encode()
146
+
147
+
148
+ def test_sync_cargo_patches_generates_and_preserves(tmp_path):
149
+ (tmp_path/'.cargo').mkdir()
150
+ config = tmp_path/'.cargo'/'config.toml'
151
+ config.write_text('[term]\nquiet = true\n\n[patch.crates-io]\n'
152
+ f'foreign = {{ path = "/elsewhere/foreign" }}\ngone = {{ path = "{tmp_path}/gone" }}\n')
153
+ crate1 = tmp_path/'crate1'
154
+ crate1.mkdir()
155
+ (crate1/'Cargo.toml').write_text('[package]\nname = "crate1"\nversion = "0.1.0"\n\n[dependencies]\nfamily = { git = "https://example.com/family" }\n')
156
+ family = tmp_path/'family'
157
+ (family/'sub').mkdir(parents=True)
158
+ (family/'Cargo.toml').write_text('[package]\nname = "family"\nversion = "0.1.0"\n\n[workspace]\nmembers = ["sub"]\n')
159
+ (family/'sub'/'Cargo.toml').write_text('[package]\nname = "family-sub"\nversion = "0.1.0"\n')
160
+
161
+ added, removed = core._sync_cargo_patches(tmp_path)
162
+ data = core.tomllib.loads(config.read_text())
163
+ cio = data['patch']['crates-io']
164
+ assert set(cio) == {'foreign', 'crate1', 'family', 'family-sub'}
165
+ assert cio['foreign']['path'] == '/elsewhere/foreign' # entries pointing outside the root are kept as-is
166
+ assert cio['crate1']['path'] == str(crate1)
167
+ assert cio['family-sub']['path'] == str(family/'sub')
168
+ assert data['patch']['https://example.com/family']['family']['path'] == str(family) # git deps on local crates get their URL table
169
+ assert data['term']['quiet'] is True # other sections untouched
170
+ assert set(added) == {'crate1', 'family', 'family-sub'} and removed == ['gone']
171
+
172
+
173
+ def test_sync_ws_package_json_preserves_local_and_merges_shared(tmp_path):
174
+ pkg, shared = tmp_path/'package.json', tmp_path/'package.json.shared'
175
+ pkg.write_text(json.dumps({'name': 'local', 'workspaces': ['../outside', 'gone', 'tools/*'], 'allowScripts': {'other': False}}))
176
+ shared.write_text('{"allowScripts": {"wasm-pack": true}}')
177
+ assert core._sync_ws_package_json(tmp_path, [tmp_path/'app']) == (['app'], ['gone'])
178
+ assert json.loads(pkg.read_text()) == {'name': 'local', 'workspaces': ['../outside', 'tools/*', 'app'],
179
+ 'allowScripts': {'other': False, 'wasm-pack': True}}
180
+
181
+
182
+
@@ -1,68 +0,0 @@
1
- import os, time
2
- import pytest, fastws.core as core
3
-
4
-
5
- def _mk_proj(root, name, version="0.1.0"):
6
- d = root/name
7
- d.mkdir()
8
- (d/"pyproject.toml").write_text(
9
- f'[project]\nname = "{name}"\nversion = "{version}"\n'
10
- '[build-system]\nrequires = ["setuptools"]\nbuild-backend = "setuptools.build_meta"\n')
11
- (d/(name.replace("-", "_") + ".py")).write_text("x = 1\n")
12
- return d
13
-
14
-
15
- def test_ws_build_incremental(tmp_path, capsys):
16
- a = _mk_proj(tmp_path, "proj-a")
17
- _mk_proj(tmp_path, "projb")
18
- out = tmp_path/".dists"
19
-
20
- core.ws_build(str(tmp_path))
21
- cap = capsys.readouterr()
22
- assert cap.out.strip() == str(out)
23
- assert "2 built" in cap.err
24
- assert sorted(p.name for p in out.glob("*.tar.gz")) == ["proj_a-0.1.0.tar.gz", "projb-0.1.0.tar.gz"]
25
-
26
- mtimes = {p.name: p.stat().st_mtime for p in out.glob("*.tar.gz")}
27
- core.ws_build(str(tmp_path))
28
- assert {p.name: p.stat().st_mtime for p in out.glob("*.tar.gz")} == mtimes
29
-
30
- future = time.time() + 5
31
- os.utime(a/"proj_a.py", (future, future))
32
- core.ws_build(str(tmp_path))
33
- m2 = {p.name: p.stat().st_mtime for p in out.glob("*.tar.gz")}
34
- assert m2["proj_a-0.1.0.tar.gz"] > mtimes["proj_a-0.1.0.tar.gz"]
35
- assert m2["projb-0.1.0.tar.gz"] == mtimes["projb-0.1.0.tar.gz"]
36
-
37
- (a/"pyproject.toml").write_text((a/"pyproject.toml").read_text().replace("0.1.0", "0.2.0"))
38
- os.utime(a/"pyproject.toml", (future + 5, future + 5))
39
- core.ws_build(str(tmp_path))
40
- names = sorted(p.name for p in out.glob("*.tar.gz"))
41
- assert names == ["proj_a-0.2.0.tar.gz", "projb-0.1.0.tar.gz"]
42
-
43
- m3 = {p.name: p.stat().st_mtime for p in out.glob("*.tar.gz")}
44
- core.ws_build(str(tmp_path), force=True)
45
- m4 = {p.name: p.stat().st_mtime for p in out.glob("*.tar.gz")}
46
- assert all(m4[k] > m3[k] for k in m3)
47
-
48
- bad = _mk_proj(tmp_path, "projc")
49
- (bad/"pyproject.toml").write_text('[project]\nname = "projc"\nversion = "0.1.0"\n[build-system]\nrequires = []\nbuild-backend = "no.such.backend"\n')
50
- with pytest.raises(SystemExit): core.ws_build(str(tmp_path))
51
- assert not list(out.glob("projc*"))
52
- assert sorted(p.name for p in out.glob("*.tar.gz")) == ["proj_a-0.2.0.tar.gz", "projb-0.1.0.tar.gz"]
53
-
54
-
55
- def test_build_dependency_selection(tmp_path):
56
- root = tmp_path/'ws'
57
- root.mkdir()
58
- app = _mk_proj(root, 'app')
59
- lib = _mk_proj(root, 'my-lib')
60
- external = _mk_proj(tmp_path, 'external')
61
- _mk_proj(root, 'unrelated')
62
- (root/'repos-local.txt').write_text(f'owner/external {external}\n')
63
- (app/'pyproject.toml').write_text('[project]\nname="app"\ndependencies=["My.Lib>=1"]\n')
64
- (lib/'pyproject.toml').write_text('[project]\nname="my-lib"\n[build-system]\nrequires=["external"]\n')
65
- (external/'pyproject.toml').write_text('[project]\nname="external"\ndependencies=["app", "published-only"]\n')
66
- assert {n for n,d in core._build_projects(root, 'repos.txt', 'APP')} == {'app', 'my-lib', 'external'}
67
- assert len(core._build_projects(root, 'repos.txt')) == 4
68
- with pytest.raises(SystemExit, match='No workspace project'): core._build_projects(root, 'repos.txt', 'missing')
@@ -1,28 +0,0 @@
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,52 +0,0 @@
1
- import os, shlex, subprocess, sys, pytest
2
- from pathlib import Path
3
- from fastgit import Git
4
- from fastws import __version__
5
- import fastws.core as core
6
-
7
-
8
- def test_setup_preserves_existing_destinations(tmp_path):
9
- marker = tmp_path/'keep.txt'
10
- marker.write_text('personal work')
11
- with pytest.raises(SystemExit, match='exists'): core.ws_setup('org/base', str(tmp_path))
12
- assert marker.read_text() == 'personal work'
13
- link = tmp_path/'link'
14
- link.symlink_to(tmp_path/'missing')
15
- with pytest.raises(SystemExit, match='exists'): core.ws_setup('org/base', str(link))
16
- assert link.is_symlink() and not (tmp_path/'missing').exists()
17
-
18
-
19
- @pytest.mark.slow
20
- def test_setup_builds_an_isolated_workspace(tmp_path, monkeypatch, capsys):
21
- "Exercise real Git, uv, and the installed ws-sync; package dependencies may need network access."
22
- pkg = Path(core.__file__).resolve().parent.parent
23
- wheels = tmp_path/'wheels'
24
- subprocess.run(['uv', 'build', '--no-sources', '--wheel', '--out-dir', str(wheels), str(pkg)], check=True)
25
- seed = tmp_path/'base'
26
- seed.mkdir()
27
- (seed/'repos.txt').write_text('')
28
- (seed/'pyproject.tmpl').write_text(
29
- f'[project]\nname = "setup-test"\nversion = "0.0.1"\nrequires-python = ">=3.13"\n'
30
- f'dependencies = ["fastws-cli=={__version__}"]\n[tool.uv.workspace]\nmembers = ["./*"]\n')
31
- g = Git(seed, raise_exc=True)
32
- g.init()
33
- g.add('.')
34
- g.commit(m='bootstrap fixture')
35
- config = tmp_path/'gitconfig'
36
- config.write_text(f'[url "{seed.as_uri()}"]\ninsteadOf = git@github.com:org/base.git\n')
37
- monkeypatch.setenv('GIT_CONFIG_GLOBAL', str(config))
38
- monkeypatch.setenv('UV_FIND_LINKS', str(wheels))
39
- other = tmp_path/'other-env'
40
- other.mkdir()
41
- (other/'keep.txt').write_text('untouched')
42
- for key in ('VIRTUAL_ENV', 'UV_PROJECT_ENVIRONMENT', 'UV_PROJECT', 'UV_WORKING_DIR'): monkeypatch.setenv(key, str(other))
43
- root = tmp_path/'new workspace'
44
- core.ws_setup('org/base', str(root), python=f'{sys.version_info.major}.{sys.version_info.minor}')
45
- assert sorted(p.name for p in other.iterdir()) == ['keep.txt']
46
- assert os.environ['VIRTUAL_ENV'] == str(other)
47
- assert (root/'.venv'/'pyvenv.cfg').exists()
48
- assert (root/'pyproject.toml').exists() and (root/'uv.lock').exists()
49
- env = dict(os.environ, VIRTUAL_ENV=str(root/'.venv'), UV_PROJECT_ENVIRONMENT=str(root/'.venv'),
50
- UV_PROJECT=str(root), UV_WORKING_DIR=str(root))
51
- subprocess.run([str(root/'.venv/bin/ws-status')], cwd=root, env=env, check=True)
52
- assert f'source {shlex.quote(str(root/".venv/bin/activate"))}' in capsys.readouterr().out
@@ -1,415 +0,0 @@
1
- import json, os, pytest
2
- from fastgit import Git
3
- import fastws.core as core
4
-
5
- os.environ.update(GIT_AUTHOR_NAME='fastws', GIT_AUTHOR_EMAIL='fastws@example.com',
6
- GIT_COMMITTER_NAME='fastws', GIT_COMMITTER_EMAIL='fastws@example.com')
7
-
8
- WS_META = '[project]\nname = "uvws"\ndependencies = [\n "repo1pkg",\n "repo2pkg",\n "keeppkg",\n]\n\n[tool.uv.sources]\nrepo1pkg = { workspace = true }\nrepo2pkg = { workspace = true }\nkeeppkg = { workspace = true }\n'
9
-
10
-
11
- def mk_repo(d, origin=None):
12
- "Real git repo at `d`, committing any existing files; with `origin`, a bare remote with main pushed"
13
- d.mkdir(exist_ok=True)
14
- g = Git(d, raise_exc=True)
15
- g.init(b='main')
16
- if not [p for p in d.iterdir() if p.name != '.git']: (d/'f.txt').write_text('x')
17
- g.add('.')
18
- g.commit(m='init')
19
- if origin:
20
- origin.mkdir(parents=True)
21
- Git(origin, raise_exc=True).init(bare=True)
22
- g.remote('add', 'origin', str(origin))
23
- g.push('-u', 'origin', 'main')
24
- return g
25
-
26
-
27
- def test_repos_file_roundtrip(tmp_path):
28
- repos_path = tmp_path/'repos.txt'
29
- repos_path.write_text('# header\nAnswerDotAI/fastws[dev]\njph00/private[docs] ~/private\nfastai/fastai sub/dir\n')
30
-
31
- assert core._load_repos(repos_path) == ['AnswerDotAI/fastws', 'jph00/private', 'fastai/fastai']
32
- assert core._load_repo_entries(repos_path, tmp_path) == [
33
- ('AnswerDotAI/fastws', tmp_path/'fastws', {'dev'}),
34
- ('jph00/private', core.Path('~/private').expanduser(), {'docs'}),
35
- ('fastai/fastai', tmp_path/'sub'/'dir', set())]
36
-
37
- # discovery and ws-add write only extras, never change the shared baseline
38
- original = repos_path.read_text()
39
- local = tmp_path/'repos-local.txt'
40
- added = core._update_repos_file(repos_path, ['answerdotai/fastws', 'jph00/private', 'fastai/new[dev]'])
41
- assert added == ['fastai/new[dev]']
42
- assert local.read_text() == 'fastai/new[dev]\n'
43
- assert repos_path.read_text() == original
44
- assert core._load_repo_entries(repos_path, tmp_path)[-1] == ('fastai/new', tmp_path/'new', {'dev'})
45
-
46
- # remove matches case-insensitively, preserves other lines (including comments), and reports absence
47
- assert core._remove_from_repos_file(local, 'FASTAI/new') is True
48
- assert core._remove_from_repos_file(repos_path, 'AnswerDotAI/gone') is False
49
- assert repos_path.read_text() == original
50
-
51
-
52
- def test_repo_lists_deduplicate_and_reject_conflicting_locations(tmp_path):
53
- base, local = tmp_path/'repos.txt', tmp_path/'repos-local.txt'
54
- base.write_text('org/core[dev]\norg/extra[docs] ../external\n')
55
- local.write_text('ORG/core[TEST, dev]\norg/extra[dev]\n')
56
- assert core._load_repo_entries(base, tmp_path) == [
57
- ('org/core', tmp_path/'core', {'dev', 'test'}), ('org/extra', tmp_path/'../external', {'docs', 'dev'})]
58
- local.write_text('org/core ../elsewhere\n')
59
- with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
60
- local.write_text('different/core\n')
61
- with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
62
- base.unlink()
63
- assert core._load_repo_entries(base, tmp_path) == [('different/core', tmp_path/'core', set())]
64
-
65
-
66
- @pytest.mark.parametrize('entry', ['org/repo[dev', 'org/repo[dev]]', 'org/repo[]'])
67
- def test_invalid_repo_extras(entry):
68
- with pytest.raises(SystemExit, match='Invalid repo entry'): core._parse_repo_line(entry)
69
-
70
-
71
- def test_ws_pyproject_from_template_adds_projects(tmp_path):
72
- (tmp_path/'pyproject.tmpl').write_text('[project]\nname = "uvws"\ndependencies = [\n "ipython>=8.34.0",\n]\n\n[tool.uv.sources]\n\n')
73
- for name in ('alpha', 'beta'):
74
- (tmp_path/name).mkdir()
75
- (tmp_path/name/'pyproject.toml').write_text(f'[project]\nname = "{name}"\n')
76
-
77
- added = core._sync_ws_pyproject(tmp_path/'pyproject.toml', tmp_path/'pyproject.tmpl', ['alpha', 'beta'])
78
- content = (tmp_path/'pyproject.toml').read_text()
79
- assert added == ['alpha', 'beta']
80
- for s in ('alpha = { workspace = true }', 'beta = { workspace = true }', '"alpha"', '"beta"', 'ipython>=8.34.0'): assert s in content
81
-
82
- added = core._sync_ws_pyproject(tmp_path/'pyproject.toml', tmp_path/'pyproject.tmpl', ['alpha', 'beta'])
83
- assert added == []
84
- assert (tmp_path/'pyproject.toml').read_text() == content
85
-
86
-
87
- def test_ws_pyproject_preserves_existing_entries(tmp_path):
88
- # a case-only difference is the same dependency, and hand-written path sources survive syncs
89
- pyproject = tmp_path/'pyproject.toml'
90
- orig = '[project]\nname = "uvws"\ndependencies = ["FastWS"]\n\n[tool.uv.sources]\nFastWS = { workspace = true }\n'
91
- pyproject.write_text(orig)
92
- assert core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['fastws']) == []
93
- assert pyproject.read_text() == orig
94
-
95
- pyproject.write_text('[project]\nname = "uvws"\ndependencies = [\n "mytool",\n]\n\n[tool.uv.sources]\nmytool = { path = "../private/mytool", editable = true }\n')
96
- (tmp_path/'alpha').mkdir()
97
- (tmp_path/'alpha'/'pyproject.toml').write_text('[project]\nname = "alpha"\n')
98
- assert core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['alpha']) == ['alpha']
99
- content = pyproject.read_text()
100
- assert 'mytool = { path = "../private/mytool", editable = true }' in content
101
- assert 'alpha = { workspace = true }' in content
102
-
103
-
104
- def test_sync_extras_preserves_constraints_markers_and_unrelated_settings(tmp_path):
105
- pyproject = tmp_path/'pyproject.toml'
106
- pyproject.write_text('[project]\nname = "ws"\ndependencies = [\'My_Pkg[old]>=1; python_version >= "3.10"\', "other[keep]"]\n'
107
- '[tool.uv.sources]\nMy_Pkg = { path = "../somewhere", editable = true }\n')
108
- for extras in ({'dev', 'test'}, set()):
109
- core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['my-pkg'], extras={'my-pkg': extras})
110
- data = core.tomllib.loads(pyproject.read_text())
111
- dep, other = data['project']['dependencies']
112
- req = core.Requirement(dep)
113
- assert req.extras == extras and str(req.specifier) == '>=1' and str(req.marker) == 'python_version >= "3.10"'
114
- assert other == 'other[keep]' and len(data['project']['dependencies']) == 2
115
- assert data['tool']['uv']['sources'] == {'My_Pkg': {'path': '../somewhere', 'editable': True}}
116
- before = pyproject.stat().st_mtime_ns
117
- core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['my-pkg'], extras={'my-pkg': extras})
118
- assert pyproject.stat().st_mtime_ns == before
119
-
120
-
121
- def test_ws_excludes_generates_from_intent_and_auto(tmp_path):
122
- pyproject = tmp_path/'pyproject.toml'
123
- pyproject.write_text(
124
- '[project]\nname = "uvws"\n\n[tool.uv.workspace]\nmembers = ["./*"]\n'
125
- 'exclude = ["_*", "tmp", "example", "stale", "pending"]\n\n[tool.fastws]\nexclude = ["wanted-out"]\n')
126
- for name in ('junk', 'fresh-clone', 'realpkg', 'tmpl', 'tracked-tmpl', 'example', 'stale', 'wanted-out', 'pending', 'rustcrate'): (tmp_path/name).mkdir()
127
- (tmp_path/'realpkg'/'pyproject.toml').write_text('[project]\nname = "realpkg"\n')
128
- for name in ('tmpl', 'tracked-tmpl'): (tmp_path/name/'pyproject.toml').write_text('[project]\nname = "{repo}"\n')
129
- (tmp_path/'rustcrate'/'Cargo.toml').write_text('[package]\nname = "rustcrate"\n')
130
- for name in ('example', 'stale', 'wanted-out'): (tmp_path/name/'pyproject.toml').write_text(f'[project]\nname = "{name}"\n')
131
-
132
- tracked = {'fresh-clone', 'example', 'pending', 'rustcrate', 'tracked-tmpl'}
133
- added, removed = core._sync_ws_excludes(pyproject, tmp_path, tracked)
134
- data = core.tomllib.loads(pyproject.read_text())
135
-
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'}
139
- assert set(removed) == {'example', 'stale'}
140
- assert data['tool']['fastws']['exclude'] == ['wanted-out'] # fastws table untouched
141
-
142
- content = pyproject.read_text()
143
- assert core._sync_ws_excludes(pyproject, tmp_path, tracked) == ([], [])
144
- assert pyproject.read_text() == content
145
-
146
- # a crate that gains a Python layer rejoins the workspace on the next sync
147
- (tmp_path/'rustcrate'/'pyproject.toml').write_text('[project]\nname = "rustcrate"\n')
148
- assert core._sync_ws_excludes(pyproject, tmp_path, tracked) == ([], ['rustcrate'])
149
-
150
-
151
- def test_external_projects_discovers_root_and_subdir_packages(tmp_path):
152
- root = tmp_path/'ws'
153
- root.mkdir()
154
- single = tmp_path/'single'
155
- single.mkdir()
156
- (single/'pyproject.toml').write_text('[project]\nname = "singlepkg"\n')
157
- multi = tmp_path/'multi'
158
- (multi/'notes').mkdir(parents=True)
159
- for name, pkg in (('tool1', 'tool1'), ('tmpl', '{repo}')):
160
- (multi/name).mkdir()
161
- (multi/name/'pyproject.toml').write_text(f'[project]\nname = "{pkg}"\n')
162
-
163
- assert core._external_projects(root, [single, multi, tmp_path/'missing']) == [('singlepkg', '../single'), ('tool1', '../multi/tool1')]
164
- (root/'repos.txt').write_text(f'org/single[dev] {single}\norg/multi[docs] {multi}\n')
165
- extras = core._repo_extras(root, core._load_repo_entries(root/'repos.txt', root))
166
- assert extras == {'singlepkg': {'dev'}, 'tool1': {'docs'}}
167
- core._sync_ws_pyproject(root/'pyproject.toml', root/'pyproject.tmpl', [], core._external_projects(root, [single, multi]), extras)
168
- data = core.tomllib.loads((root/'pyproject.toml').read_text())
169
- assert data['project']['dependencies'] == ['singlepkg[dev]', 'tool1[docs]']
170
- assert data['tool']['uv']['sources']['tool1'] == {'path': '../multi/tool1', 'editable': True}
171
-
172
-
173
- @pytest.mark.parametrize('manifest', ['pyproject.toml', 'Cargo.toml', 'package.json'])
174
- def test_project_dirs_expands_patterns_and_filters_manifests(tmp_path, manifest):
175
- for name in ('a', 'b', 'empty'): (tmp_path/'pkgs'/name).mkdir(parents=True)
176
- for name in ('a', 'b'): (tmp_path/'pkgs'/name/manifest).write_text('')
177
- members, exclude = ['pkgs/*', 'pkgs/a'], ['./pkgs/b']
178
- assert core._project_dirs(tmp_path, manifest, members, exclude) == [tmp_path/'pkgs'/'a']
179
- assert core._project_dirs(tmp_path, members=members, exclude=exclude) == [tmp_path/'pkgs'/'a', tmp_path/'pkgs'/'empty']
180
-
181
-
182
- def test_ws_projects_skip_excluded_dirs_and_template_names(tmp_path):
183
- (tmp_path/'pyproject.toml').write_text('[tool.uv.workspace]\nmembers = ["./*"]\nexclude = ["skip-*"]\n')
184
- for name, pkg in (('keep', 'keepme'), ('skip-template', 'skipme'), ('template', '{repo}')):
185
- (tmp_path/name).mkdir()
186
- (tmp_path/name/'pyproject.toml').write_text(f'[project]\nname = "{pkg}"\n')
187
-
188
- assert core._ws_projects(tmp_path) == ['keepme']
189
-
190
-
191
- def test_remove_from_pyproject(tmp_path):
192
- pyproject = tmp_path/'pyproject.toml'
193
- pyproject.write_text('[project]\nname = "uvws"\ndependencies = [\n "alpha",\n "mytool",\n]\n\n[tool.uv.sources]\nalpha = { workspace = true }\nmytool = { path = "../private/mytool", editable = true }\n')
194
-
195
- assert core._remove_from_pyproject(pyproject, ['Alpha']) == ['alpha'] # case-insensitive
196
- content = pyproject.read_text()
197
- assert 'alpha' not in content
198
- assert 'mytool = { path = "../private/mytool", editable = true }' in content # path sources survive
199
-
200
- assert core._remove_from_pyproject(pyproject, ['alpha']) == []
201
- assert pyproject.read_text() == content
202
-
203
-
204
- async def test_git_repo_resolution(tmp_path):
205
- repo = tmp_path/'proj'
206
- repo.mkdir()
207
- (repo/'pyproject.toml').write_text('[project]\nname = "proj"\n')
208
- g = mk_repo(repo)
209
-
210
- with pytest.raises(SystemExit, match='origin'): core._origin_repo(repo)
211
- assert await core._discover_ws_repos(tmp_path) == []
212
-
213
- g.remote('add', 'origin', 'git@github.com:AnswerDotAI/Proj.git')
214
- assert core._origin_repo(repo) == 'AnswerDotAI/Proj'
215
- assert await core._discover_ws_repos(tmp_path) == ['AnswerDotAI/Proj']
216
- assert core._resolve_add_target(tmp_path, 'proj') == ('AnswerDotAI/Proj', True, None)
217
-
218
- # discovery covers every root git dir, even excluded non-Python ones; `_`-prefixed dirs are private
219
- crate = tmp_path/'crate'
220
- crate.mkdir()
221
- (crate/'Cargo.toml').write_text('[package]\nname = "crate"\n')
222
- mk_repo(crate).remote('add', 'origin', 'git@github.com:AnswerDotAI/crate.git')
223
- hidden = tmp_path/'_scratch'
224
- hidden.mkdir()
225
- mk_repo(hidden).remote('add', 'origin', 'git@github.com:AnswerDotAI/scratch.git')
226
- (tmp_path/'pyproject.toml').write_text('[tool.uv.workspace]\nmembers = ["./*"]\nexclude = ["crate"]\n')
227
- assert sorted(await core._discover_ws_repos(tmp_path)) == ['AnswerDotAI/Proj', 'AnswerDotAI/crate']
228
-
229
-
230
- def test_ws_remove_refuses_unsafe_repos(tmp_path):
231
- (tmp_path/'repos.txt').write_text('AnswerDotAI/keep\n')
232
- local = tmp_path/'repos-local.txt'
233
- local.write_text('AnswerDotAI/repo1\nAnswerDotAI/repo2\norg/external ../external\n')
234
- (tmp_path/'pyproject.toml').write_text(WS_META)
235
- repo, repo2 = tmp_path/'repo1', tmp_path/'repo2'
236
- for d in repo, repo2:
237
- d.mkdir()
238
- (d/'pyproject.toml').write_text(f'[project]\nname = "{d.name}pkg"\n')
239
- g = mk_repo(d, origin=tmp_path/'origins'/d.name)
240
- with pytest.raises(SystemExit, match='baseline'): core.ws_remove('repo1', 'AnswerDotAI/keep', workspace=str(tmp_path))
241
- with pytest.raises(SystemExit, match='location'): core.ws_remove('repo1', 'org/external', workspace=str(tmp_path))
242
-
243
- (repo2/'pyproject.toml').write_text('[project]\nname = "repo2pkg"\nversion = "1"\n')
244
- with pytest.raises(SystemExit, match='uncommitted'): core.ws_remove('repo1', 'repo2', workspace=str(tmp_path))
245
- g.commit('-a', m='ahead')
246
- with pytest.raises(SystemExit, match='unpushed'): core.ws_remove('repo1', 'repo2', workspace=str(tmp_path))
247
- assert repo.exists() and repo2.exists()
248
- assert local.read_text() == 'AnswerDotAI/repo1\nAnswerDotAI/repo2\norg/external ../external\n'
249
- assert (tmp_path/'pyproject.toml').read_text() == WS_META
250
- assert (tmp_path/'repos.txt').read_text() == 'AnswerDotAI/keep\n'
251
-
252
-
253
- def test_upgrade_stamp(tmp_path):
254
- assert core._should_upgrade(tmp_path) # no stamp yet
255
- core._upgrade_stamp(tmp_path).touch()
256
- assert not core._should_upgrade(tmp_path) # fresh stamp
257
- old = core.time.time() - 90000
258
- os.utime(core._upgrade_stamp(tmp_path), (old, old))
259
- assert core._should_upgrade(tmp_path) # stamp >24h old
260
-
261
-
262
- def test_cargo_keys_hash_contents_and_patched_git_deps(tmp_path):
263
- crate, dep = tmp_path/'crate', tmp_path/'dep'
264
- for d in crate, dep: (d/'src').mkdir(parents=True)
265
- (crate/'.git').mkdir()
266
- (tmp_path/'.cargo').mkdir()
267
- # exclude the crate from the uv workspace: crates are keyed regardless of uv membership
268
- (tmp_path/'pyproject.toml').write_text('[tool.uv.workspace]\nmembers = ["*"]\nexclude = ["crate"]\n')
269
- (tmp_path/'.cargo'/'config.toml').write_text(f'[patch."https://example.com/dep"]\ndep = {{ path = "{dep}" }}\n')
270
- (crate/'Cargo.toml').write_text('[package]\nname = "crate"\nversion = "0.1.0"\n\n[dependencies]\ndep = { git = "https://example.com/dep" }\n')
271
- lock = crate/'Cargo.lock'
272
- lock.write_text('first lock\n')
273
- (crate/'src'/'lib.rs').write_text('root source\n')
274
- (dep/'Cargo.toml').write_text('[package]\nname = "dep"\nversion = "0.1.0"\n')
275
- dep_src = dep/'src'/'lib.rs'
276
- dep_src.write_text('dependency source\n')
277
-
278
- core._sync_cargo_keys(tmp_path)
279
- key = crate/'.git'/'fastws-cargo-key'
280
- first, mtime = key.read_text(), key.stat().st_mtime_ns
281
-
282
- lock_mtime = lock.stat().st_mtime_ns
283
- os.utime(lock, ns=(lock_mtime + 1_000_000_000, lock_mtime + 1_000_000_000))
284
- core._sync_cargo_keys(tmp_path)
285
- assert key.read_text() == first and key.stat().st_mtime_ns == mtime # mtime alone doesn't change the key
286
-
287
- lock.write_text('second lock\n')
288
- core._sync_cargo_keys(tmp_path)
289
- second = key.read_text()
290
- assert second != first # lock content does
291
-
292
- dep_src.write_text('changed dependency source\n')
293
- core._sync_cargo_keys(tmp_path)
294
- assert key.read_text() != second # so does a patched dep's source
295
-
296
-
297
- def test_cargo_key_ignores_unused_patch_order(tmp_path):
298
- crate = tmp_path/'crate'
299
- (crate/'.git').mkdir(parents=True)
300
- (crate/'Cargo.toml').write_text('[package]\nname = "crate"\n')
301
- lock = crate/'Cargo.lock'
302
- head = 'version = 4\n\n[[package]]\nname = "crate"\nversion = "0.1.0"\n\n'
303
- a = '[[patch.unused]]\nname = "a"\nversion = "1"\n\n'
304
- b = '[[patch.unused]]\nname = "b"\nversion = "1"\n'
305
- lock.write_text(head+a+b)
306
- core._sync_cargo_keys(tmp_path)
307
- key = crate/'.git'/'fastws-cargo-key'
308
- first, mtime = key.read_text(), key.stat().st_mtime_ns
309
-
310
- lock.write_text(head+b+'\n'+a)
311
- core._sync_cargo_keys(tmp_path)
312
- assert key.read_text() == first and key.stat().st_mtime_ns == mtime
313
-
314
- lock.write_text(head.replace('0.1.0', '0.1.1')+a+b)
315
- core._sync_cargo_keys(tmp_path)
316
- assert key.read_text() != first
317
-
318
-
319
- def test_sync_cargo_patches_generates_and_preserves(tmp_path):
320
- (tmp_path/'.cargo').mkdir()
321
- config = tmp_path/'.cargo'/'config.toml'
322
- config.write_text('[term]\nquiet = true\n\n[patch.crates-io]\n'
323
- f'foreign = {{ path = "/elsewhere/foreign" }}\ngone = {{ path = "{tmp_path}/gone" }}\n')
324
- crate1 = tmp_path/'crate1'
325
- crate1.mkdir()
326
- (crate1/'Cargo.toml').write_text('[package]\nname = "crate1"\nversion = "0.1.0"\n\n[dependencies]\nfamily = { git = "https://example.com/family" }\n')
327
- family = tmp_path/'family'
328
- (family/'sub').mkdir(parents=True)
329
- (family/'Cargo.toml').write_text('[package]\nname = "family"\nversion = "0.1.0"\n\n[workspace]\nmembers = ["sub"]\n')
330
- (family/'sub'/'Cargo.toml').write_text('[package]\nname = "family-sub"\nversion = "0.1.0"\n')
331
- scratch = tmp_path/'_scratch'
332
- scratch.mkdir()
333
- (scratch/'Cargo.toml').write_text('[package]\nname = "scratch"\n')
334
-
335
- added, removed = core._sync_cargo_patches(tmp_path)
336
- data = core.tomllib.loads(config.read_text())
337
- cio = data['patch']['crates-io']
338
- # every local crate patched, nested cargo workspace members included, `_`-prefixed dirs skipped
339
- assert set(cio) == {'foreign', 'crate1', 'family', 'family-sub'}
340
- assert cio['foreign']['path'] == '/elsewhere/foreign' # entries pointing outside the root are kept as-is
341
- assert cio['crate1']['path'] == str(crate1)
342
- assert cio['family-sub']['path'] == str(family/'sub')
343
- assert data['patch']['https://example.com/family']['family']['path'] == str(family) # git deps on local crates get their URL table
344
- assert data['term']['quiet'] is True # other sections untouched
345
- assert set(added) == {'crate1', 'family', 'family-sub'} and removed == ['gone']
346
-
347
- content = config.read_text()
348
- assert core._sync_cargo_patches(tmp_path) == ([], [])
349
- assert config.read_text() == content
350
-
351
- # a missing config file is created
352
- bare = tmp_path/'ws2'
353
- (bare/'c').mkdir(parents=True)
354
- (bare/'c'/'Cargo.toml').write_text('[package]\nname = "c"\n')
355
- core._sync_cargo_patches(bare)
356
- assert core.tomllib.loads((bare/'.cargo'/'config.toml').read_text())['patch']['crates-io']['c']['path'] == str(bare/'c')
357
-
358
-
359
- def test_sync_cargo_wrapper_preserves_existing(tmp_path):
360
- config = tmp_path/'.cargo'/'config.toml'
361
- config.parent.mkdir()
362
- config.write_text('[build]\nrustc-wrapper = "other-cache"\n')
363
- assert not core._sync_cargo_wrapper(tmp_path)
364
- assert core.tomllib.loads(config.read_text())['build']['rustc-wrapper'] == 'other-cache'
365
-
366
-
367
- def test_npm_dirs_discovers_root_and_declared_packages(tmp_path):
368
- for name in ('app', 'crate', 'crate/wasm', 'crate/frontend', 'crate/docs', '_scratch', 'node_modules', 'crate/node_modules'):
369
- d = tmp_path/name
370
- d.mkdir(parents=True, exist_ok=True)
371
- (d/'package.json').write_text('{}')
372
- (tmp_path/'app'/'package-lock.json').write_text('{}')
373
- (tmp_path/'crate'/'package.json').write_text('{"private": true, "workspaces": ["wasm", "front*", "node_modules", "wasm", "missing"]}')
374
- (tmp_path/'crate'/'frontend'/'bun.lock').write_text('') # lockfiles do not override explicit membership
375
-
376
- assert core._npm_dirs(tmp_path) == [tmp_path/'app', tmp_path/'crate', tmp_path/'crate'/'wasm', tmp_path/'crate'/'frontend']
377
-
378
-
379
- def test_npm_dirs_honours_fastws_exclude(tmp_path):
380
- (tmp_path/'pyproject.toml').write_text('[tool.fastws]\nexclude = ["app", "tool*", "py/examples"]\n')
381
- for name in ('app', 'lib', 'tools'):
382
- (tmp_path/name).mkdir()
383
- (tmp_path/name/'package.json').write_text('{}')
384
- for name in ('examples', 'wasm'):
385
- (tmp_path/'py'/name).mkdir(parents=True)
386
- (tmp_path/'py'/name/'package.json').write_text('{}') # examples is excluded by its root-relative path
387
- (tmp_path/'py'/'package.json').write_text('{"private": true, "workspaces": ["*"]}')
388
-
389
- assert core._npm_dirs(tmp_path) == [tmp_path/'lib', tmp_path/'py', tmp_path/'py'/'wasm']
390
-
391
-
392
- def test_sync_ws_package_json_preserves_local_and_merges_shared(tmp_path):
393
- pkg, shared = tmp_path/'package.json', tmp_path/'package.json.shared'
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}}
399
-
400
-
401
- def test_ws_excludes_treat_npm_only_dirs_like_cargo_only(tmp_path):
402
- pyproject = tmp_path/'pyproject.toml'
403
- pyproject.write_text('[project]\nname = "uvws"\n\n[tool.uv.workspace]\nmembers = ["./*"]\nexclude = []\n')
404
- for name in ('app', 'pending'): (tmp_path/name).mkdir()
405
- (tmp_path/'app'/'package.json').write_text('{}')
406
-
407
- # a tracked npm-only checkout is a valid JS project: excluded from uv, never pending; an empty tracked dir still awaits scaffolding
408
- assert core._sync_ws_excludes(pyproject, tmp_path, {'app', 'pending'}) == (['app'], [])
409
- assert core._pending_dirs(tmp_path) == ['pending']
410
-
411
-
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()
File without changes
File without changes