fastws-cli 0.0.24__tar.gz → 0.0.26__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.
Files changed (22) hide show
  1. {fastws_cli-0.0.24/fastws_cli.egg-info → fastws_cli-0.0.26}/PKG-INFO +16 -3
  2. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/README.md +15 -2
  3. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/fastws/__init__.py +3 -1
  4. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/fastws/build_backend.py +6 -4
  5. fastws_cli-0.0.26/fastws/cargo.py +143 -0
  6. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/fastws/core.py +92 -55
  7. {fastws_cli-0.0.24 → fastws_cli-0.0.26/fastws_cli.egg-info}/PKG-INFO +16 -3
  8. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/fastws_cli.egg-info/entry_points.txt +1 -0
  9. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/pyproject.toml +1 -0
  10. fastws_cli-0.0.24/fastws/cargo.py +0 -81
  11. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/LICENSE +0 -0
  12. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/fastws/releases.py +0 -0
  13. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/fastws_cli.egg-info/SOURCES.txt +0 -0
  14. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/fastws_cli.egg-info/dependency_links.txt +0 -0
  15. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/fastws_cli.egg-info/requires.txt +0 -0
  16. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/fastws_cli.egg-info/top_level.txt +0 -0
  17. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/setup.cfg +0 -0
  18. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/tests/test_build.py +0 -0
  19. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/tests/test_build_backend.py +0 -0
  20. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/tests/test_releases.py +0 -0
  21. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/tests/test_setup.py +0 -0
  22. {fastws_cli-0.0.24 → fastws_cli-0.0.26}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.24
3
+ Version: 0.0.26
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License-Expression: Apache-2.0
@@ -217,9 +217,11 @@ Set `native-binaries = true` under `[tool.fastws]` to build and install the bina
217
217
 
218
218
  `cargo develop` also copies the `data/` subdirectory of `[tool.maturin].data` into the active venv, as a wheel install does. For example, `data/share/jupyter/kernels/<name>/kernel.json` is installed as `share/jupyter/kernels/<name>/kernel.json`, where Jupyter finds the kernel. `cargo develop` installs the binaries it builds, not the staged copies in `scripts/`.
219
219
 
220
- For distribution 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 a generated wheel-data directory. The backend replaces its `scripts/` subdirectory with the current Python-free binaries, then delegates packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries.
220
+ Each Cargo build that fastws runs holds a shared `flock` on `/tmp/aai-ws-timing.lock`. A speed test that takes that lock exclusively then never times code while a build runs. A build waits up to 5 minutes for such a timing to finish, then goes ahead without the lock. Python code takes the same lock with `fastws.cargo.timing_lock`: `with timing_lock(exclusive=True):` around a timing, and `with timing_lock():` around a test run. An exclusive hold that has waited 30 seconds prints the processes that have the lock's file open. It raises `TimeoutError` after 5 minutes.
221
221
 
222
- Editable builds through this backend install only Python metadata and the source path. `ws-sync` then runs `cargo develop` to build and install the extension, the binaries and the wheel data files. After standalone `uv sync`, run `cargo develop` yourself.
222
+ Use `fastws.build_backend` as the build backend of a mixed project, and include `fastws-cli>=0.0.25` alongside maturin in `[build-system].requires`. Its editable builds install only Python metadata and the source path, and compile no Rust. `ws-sync` then runs `cargo develop` to build and install the extension, the binaries and the wheel data files. After standalone `uv sync`, run `cargo develop` yourself.
223
+
224
+ For distribution wheels containing the binaries, also set `[tool.maturin].data` to a generated wheel-data directory. With `native-binaries = true`, wheel builds through the backend replace its `scripts/` subdirectory with the current Python-free binaries, then delegate packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries. Without `native-binaries`, wheel builds go straight to maturin, and `[tool.maturin].data` is optional.
223
225
 
224
226
  Direct maturin CLI builds bypass the Python build backend. Stage the binaries first with the shared command:
225
227
 
@@ -228,6 +230,17 @@ cargo stage --profile dist
228
230
  maturin build --profile dist
229
231
  ```
230
232
 
233
+ ### `cargo wait`
234
+
235
+ Wait until a Cargo workspace compiles:
236
+
237
+ ```bash
238
+ cargo wait
239
+ cargo wait --path /path/to/workspace
240
+ ```
241
+
242
+ Use it when another process's unfinished edits stop the workspace compiling, such as a second agent working in the same tree. Each check runs `cargo check --all-targets` while holding the timing lock shared, as builds do. After a failed check, `cargo wait` prints Cargo's first error line. It checks again once a source file of a local package changes and then stays unchanged for 2 seconds. Local packages are the workspace's members and its path dependencies, such as a sibling crate that `ws-sync` patches in. When a check passes, it prints `compiles` and exits. After `--patience` seconds, 1800 by default, it prints the last check's errors and exits with status 1.
243
+
231
244
  ### `ws-sync`
232
245
 
233
246
  Syncing proceeds in this order:
@@ -191,9 +191,11 @@ Set `native-binaries = true` under `[tool.fastws]` to build and install the bina
191
191
 
192
192
  `cargo develop` also copies the `data/` subdirectory of `[tool.maturin].data` into the active venv, as a wheel install does. For example, `data/share/jupyter/kernels/<name>/kernel.json` is installed as `share/jupyter/kernels/<name>/kernel.json`, where Jupyter finds the kernel. `cargo develop` installs the binaries it builds, not the staged copies in `scripts/`.
193
193
 
194
- For distribution 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 a generated wheel-data directory. The backend replaces its `scripts/` subdirectory with the current Python-free binaries, then delegates packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries.
194
+ Each Cargo build that fastws runs holds a shared `flock` on `/tmp/aai-ws-timing.lock`. A speed test that takes that lock exclusively then never times code while a build runs. A build waits up to 5 minutes for such a timing to finish, then goes ahead without the lock. Python code takes the same lock with `fastws.cargo.timing_lock`: `with timing_lock(exclusive=True):` around a timing, and `with timing_lock():` around a test run. An exclusive hold that has waited 30 seconds prints the processes that have the lock's file open. It raises `TimeoutError` after 5 minutes.
195
195
 
196
- Editable builds through this backend install only Python metadata and the source path. `ws-sync` then runs `cargo develop` to build and install the extension, the binaries and the wheel data files. After standalone `uv sync`, run `cargo develop` yourself.
196
+ Use `fastws.build_backend` as the build backend of a mixed project, and include `fastws-cli>=0.0.25` alongside maturin in `[build-system].requires`. Its editable builds install only Python metadata and the source path, and compile no Rust. `ws-sync` then runs `cargo develop` to build and install the extension, the binaries and the wheel data files. After standalone `uv sync`, run `cargo develop` yourself.
197
+
198
+ For distribution wheels containing the binaries, also set `[tool.maturin].data` to a generated wheel-data directory. With `native-binaries = true`, wheel builds through the backend replace its `scripts/` subdirectory with the current Python-free binaries, then delegate packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries. Without `native-binaries`, wheel builds go straight to maturin, and `[tool.maturin].data` is optional.
197
199
 
198
200
  Direct maturin CLI builds bypass the Python build backend. Stage the binaries first with the shared command:
199
201
 
@@ -202,6 +204,17 @@ cargo stage --profile dist
202
204
  maturin build --profile dist
203
205
  ```
204
206
 
207
+ ### `cargo wait`
208
+
209
+ Wait until a Cargo workspace compiles:
210
+
211
+ ```bash
212
+ cargo wait
213
+ cargo wait --path /path/to/workspace
214
+ ```
215
+
216
+ Use it when another process's unfinished edits stop the workspace compiling, such as a second agent working in the same tree. Each check runs `cargo check --all-targets` while holding the timing lock shared, as builds do. After a failed check, `cargo wait` prints Cargo's first error line. It checks again once a source file of a local package changes and then stays unchanged for 2 seconds. Local packages are the workspace's members and its path dependencies, such as a sibling crate that `ws-sync` patches in. When a check passes, it prints `compiles` and exits. After `--patience` seconds, 1800 by default, it prints the last check's errors and exits with status 1.
217
+
205
218
  ### `ws-sync`
206
219
 
207
220
  Syncing proceeds in this order:
@@ -1,4 +1,6 @@
1
- __version__ = "0.0.24"
1
+ __version__ = "0.0.26"
2
+
3
+
2
4
 
3
5
 
4
6
 
@@ -1,4 +1,4 @@
1
- r"Maturin build backend that includes Cargo binaries in Python wheels."
1
+ r"Maturin build backend whose editable installs compile no Rust, and whose wheels can include Cargo binaries."
2
2
  import argparse, base64, csv, hashlib, io
3
3
  from pathlib import Path
4
4
  from tempfile import TemporaryDirectory
@@ -8,13 +8,15 @@ from .cargo import settings, stage_binaries
8
8
 
9
9
 
10
10
  def _stage(config_settings):
11
+ root = Path.cwd()
12
+ tool = settings(root)
13
+ if not tool.get('fastws', {}).get('native-binaries'): return
11
14
  parser = argparse.ArgumentParser(add_help=False)
12
15
  parser.add_argument('--profile')
13
16
  parser.add_argument('--release', action='store_true')
14
17
  parser.add_argument('--target')
15
18
  args, _ = parser.parse_known_args(maturin.get_maturin_pep517_args(config_settings))
16
- root = Path.cwd()
17
- profile = args.profile or ('release' if args.release else settings(root)['maturin'].get('profile', 'release'))
19
+ profile = args.profile or ('release' if args.release else tool['maturin'].get('profile', 'release'))
18
20
  stage_binaries(root, profile, args.target)
19
21
 
20
22
 
@@ -46,7 +48,7 @@ def build_editable(wheel_directory, config_settings=None, metadata_directory=Non
46
48
 
47
49
 
48
50
  def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
49
- (Path.cwd()/settings(Path.cwd())['maturin']['data']).mkdir(parents=True, exist_ok=True)
51
+ if data := settings(Path.cwd())['maturin'].get('data'): (Path.cwd()/data).mkdir(parents=True, exist_ok=True)
50
52
  return maturin.prepare_metadata_for_build_wheel(metadata_directory, config_settings)
51
53
 
52
54
 
@@ -0,0 +1,143 @@
1
+ r"Build and install Cargo artifacts for mixed Rust/Python projects."
2
+ import fcntl, json, os, shutil, subprocess, sys, sysconfig, time
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+
6
+ try: import tomllib
7
+ except ModuleNotFoundError: import tomli as tomllib
8
+
9
+ # The file whose lock keeps timings and builds apart on this machine. Timings take it exclusively. Builds and test runs take it shared.
10
+ _TIMING_LOCK = '/tmp/aai-ws-timing.lock'
11
+
12
+
13
+ class ConfigError(ValueError):
14
+ "A project's `pyproject.toml` lacks a setting that a command needs."
15
+
16
+
17
+ def settings(root):
18
+ "Read the project's tool configuration."
19
+ return tomllib.loads((root/'pyproject.toml').read_text()).get('tool', {})
20
+
21
+
22
+ def cargo(root, *args):
23
+ "Run Cargo in the project and return its standard output."
24
+ return subprocess.run(['cargo', *args], cwd=root, stdout=subprocess.PIPE, text=True, check=True).stdout
25
+
26
+
27
+ def members(root):
28
+ "The manifests of the workspace's member packages."
29
+ return {Path(p['manifest_path']) for p in json.loads(cargo(root, 'metadata', '--no-deps', '--format-version', '1'))['packages']}
30
+
31
+
32
+ def _lock_users():
33
+ "The other processes that have the timing lock's file open, as `pid command` strings from `lsof`. On macOS, where `lsof` doesn't show `flock` holds, the list also includes any process waiting for the lock."
34
+ users, pid = [], ''
35
+ for line in subprocess.run(['lsof', '+c', '0', '-F', 'pc', _TIMING_LOCK], capture_output=True, text=True).stdout.splitlines():
36
+ if line[:1] == 'p': pid = line[1:]
37
+ elif line[:1] == 'c' and pid != str(os.getpid()): users.append(f'{pid} {line[1:]}')
38
+ return users
39
+
40
+
41
+ @contextmanager
42
+ def timing_lock(
43
+ exclusive=False, # Take the lock exclusively, as a timing does; False: take it shared, as a build or a test run does
44
+ ):
45
+ "Hold the lock that keeps this machine's timings apart from its builds and test runs. A hold waits up to 5 minutes for the lock. After that, a shared hold goes ahead without it. An exclusive hold raises `TimeoutError`. An exclusive hold that has waited 30 seconds prints, once, the other processes that have the lock's file open."
46
+ with open(_TIMING_LOCK, 'a') as f:
47
+ start, waiting, reported = time.monotonic(), False, False
48
+ while True:
49
+ try:
50
+ fcntl.flock(f, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
51
+ break
52
+ except BlockingIOError: pass
53
+ waited = time.monotonic() - start
54
+ if waited > 300:
55
+ if exclusive: raise TimeoutError(f'a build or another timing has held {_TIMING_LOCK} for 5 minutes')
56
+ print(f'going ahead although a timing still holds {_TIMING_LOCK}', file=sys.stderr)
57
+ break
58
+ if not waiting: print(f'waiting for {"a build or another timing" if exclusive else "a timing"} to release {_TIMING_LOCK}', file=sys.stderr)
59
+ waiting = True
60
+ if exclusive and not reported and waited > 30:
61
+ print(f'{_TIMING_LOCK} is still taken after 30 seconds. Processes with it open: {", ".join(_lock_users()) or "none"}', file=sys.stderr)
62
+ reported = True
63
+ time.sleep(0.25)
64
+ yield
65
+
66
+
67
+ def check(root):
68
+ "Check every target of the workspace with Cargo, holding the timing lock shared. Cargo's messages are in the result's `stderr`."
69
+ with timing_lock(): return subprocess.run(['cargo', 'check', '--all-targets', '--message-format=short'], cwd=root, stderr=subprocess.PIPE, text=True)
70
+
71
+
72
+ def sources(root):
73
+ "The manifests, and the target directories or files, of the local packages that the workspace builds: its members and its path dependencies."
74
+ res = set()
75
+ for p in json.loads(cargo(root, 'metadata', '--format-version', '1'))['packages']:
76
+ if p['source'] is not None: continue
77
+ manifest = Path(p['manifest_path'])
78
+ res.add(manifest)
79
+ for t in p['targets']:
80
+ src = Path(t['src_path'])
81
+ res.add(src if src.parent == manifest.parent else src.parent)
82
+ return res
83
+
84
+
85
+ def build(root, *, library=True, binaries=False, profile=None, target=None, no_default_features=False):
86
+ "Build with Cargo and return the compiler artifacts of the workspace's packages."
87
+ cfg = settings(root)['maturin']
88
+ cmd = ['build', '--manifest-path', str((root/'Cargo.toml').resolve()), '--message-format=json-diagnostic-short,json-render-diagnostics']
89
+ if library: cmd.append('--lib')
90
+ if binaries: cmd.append('--bins')
91
+ if profile := profile or cfg.get('profile'): cmd += ['--profile', profile]
92
+ if target: cmd += ['--target', target]
93
+ if no_default_features: cmd.append('--no-default-features')
94
+ else:
95
+ if features := cfg.get('features'): cmd += ['--features', ','.join(features)]
96
+ for flag in ('all-features', 'no-default-features'):
97
+ if cfg.get(flag): cmd.append('--' + flag)
98
+ with timing_lock(): messages = [json.loads(line) for line in cargo(root, *cmd).splitlines()]
99
+ workspace = members(root)
100
+ return [m for m in messages if m.get('reason') == 'compiler-artifact' and Path(m['manifest_path']) in workspace]
101
+
102
+
103
+ def replace(source, destination):
104
+ "Replace an installed artifact without changing the file held by running processes."
105
+ destination.parent.mkdir(parents=True, exist_ok=True)
106
+ tmp = destination.with_name(destination.name + '.tmp')
107
+ shutil.copy2(source, tmp)
108
+ os.replace(tmp, destination)
109
+ return destination
110
+
111
+
112
+ def develop(root):
113
+ "Install a Python extension, opted-in binaries and wheel data files from one Cargo build."
114
+ tool = settings(root)
115
+ cfg = tool['maturin']
116
+ if 'python-source' not in cfg: raise ConfigError(r'cargo develop requires tool.maturin.python-source')
117
+ binaries = tool.get('fastws', {}).get('native-binaries', False)
118
+ artifacts = build(root, binaries=binaries, profile='test')
119
+ artifact, = [m for m in artifacts if 'cdylib' in m['target']['crate_types']]
120
+ lib, = [f for f in artifact['filenames'] if Path(f).suffix in ('.dylib', '.so', '.dll')]
121
+ module = cfg.get('module-name', artifact['target']['name'])
122
+ dest = root/cfg['python-source']/Path(*module.split('.'))
123
+ replace(lib, dest.with_name(dest.name + sysconfig.get_config_var('EXT_SUFFIX')))
124
+ env = Path(os.environ.get('VIRTUAL_ENV', sys.prefix))
125
+ if binaries:
126
+ scripts = env/('Scripts' if os.name == 'nt' else 'bin')
127
+ for m in artifacts:
128
+ if m.get('executable'): replace(m['executable'], scripts/Path(m['executable']).name)
129
+ # Editable builds through `build_backend` don't install the wheel's data files, which a wheel install puts under the prefix.
130
+ if 'data' in cfg:
131
+ data = root/cfg['data']/'data'
132
+ for f in data.rglob('*'):
133
+ if f.is_file(): replace(f, env/f.relative_to(data))
134
+
135
+
136
+ def stage_binaries(root, profile=None, target=None):
137
+ "Stage Python-free Cargo binaries in maturin's wheel-data directory."
138
+ cfg = settings(root)['maturin']
139
+ if 'data' not in cfg: raise ConfigError(r'staging native binaries requires tool.maturin.data')
140
+ dest = root/cfg['data']/'scripts'
141
+ artifacts = build(root, library=False, binaries=True, profile=profile, target=target, no_default_features=True)
142
+ if dest.exists(): shutil.rmtree(dest)
143
+ return [replace(m['executable'], dest/Path(m['executable']).name) for m in artifacts if m.get('executable')]
@@ -1,9 +1,10 @@
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", "cargo_develop", "cargo_stage"]
3
+ __all__ = ["ws_setup", "ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_build", "ws_sync", "ws_add", "ws_remove", "cargo_develop", "cargo_stage", "cargo_wait"]
4
4
 
5
5
  import ast, fnmatch, glob, hashlib, json, os, re, shlex, shutil, subprocess, sys, time
6
6
  from pathlib import Path
7
+ from contextlib import contextmanager
7
8
  from concurrent.futures import ThreadPoolExecutor
8
9
 
9
10
  from fastcore.parallel import parallel_async_gen
@@ -556,6 +557,14 @@ def _build_projects(root: Path, repos_file: str, project: str = None) -> list[tu
556
557
  dirs = dep_closure(key, graph)
557
558
  return [(n,d) for n,d in res if d in dirs]
558
559
 
560
+ @contextmanager
561
+ def _quietly():
562
+ "Exit without a traceback where a command fails or a project lacks a setting. A failed command exits with its status. Use this only where the command printed its own errors. A missing setting prints its message and exits with status 1."
563
+ from .cargo import ConfigError
564
+ try: yield
565
+ except subprocess.CalledProcessError as e: raise SystemExit(e.returncode) from None
566
+ except ConfigError as e: raise SystemExit(str(e)) from None
567
+
559
568
  @call_parse(pos=['command'])
560
569
  def cargo_develop(
561
570
  command: str = 'develop', # Subcommand name supplied by Cargo
@@ -563,7 +572,7 @@ def cargo_develop(
563
572
  ):
564
573
  r"Build and install a mixed Rust/Python project's extension and opted-in binaries."
565
574
  from .cargo import develop
566
- develop(Path(path).resolve())
575
+ with _quietly(): develop(Path(path).resolve())
567
576
 
568
577
  @call_parse(pos=['command'])
569
578
  def cargo_stage(
@@ -574,7 +583,33 @@ def cargo_stage(
574
583
  ):
575
584
  r"Stage Python-free native binaries for maturin wheel builds."
576
585
  from .cargo import stage_binaries
577
- for dest in stage_binaries(Path(path).resolve(), profile or None, target or None): print(f'Staged {dest}')
586
+ with _quietly():
587
+ for dest in stage_binaries(Path(path).resolve(), profile or None, target or None): print(f'Staged {dest}')
588
+
589
+ def _newest(paths):
590
+ "Newest file mtime among `paths`, walking each directory as `_src_mtime` does"
591
+ return max((_src_mtime(p) if p.is_dir() else p.stat().st_mtime) for p in paths if p.exists())
592
+
593
+ @call_parse(pos=['command'])
594
+ def cargo_wait(
595
+ command: str = 'wait', # Subcommand name supplied by Cargo
596
+ path: str = '.', # Cargo workspace directory
597
+ patience: int = 1800, # Seconds to wait before giving up
598
+ ):
599
+ r"Wait until a Cargo workspace compiles, checking again after each edit to its local packages' sources."
600
+ from .cargo import check, sources
601
+ root, start = Path(path).resolve(), time.monotonic()
602
+ with _quietly(): srcs = sources(root)
603
+ while (res := check(root)).returncode:
604
+ if time.monotonic() - start > patience: raise SystemExit(f'{res.stderr}gave up after {patience} s')
605
+ print(next((l for l in res.stderr.splitlines() if 'error' in l), 'cargo check failed'), file=sys.stderr)
606
+ before = _newest(srcs)
607
+ while (now := _newest(srcs)) == before and time.monotonic() - start < patience: time.sleep(1)
608
+ while True:
609
+ time.sleep(2)
610
+ if (later := _newest(srcs)) == now: break
611
+ now = later
612
+ print('compiles')
578
613
 
579
614
  @call_parse
580
615
  def ws_build(
@@ -898,58 +933,60 @@ async def ws_sync(
898
933
  upgrade: bool = False, # Force the once-daily dependency upgrade pass
899
934
  ):
900
935
  "Pull the workspace baseline, clone missing shared/local repos, then pull projects and sync; upgrade dependencies daily."
901
- root = _ws_root(workspace, repos_file, pyproject_file, template_file)
902
- repos_path = _resolve_path(root, repos_file)
903
- pyproject_path = _resolve_path(root, pyproject_file)
904
- template_path = _resolve_path(root, template_file)
905
- await _pull_workspace(root)
906
- entries = _load_repo_entries(repos_path, root)
907
- await _clone_repos(entries, workers)
908
- repos = await _discover_ws_repos(root)
909
-
910
- if missing_repos := _update_repos_file(repos_path, repos): print(f"Added local repos: {', '.join(missing_repos)}")
911
- entries = _load_repo_entries(repos_path, root)
912
- ext_dirs = [d for _,d,_ in entries if d.resolve().parent != root.resolve()]
913
- dirs = [d for _,d,_ in entries]
914
- try: dirs = await _changed_dirs(dirs)
915
- except Exception: pass
916
- await _pull(dirs, workers=workers)
917
-
918
- if not pyproject_path.exists(): _sync_ws_pyproject(pyproject_path, template_path, [])
919
- added_ex, removed_ex = _sync_ws_excludes(pyproject_path, root, {d.name for _,d,_ in entries if d.resolve().parent == root.resolve()})
920
- if added_ex: print(f"Auto-excluded from the workspace: {', '.join(added_ex)}")
921
- if removed_ex: print(f"No longer excluded from the workspace: {', '.join(removed_ex)}")
922
-
923
- missing_projects = _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root), _external_projects(root, ext_dirs),
924
- _repo_extras(root, entries))
925
- if missing_projects: print(f"Added workspace projects: {', '.join(missing_projects)}")
926
- projects = _build_projects(root, repos_file)
927
- _sync_build_isolation(pyproject_path, projects)
928
-
929
- wrapper_added = _sync_cargo_wrapper(root)
930
- added_p, removed_p = _sync_cargo_patches(root)
931
- if added_p: print(f"Cargo patches added: {', '.join(added_p)}")
932
- if removed_p: print(f"Cargo patches removed: {', '.join(removed_p)}")
933
- if wrapper_added: print("Cargo builds now use sccache")
934
-
935
- js_members = _npm_dirs(root)
936
- if _native_js(js_members): _check_rustup()
937
- added_j, removed_j = _sync_ws_package_json(root, js_members)
938
- if added_j: print(f"JS workspace packages added: {', '.join(added_j)}")
939
- if removed_j: print(f"JS workspace packages removed: {', '.join(removed_j)}")
940
-
941
- if bad := _pending_dirs(root):
942
- print(f"⚠️ Skipping uv sync, not Python projects yet (scaffold with e.g. nbdev-new or ship-new, or remove): {', '.join(bad)}")
943
- return
944
- up = upgrade or _should_upgrade(root)
945
- if up: _cargo_update(root, workers=workers)
946
- _sync_cargo_keys(root)
947
- subprocess.run(["uv", "sync", "-U"] if up else ["uv", "sync"], check=True, cwd=root)
948
- for _,d in projects:
949
- cfg = tomllib.loads((d/'pyproject.toml').read_text()).get('tool', {}).get('maturin', {})
950
- if 'python-source' in cfg and cfg.get('bindings') != 'bin': cargo_develop(path=str(d))
951
- if up: _upgrade_stamp(root).touch()
952
- if js_members: _sync_js(root, js_members)
936
+ with _quietly():
937
+ root = _ws_root(workspace, repos_file, pyproject_file, template_file)
938
+ repos_path = _resolve_path(root, repos_file)
939
+ pyproject_path = _resolve_path(root, pyproject_file)
940
+ template_path = _resolve_path(root, template_file)
941
+ await _pull_workspace(root)
942
+ entries = _load_repo_entries(repos_path, root)
943
+ await _clone_repos(entries, workers)
944
+ repos = await _discover_ws_repos(root)
945
+
946
+ if missing_repos := _update_repos_file(repos_path, repos): print(f"Added local repos: {', '.join(missing_repos)}")
947
+ entries = _load_repo_entries(repos_path, root)
948
+ ext_dirs = [d for _,d,_ in entries if d.resolve().parent != root.resolve()]
949
+ dirs = [d for _,d,_ in entries]
950
+ try: dirs = await _changed_dirs(dirs)
951
+ except Exception: pass
952
+ await _pull(dirs, workers=workers)
953
+
954
+ if not pyproject_path.exists(): _sync_ws_pyproject(pyproject_path, template_path, [])
955
+ added_ex, removed_ex = _sync_ws_excludes(pyproject_path, root, {d.name for _,d,_ in entries if d.resolve().parent == root.resolve()})
956
+ if added_ex: print(f"Auto-excluded from the workspace: {', '.join(added_ex)}")
957
+ if removed_ex: print(f"No longer excluded from the workspace: {', '.join(removed_ex)}")
958
+
959
+ missing_projects = _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root), _external_projects(root, ext_dirs),
960
+ _repo_extras(root, entries))
961
+ if missing_projects: print(f"Added workspace projects: {', '.join(missing_projects)}")
962
+ projects = _build_projects(root, repos_file)
963
+ _sync_build_isolation(pyproject_path, projects)
964
+
965
+ wrapper_added = _sync_cargo_wrapper(root)
966
+ added_p, removed_p = _sync_cargo_patches(root)
967
+ if added_p: print(f"Cargo patches added: {', '.join(added_p)}")
968
+ if removed_p: print(f"Cargo patches removed: {', '.join(removed_p)}")
969
+ if wrapper_added: print("Cargo builds now use sccache")
970
+
971
+ js_members = _npm_dirs(root)
972
+ if _native_js(js_members): _check_rustup()
973
+ added_j, removed_j = _sync_ws_package_json(root, js_members)
974
+ if added_j: print(f"JS workspace packages added: {', '.join(added_j)}")
975
+ if removed_j: print(f"JS workspace packages removed: {', '.join(removed_j)}")
976
+
977
+ if bad := _pending_dirs(root):
978
+ print(f"⚠️ Skipping uv sync, not Python projects yet (scaffold with e.g. nbdev-new or ship-new, or remove): {', '.join(bad)}")
979
+ return
980
+ up = upgrade or _should_upgrade(root)
981
+ if up: _cargo_update(root, workers=workers)
982
+ _sync_cargo_keys(root)
983
+ subprocess.run(["uv", "sync", "-U"] if up else ["uv", "sync"], check=True, cwd=root)
984
+ from .cargo import develop
985
+ for _,d in projects:
986
+ cfg = tomllib.loads((d/'pyproject.toml').read_text()).get('tool', {}).get('maturin', {})
987
+ if 'python-source' in cfg and cfg.get('bindings') != 'bin': develop(d.resolve())
988
+ if up: _upgrade_stamp(root).touch()
989
+ if js_members: _sync_js(root, js_members)
953
990
 
954
991
  @call_parse
955
992
  async def ws_add(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.24
3
+ Version: 0.0.26
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License-Expression: Apache-2.0
@@ -217,9 +217,11 @@ Set `native-binaries = true` under `[tool.fastws]` to build and install the bina
217
217
 
218
218
  `cargo develop` also copies the `data/` subdirectory of `[tool.maturin].data` into the active venv, as a wheel install does. For example, `data/share/jupyter/kernels/<name>/kernel.json` is installed as `share/jupyter/kernels/<name>/kernel.json`, where Jupyter finds the kernel. `cargo develop` installs the binaries it builds, not the staged copies in `scripts/`.
219
219
 
220
- For distribution 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 a generated wheel-data directory. The backend replaces its `scripts/` subdirectory with the current Python-free binaries, then delegates packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries.
220
+ Each Cargo build that fastws runs holds a shared `flock` on `/tmp/aai-ws-timing.lock`. A speed test that takes that lock exclusively then never times code while a build runs. A build waits up to 5 minutes for such a timing to finish, then goes ahead without the lock. Python code takes the same lock with `fastws.cargo.timing_lock`: `with timing_lock(exclusive=True):` around a timing, and `with timing_lock():` around a test run. An exclusive hold that has waited 30 seconds prints the processes that have the lock's file open. It raises `TimeoutError` after 5 minutes.
221
221
 
222
- Editable builds through this backend install only Python metadata and the source path. `ws-sync` then runs `cargo develop` to build and install the extension, the binaries and the wheel data files. After standalone `uv sync`, run `cargo develop` yourself.
222
+ Use `fastws.build_backend` as the build backend of a mixed project, and include `fastws-cli>=0.0.25` alongside maturin in `[build-system].requires`. Its editable builds install only Python metadata and the source path, and compile no Rust. `ws-sync` then runs `cargo develop` to build and install the extension, the binaries and the wheel data files. After standalone `uv sync`, run `cargo develop` yourself.
223
+
224
+ For distribution wheels containing the binaries, also set `[tool.maturin].data` to a generated wheel-data directory. With `native-binaries = true`, wheel builds through the backend replace its `scripts/` subdirectory with the current Python-free binaries, then delegate packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries. Without `native-binaries`, wheel builds go straight to maturin, and `[tool.maturin].data` is optional.
223
225
 
224
226
  Direct maturin CLI builds bypass the Python build backend. Stage the binaries first with the shared command:
225
227
 
@@ -228,6 +230,17 @@ cargo stage --profile dist
228
230
  maturin build --profile dist
229
231
  ```
230
232
 
233
+ ### `cargo wait`
234
+
235
+ Wait until a Cargo workspace compiles:
236
+
237
+ ```bash
238
+ cargo wait
239
+ cargo wait --path /path/to/workspace
240
+ ```
241
+
242
+ Use it when another process's unfinished edits stop the workspace compiling, such as a second agent working in the same tree. Each check runs `cargo check --all-targets` while holding the timing lock shared, as builds do. After a failed check, `cargo wait` prints Cargo's first error line. It checks again once a source file of a local package changes and then stays unchanged for 2 seconds. Local packages are the workspace's members and its path dependencies, such as a sibling crate that `ws-sync` patches in. When a check passes, it prints `compiles` and exits. After `--patience` seconds, 1800 by default, it prints the last check's errors and exits with status 1.
243
+
231
244
  ### `ws-sync`
232
245
 
233
246
  Syncing proceeds in this order:
@@ -1,6 +1,7 @@
1
1
  [console_scripts]
2
2
  cargo-develop = fastws.core:cargo_develop
3
3
  cargo-stage = fastws.core:cargo_stage
4
+ cargo-wait = fastws.core:cargo_wait
4
5
  ws-add = fastws.core:ws_add
5
6
  ws-branches = fastws.core:ws_branches
6
7
  ws-build = fastws.core:ws_build
@@ -36,6 +36,7 @@ dev = [
36
36
  [project.scripts]
37
37
  cargo-develop = "fastws.core:cargo_develop"
38
38
  cargo-stage = "fastws.core:cargo_stage"
39
+ cargo-wait = "fastws.core:cargo_wait"
39
40
  ws-setup = "fastws.core:ws_setup"
40
41
  ws-clone = "fastws.core:ws_clone"
41
42
  ws-pull = "fastws.core:ws_pull"
@@ -1,81 +0,0 @@
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 cargo(root, *args):
15
- "Run Cargo in the project and return its standard output."
16
- return subprocess.run(['cargo', *args], cwd=root, stdout=subprocess.PIPE, text=True, check=True).stdout
17
-
18
-
19
- def members(root):
20
- "The manifests of the workspace's member packages."
21
- return {Path(p['manifest_path']) for p in json.loads(cargo(root, 'metadata', '--no-deps', '--format-version', '1'))['packages']}
22
-
23
-
24
- def build(root, *, library=True, binaries=False, profile=None, target=None, no_default_features=False):
25
- "Build with Cargo and return the compiler artifacts of the workspace's packages."
26
- cfg = settings(root)['maturin']
27
- cmd = ['build', '--manifest-path', str((root/'Cargo.toml').resolve()), '--message-format=json-render-diagnostics']
28
- if library: cmd.append('--lib')
29
- if binaries: cmd.append('--bins')
30
- if profile := profile or cfg.get('profile'): cmd += ['--profile', profile]
31
- if target: cmd += ['--target', target]
32
- if no_default_features: cmd.append('--no-default-features')
33
- else:
34
- if features := cfg.get('features'): cmd += ['--features', ','.join(features)]
35
- for flag in ('all-features', 'no-default-features'):
36
- if cfg.get(flag): cmd.append('--' + flag)
37
- messages = [json.loads(line) for line in cargo(root, *cmd).splitlines()]
38
- workspace = members(root)
39
- return [m for m in messages if m.get('reason') == 'compiler-artifact' and Path(m['manifest_path']) in workspace]
40
-
41
-
42
- def replace(source, destination):
43
- "Replace an installed artifact without changing the file held by running processes."
44
- destination.parent.mkdir(parents=True, exist_ok=True)
45
- tmp = destination.with_name(destination.name + '.tmp')
46
- shutil.copy2(source, tmp)
47
- os.replace(tmp, destination)
48
- return destination
49
-
50
-
51
- def develop(root):
52
- "Install a Python extension, opted-in binaries and wheel data files from one Cargo build."
53
- tool = settings(root)
54
- cfg = tool['maturin']
55
- if 'python-source' not in cfg: raise SystemExit(r'cargo develop requires tool.maturin.python-source')
56
- binaries = tool.get('fastws', {}).get('native-binaries', False)
57
- artifacts = build(root, binaries=binaries, profile='test')
58
- artifact, = [m for m in artifacts if 'cdylib' in m['target']['crate_types']]
59
- lib, = [f for f in artifact['filenames'] if Path(f).suffix in ('.dylib', '.so', '.dll')]
60
- module = cfg.get('module-name', artifact['target']['name'])
61
- dest = root/cfg['python-source']/Path(*module.split('.'))
62
- replace(lib, dest.with_name(dest.name + sysconfig.get_config_var('EXT_SUFFIX')))
63
- env = Path(os.environ.get('VIRTUAL_ENV', sys.prefix))
64
- if binaries:
65
- scripts = env/('Scripts' if os.name == 'nt' else 'bin')
66
- for m in artifacts:
67
- if m.get('executable'): replace(m['executable'], scripts/Path(m['executable']).name)
68
- # Editable builds through `build_backend` don't install the wheel's data files, which a wheel install puts under the prefix.
69
- if 'data' in cfg:
70
- data = root/cfg['data']/'data'
71
- for f in data.rglob('*'):
72
- if f.is_file(): replace(f, env/f.relative_to(data))
73
-
74
-
75
- def stage_binaries(root, profile=None, target=None):
76
- "Stage Python-free Cargo binaries in maturin's wheel-data directory."
77
- cfg = settings(root)['maturin']
78
- dest = root/cfg['data']/'scripts'
79
- artifacts = build(root, library=False, binaries=True, profile=profile, target=target, no_default_features=True)
80
- if dest.exists(): shutil.rmtree(dest)
81
- return [replace(m['executable'], dest/Path(m['executable']).name) for m in artifacts if m.get('executable')]
File without changes
File without changes