fastws-cli 0.0.32__tar.gz → 0.0.33__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 (29) hide show
  1. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/PKG-INFO +28 -5
  2. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/README.md +27 -4
  3. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/__init__.py +2 -1
  4. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/cargo.py +73 -97
  5. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/core.py +36 -14
  6. fastws_cli-0.0.33/fastws/procs.py +46 -0
  7. fastws_cli-0.0.33/fastws/store.py +166 -0
  8. fastws_cli-0.0.33/fastws/timelock.py +76 -0
  9. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/PKG-INFO +28 -5
  10. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/SOURCES.txt +7 -1
  11. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/pyproject.toml +3 -0
  12. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_build.py +9 -7
  13. fastws_cli-0.0.33/tests/test_procs.py +35 -0
  14. fastws_cli-0.0.33/tests/test_store.py +33 -0
  15. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_sync.py +10 -0
  16. fastws_cli-0.0.33/tests/test_timelock.py +21 -0
  17. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/LICENSE +0 -0
  18. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/build_backend.py +0 -0
  19. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/news.py +0 -0
  20. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/releases.py +0 -0
  21. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/dependency_links.txt +0 -0
  22. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/entry_points.txt +0 -0
  23. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/requires.txt +0 -0
  24. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/top_level.txt +0 -0
  25. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/setup.cfg +0 -0
  26. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_build_backend.py +0 -0
  27. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_news.py +0 -0
  28. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_releases.py +0 -0
  29. {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.32
3
+ Version: 0.0.33
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License-Expression: Apache-2.0
@@ -200,7 +200,7 @@ ws-build --project solveit # only this package and its transitive workspace dep
200
200
 
201
201
  ### `cargo develop`
202
202
 
203
- Rebuild a mixed Rust/Python project's extension and replace it in the editable source package:
203
+ Build a mixed Rust/Python project's extension and opted-in binaries, keep them with its Python package as one build, and install that build in the editable source package and the venv:
204
204
 
205
205
  ```bash
206
206
  cargo develop
@@ -221,7 +221,13 @@ Set `native-binaries = true` under `[tool.fastws]` to build and install the bina
221
221
 
222
222
  Set `post-develop-module = "package.generator"` under `[tool.fastws]` to run a generator after installing the native extension and binaries, before copying wheel data. It runs with the current Python in a fresh process from the project root, including when Cargo reuses its build. A failure stops `cargo develop` or `ws-sync`. Use it for generated files that need the rebuilt extension's metadata.
223
223
 
224
- On Unix, 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. Windows builds and tests run without this timing coordination.
224
+ `cargo develop` keeps each build it installs in `~/.cache/fastws/builds/<project>/<id>/`, read-only. Files are copied first, then hashed, and hard-linked to blobs named by those captured bytes' sha256. The build id hashes these artifacts and the current digests of inputs listed by Cargo's dep-info. Input digests are observations at capture time, not proof of what the compiler read. The record's `started` gives the build's start time; `edited` flags inputs **possibly modified since build started**, by mtime, including inputs of a first build or newly discovered dependencies. Identical inputs and artifacts reuse their first capture's record.
225
+
226
+ The installed extension and binaries are symbolic links through `<project>/installed`, which `cargo develop` points at the new build in one step. `fastws.store.builds(root)` gives the newest build records, with input changes since the preceding build, and `fastws.store.resolve(root, id)` resolves a build-id prefix. Each develop deletes builds that are neither installed, among the project's last 20, nor from the last 2 days, then old unlinked blobs.
227
+
228
+ On Unix, cooperating `cargo develop` runs take a per-project lock through compilation, capture, installation, the post-develop generator and wheel data. Store readers and capture/install operations hold shared store access; collection takes it exclusively. Ordinary Cargo commands do not take the project lock: agents must not run competing raw Cargo builds against that project during develop. Windows installs copies and does not take these locks.
229
+
230
+ On Unix, every rustc call holds a shared `flock` on `/tmp/aai-ws-timing.lock`, through the rustc wrapper that sync writes (see `ws-sync`). A speed test that takes that lock exclusively stays apart from participating builds and test runs. A timing also holds `/tmp/aai-ws-timing.gate` while it waits for the lock and holds it, and a shared hold passes that file first, so new rustc calls and test runs wait behind the timing. Both shared and exclusive acquisition fail after 5 minutes; work never proceeds unlocked. Python code takes the same lock with `fastws.timelock.timing_lock`: `with timing_lock(exclusive=True):` around a timing, and `with timing_lock():` around a test run. Shared-inside-shared in one thread reuses its hold; each thread otherwise takes its own hold. Any nesting involving exclusive raises `RuntimeError` immediately. An exclusive hold that has waited 30 seconds prints the processes that have the lock's file open. Windows builds and tests run without this timing coordination.
225
231
 
226
232
  Use `fastws.build_backend` as the build backend of a mixed project, and include `fastws-cli>=0.0.29` alongside `maturin>=1.13,<2.0` 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.
227
233
 
@@ -245,7 +251,22 @@ cargo wait
245
251
  cargo wait --path /path/to/workspace
246
252
  ```
247
253
 
248
- 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.
254
+ 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`, whose rustc calls hold the timing lock shared, as every build's 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.
255
+
256
+ ### Rust tests from Python
257
+
258
+ `fastws.cargo.run_tests` builds native tests once with Cargo, without a time limit, then runs the executables that Cargo reports directly from their package directories. Its `timeout` defaults to 30 seconds shared across the test binaries, excluding compilation. Source edits between building and running do not trigger another build: the results describe the compiled build, not necessarily the latest working tree. It supports directly runnable native tests, not cross-target runners.
259
+
260
+ Pass Cargo build/selection options positionally, and test filters and harness options in `test_args`. A failed binary stops the run unless `--no-fail-fast` is given. Results contain compiler diagnostics and each binary's outcomes and captured failures.
261
+
262
+ ```python
263
+ from fastws.cargo import run_tests
264
+
265
+ native = run_tests('.', '--no-fail-fast')
266
+ focused = run_tests('.', '--test', 'core', test_args=('test_name', '--nocapture'))
267
+ ```
268
+
269
+ Doctests are deliberately not run; keep executable examples in ordinary Rust tests. `run_tests` accepts `env` overrides and `timeout=None` to disable its watchdog. It takes no timing lock around the run: rustc's wrapper owns its shared holds, and tests that time own their exclusive holds.
249
270
 
250
271
  ### `ws-sync`
251
272
 
@@ -266,9 +287,11 @@ Existing globs, entries for missing directories, and entries for checkouts that
266
287
 
267
288
  Each sync regenerates Cargo overrides in the workspace's `.cargo/config.toml`. `[patch.crates-io]` gets an entry for each local crate, including nested Cargo workspace members, except a crate marked `publish = false`, which never comes from crates.io. Git dependencies that name local crates get entries under the matching URL. Builds under the workspace root use these local checkouts. Entries pointing outside the root and other configuration sections are preserved.
268
289
 
290
+ Sync also defaults `[term] quiet = true` in that file to suppress routine Cargo output and warnings. An explicit `quiet = false` is preserved.
291
+
269
292
  Do not commit a `Cargo.lock` generated under these patches. Its source-less local entries cannot be resolved on another machine.
270
293
 
271
- When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved. If neither a wrapper nor `sccache` is present, sync warns with the install command (`cargo install sccache --locked`) and continues; rerun `ws-sync` after installing to enable it.
294
+ Sync writes `.cargo/rustc-lock` and makes it Cargo's `rustc-wrapper`. The script runs each rustc call holding the timing lock shared (see `cargo develop` above). Inside the lock it runs the wrapper that the config named before sync first wrote the script, where another tool set one, and otherwise `sccache` when it's installed, so unchanged compilation units are reused across the workspace's otherwise-independent Cargo target directories. Later syncs keep that inner wrapper. The script costs about 9 ms a rustc call, mostly the Python interpreter's start. If `sccache` isn't installed, sync warns with the install command (`cargo install sccache --locked`) and continues; rerun `ws-sync` after installing to enable it. On Windows, where the lock does nothing, the config keeps another tool's wrapper, or else names `sccache`.
272
295
 
273
296
  Fastws discovers JavaScript packages directly under the workspace root. It reads each package's `workspaces` list for declared nested packages. These lists accept paths and globs. Fastws ignores undeclared nested packages. Lockfiles do not affect discovery. `[tool.fastws].exclude` matches paths relative to the workspace root. Fastws skips `node_modules` and directories whose names start with `.` or `_`.
274
297
 
@@ -172,7 +172,7 @@ ws-build --project solveit # only this package and its transitive workspace dep
172
172
 
173
173
  ### `cargo develop`
174
174
 
175
- Rebuild a mixed Rust/Python project's extension and replace it in the editable source package:
175
+ Build a mixed Rust/Python project's extension and opted-in binaries, keep them with its Python package as one build, and install that build in the editable source package and the venv:
176
176
 
177
177
  ```bash
178
178
  cargo develop
@@ -193,7 +193,13 @@ Set `native-binaries = true` under `[tool.fastws]` to build and install the bina
193
193
 
194
194
  Set `post-develop-module = "package.generator"` under `[tool.fastws]` to run a generator after installing the native extension and binaries, before copying wheel data. It runs with the current Python in a fresh process from the project root, including when Cargo reuses its build. A failure stops `cargo develop` or `ws-sync`. Use it for generated files that need the rebuilt extension's metadata.
195
195
 
196
- On Unix, 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. Windows builds and tests run without this timing coordination.
196
+ `cargo develop` keeps each build it installs in `~/.cache/fastws/builds/<project>/<id>/`, read-only. Files are copied first, then hashed, and hard-linked to blobs named by those captured bytes' sha256. The build id hashes these artifacts and the current digests of inputs listed by Cargo's dep-info. Input digests are observations at capture time, not proof of what the compiler read. The record's `started` gives the build's start time; `edited` flags inputs **possibly modified since build started**, by mtime, including inputs of a first build or newly discovered dependencies. Identical inputs and artifacts reuse their first capture's record.
197
+
198
+ The installed extension and binaries are symbolic links through `<project>/installed`, which `cargo develop` points at the new build in one step. `fastws.store.builds(root)` gives the newest build records, with input changes since the preceding build, and `fastws.store.resolve(root, id)` resolves a build-id prefix. Each develop deletes builds that are neither installed, among the project's last 20, nor from the last 2 days, then old unlinked blobs.
199
+
200
+ On Unix, cooperating `cargo develop` runs take a per-project lock through compilation, capture, installation, the post-develop generator and wheel data. Store readers and capture/install operations hold shared store access; collection takes it exclusively. Ordinary Cargo commands do not take the project lock: agents must not run competing raw Cargo builds against that project during develop. Windows installs copies and does not take these locks.
201
+
202
+ On Unix, every rustc call holds a shared `flock` on `/tmp/aai-ws-timing.lock`, through the rustc wrapper that sync writes (see `ws-sync`). A speed test that takes that lock exclusively stays apart from participating builds and test runs. A timing also holds `/tmp/aai-ws-timing.gate` while it waits for the lock and holds it, and a shared hold passes that file first, so new rustc calls and test runs wait behind the timing. Both shared and exclusive acquisition fail after 5 minutes; work never proceeds unlocked. Python code takes the same lock with `fastws.timelock.timing_lock`: `with timing_lock(exclusive=True):` around a timing, and `with timing_lock():` around a test run. Shared-inside-shared in one thread reuses its hold; each thread otherwise takes its own hold. Any nesting involving exclusive raises `RuntimeError` immediately. An exclusive hold that has waited 30 seconds prints the processes that have the lock's file open. Windows builds and tests run without this timing coordination.
197
203
 
198
204
  Use `fastws.build_backend` as the build backend of a mixed project, and include `fastws-cli>=0.0.29` alongside `maturin>=1.13,<2.0` 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.
199
205
 
@@ -217,7 +223,22 @@ cargo wait
217
223
  cargo wait --path /path/to/workspace
218
224
  ```
219
225
 
220
- 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.
226
+ 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`, whose rustc calls hold the timing lock shared, as every build's 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.
227
+
228
+ ### Rust tests from Python
229
+
230
+ `fastws.cargo.run_tests` builds native tests once with Cargo, without a time limit, then runs the executables that Cargo reports directly from their package directories. Its `timeout` defaults to 30 seconds shared across the test binaries, excluding compilation. Source edits between building and running do not trigger another build: the results describe the compiled build, not necessarily the latest working tree. It supports directly runnable native tests, not cross-target runners.
231
+
232
+ Pass Cargo build/selection options positionally, and test filters and harness options in `test_args`. A failed binary stops the run unless `--no-fail-fast` is given. Results contain compiler diagnostics and each binary's outcomes and captured failures.
233
+
234
+ ```python
235
+ from fastws.cargo import run_tests
236
+
237
+ native = run_tests('.', '--no-fail-fast')
238
+ focused = run_tests('.', '--test', 'core', test_args=('test_name', '--nocapture'))
239
+ ```
240
+
241
+ Doctests are deliberately not run; keep executable examples in ordinary Rust tests. `run_tests` accepts `env` overrides and `timeout=None` to disable its watchdog. It takes no timing lock around the run: rustc's wrapper owns its shared holds, and tests that time own their exclusive holds.
221
242
 
222
243
  ### `ws-sync`
223
244
 
@@ -238,9 +259,11 @@ Existing globs, entries for missing directories, and entries for checkouts that
238
259
 
239
260
  Each sync regenerates Cargo overrides in the workspace's `.cargo/config.toml`. `[patch.crates-io]` gets an entry for each local crate, including nested Cargo workspace members, except a crate marked `publish = false`, which never comes from crates.io. Git dependencies that name local crates get entries under the matching URL. Builds under the workspace root use these local checkouts. Entries pointing outside the root and other configuration sections are preserved.
240
261
 
262
+ Sync also defaults `[term] quiet = true` in that file to suppress routine Cargo output and warnings. An explicit `quiet = false` is preserved.
263
+
241
264
  Do not commit a `Cargo.lock` generated under these patches. Its source-less local entries cannot be resolved on another machine.
242
265
 
243
- When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved. If neither a wrapper nor `sccache` is present, sync warns with the install command (`cargo install sccache --locked`) and continues; rerun `ws-sync` after installing to enable it.
266
+ Sync writes `.cargo/rustc-lock` and makes it Cargo's `rustc-wrapper`. The script runs each rustc call holding the timing lock shared (see `cargo develop` above). Inside the lock it runs the wrapper that the config named before sync first wrote the script, where another tool set one, and otherwise `sccache` when it's installed, so unchanged compilation units are reused across the workspace's otherwise-independent Cargo target directories. Later syncs keep that inner wrapper. The script costs about 9 ms a rustc call, mostly the Python interpreter's start. If `sccache` isn't installed, sync warns with the install command (`cargo install sccache --locked`) and continues; rerun `ws-sync` after installing to enable it. On Windows, where the lock does nothing, the config keeps another tool's wrapper, or else names `sccache`.
244
267
 
245
268
  Fastws discovers JavaScript packages directly under the workspace root. It reads each package's `workspaces` list for declared nested packages. These lists accept paths and globs. Fastws ignores undeclared nested packages. Lockfiles do not affect discovery. `[tool.fastws].exclude` matches paths relative to the workspace root. Fastws skips `node_modules` and directories whose names start with `.` or `_`.
246
269
 
@@ -1,4 +1,5 @@
1
- __version__ = "0.0.32"
1
+ __version__ = "0.0.33"
2
+
2
3
 
3
4
 
4
5
 
@@ -1,17 +1,15 @@
1
1
  r"Build, test and install Cargo artifacts for mixed Rust/Python projects."
2
2
  import json, os, re, shutil, subprocess, sys, sysconfig, time
3
3
  from collections import Counter
4
- from contextlib import contextmanager
5
4
  from dataclasses import dataclass
6
5
  from pathlib import Path
7
6
  from typing import NamedTuple
7
+ from . import store
8
+ from .procs import bounded
8
9
 
9
10
  try: import tomllib
10
11
  except ModuleNotFoundError: import tomli as tomllib
11
12
 
12
- # The file whose lock keeps timings and builds apart on this machine. Timings take it exclusively. Builds and test runs take it shared.
13
- _TIMING_LOCK = '/tmp/aai-ws-timing.lock'
14
-
15
13
 
16
14
  class ConfigError(ValueError):
17
15
  "A project's `pyproject.toml` lacks a setting that a command needs."
@@ -32,52 +30,11 @@ def members(root):
32
30
  return {Path(p['manifest_path']) for p in json.loads(cargo(root, 'metadata', '--no-deps', '--format-version', '1'))['packages']}
33
31
 
34
32
 
35
- def _lock_users():
36
- "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."
37
- users, pid = [], ''
38
- for line in subprocess.run(['lsof', '+c', '0', '-F', 'pc', _TIMING_LOCK], capture_output=True, text=True).stdout.splitlines():
39
- if line[:1] == 'p': pid = line[1:]
40
- elif line[:1] == 'c' and pid != str(os.getpid()): users.append(f'{pid} {line[1:]}')
41
- return users
42
-
43
-
44
- @contextmanager
45
- def timing_lock(
46
- exclusive=False, # Take the lock exclusively, as a timing does; False: take it shared, as a build or a test run does
47
- ):
48
- "Hold the Unix lock that keeps this machine's timings apart from its builds and test runs; a no-op on Windows. 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."
49
- if os.name == 'nt':
50
- yield
51
- return
52
- import fcntl
53
- with open(_TIMING_LOCK, 'a') as f:
54
- start, waiting, reported = time.monotonic(), False, False
55
- while True:
56
- try:
57
- fcntl.flock(f, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
58
- break
59
- except BlockingIOError: pass
60
- waited = time.monotonic() - start
61
- if waited > 300:
62
- if exclusive: raise TimeoutError(f'a build or another timing has held {_TIMING_LOCK} for 5 minutes')
63
- print(f'going ahead although a timing still holds {_TIMING_LOCK}', file=sys.stderr)
64
- break
65
- if not waiting: print(f'waiting for {"a build or another timing" if exclusive else "a timing"} to release {_TIMING_LOCK}', file=sys.stderr)
66
- waiting = True
67
- if exclusive and not reported and waited > 30:
68
- print(f'{_TIMING_LOCK} is still taken after 30 seconds. Processes with it open: {", ".join(_lock_users()) or "none"}', file=sys.stderr)
69
- reported = True
70
- time.sleep(0.25)
71
- yield
72
-
73
-
74
33
  def check(root):
75
- "Check every target of the workspace with Cargo, holding the timing lock shared. Cargo's messages are in the result's `stderr`, which `diagnostics` reads."
76
- with timing_lock(): return subprocess.run(['cargo', 'check', '--all-targets', '--message-format=short'], cwd=Path(root).expanduser(), stderr=subprocess.PIPE, text=True)
34
+ "Check every target of the workspace with Cargo. Cargo's messages are in the result's `stderr`, which `diagnostics` reads."
35
+ return subprocess.run(['cargo', 'check', '--all-targets', '--message-format=short'], cwd=Path(root).expanduser(), stderr=subprocess.PIPE, text=True)
77
36
 
78
37
 
79
- # Cargo's line that starts a test binary: `Running` with its target and executable, or `Doc-tests` with its package.
80
- _SUITE = re.compile(r'^\s+(?:Running (.+) \([^)]*\)|(Doc-tests .+))$')
81
38
  _TEST = re.compile(r'^test (.+) \.\.\. (ok|FAILED|ignored)')
82
39
  _FAILURE = re.compile(r'^---- (.+) std(?:out|err) ----$')
83
40
  _DIAGNOSTIC = re.compile(r'^(\S.*?):(\d+):(\d+): (warning|error)(?:\[\w+\])?: (.*)$')
@@ -105,17 +62,17 @@ def _lines(text, n=20, tail=False):
105
62
 
106
63
  @dataclass
107
64
  class Suite:
108
- "What one test binary printed when `cargo test` ran it."
109
- name:str # Cargo's name for the binary, such as `tests/core.rs`, `unittests src/lib.rs` or `Doc-tests basedpl`
110
- output:str # Everything printed after Cargo's line that starts the binary, until the next binary starts
65
+ "What one native test binary printed."
66
+ name:str # The target source, such as `tests/core.rs` or `unittests src/lib.rs`
67
+ output:str # Everything the binary printed, standard output and standard error merged
111
68
  tests:dict # Each test's outcome: `ok`, `FAILED` or `ignored`
112
69
  failures:dict # Each failing test's captured output
113
70
  summary:str # libtest's `test result:` line, empty for a binary that crashed or has its own harness
114
- failed:bool # Did Cargo report the binary as failing?
71
+ failed:bool # Did the binary exit unsuccessfully?
115
72
 
116
73
  @classmethod
117
- def read(cls, name, lines):
118
- "Read one binary's lines."
74
+ def read(cls, name, lines, returncode):
75
+ "Read one binary's lines and exit status."
119
76
  failures, current = {}, None
120
77
  for line in lines:
121
78
  if m := _FAILURE.match(line): current = failures.setdefault(m[1], [])
@@ -123,7 +80,7 @@ class Suite:
123
80
  elif current is not None: current.append(line)
124
81
  return cls(name, '\n'.join(lines), {m[1]: m[2] for l in lines if (m := _TEST.match(l))},
125
82
  {k: '\n'.join(v).strip('\n') for k,v in failures.items()},
126
- next((l for l in lines if l.startswith('test result: ')), ''), any(l.startswith('error: test failed') for l in lines))
83
+ next((l for l in lines if l.startswith('test result: ')), ''), bool(returncode))
127
84
 
128
85
  def __str__(self):
129
86
  n = Counter(self.tests.values())
@@ -135,11 +92,12 @@ class Suite:
135
92
 
136
93
  @dataclass
137
94
  class Results:
138
- "What `cargo test` printed, read into the build's compiler messages and each test binary's results. It displays as a summary: the compiler messages, each binary that ran tests, and each failure's output."
95
+ "A build's compiler messages and each native test binary's results. It displays as a summary: the compiler messages, each binary that ran tests, and each failure's output."
139
96
  returncode:int
140
- text:str # Everything Cargo and the tests printed, standard output and standard error in the order they were written
97
+ text:str # The build's messages and each binary's merged standard output and standard error
141
98
  diagnostics:list # Each `Diagnostic` from the build, before the first binary ran
142
- suites:list # Each binary's `Suite`, in the order Cargo ran them
99
+ suites:list # Each binary's `Suite`, in execution order
100
+ seconds:float=0 # Wall time spent executing native test binaries, excluding compilation
143
101
 
144
102
  @property
145
103
  def ok(self): return self.returncode == 0
@@ -159,27 +117,38 @@ class Results:
159
117
  return '\n'.join(res)
160
118
 
161
119
 
162
- def _blocks(text):
163
- "Split `text` at Cargo's lines that start a test binary, into `(name, lines)` pairs. The first pair, named `None`, holds the build's lines."
164
- res = [(None, [])]
165
- for line in text.splitlines():
166
- if m := _SUITE.match(line): res.append((m[1] or m[2], []))
167
- else: res[-1][1].append(line)
168
- return res
169
-
170
-
171
120
  def run_tests(
172
121
  root, # A Cargo package or workspace (expands `~`)
173
- *args, # Arguments for `cargo test`, such as `'--test', 'core'`, a test name to filter by, or `'--', '--nocapture'`
122
+ *args, # Cargo build/selection options, such as `'--test', 'core'` or `'--profile', 'checked'`; `--no-fail-fast` runs every binary
123
+ test_args=(), # Arguments passed directly to each test binary, such as `('name', '--nocapture')`
174
124
  env:dict=None, # Environment variables to set for the run, such as `{'BASEDPL_COSTS': '1'}`
175
- timeout:float=None # Seconds before the run stops, raising `subprocess.TimeoutExpired`
125
+ timeout:float=30 # Seconds shared by all native test binaries after building, raising `TimeoutError` on expiry; None: no limit
176
126
  )->Results:
177
- "Run `cargo test` in `root` and read what it prints. Compiler messages come in Cargo's short format. It doesn't hold the timing lock as `check` and `build` do: tests that time take the lock exclusively themselves, and a shared hold here would block them."
178
- # A quiet Cargo config hides the `Running` lines that divide the binaries' output.
179
- p = subprocess.run(['cargo', 'test', '--message-format=short', *args], cwd=Path(root).expanduser(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=timeout,
180
- env=os.environ | {'CARGO_TERM_QUIET': 'false', 'CARGO_TERM_COLOR': 'never'} | (env or {}))
181
- (_, build), *runs = _blocks(p.stdout)
182
- return Results(p.returncode, p.stdout, diagnostics('\n'.join(build)), [Suite.read(*b) for b in runs])
127
+ "Build native tests once, with no time limit, then run Cargo's reported executables directly from their package directories. Source edits after building cannot trigger another build; results describe the compiled build, not the latest working tree. `timeout` covers the binaries together, excluding compilation. Doctests are not run. This takes no timing lock: rustc's wrapper and tests that time own their holds."
128
+ root, env = Path(root).expanduser().resolve(), os.environ | {'CARGO_TERM_QUIET': 'false', 'CARGO_TERM_COLOR': 'never'} | (env or {})
129
+ built = bounded(['cargo', 'test', '--no-run', '--message-format=json-diagnostic-short', *args], seconds=None, merge=True, cwd=root, env=env)
130
+ text, artifacts = [], []
131
+ for line in built.stdout.splitlines(keepends=True):
132
+ if not line.startswith('{'): text.append(line); continue
133
+ m = json.loads(line)
134
+ if m['reason'] == 'compiler-message': text.append(m['message']['rendered'])
135
+ elif m['reason'] == 'compiler-artifact' and m['profile']['test'] and m.get('executable'): artifacts.append(m)
136
+ text = ''.join(text)
137
+ found = list(dict.fromkeys(diagnostics(text)))
138
+ if built.returncode: return Results(built.returncode, text, found, [])
139
+ suites, code, start = [], 0, time.monotonic()
140
+ for m in artifacts:
141
+ package = Path(m['manifest_path']).parent
142
+ name = Path(m['target']['src_path']).relative_to(package).as_posix()
143
+ if 'test' not in m['target']['kind']: name = 'unittests ' + name
144
+ left = None if timeout is None else max(0, timeout - (time.monotonic() - start))
145
+ p = bounded([m['executable'], *test_args], left, merge=True, cwd=package, env=env | {'CARGO_MANIFEST_DIR': str(package)})
146
+ suites.append(Suite.read(name, p.stdout.splitlines(), p.returncode))
147
+ text += f' Running {name} ({m["executable"]})\n' + p.stdout
148
+ if p.returncode:
149
+ code = p.returncode
150
+ if '--no-fail-fast' not in args: break
151
+ return Results(code, text, found, suites, time.monotonic() - start)
183
152
 
184
153
 
185
154
  def sources(root):
@@ -236,7 +205,7 @@ def build(
236
205
  for flag, active in [('all-features', all_features), ('no-default-features', no_default_features)]:
237
206
  if active or cfg.get(flag): cmd.append('--' + flag)
238
207
  extra, env = _maturin_flags(target, os.environ) if for_maturin else ([], None)
239
- with timing_lock(): result = subprocess.run(['cargo', *cmd, *extra], cwd=root, stdout=subprocess.PIPE, text=True, env=env)
208
+ result = subprocess.run(['cargo', *cmd, *extra], cwd=root, stdout=subprocess.PIPE, text=True, env=env)
240
209
  messages = [json.loads(line) for line in result.stdout.splitlines()]
241
210
  for m in messages:
242
211
  if m.get('reason') != 'compiler-message': continue
@@ -257,30 +226,37 @@ def replace(source, destination):
257
226
 
258
227
 
259
228
  def develop(root):
260
- "Install a Python extension, opted-in binaries and wheel data, running the project's post-develop module before wheel data."
229
+ "Serialize this project's cooperating develop runs through build, artifact capture, installation, generator and wheel data. Raw Cargo commands do not take this lock."
261
230
  root = Path(root).expanduser()
262
- tool = settings(root)
263
- cfg = tool['maturin']
264
- if 'python-source' not in cfg: raise ConfigError(r'cargo develop requires tool.maturin.python-source')
265
- binaries = tool.get('fastws', {}).get('native-binaries', False)
266
- artifacts = build(root, binaries=binaries, profile='test')
267
- artifact, = [m for m in artifacts if 'cdylib' in m['target']['crate_types']]
268
- lib, = [f for f in artifact['filenames'] if Path(f).suffix in ('.dylib', '.so', '.dll')]
269
- module = cfg.get('module-name', artifact['target']['name'])
270
- dest = root/cfg['python-source']/Path(*module.split('.'))
271
- replace(lib, dest.with_name(dest.name + sysconfig.get_config_var('EXT_SUFFIX')))
272
- env = Path(os.environ.get('VIRTUAL_ENV', sys.prefix))
273
- if binaries:
231
+ with store.building(root) as started:
232
+ tool = settings(root)
233
+ cfg = tool['maturin']
234
+ if 'python-source' not in cfg: raise ConfigError(r'cargo develop requires tool.maturin.python-source')
235
+ binaries = tool.get('fastws', {}).get('native-binaries', False)
236
+ artifacts = build(root, binaries=binaries, profile='test')
237
+ artifact, = [m for m in artifacts if 'cdylib' in m['target']['crate_types']]
238
+ lib, = [Path(f) for f in artifact['filenames'] if Path(f).suffix in ('.dylib', '.so', '.dll')]
239
+ module = cfg.get('module-name', artifact['target']['name'])
240
+ src = root/cfg['python-source']
241
+ ext = src/Path(*module.split('.'))
242
+ ext = ext.with_name(ext.name + sysconfig.get_config_var('EXT_SUFFIX'))
243
+ env = Path(os.environ.get('VIRTUAL_ENV', sys.prefix))
274
244
  scripts = env/('Scripts' if os.name == 'nt' else 'bin')
275
- for m in artifacts:
276
- if m.get('executable'): replace(m['executable'], scripts/Path(m['executable']).name)
277
- if generator := tool.get('fastws', {}).get('post-develop-module'):
278
- subprocess.run([sys.executable, '-m', generator], cwd=root, check=True)
279
- # Editable builds through `build_backend` don't install the wheel's data files, which a wheel install puts under the prefix.
280
- if 'data' in cfg:
281
- data = root/cfg['data']/'data'
282
- for f in data.rglob('*'):
283
- if f.is_file(): replace(f, env/f.relative_to(data))
245
+ exes = [Path(m['executable']) for m in artifacts if binaries and m.get('executable')]
246
+ package = src/module.split('.')[0]
247
+ files = {f'python/{f.relative_to(src)}': f for f in package.rglob('*') if f.is_file() and f != ext and '__pycache__' not in f.parts}
248
+ files |= {f'python/{ext.relative_to(src)}': lib} | {f'bin/{e.name}': e for e in exes}
249
+ with store.access():
250
+ kept = store.keep(root, files, dict.fromkeys(f for a in [lib, *exes] for f in store.dep_files(a)), started)
251
+ store.install(root, kept, {ext: f'python/{ext.relative_to(src)}'} | {scripts/e.name: f'bin/{e.name}' for e in exes})
252
+ store.collect()
253
+ if generator := tool.get('fastws', {}).get('post-develop-module'):
254
+ subprocess.run([sys.executable, '-m', generator], cwd=root, check=True)
255
+ # Editable builds through `build_backend` don't install the wheel's data files, which a wheel install puts under the prefix.
256
+ if 'data' in cfg:
257
+ data = root/cfg['data']/'data'
258
+ for f in data.rglob('*'):
259
+ if f.is_file(): replace(f, env/f.relative_to(data))
284
260
 
285
261
 
286
262
  def stage_binaries(root, profile=None, target=None, features=None, all_features=False, no_default_features=False):
@@ -726,7 +726,7 @@ def _entry_changes(current, new):
726
726
  return [k for k in new if k not in current], [k for k in current if k not in new]
727
727
 
728
728
  def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
729
- """Regenerate `[patch]` entries in the workspace `.cargo/config.toml` and return (added, removed).
729
+ """Regenerate workspace Cargo patches, default to quiet output, and return (added, removed).
730
730
 
731
731
  Every local crate gets a `[patch.crates-io]` entry, except one marked `publish = false`, which never comes
732
732
  from crates.io. A member's git dep on a local crate gets an entry under that URL. Builds anywhere under `root`
@@ -739,8 +739,13 @@ def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
739
739
  desired = {"crates-io": {pkg["name"]: str(d) for pkg, d in pkgs if pkg.get("publish") is not False}}
740
740
  for url, entries in _git_dep_tables(root, crates).items(): desired[url] = {name: str(d) for name, d in entries.items()}
741
741
  config = root/".cargo"/"config.toml"
742
- content = config.read_text() if config.exists() else ""
743
- old = tomllib.loads(content).get("patch", {}) if content else {}
742
+ original = content = config.read_text() if config.exists() else ""
743
+ data = tomllib.loads(content) if content else {}
744
+ old = data.get("patch", {})
745
+ if "quiet" not in data.get("term", {}):
746
+ if match := re.search(r"(?m)^\[term\][^\n]*(?:\n|$)", content):
747
+ content = content[:match.end()].rstrip("\n") + "\nquiet = true\n" + content[match.end():]
748
+ else: content = _replace_table(content, "term", "quiet = true").lstrip()
744
749
  def inside(spec):
745
750
  if not isinstance(spec, dict) or not (p := spec.get("path")): return False
746
751
  p = Path(p).expanduser()
@@ -756,7 +761,7 @@ def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
756
761
  + "".join(f"{name} = {_fmt_source(spec)}\n" for name, spec in sorted(entries.items()))
757
762
  for url, entries in tables.items() if entries)
758
763
  new = (_strip_patch_tables(content).rstrip() + "\n\n" + body).lstrip() + "\n" if body else _strip_patch_tables(content)
759
- if new == content: return [], []
764
+ if new == original: return [], []
760
765
  tomllib.loads(new) # never write an unparseable config
761
766
  config.parent.mkdir(exist_ok=True)
762
767
  config.write_text(new)
@@ -765,23 +770,41 @@ def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
765
770
  added, removed = _entry_changes(before, after)
766
771
  return sorted(added), sorted(removed)
767
772
 
768
- def _sync_cargo_wrapper(root: Path) -> bool:
769
- "Add sccache to the generated Cargo config when installed, without overriding another wrapper"
770
- config = root/".cargo"/"config.toml"
773
+ # The rustc wrapper that `_sync_cargo_wrapper` writes. It imports only `fastws.timelock`, by its file, so that each rustc call pays for little more than the interpreter's start.
774
+ _RUSTC_LOCK = r"""#!{python} -IS
775
+ # Written by ws-sync. Cargo runs each rustc call through this script, which holds the machine's timing lock shared until rustc ends (`fastws.timelock`){via}.
776
+ import sys
777
+ sys.path.insert(0, {dir!r})
778
+ from timelock import exec_shared
779
+ INNER = {inner!r} # The wrapper that each rustc call runs through inside the lock, kept by later syncs; None: rustc alone
780
+ exec_shared([INNER, *sys.argv[1:]] if INNER else sys.argv[1:])
781
+ """
782
+
783
+ def _sync_cargo_wrapper(root: Path):
784
+ "Point the generated Cargo config's `rustc-wrapper` at `.cargo/rustc-lock`, written here, which runs each rustc call holding the machine's timing lock shared (`fastws.timelock`). Inside the lock it runs the wrapper that the config named before, where that's another tool's, or else sccache where it's installed. On Windows, where the lock does nothing, the config keeps another tool's wrapper, or else names sccache."
785
+ config, script = root/".cargo"/"config.toml", root/".cargo"/"rustc-lock"
771
786
  content = config.read_text() if config.exists() else ""
772
- data = tomllib.loads(content) if content else {}
773
- if data.get("build", {}).get("rustc-wrapper"): return False
774
- if not (wrapper := shutil.which("sccache")):
787
+ current = (tomllib.loads(content) if content else {}).get("build", {}).get("rustc-wrapper")
788
+ if not (sccache := shutil.which("sccache")):
775
789
  print("⚠️ sccache not found; Rust builds won't use compiler caching.\n"
776
790
  "Install it with: cargo install sccache --locked\nThen rerun ws-sync to enable it.")
777
- return False
791
+ recorded = script.exists() and current == str(script) and re.search(r"(?m)^INNER = (.+?) #", script.read_text())
792
+ previous = ast.literal_eval(recorded[1]) if recorded else current
793
+ inner = previous if previous not in (None, str(script)) else sccache
794
+ if os.name == "nt": wrapper = inner
795
+ else:
796
+ script.parent.mkdir(exist_ok=True)
797
+ script.write_text(_RUSTC_LOCK.format(python=sys.executable, dir=str(Path(__file__).parent), via=f", through {Path(inner).name}" if inner else "", inner=inner))
798
+ script.chmod(0o755)
799
+ wrapper = str(script)
800
+ if not wrapper or current == wrapper: return
801
+ content = re.sub(r"(?m)^rustc-wrapper = .*\n", "", content)
778
802
  line = f'rustc-wrapper = {json.dumps(wrapper)}\n'
779
803
  if match := re.search(r"(?m)^\[build\][^\n]*\n", content): new = content[:match.end()] + line + content[match.end():]
780
804
  else: new = content.rstrip() + ("\n\n" if content.strip() else "") + "[build]\n" + line
781
805
  tomllib.loads(new)
782
806
  config.parent.mkdir(exist_ok=True)
783
807
  config.write_text(new)
784
- return True
785
808
 
786
809
  def _cargo_dep_tables(data):
787
810
  for name in "dependencies","build-dependencies": yield data.get(name, {})
@@ -968,11 +991,10 @@ async def ws_sync(
968
991
  projects = _build_projects(root, repos_file)
969
992
  _sync_build_isolation(pyproject_path, projects)
970
993
 
971
- wrapper_added = _sync_cargo_wrapper(root)
994
+ _sync_cargo_wrapper(root)
972
995
  added_p, removed_p = _sync_cargo_patches(root)
973
996
  if added_p: print(f"Cargo patches added: {', '.join(added_p)}")
974
997
  if removed_p: print(f"Cargo patches removed: {', '.join(removed_p)}")
975
- if wrapper_added: print("Cargo builds now use sccache")
976
998
 
977
999
  js_members = _npm_dirs(root)
978
1000
  if _native_js(js_members): _check_rustup()
@@ -0,0 +1,46 @@
1
+ r"Bounded commands, with one process-group owner for each Unix job."
2
+ import os, signal, subprocess
3
+ from contextlib import suppress
4
+ from pathlib import Path
5
+
6
+ __all__ = ['bounded', 'kill_group', 'JOB_ENV']
7
+ JOB_ENV = 'FASTWS_JOB' # Owners mark the environment of their job; subordinate commands inherit its process group
8
+
9
+
10
+ def kill_group(p):
11
+ "Kill the process group that `p` owns, then reap its leader, even if the leader already exited. Only use for a process launched with `start_new_session=True`."
12
+ with suppress(ProcessLookupError): os.killpg(p.pid, signal.SIGKILL)
13
+ p.wait()
14
+
15
+
16
+ def bounded(
17
+ cmd, # The command and its arguments, as `subprocess.Popen` takes them
18
+ seconds:float=None, # Seconds that it may run; None: no limit
19
+ input:str=None, # Text written to its standard input
20
+ merge:bool=False, # Merge standard error into standard output, in the order written?
21
+ **kwargs # Other `subprocess.Popen` arguments, such as `cwd` and `env`
22
+ )->subprocess.CompletedProcess:
23
+ "Run a command with captured text and a wall-clock limit. Without `FASTWS_JOB`, own a new session and kill its process group on completion, timeout or error. Within a marked job, inherit its group; a timeout/error kills only the direct child and raises. Let that error end the job so its owner cleans up descendants, even after its leader exits. Environment overrides retain the marker. Commands must not detach from the job's group."
24
+ owner = not os.environ.get(JOB_ENV)
25
+ env = kwargs.pop('env', None)
26
+ env = dict(os.environ if env is None else env) | {JOB_ENV: '1'}
27
+ with subprocess.Popen(cmd, stdin=None if input is None else subprocess.PIPE, stdout=subprocess.PIPE,
28
+ stderr=subprocess.STDOUT if merge else subprocess.PIPE, text=True, start_new_session=owner, env=env, **kwargs) as p:
29
+ try:
30
+ try: out, err = p.communicate(input, timeout=seconds)
31
+ except BaseException as e:
32
+ if owner:
33
+ kill_group(p)
34
+ out, err = p.communicate()
35
+ else:
36
+ p.kill()
37
+ p.wait()
38
+ out, err = (s.decode(errors='replace') if isinstance(s, bytes) else s or ''
39
+ for s in (getattr(e, 'output', ''), getattr(e, 'stderr', '')))
40
+ if not isinstance(e, subprocess.TimeoutExpired): raise
41
+ tail = '\n'.join(f' {line}' for line in (out + (err or '')).splitlines()[-20:])
42
+ cleanup = 'its process group was killed' if owner else 'its child was killed; the job owner must clean up descendants'
43
+ raise TimeoutError(f"`{Path(str(cmd[0])).name}` didn't finish in {seconds:.3g} s: {cleanup}. Pass a larger `timeout`, or run less at once." + (f" Its last lines:\n{tail}" if tail else '')) from None
44
+ finally:
45
+ if owner: kill_group(p)
46
+ return subprocess.CompletedProcess(cmd, p.returncode, out, err)
@@ -0,0 +1,166 @@
1
+ r"""Builds that `cargo develop` installs, each kept once, read-only, and named by the hash of its inputs and files. A project's installed build is a pointer to one of them, which installing replaces in one step, so a process or a timing that resolves a build never sees it change.
2
+
3
+ A build is a directory under `STORE/<project>/`, named by its id, that holds `build.json` and a hard link to a blob for each file: `python/…` for the Python package with its extension, `bin/…` for binaries and `data/…` for wheel data. Each blob, under `STORE/blobs/`, is named by its contents' sha256, so files that don't change between builds take no more space. Installed paths are symbolic links through `STORE/<project>/installed`, a link to the installed build's directory. Windows installs copies."""
4
+ import hashlib, json, os, re, shutil, time, tomllib
5
+ if os.name != 'nt': import fcntl
6
+ from contextlib import contextmanager
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from tempfile import TemporaryDirectory
10
+
11
+ STORE = Path(os.environ.get('XDG_CACHE_HOME') or '~/.cache').expanduser()/'fastws'/'builds' # Each project's builds, and the blobs that their files link to
12
+ KEEP, KEEP_DAYS = 20, 2 # `collect` keeps each project's installed build, its last `KEEP` builds and its builds of the last `KEEP_DAYS` days
13
+
14
+
15
+ def _project(root): return STORE/tomllib.loads((Path(root)/'pyproject.toml').read_text())['project']['name']
16
+
17
+ @contextmanager
18
+ def building(root):
19
+ "On Unix, serialize cooperating `cargo develop` runs through build, capture and installation; yield the build's start time. Other Cargo commands do not take this lock."
20
+ project = _project(root)
21
+ project.mkdir(parents=True, exist_ok=True)
22
+ with open(project/'build.lock', 'a') as lock:
23
+ if os.name != 'nt': fcntl.flock(lock, fcntl.LOCK_EX)
24
+ yield time.time()
25
+
26
+ @contextmanager
27
+ def access(exclusive=False):
28
+ "On Unix, keep collection apart from readers and capture/install. Hold this shared across `keep` and `install`; `collect` takes it exclusively."
29
+ STORE.mkdir(parents=True, exist_ok=True)
30
+ with open(STORE/'store.lock', 'a') as lock:
31
+ if os.name != 'nt': fcntl.flock(lock, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
32
+ yield
33
+
34
+ def _digest(path):
35
+ with open(path, 'rb') as f: return hashlib.file_digest(f, 'sha256').hexdigest()
36
+
37
+ def _blob(path):
38
+ "The blob that holds `path`'s contents, read-only and executable where `path` is, added to the store where it's new."
39
+ directory = STORE/'blobs'
40
+ directory.mkdir(parents=True, exist_ok=True)
41
+ with TemporaryDirectory(dir=directory, suffix='.tmp') as tmp:
42
+ captured = Path(tmp)/'file'
43
+ shutil.copy2(path, captured)
44
+ blob = directory/_digest(captured)
45
+ captured.chmod(0o555 if captured.stat().st_mode & 0o111 else 0o444)
46
+ try: os.link(captured, blob)
47
+ except FileExistsError: pass
48
+ return blob
49
+
50
+
51
+ def dep_files(artifact):
52
+ "The files that Cargo's dep-info lists for `artifact`, the `.d` file beside it, with the files under each directory it lists, such as one that a build script watches."
53
+ deps = re.split(r'(?<!\\)\s+', Path(artifact).with_suffix('.d').read_text().split(': ', 1)[1].strip())
54
+ for p in (Path(d.replace('\\ ', ' ')) for d in deps):
55
+ yield from (sorted(f for f in p.rglob('*') if f.is_file()) if p.is_dir() else [p])
56
+
57
+
58
+ def _kept(d):
59
+ "The builds in project directory `d`, leaving out its `installed` link and builds still being written."
60
+ return [p for p in d.iterdir() if not p.is_symlink() and not p.name.endswith('.tmp') and (p/'build.json').exists()] if d.exists() else []
61
+
62
+
63
+ def _metas(d):
64
+ "The `build.json` records of the builds in project directory `d`, oldest first."
65
+ return sorted((json.loads((p/'build.json').read_text()) for p in _kept(d)), key=lambda m: m['time'])
66
+
67
+
68
+ def keep(
69
+ root, # The project directory, whose `pyproject.toml` names the project
70
+ files:dict, # Each path in the build, such as `bin/bpl`, to the file to keep there
71
+ inputs, # The files the build was made from
72
+ started:float, # The build's start time, as `building` gives it
73
+ )->Path:
74
+ "Keep captured artifacts and observed input digests. Hold `building` and `access` through capture/install. `edited` flags inputs possibly modified since build start, by mtime, not compiler provenance. Identical inputs and artifacts reuse their first capture's record."
75
+ sources = {str(p): _digest(p) for p in inputs}
76
+ blobs = {rel: _blob(src) for rel, src in files.items()}
77
+ key = json.dumps([sorted(sources.items()), sorted((rel, b.name) for rel, b in blobs.items())], sort_keys=True)
78
+ build = _project(root)/hashlib.sha256(key.encode()).hexdigest()[:16]
79
+ if build.exists(): return build
80
+ with TemporaryDirectory(dir=build.parent, suffix='.tmp') as tmp:
81
+ tmp = Path(tmp)
82
+ for rel, blob in blobs.items():
83
+ (tmp/rel).parent.mkdir(parents=True, exist_ok=True)
84
+ os.link(blob, tmp/rel)
85
+ edited = sorted(f for f in sources if Path(f).stat().st_mtime >= started)
86
+ (tmp/'build.json').write_text(json.dumps(dict(id=build.name, time=time.time(), started=started, author=os.environ.get('EXHASH_AUTHOR'), inputs=sources, edited=edited), indent=1))
87
+ os.replace(tmp, build)
88
+ return build
89
+
90
+
91
+ def _link(path, target):
92
+ "Make `path` a symbolic link to `target`, replacing what was there in one step."
93
+ if path.is_symlink() and os.readlink(path) == str(target): return
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ tmp = path.with_name(f'{path.name}.{os.getpid()}.tmp')
96
+ tmp.unlink(missing_ok=True)
97
+ tmp.symlink_to(target)
98
+ os.replace(tmp, path)
99
+
100
+
101
+ def install(
102
+ root, # The project directory
103
+ build:Path, # A build that `keep` gave
104
+ paths:dict, # Each installed path, such as the extension in the project's Python package, to its path in the build
105
+ ):
106
+ "Install a captured build while holding `access`. Each path links through the project's `installed` link, which then points at `build`. Windows copies the files instead."
107
+ installed = _project(root)/'installed'
108
+ for path, rel in paths.items():
109
+ if os.name == 'nt': shutil.copy2(build/rel, path)
110
+ else: _link(Path(path), installed/rel)
111
+ if os.name != 'nt': _link(installed, build.name)
112
+
113
+
114
+ def resolve(
115
+ root, # The project directory
116
+ build:str=None, # A prefix of a kept build's id; None: the installed build
117
+ )->Path:
118
+ "The directory of the build of the project at `root` that `build` names, as it is now."
119
+ with access():
120
+ d = _project(root)
121
+ if build is None: return (d/'installed').resolve()
122
+ found = [p for p in _kept(d) if p.name.startswith(build)]
123
+ if len(found) != 1: raise ValueError(f'{len(found)} builds of {d.name} have ids starting {build!r}')
124
+ return found[0]
125
+
126
+
127
+ class Builds(list):
128
+ "A project's first capture of each build, oldest first: `id`, `time`, `started`, `author`, capture-time `inputs` digests, `edited` and `changes`. `edited` flags inputs possibly modified since build start, by mtime, not what the compiler read. `changes` lists inputs whose digest differs from the build before's, or None for the first. `installed` is the installed build's id."
129
+ def __init__(self, rows=(), installed=None):
130
+ super().__init__(rows)
131
+ self.installed = installed
132
+ def __repr__(self): return '\n'.join(map(self._line, self))
133
+ def __getitem__(self, i): return Builds(super().__getitem__(i), self.installed) if isinstance(i, slice) else super().__getitem__(i)
134
+ def _line(self, m):
135
+ names = ['(first)'] if m['changes'] is None else sorted(Path(f).name for f in m['changes'])
136
+ edited = f" possibly modified since build start: {', '.join(sorted(Path(f).name for f in m['edited']))}" if m['edited'] else ''
137
+ return f"{'*' if m['id'] == self.installed else ' '} {m['id']} {datetime.fromtimestamp(m['time']):%m-%d %H:%M:%S} {m['author'] or '-':<8} {', '.join(names[:8]) or '(no input changed)'}{' …' if len(names) > 8 else ''}{edited}"
138
+
139
+
140
+ def builds(
141
+ root, # The project directory
142
+ n:int=10, # How many of the newest builds to give; None: every kept build
143
+ )->Builds:
144
+ "The newest `n` builds, oldest first, with their input changes and diagnostic mtime flags. `*` marks the installed build."
145
+ with access():
146
+ d = _project(root)
147
+ metas = _metas(d)
148
+ for prev, m in zip([None, *metas], metas):
149
+ m['changes'] = None if prev is None else sorted(f for f, h in m['inputs'].items() if prev['inputs'].get(f) != h)
150
+ m.setdefault('edited', [])
151
+ return Builds(metas[-n:] if n else metas, (d/'installed').resolve().name if (d/'installed').exists() else None)
152
+
153
+
154
+ def collect():
155
+ "Delete builds neither installed, among the last `KEEP`, nor from the last `KEEP_DAYS` days, then unlinked blobs at least an hour old. Exclusive store access keeps this apart from readers and capture/install operations. Call outside `access`."
156
+ if not STORE.exists(): return
157
+ with access(exclusive=True):
158
+ now = time.time()
159
+ for d in (p for p in STORE.iterdir() if p.is_dir() and p.name != 'blobs'):
160
+ installed = (d/'installed').resolve() if (d/'installed').exists() else None
161
+ kept = sorted(_kept(d), key=lambda p: (p/'build.json').stat().st_mtime)
162
+ for p in kept[:-KEEP]:
163
+ if p != installed and now - (p/'build.json').stat().st_mtime > KEEP_DAYS*86400: shutil.rmtree(p)
164
+ for blob in (STORE/'blobs').iterdir() if (STORE/'blobs').exists() else []:
165
+ st = blob.stat()
166
+ if st.st_nlink == 1 and now - st.st_mtime > 3600: blob.unlink()
@@ -0,0 +1,76 @@
1
+ r"The machine's timing lock, which keeps timings apart from builds and test runs, as `rustymath::timing` describes: a timing locks `LOCK` exclusively, holding `GATE` exclusively from the start of its wait, and anything else passes `GATE` and then locks `LOCK` shared. It imports only the standard library, because the rustc wrapper that `ws-sync` writes loads it for every rustc call."
2
+ import os, sys, threading, time
3
+ from contextlib import contextmanager
4
+ if os.name != 'nt': import fcntl
5
+
6
+ LOCK = '/tmp/aai-ws-timing.lock' # Timings lock it exclusively; builds and test runs lock it shared
7
+ GATE = '/tmp/aai-ws-timing.gate' # A timing holds it exclusively while it waits for `LOCK` and holds it; a shared hold locks it for a moment first
8
+ _holds = threading.local() # This thread's hold mode, as `exclusive`; nested shared holds reuse it
9
+
10
+
11
+ def _users():
12
+ "The other processes that have `LOCK` 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."
13
+ import subprocess
14
+ users, pid = [], ''
15
+ for line in subprocess.run(['lsof', '+c', '0', '-F', 'pc', LOCK], capture_output=True, text=True).stdout.splitlines():
16
+ if line[:1] == 'p': pid = line[1:]
17
+ elif line[:1] == 'c' and pid != str(os.getpid()): users.append(f'{pid} {line[1:]}')
18
+ return users
19
+
20
+
21
+ class _Wait:
22
+ "One hold's wait for `GATE` and then `LOCK`, against one deadline of 5 minutes."
23
+ def __init__(self, exclusive, quiet): self.exclusive, self.quiet, self.start, self.waiting, self.reported = exclusive, quiet, time.monotonic(), False, False
24
+
25
+ def take(self, f):
26
+ "Lock `f` as the hold needs it, waiting up to 5 minutes before raising `TimeoutError`. An exclusive hold that has waited 30 seconds prints, once, the other processes that have `LOCK` open."
27
+ others = 'a build or another timing' if self.exclusive else 'a timing'
28
+ while True:
29
+ try: return fcntl.flock(f, (fcntl.LOCK_EX if self.exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
30
+ except BlockingIOError: pass
31
+ waited = time.monotonic() - self.start
32
+ if waited > 300: raise TimeoutError(f'{others} has held {LOCK} for 5 minutes')
33
+ if not self.waiting and not self.quiet: print(f'waiting for {others} to release {LOCK}', file=sys.stderr)
34
+ if self.exclusive and not self.reported and waited > 30:
35
+ print(f'{LOCK} is still taken after 30 seconds. Processes with it open: {", ".join(_users()) or "none"}', file=sys.stderr)
36
+ self.reported = True
37
+ self.waiting = True
38
+ time.sleep(0.25)
39
+
40
+
41
+ @contextmanager
42
+ def _held(exclusive, quiet=False):
43
+ "Hold `LOCK`, and `GATE` too when exclusive. `quiet` leaves out the message that the wait has started."
44
+ with open(GATE, 'a') as gate, open(LOCK, 'a') as lock:
45
+ wait = _Wait(exclusive, quiet)
46
+ wait.take(gate)
47
+ wait.take(lock)
48
+ if not exclusive: gate.close()
49
+ yield lock
50
+
51
+
52
+ @contextmanager
53
+ def timing_lock(
54
+ exclusive=False, # Take the lock exclusively, as a timing does; False: take it shared, as a build or a test run does
55
+ ):
56
+ "Hold the Unix lock that keeps this machine's timings apart from participating builds and test runs; a no-op on Windows. Acquisition raises `TimeoutError` after 5 minutes. Shared-inside-shared in the same thread reuses its hold. Any nesting involving exclusive raises `RuntimeError`. Each thread takes its own shared lock; one thread's release never ends another's hold."
57
+ if os.name == 'nt':
58
+ yield
59
+ return
60
+ held = getattr(_holds, 'exclusive', None)
61
+ if held is not None:
62
+ if held or exclusive: raise RuntimeError('timing locks can only nest shared inside shared')
63
+ yield
64
+ return
65
+ with _held(exclusive):
66
+ _holds.exclusive = exclusive
67
+ try: yield
68
+ finally: del _holds.exclusive
69
+
70
+
71
+ def exec_shared(argv):
72
+ "Replace this process with `argv`, which then holds the lock shared until it ends, as the rustc wrapper does for each rustc call. It prints nothing while it waits, since Cargo shows each rustc call's output."
73
+ if os.name == 'nt': os.execv(argv[0], argv)
74
+ with _held(False, quiet=True) as lock:
75
+ os.set_inheritable(lock.fileno(), True)
76
+ os.execv(argv[0], argv)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.32
3
+ Version: 0.0.33
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License-Expression: Apache-2.0
@@ -200,7 +200,7 @@ ws-build --project solveit # only this package and its transitive workspace dep
200
200
 
201
201
  ### `cargo develop`
202
202
 
203
- Rebuild a mixed Rust/Python project's extension and replace it in the editable source package:
203
+ Build a mixed Rust/Python project's extension and opted-in binaries, keep them with its Python package as one build, and install that build in the editable source package and the venv:
204
204
 
205
205
  ```bash
206
206
  cargo develop
@@ -221,7 +221,13 @@ Set `native-binaries = true` under `[tool.fastws]` to build and install the bina
221
221
 
222
222
  Set `post-develop-module = "package.generator"` under `[tool.fastws]` to run a generator after installing the native extension and binaries, before copying wheel data. It runs with the current Python in a fresh process from the project root, including when Cargo reuses its build. A failure stops `cargo develop` or `ws-sync`. Use it for generated files that need the rebuilt extension's metadata.
223
223
 
224
- On Unix, 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. Windows builds and tests run without this timing coordination.
224
+ `cargo develop` keeps each build it installs in `~/.cache/fastws/builds/<project>/<id>/`, read-only. Files are copied first, then hashed, and hard-linked to blobs named by those captured bytes' sha256. The build id hashes these artifacts and the current digests of inputs listed by Cargo's dep-info. Input digests are observations at capture time, not proof of what the compiler read. The record's `started` gives the build's start time; `edited` flags inputs **possibly modified since build started**, by mtime, including inputs of a first build or newly discovered dependencies. Identical inputs and artifacts reuse their first capture's record.
225
+
226
+ The installed extension and binaries are symbolic links through `<project>/installed`, which `cargo develop` points at the new build in one step. `fastws.store.builds(root)` gives the newest build records, with input changes since the preceding build, and `fastws.store.resolve(root, id)` resolves a build-id prefix. Each develop deletes builds that are neither installed, among the project's last 20, nor from the last 2 days, then old unlinked blobs.
227
+
228
+ On Unix, cooperating `cargo develop` runs take a per-project lock through compilation, capture, installation, the post-develop generator and wheel data. Store readers and capture/install operations hold shared store access; collection takes it exclusively. Ordinary Cargo commands do not take the project lock: agents must not run competing raw Cargo builds against that project during develop. Windows installs copies and does not take these locks.
229
+
230
+ On Unix, every rustc call holds a shared `flock` on `/tmp/aai-ws-timing.lock`, through the rustc wrapper that sync writes (see `ws-sync`). A speed test that takes that lock exclusively stays apart from participating builds and test runs. A timing also holds `/tmp/aai-ws-timing.gate` while it waits for the lock and holds it, and a shared hold passes that file first, so new rustc calls and test runs wait behind the timing. Both shared and exclusive acquisition fail after 5 minutes; work never proceeds unlocked. Python code takes the same lock with `fastws.timelock.timing_lock`: `with timing_lock(exclusive=True):` around a timing, and `with timing_lock():` around a test run. Shared-inside-shared in one thread reuses its hold; each thread otherwise takes its own hold. Any nesting involving exclusive raises `RuntimeError` immediately. An exclusive hold that has waited 30 seconds prints the processes that have the lock's file open. Windows builds and tests run without this timing coordination.
225
231
 
226
232
  Use `fastws.build_backend` as the build backend of a mixed project, and include `fastws-cli>=0.0.29` alongside `maturin>=1.13,<2.0` 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.
227
233
 
@@ -245,7 +251,22 @@ cargo wait
245
251
  cargo wait --path /path/to/workspace
246
252
  ```
247
253
 
248
- 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.
254
+ 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`, whose rustc calls hold the timing lock shared, as every build's 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.
255
+
256
+ ### Rust tests from Python
257
+
258
+ `fastws.cargo.run_tests` builds native tests once with Cargo, without a time limit, then runs the executables that Cargo reports directly from their package directories. Its `timeout` defaults to 30 seconds shared across the test binaries, excluding compilation. Source edits between building and running do not trigger another build: the results describe the compiled build, not necessarily the latest working tree. It supports directly runnable native tests, not cross-target runners.
259
+
260
+ Pass Cargo build/selection options positionally, and test filters and harness options in `test_args`. A failed binary stops the run unless `--no-fail-fast` is given. Results contain compiler diagnostics and each binary's outcomes and captured failures.
261
+
262
+ ```python
263
+ from fastws.cargo import run_tests
264
+
265
+ native = run_tests('.', '--no-fail-fast')
266
+ focused = run_tests('.', '--test', 'core', test_args=('test_name', '--nocapture'))
267
+ ```
268
+
269
+ Doctests are deliberately not run; keep executable examples in ordinary Rust tests. `run_tests` accepts `env` overrides and `timeout=None` to disable its watchdog. It takes no timing lock around the run: rustc's wrapper owns its shared holds, and tests that time own their exclusive holds.
249
270
 
250
271
  ### `ws-sync`
251
272
 
@@ -266,9 +287,11 @@ Existing globs, entries for missing directories, and entries for checkouts that
266
287
 
267
288
  Each sync regenerates Cargo overrides in the workspace's `.cargo/config.toml`. `[patch.crates-io]` gets an entry for each local crate, including nested Cargo workspace members, except a crate marked `publish = false`, which never comes from crates.io. Git dependencies that name local crates get entries under the matching URL. Builds under the workspace root use these local checkouts. Entries pointing outside the root and other configuration sections are preserved.
268
289
 
290
+ Sync also defaults `[term] quiet = true` in that file to suppress routine Cargo output and warnings. An explicit `quiet = false` is preserved.
291
+
269
292
  Do not commit a `Cargo.lock` generated under these patches. Its source-less local entries cannot be resolved on another machine.
270
293
 
271
- When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved. If neither a wrapper nor `sccache` is present, sync warns with the install command (`cargo install sccache --locked`) and continues; rerun `ws-sync` after installing to enable it.
294
+ Sync writes `.cargo/rustc-lock` and makes it Cargo's `rustc-wrapper`. The script runs each rustc call holding the timing lock shared (see `cargo develop` above). Inside the lock it runs the wrapper that the config named before sync first wrote the script, where another tool set one, and otherwise `sccache` when it's installed, so unchanged compilation units are reused across the workspace's otherwise-independent Cargo target directories. Later syncs keep that inner wrapper. The script costs about 9 ms a rustc call, mostly the Python interpreter's start. If `sccache` isn't installed, sync warns with the install command (`cargo install sccache --locked`) and continues; rerun `ws-sync` after installing to enable it. On Windows, where the lock does nothing, the config keeps another tool's wrapper, or else names `sccache`.
272
295
 
273
296
  Fastws discovers JavaScript packages directly under the workspace root. It reads each package's `workspaces` list for declared nested packages. These lists accept paths and globs. Fastws ignores undeclared nested packages. Lockfiles do not affect discovery. `[tool.fastws].exclude` matches paths relative to the workspace root. Fastws skips `node_modules` and directories whose names start with `.` or `_`.
274
297
 
@@ -6,7 +6,10 @@ fastws/build_backend.py
6
6
  fastws/cargo.py
7
7
  fastws/core.py
8
8
  fastws/news.py
9
+ fastws/procs.py
9
10
  fastws/releases.py
11
+ fastws/store.py
12
+ fastws/timelock.py
10
13
  fastws_cli.egg-info/PKG-INFO
11
14
  fastws_cli.egg-info/SOURCES.txt
12
15
  fastws_cli.egg-info/dependency_links.txt
@@ -16,6 +19,9 @@ fastws_cli.egg-info/top_level.txt
16
19
  tests/test_build.py
17
20
  tests/test_build_backend.py
18
21
  tests/test_news.py
22
+ tests/test_procs.py
19
23
  tests/test_releases.py
20
24
  tests/test_setup.py
21
- tests/test_sync.py
25
+ tests/test_store.py
26
+ tests/test_sync.py
27
+ tests/test_timelock.py
@@ -60,3 +60,6 @@ version = { attr = "fastws.__version__" }
60
60
 
61
61
  [tool.setuptools.packages.find]
62
62
  include = ["fastws"]
63
+
64
+ [tool.pytest.ini_options]
65
+ asyncio_default_fixture_loop_scope = "function"
@@ -48,14 +48,14 @@ def test_run_tests(tmp_path):
48
48
  (tmp_path/'Cargo.toml').write_text('[package]\nname = "app"\nversion = "0.1.0"\nedition = "2021"\n')
49
49
  (tmp_path/'src').mkdir()
50
50
  (tmp_path/'src/lib.rs').write_text('''pub fn unused() { let x = 1; }
51
- /// ```
52
- /// assert_eq!(app::two(), 2);
53
- /// ```
54
51
  pub fn two() -> i32 { 2 }
55
52
  #[cfg(test)] mod t {
56
53
  #[test] fn passes() {}
57
54
  #[test] fn fails() { panic!("broken"); }
58
- #[test] #[ignore] fn skipped() {}
55
+ #[test] #[ignore] fn skipped() {
56
+ println!("started slow test");
57
+ std::thread::sleep(std::time::Duration::from_secs(60));
58
+ }
59
59
  }
60
60
  ''')
61
61
  (tmp_path/'tests').mkdir()
@@ -65,9 +65,11 @@ pub fn two() -> i32 { 2 }
65
65
  r = run_tests(tmp_path, '--no-fail-fast')
66
66
  assert not r.ok
67
67
  assert [(d.path, d.line, d.level) for d in r.diagnostics] == [('src/lib.rs', 1, 'warning')]
68
- unit, more, docs = r.suites
69
- assert [s.name for s in r.suites] == ['unittests src/lib.rs', 'tests/more.rs', 'Doc-tests app']
68
+ suites = {s.name: s for s in r.suites}
69
+ unit, more = suites['unittests src/lib.rs'], suites['tests/more.rs']
70
+ assert len(suites) == 2
70
71
  assert unit.tests == {'t::passes': 'ok', 't::fails': 'FAILED', 't::skipped': 'ignored'} and unit.failed
71
72
  assert 'broken' in unit.failures['t::fails'] and list(unit.failures) == ['t::fails']
72
73
  assert more.tests == {'other': 'ok'} and not more.failed
73
- assert list(docs.tests.values()) == ['ok']
74
+ with pytest.raises(TimeoutError, match='started slow test'):
75
+ run_tests(tmp_path, '--lib', test_args=('skipped', '--ignored', '--nocapture'), timeout=.5)
@@ -0,0 +1,35 @@
1
+ import fcntl, os, subprocess, sys, pytest
2
+ from fastws.procs import JOB_ENV, kill_group
3
+
4
+
5
+ def test_nested_timeout_leaves_cleanup_to_job_owner(tmp_path):
6
+ lock = tmp_path/'held'
7
+ descendant = '''import fcntl, os, sys, time
8
+ f = open(sys.argv[1], 'a')
9
+ fcntl.flock(f, fcntl.LOCK_SH)
10
+ print('descendant group', os.getpgrp(), flush=True)
11
+ time.sleep(60)
12
+ '''
13
+ child = f'''import os, subprocess, sys, time
14
+ print('child group', os.getpgrp(), os.environ.get('FASTWS_JOB'), flush=True)
15
+ subprocess.Popen([sys.executable, '-c', {descendant!r}, {str(lock)!r}])
16
+ time.sleep(60)
17
+ '''
18
+ job = f'''import os, sys, time
19
+ from fastws.procs import bounded
20
+ print('job group', os.getpgrp(), flush=True)
21
+ try: bounded([sys.executable, '-c', {child!r}], .5, merge=True, env={{}})
22
+ except TimeoutError as e: print(e, flush=True)
23
+ print('job survived inner timeout', flush=True)
24
+ time.sleep(60)
25
+ '''
26
+ with subprocess.Popen([sys.executable, '-c', job], start_new_session=True, env=os.environ | {JOB_ENV: '1'},
27
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) as p:
28
+ try:
29
+ with pytest.raises(subprocess.TimeoutExpired): p.communicate(timeout=.8)
30
+ finally: kill_group(p)
31
+ output, _ = p.communicate(timeout=2)
32
+ group = output.split('job group ')[1].split()[0]
33
+ assert f'child group {group} 1' in output and f'descendant group {group}' in output
34
+ assert 'job survived inner timeout' in output
35
+ with lock.open() as f: fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -0,0 +1,33 @@
1
+ import hashlib, os, time, pytest
2
+ from fastws import store
3
+
4
+ pytestmark = pytest.mark.skipif(os.name == 'nt', reason='Unix store locks and symbolic-link installation')
5
+
6
+
7
+ def test_capture_install_and_collect(tmp_path, monkeypatch):
8
+ monkeypatch.setattr(store, 'STORE', tmp_path/'store')
9
+ root = tmp_path/'project'
10
+ root.mkdir()
11
+ (root/'pyproject.toml').write_text('[project]\nname = "example"\n')
12
+ source, artifact, installed = root/'source', root/'artifact', root/'installed'
13
+ with store.building(root) as started, store.access():
14
+ source.write_text('new input')
15
+ artifact.write_bytes(b'first')
16
+ first = store.keep(root, {'bin/tool': artifact, 'python/data': artifact}, [source], started)
17
+ assert store.keep(root, {'bin/tool': artifact, 'python/data': artifact}, [source], started) == first
18
+ store.install(root, first, {installed: 'bin/tool'})
19
+ assert installed.read_bytes() == b'first' and store.resolve(root) == first
20
+ assert (first/'bin/tool').stat().st_ino == (first/'python/data').stat().st_ino
21
+ assert store.builds(root)[0]['edited'] == [str(source)]
22
+ kept = []
23
+ with store.building(root) as started, store.access():
24
+ for i in range(store.KEEP+1):
25
+ artifact.write_text(str(i))
26
+ kept.append(store.keep(root, {'bin/tool': artifact}, [source], started))
27
+ old = time.time() - (store.KEEP_DAYS+1)*86400
28
+ for i, path in enumerate([first, *kept]): os.utime(path/'build.json', (old+i, old+i))
29
+ for blob in (store.STORE/'blobs').iterdir(): os.utime(blob, (old, old))
30
+ store.collect()
31
+ assert first.exists() and kept[-1].exists() and not kept[0].exists()
32
+ assert installed.read_bytes() == b'first'
33
+ assert not (store.STORE/'blobs'/hashlib.sha256(b'0').hexdigest()).exists()
@@ -91,6 +91,16 @@ def test_sync_extras_preserves_constraints_markers_and_unrelated_settings(tmp_pa
91
91
  assert other == 'other[keep]' and len(data['project']['dependencies']) == 2
92
92
  assert data['tool']['uv']['sources'] == {'My_Pkg': {'path': '../somewhere', 'editable': True}}
93
93
 
94
+ @pytest.mark.skipif(os.name == 'nt', reason='Windows has no timing lock, so no rustc-lock script')
95
+ def test_rustc_wrapper_keeps_another_tools_wrapper(tmp_path):
96
+ config, script = tmp_path/'.cargo'/'config.toml', tmp_path/'.cargo'/'rustc-lock'
97
+ config.parent.mkdir()
98
+ config.write_text('[build]\nrustc-wrapper = "/opt/custom/wrap"\n')
99
+ for _ in range(2):
100
+ core._sync_cargo_wrapper(tmp_path)
101
+ assert f'rustc-wrapper = "{script}"' in config.read_text()
102
+ assert "INNER = '/opt/custom/wrap'" in script.read_text()
103
+
94
104
 
95
105
  def test_remove_from_pyproject(tmp_path):
96
106
  pyproject = tmp_path/'pyproject.toml'
@@ -0,0 +1,21 @@
1
+ import os, threading, pytest
2
+ from fastws import timelock
3
+ if os.name != 'nt': import fcntl
4
+
5
+ @pytest.mark.skipif(os.name == 'nt', reason='Windows has no timing lock')
6
+ def test_shared_hold_outlives_other_thread(tmp_path, monkeypatch):
7
+ "One thread's release leaves the other thread's shared hold in place."
8
+ monkeypatch.setattr(timelock, 'LOCK', str(tmp_path/'lock'))
9
+ monkeypatch.setattr(timelock, 'GATE', str(tmp_path/'gate'))
10
+ held, release = threading.Event(), threading.Event()
11
+ def hold():
12
+ with timelock.timing_lock(): held.set(); assert release.wait(3)
13
+ t = threading.Thread(target=hold, daemon=True)
14
+ try:
15
+ with timelock.timing_lock():
16
+ t.start(); assert held.wait(3)
17
+ with open(timelock.LOCK) as f, pytest.raises(BlockingIOError): fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
18
+ finally:
19
+ release.set()
20
+ if t.ident is not None: t.join(3)
21
+ assert not t.is_alive()
File without changes
File without changes
File without changes