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.
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/PKG-INFO +28 -5
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/README.md +27 -4
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/__init__.py +2 -1
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/cargo.py +73 -97
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/core.py +36 -14
- fastws_cli-0.0.33/fastws/procs.py +46 -0
- fastws_cli-0.0.33/fastws/store.py +166 -0
- fastws_cli-0.0.33/fastws/timelock.py +76 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/PKG-INFO +28 -5
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/SOURCES.txt +7 -1
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/pyproject.toml +3 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_build.py +9 -7
- fastws_cli-0.0.33/tests/test_procs.py +35 -0
- fastws_cli-0.0.33/tests/test_store.py +33 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_sync.py +10 -0
- fastws_cli-0.0.33/tests/test_timelock.py +21 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/LICENSE +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/build_backend.py +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/news.py +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws/releases.py +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/dependency_links.txt +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/entry_points.txt +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/requires.txt +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/fastws_cli.egg-info/top_level.txt +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/setup.cfg +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_build_backend.py +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_news.py +0 -0
- {fastws_cli-0.0.32 → fastws_cli-0.0.33}/tests/test_releases.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,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
|
|
76
|
-
|
|
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
|
|
109
|
-
name:str #
|
|
110
|
-
output:str # Everything
|
|
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
|
|
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: ')), ''),
|
|
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
|
-
"
|
|
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 #
|
|
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
|
|
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, #
|
|
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=
|
|
125
|
+
timeout:float=30 # Seconds shared by all native test binaries after building, raising `TimeoutError` on expiry; None: no limit
|
|
176
126
|
)->Results:
|
|
177
|
-
"
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
-
|
|
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 ==
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
773
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
25
|
+
tests/test_store.py
|
|
26
|
+
tests/test_sync.py
|
|
27
|
+
tests/test_timelock.py
|
|
@@ -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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|