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.
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/PKG-INFO +35 -5
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/README.md +32 -2
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/fastws/__init__.py +2 -1
- fastws_cli-0.0.20/fastws/build_backend.py +38 -0
- fastws_cli-0.0.20/fastws/cargo.py +67 -0
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/fastws/core.py +24 -1
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/fastws_cli.egg-info/PKG-INFO +35 -5
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/fastws_cli.egg-info/SOURCES.txt +2 -0
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/fastws_cli.egg-info/entry_points.txt +2 -0
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/fastws_cli.egg-info/requires.txt +1 -1
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/pyproject.toml +5 -7
- fastws_cli-0.0.20/tests/test_build.py +25 -0
- fastws_cli-0.0.20/tests/test_releases.py +15 -0
- fastws_cli-0.0.20/tests/test_setup.py +15 -0
- fastws_cli-0.0.20/tests/test_sync.py +182 -0
- fastws_cli-0.0.19/tests/test_build.py +0 -68
- fastws_cli-0.0.19/tests/test_releases.py +0 -28
- fastws_cli-0.0.19/tests/test_setup.py +0 -52
- fastws_cli-0.0.19/tests/test_sync.py +0 -415
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/LICENSE +0 -0
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/fastws/releases.py +0 -0
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/fastws_cli.egg-info/dependency_links.txt +0 -0
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/fastws_cli.egg-info/top_level.txt +0 -0
- {fastws_cli-0.0.19 → fastws_cli-0.0.20}/setup.cfg +0 -0
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
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 =
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|