fastws-cli 0.0.33__tar.gz → 0.0.34__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 (32) hide show
  1. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/PKG-INFO +36 -5
  2. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/README.md +35 -4
  3. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws/__init__.py +2 -1
  4. fastws_cli-0.0.34/fastws/_toml.py +4 -0
  5. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws/cargo.py +77 -56
  6. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws/core.py +22 -13
  7. fastws_cli-0.0.34/fastws/integrate.py +136 -0
  8. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws/store.py +6 -2
  9. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws_cli.egg-info/PKG-INFO +36 -5
  10. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws_cli.egg-info/SOURCES.txt +3 -0
  11. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/tests/test_build.py +26 -5
  12. fastws_cli-0.0.34/tests/test_integrate.py +46 -0
  13. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/LICENSE +0 -0
  14. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws/build_backend.py +0 -0
  15. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws/news.py +0 -0
  16. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws/procs.py +0 -0
  17. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws/releases.py +0 -0
  18. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws/timelock.py +0 -0
  19. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws_cli.egg-info/dependency_links.txt +0 -0
  20. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws_cli.egg-info/entry_points.txt +0 -0
  21. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws_cli.egg-info/requires.txt +0 -0
  22. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/fastws_cli.egg-info/top_level.txt +0 -0
  23. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/pyproject.toml +0 -0
  24. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/setup.cfg +0 -0
  25. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/tests/test_build_backend.py +0 -0
  26. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/tests/test_news.py +0 -0
  27. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/tests/test_procs.py +0 -0
  28. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/tests/test_releases.py +0 -0
  29. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/tests/test_setup.py +0 -0
  30. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/tests/test_store.py +0 -0
  31. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/tests/test_sync.py +0 -0
  32. {fastws_cli-0.0.33 → fastws_cli-0.0.34}/tests/test_timelock.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.33
3
+ Version: 0.0.34
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License-Expression: Apache-2.0
@@ -205,9 +205,10 @@ Build a mixed Rust/Python project's extension and opted-in binaries, keep them w
205
205
  ```bash
206
206
  cargo develop
207
207
  cargo develop --path /path/to/project
208
+ cargo develop --profile quick
208
209
  ```
209
210
 
210
- Run from the project root, or give its directory with `--path`. The command reads `python-source`, `module-name`, `manifest-path` and features from `[tool.maturin]`. It always builds with Cargo's `test` profile, matching bare `cargo test`. The project must produce a `cdylib` and set `python-source`. To use release settings without command-line flags, add `[profile.test]` with `inherits = "release"` to `Cargo.toml`. Keep incremental compilation enabled for the local crate in that profile.
211
+ Run from the project root, or give its directory with `--path`. The command reads `python-source`, `module-name`, `manifest-path` and features from `[tool.maturin]`. `[tool.fastws] dev-profile = "quick"` selects a project's default for both `cargo develop` and `fastws.cargo.run_tests`. Without that setting they use `test`. An explicit `--profile NAME` overrides the project default. The project must produce a `cdylib` and set `python-source`. Define the selected profile in `Cargo.toml` and keep incremental compilation enabled for the local crate.
211
212
 
212
213
  Make the initial editable installation through `ws-sync` or maturin. Repeat that installation when package metadata or entry points change. Running Python processes keep the extension they loaded.
213
214
 
@@ -253,11 +254,11 @@ cargo wait --path /path/to/workspace
253
254
 
254
255
  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
 
256
- ### Rust tests from Python
257
+ ### Rust builds and tests from Python
257
258
 
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
+ `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. Each binary gets its own `timeout`, 30 seconds by default, excluding compilation. A binary still running when its `timeout` ends is killed and counts as a failed binary, with its suite's `timed_out` set. 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
 
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
+ The default profile is `[tool.fastws].dev-profile`, or `test` when unset. Explicit `--profile NAME`, `--profile=NAME`, `--release` and `-r` override it. 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
 
262
263
  ```python
263
264
  from fastws.cargo import run_tests
@@ -268,6 +269,34 @@ focused = run_tests('.', '--test', 'core', test_args=('test_name', '--nocapture'
268
269
 
269
270
  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.
270
271
 
272
+ `fastws.cargo.check` runs any Cargo subcommand, `check --all-targets` by default, and returns the same `Results` without test suites. A failing run is a result, not an exception. Each `Diagnostic` in its `diagnostics` holds the message's path, line, column, level and text, and its error code or lint as `code`, such as `E0308` or `clippy::too_many_lines`. The summary lists errors first. `env` sets environment variables for the run, as for `run_tests`. A subcommand that rejects `--message-format`, such as `cargo llvm-lines`, runs with `diagnostics=False`: its result holds the exit status and output, with no diagnostics.
273
+
274
+ ```python
275
+ from fastws.cargo import check
276
+
277
+ lints = check('.', 'clippy', '--all-targets', '--', '-W', 'clippy::too_many_lines', env={'CLIPPY_CONF_DIR': 'review'})
278
+ errors = [d for d in lints.diagnostics if d.level == 'error']
279
+ sizes = check('.', 'llvm-lines', '--lib', diagnostics=False).text
280
+ ```
281
+
282
+ ### Integration snapshots
283
+
284
+ `fastws.integrate` builds several repos together while people or agents keep editing them. `integrate` snapshots each repo's working tree as a commit (untracked files included, ignored files not), checks each commit out as a detached worktree under one root, and writes the root's Cargo config. `HEAD` and the index of each live repo don't change. The config is the workspace's, with the patches for crates under the root pointing at the root's worktrees, so a build there reads the snapshots and every other local crate from the workspace. Keep the root outside the workspace, where `ws-sync` and uv don't treat its worktrees as members.
285
+
286
+ `errors` checks each snapshot in turn and keeps the errors in that repo's own files. A repo's check also reports its dependencies' errors, under paths outside the repo, and each dependency's own check already covers those. `owners` reads each owner's path globs from the bullets under a Markdown file's `Files` heading, one ``- Owner: `glob` `glob` `` line each, in order. `by_owner` groups each repo's compiler messages by owner, then by file, and the first owner whose globs match a path owns it. It prefixes each path with its repo's name, so a path into a sibling repo, such as `../rustymath/src/x.rs` from basedpl's check, groups as `rustymath/src/x.rs`. `run_tests` with `--no-fail-fast` runs every test binary, each within its own `timeout`, so a slow or crashing binary doesn't stop the others. `code_count` counts the Rust code lines in a sequence of lines, leaving out blanks and comments. `code_lines` applies it to each Rust file under a directory. `exhash`'s `Journal.stat(count=code_count)` counts the code lines that edits add and remove. `record` writes a run's messages, each repo's test `Results` with each failure's output, and any sizes, as a Markdown record, and returns each owner's message count.
287
+
288
+ ```python
289
+ from fastws.cargo import run_tests
290
+ from fastws.integrate import by_owner, code_lines, errors, integrate, owners, record
291
+
292
+ root = '~/git/integration'
293
+ commits = integrate({'basedpl': '~/aai-ws/basedpl', 'rustymath': '~/aai-ws/rustymath'}, root)
294
+ errs = errors(root, ['rustymath', 'basedpl'])
295
+ groups = by_owner(errs, owners('~/aai-ws/basedpl/meta/owners.md'))
296
+ tests = {r: run_tests(f'{root}/{r}', '--no-fail-fast') for r in errs if not errs[r]}
297
+ sizes = {r: code_lines(f'{root}/{r}/src') for r in errs}
298
+ counts = record(f'{root}/run8.md', 'Run 8', commits, groups, tests=tests, sizes=sizes)
299
+ ```
271
300
  ### `ws-sync`
272
301
 
273
302
  Syncing proceeds in this order:
@@ -293,6 +322,8 @@ Do not commit a `Cargo.lock` generated under these patches. Its source-less loca
293
322
 
294
323
  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`.
295
324
 
325
+ For an isolated build workspace, `fastws.core.cargo_setup(root)` regenerates these Cargo patches and the same compiler wrapper without pulling, syncing Python or building.
326
+
296
327
  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 `_`.
297
328
 
298
329
  Each sync updates `workspaces` in the root `package.json`. It creates the file when needed. It preserves external paths, globs, and unrelated settings. It reports added and removed packages.
@@ -177,9 +177,10 @@ Build a mixed Rust/Python project's extension and opted-in binaries, keep them w
177
177
  ```bash
178
178
  cargo develop
179
179
  cargo develop --path /path/to/project
180
+ cargo develop --profile quick
180
181
  ```
181
182
 
182
- Run from the project root, or give its directory with `--path`. The command reads `python-source`, `module-name`, `manifest-path` and features from `[tool.maturin]`. It always builds with Cargo's `test` profile, matching bare `cargo test`. The project must produce a `cdylib` and set `python-source`. To use release settings without command-line flags, add `[profile.test]` with `inherits = "release"` to `Cargo.toml`. Keep incremental compilation enabled for the local crate in that profile.
183
+ Run from the project root, or give its directory with `--path`. The command reads `python-source`, `module-name`, `manifest-path` and features from `[tool.maturin]`. `[tool.fastws] dev-profile = "quick"` selects a project's default for both `cargo develop` and `fastws.cargo.run_tests`. Without that setting they use `test`. An explicit `--profile NAME` overrides the project default. The project must produce a `cdylib` and set `python-source`. Define the selected profile in `Cargo.toml` and keep incremental compilation enabled for the local crate.
183
184
 
184
185
  Make the initial editable installation through `ws-sync` or maturin. Repeat that installation when package metadata or entry points change. Running Python processes keep the extension they loaded.
185
186
 
@@ -225,11 +226,11 @@ cargo wait --path /path/to/workspace
225
226
 
226
227
  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
 
228
- ### Rust tests from Python
229
+ ### Rust builds and tests from Python
229
230
 
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
+ `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. Each binary gets its own `timeout`, 30 seconds by default, excluding compilation. A binary still running when its `timeout` ends is killed and counts as a failed binary, with its suite's `timed_out` set. 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
 
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
+ The default profile is `[tool.fastws].dev-profile`, or `test` when unset. Explicit `--profile NAME`, `--profile=NAME`, `--release` and `-r` override it. 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
 
234
235
  ```python
235
236
  from fastws.cargo import run_tests
@@ -240,6 +241,34 @@ focused = run_tests('.', '--test', 'core', test_args=('test_name', '--nocapture'
240
241
 
241
242
  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.
242
243
 
244
+ `fastws.cargo.check` runs any Cargo subcommand, `check --all-targets` by default, and returns the same `Results` without test suites. A failing run is a result, not an exception. Each `Diagnostic` in its `diagnostics` holds the message's path, line, column, level and text, and its error code or lint as `code`, such as `E0308` or `clippy::too_many_lines`. The summary lists errors first. `env` sets environment variables for the run, as for `run_tests`. A subcommand that rejects `--message-format`, such as `cargo llvm-lines`, runs with `diagnostics=False`: its result holds the exit status and output, with no diagnostics.
245
+
246
+ ```python
247
+ from fastws.cargo import check
248
+
249
+ lints = check('.', 'clippy', '--all-targets', '--', '-W', 'clippy::too_many_lines', env={'CLIPPY_CONF_DIR': 'review'})
250
+ errors = [d for d in lints.diagnostics if d.level == 'error']
251
+ sizes = check('.', 'llvm-lines', '--lib', diagnostics=False).text
252
+ ```
253
+
254
+ ### Integration snapshots
255
+
256
+ `fastws.integrate` builds several repos together while people or agents keep editing them. `integrate` snapshots each repo's working tree as a commit (untracked files included, ignored files not), checks each commit out as a detached worktree under one root, and writes the root's Cargo config. `HEAD` and the index of each live repo don't change. The config is the workspace's, with the patches for crates under the root pointing at the root's worktrees, so a build there reads the snapshots and every other local crate from the workspace. Keep the root outside the workspace, where `ws-sync` and uv don't treat its worktrees as members.
257
+
258
+ `errors` checks each snapshot in turn and keeps the errors in that repo's own files. A repo's check also reports its dependencies' errors, under paths outside the repo, and each dependency's own check already covers those. `owners` reads each owner's path globs from the bullets under a Markdown file's `Files` heading, one ``- Owner: `glob` `glob` `` line each, in order. `by_owner` groups each repo's compiler messages by owner, then by file, and the first owner whose globs match a path owns it. It prefixes each path with its repo's name, so a path into a sibling repo, such as `../rustymath/src/x.rs` from basedpl's check, groups as `rustymath/src/x.rs`. `run_tests` with `--no-fail-fast` runs every test binary, each within its own `timeout`, so a slow or crashing binary doesn't stop the others. `code_count` counts the Rust code lines in a sequence of lines, leaving out blanks and comments. `code_lines` applies it to each Rust file under a directory. `exhash`'s `Journal.stat(count=code_count)` counts the code lines that edits add and remove. `record` writes a run's messages, each repo's test `Results` with each failure's output, and any sizes, as a Markdown record, and returns each owner's message count.
259
+
260
+ ```python
261
+ from fastws.cargo import run_tests
262
+ from fastws.integrate import by_owner, code_lines, errors, integrate, owners, record
263
+
264
+ root = '~/git/integration'
265
+ commits = integrate({'basedpl': '~/aai-ws/basedpl', 'rustymath': '~/aai-ws/rustymath'}, root)
266
+ errs = errors(root, ['rustymath', 'basedpl'])
267
+ groups = by_owner(errs, owners('~/aai-ws/basedpl/meta/owners.md'))
268
+ tests = {r: run_tests(f'{root}/{r}', '--no-fail-fast') for r in errs if not errs[r]}
269
+ sizes = {r: code_lines(f'{root}/{r}/src') for r in errs}
270
+ counts = record(f'{root}/run8.md', 'Run 8', commits, groups, tests=tests, sizes=sizes)
271
+ ```
243
272
  ### `ws-sync`
244
273
 
245
274
  Syncing proceeds in this order:
@@ -265,6 +294,8 @@ Do not commit a `Cargo.lock` generated under these patches. Its source-less loca
265
294
 
266
295
  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`.
267
296
 
297
+ For an isolated build workspace, `fastws.core.cargo_setup(root)` regenerates these Cargo patches and the same compiler wrapper without pulling, syncing Python or building.
298
+
268
299
  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 `_`.
269
300
 
270
301
  Each sync updates `workspaces` in the root `package.json`. It creates the file when needed. It preserves external paths, globs, and unrelated settings. It reports added and removed packages.
@@ -1,4 +1,5 @@
1
- __version__ = "0.0.33"
1
+ __version__ = "0.0.34"
2
+
2
3
 
3
4
 
4
5
 
@@ -0,0 +1,4 @@
1
+ __all__ = ['tomllib']
2
+
3
+ try: import tomllib
4
+ except ModuleNotFoundError: import tomli as tomllib
@@ -1,14 +1,12 @@
1
1
  r"Build, test and install Cargo artifacts for mixed Rust/Python projects."
2
- import json, os, re, shutil, subprocess, sys, sysconfig, time
2
+ import json, os, re, shutil, signal, subprocess, sys, sysconfig, time
3
3
  from collections import Counter
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
5
  from pathlib import Path
6
6
  from typing import NamedTuple
7
7
  from . import store
8
8
  from .procs import bounded
9
-
10
- try: import tomllib
11
- except ModuleNotFoundError: import tomli as tomllib
9
+ from ._toml import tomllib
12
10
 
13
11
 
14
12
  class ConfigError(ValueError):
@@ -20,9 +18,13 @@ def settings(root):
20
18
  return tomllib.loads((Path(root).expanduser()/'pyproject.toml').read_text()).get('tool', {})
21
19
 
22
20
 
21
+ def _dev_profile(root):
22
+ return settings(root).get('fastws', {}).get('dev-profile', 'test') if (root/'pyproject.toml').exists() else 'test'
23
+
24
+
23
25
  def cargo(root, *args):
24
- "Run Cargo in the project and return its standard output."
25
- return subprocess.run(['cargo', *args], cwd=Path(root).expanduser(), stdout=subprocess.PIPE, text=True, check=True).stdout
26
+ "Run Cargo in the project and return its standard output. A failure raises `CalledProcessError`, holding Cargo's standard error."
27
+ return subprocess.run(['cargo', *args], cwd=Path(root).expanduser(), capture_output=True, text=True, check=True).stdout
26
28
 
27
29
 
28
30
  def members(root):
@@ -30,32 +32,39 @@ def members(root):
30
32
  return {Path(p['manifest_path']) for p in json.loads(cargo(root, 'metadata', '--no-deps', '--format-version', '1'))['packages']}
31
33
 
32
34
 
33
- def check(root):
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)
36
-
37
-
38
35
  _TEST = re.compile(r'^test (.+) \.\.\. (ok|FAILED|ignored)')
39
36
  _FAILURE = re.compile(r'^---- (.+) std(?:out|err) ----$')
40
- _DIAGNOSTIC = re.compile(r'^(\S.*?):(\d+):(\d+): (warning|error)(?:\[\w+\])?: (.*)$')
41
37
 
42
38
 
43
39
  class Diagnostic(NamedTuple):
44
- "A compiler message, read from Cargo's short format."
40
+ "A compiler message at its primary span, read from Cargo's JSON messages."
45
41
  path:str; line:int; col:int; level:str; message:str
46
- def __str__(self): return f'{self.level}: {self.path}:{self.line}: {self.message}'
42
+ code:str|None=None # The error code or lint, such as `E0308` or `clippy::too_many_lines`
43
+ def __str__(self): return f'{self.level}{f"[{self.code}]" if self.code else ""}: {self.path}:{self.line}: {self.message}'
47
44
 
48
45
 
49
- def diagnostics(text):
50
- "Each `Diagnostic` in `text`, which holds Cargo's short-format messages, such as `check`'s `stderr`."
51
- return [Diagnostic(m[1], int(m[2]), int(m[3]), m[4], m[5]) for l in text.splitlines() if (m := _DIAGNOSTIC.match(l))]
46
+ def _run(root, args, env=None, diagnostics=True):
47
+ "Run the Cargo subcommand and options `args` in `root`, with the environment variables `env` set: its exit status, its output, each `Diagnostic` once, and Cargo's other JSON messages, such as artifacts. With `diagnostics`, Cargo writes JSON messages and the output renders each compiler message short; without, the output is as Cargo printed it."
48
+ flags = ('--message-format=json-diagnostic-short',) if diagnostics else ()
49
+ p = bounded(['cargo', args[0], *flags, *args[1:]], seconds=None, merge=True, cwd=root, env=os.environ | (env or {}))
50
+ if not diagnostics: return p.returncode, p.stdout, [], []
51
+ text, found, rest = [], [], []
52
+ for line in p.stdout.splitlines(keepends=True):
53
+ if not line.startswith('{'): text.append(line); continue
54
+ m = json.loads(line)
55
+ if m['reason'] != 'compiler-message': rest.append(m); continue
56
+ msg = m['message']
57
+ text.append(msg['rendered'])
58
+ if s := next((s for s in msg['spans'] if s['is_primary']), None):
59
+ found.append(Diagnostic(s['file_name'], s['line_start'], s['column_start'], msg['level'], msg['message'], (msg['code'] or {}).get('code')))
60
+ return p.returncode, ''.join(text), list(dict.fromkeys(found)), rest
52
61
 
53
62
 
54
63
  def _lines(text, n=20, tail=False):
55
- "Up to `n` lines of `text`, indented, from its end if `tail`, with a count of the lines left out."
64
+ "Up to `n` lines of `text` (None: all), indented, from its end if `tail`, with a count of the lines left out."
56
65
  lines = text.splitlines()
66
+ if n is None or len(lines) <= n: return [f' {l}' for l in lines]
57
67
  shown = [f' {l}' for l in (lines[-n:] if tail else lines[:n])]
58
- if len(lines) <= n: return shown
59
68
  more = f' … {len(lines)-n} more lines'
60
69
  return [more, *shown] if tail else [*shown, more]
61
70
 
@@ -69,52 +78,69 @@ class Suite:
69
78
  failures:dict # Each failing test's captured output
70
79
  summary:str # libtest's `test result:` line, empty for a binary that crashed or has its own harness
71
80
  failed:bool # Did the binary exit unsuccessfully?
81
+ timed_out:bool=False # Was the binary killed for running out of time?
72
82
 
73
83
  @classmethod
74
- def read(cls, name, lines, returncode):
75
- "Read one binary's lines and exit status."
76
- failures, current = {}, None
84
+ def read(cls, name, lines, returncode, timed_out=False):
85
+ "Read one binary's lines and exit status, and whether it was killed for running out of time."
86
+ failures, current, in_failures = {}, None, False
77
87
  for line in lines:
78
- if m := _FAILURE.match(line): current = failures.setdefault(m[1], [])
79
- elif line == 'failures:': current = None
88
+ if line in ('successes:', 'failures:'): current, in_failures = None, line == 'failures:'
89
+ elif in_failures and (m := _FAILURE.match(line)): current = failures.setdefault(m[1], [])
80
90
  elif current is not None: current.append(line)
81
91
  return cls(name, '\n'.join(lines), {m[1]: m[2] for l in lines if (m := _TEST.match(l))},
82
92
  {k: '\n'.join(v).strip('\n') for k,v in failures.items()},
83
- next((l for l in lines if l.startswith('test result: ')), ''), bool(returncode))
93
+ next((l for l in lines if l.startswith('test result: ')), ''), bool(returncode), timed_out)
84
94
 
85
95
  def __str__(self):
86
96
  n = Counter(self.tests.values())
87
97
  status = ', '.join(f'{n[o]} {w}' for o,w in [('ok', 'passed'), ('FAILED', 'failed'), ('ignored', 'ignored')] if n[o])
88
- if self.failed and not n['FAILED']: status = f'{status}, then failed' if status else 'failed'
98
+ if self.failed and not n['FAILED']:
99
+ end = 'ran out of time' if self.timed_out else 'failed'
100
+ status = f'{status}, then {end}' if status else end
89
101
  time = f' in {m[1]}' if (m := re.search(r'finished in (\S+)$', self.summary)) else ''
90
102
  return f'{self.name}: {status or "ok"}{time}'
91
103
 
92
104
 
93
105
  @dataclass
94
106
  class Results:
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."
107
+ "A Cargo run's outcome, its compiler messages and, for tests, each native test binary's results. It displays as a summary: the outcome, the compiler messages with errors first, each binary that ran tests, and each failure's output."
108
+ command:str # The Cargo subcommand, such as `test` or `clippy`
96
109
  returncode:int
97
110
  text:str # The build's messages and each binary's merged standard output and standard error
98
111
  diagnostics:list # Each `Diagnostic` from the build, before the first binary ran
99
- suites:list # Each binary's `Suite`, in execution order
112
+ suites:list=field(default_factory=list) # Each test binary's `Suite`, in execution order
100
113
  seconds:float=0 # Wall time spent executing native test binaries, excluding compilation
101
114
 
102
115
  @property
103
116
  def ok(self): return self.returncode == 0
104
117
 
105
- def __repr__(self):
106
- res = ['cargo test passed' if self.ok else f'cargo test failed with exit status {self.returncode}']
118
+ def summary(self, lines:int|None=20):
119
+ "The outcome, compiler messages, each binary's result and each failure's output, with up to `lines` lines of each output (None: all)"
120
+ res = [f'cargo {self.command} passed' if self.ok else f'cargo {self.command} failed with exit status {self.returncode}']
107
121
  diags = sorted(self.diagnostics, key=lambda d: d.level != 'error')
108
- res += [str(d) for d in diags[:20]]
109
- if len(diags) > 20: res.append(f'{len(diags)-20} more compiler messages in `diagnostics`')
122
+ res += [str(d) for d in diags[:lines]]
123
+ if lines is not None and len(diags) > lines: res.append(f'{len(diags)-lines} more compiler messages in `diagnostics`')
110
124
  ran = [s for s in self.suites if s.tests or not s.summary]
111
125
  for s in ran:
112
126
  res.append(str(s))
113
- for t,out in s.failures.items(): res += [f' FAILED {t}', *_lines(out)]
114
- if s.failed and not s.failures: res += _lines(s.output, tail=True)
127
+ for t,out in s.failures.items(): res += [f' FAILED {t}', *_lines(out, lines)]
128
+ if s.failed and not s.failures: res += _lines(s.output, lines, tail=True)
115
129
  if idle := len(self.suites) - len(ran): res.append(f'{idle} test binaries ran no tests')
116
- if not (self.ok or self.suites or diags): res += _lines(self.text, tail=True)
130
+ if not (self.ok or self.suites or diags): res += _lines(self.text, lines, tail=True)
117
131
  return '\n'.join(res)
132
+ __repr__ = summary
133
+
134
+
135
+ def check(
136
+ root, # A Cargo package or workspace (expands `~`)
137
+ *args, # The Cargo subcommand and its options, such as `'clippy', '--all-targets', '--', '-W', 'clippy::pedantic'`; none: `check --all-targets`
138
+ env:dict=None, # Environment variables to set for the run, such as `{'CLIPPY_CONF_DIR': '/path/to/review'}`
139
+ diagnostics:bool=True, # Ask Cargo for JSON compiler messages? False runs a subcommand that rejects `--message-format`, such as `llvm-lines`, with no diagnostics
140
+ )->Results:
141
+ "Run a Cargo subcommand and return its `Results`, with each compiler message as a `Diagnostic`. A failing run is a result, not an exception."
142
+ args = args or ('check', '--all-targets')
143
+ return Results(args[0], *_run(Path(root).expanduser(), args, env, diagnostics)[:3])
118
144
 
119
145
 
120
146
  def run_tests(
@@ -122,33 +148,28 @@ def run_tests(
122
148
  *args, # Cargo build/selection options, such as `'--test', 'core'` or `'--profile', 'checked'`; `--no-fail-fast` runs every binary
123
149
  test_args=(), # Arguments passed directly to each test binary, such as `('name', '--nocapture')`
124
150
  env:dict=None, # Environment variables to set for the run, such as `{'BASEDPL_COSTS': '1'}`
125
- timeout:float=30 # Seconds shared by all native test binaries after building, raising `TimeoutError` on expiry; None: no limit
151
+ timeout:float=30 # Seconds that each native test binary may run after building; None: no limit
126
152
  )->Results:
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, [])
153
+ "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` bounds each binary on its own, excluding compilation. A binary that runs out of time is killed and reported as a failed binary whose suite is `timed_out`. An omitted profile uses tool.fastws.dev-profile, or test. Doctests are not run. This takes no timing lock: rustc's wrapper and tests that time own their holds."
154
+ root, env = Path(root).expanduser().resolve(), {'CARGO_TERM_QUIET': 'false', 'CARGO_TERM_COLOR': 'never'} | (env or {})
155
+ has_profile = any(a in ('-r', '--release', '--profile') or isinstance(a, str) and a.startswith('--profile=') for a in args)
156
+ if not has_profile: args += ('--profile', _dev_profile(root))
157
+ code, text, found, rest = _run(root, ('test', '--no-run', *args), env)
158
+ if code: return Results('test', code, text, found)
159
+ artifacts = [m for m in rest if m['reason'] == 'compiler-artifact' and m['profile']['test'] and m.get('executable')]
139
160
  suites, code, start = [], 0, time.monotonic()
140
161
  for m in artifacts:
141
162
  package = Path(m['manifest_path']).parent
142
163
  name = Path(m['target']['src_path']).relative_to(package).as_posix()
143
164
  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))
165
+ try: p, late = bounded([m['executable'], *test_args], timeout, merge=True, cwd=package, env=os.environ | env | {'CARGO_MANIFEST_DIR': str(package)}), False
166
+ except TimeoutError as e: p, late = subprocess.CompletedProcess(m['executable'], -signal.SIGKILL, f'{e}\n'), True
167
+ suites.append(Suite.read(name, p.stdout.splitlines(), p.returncode, late))
147
168
  text += f' Running {name} ({m["executable"]})\n' + p.stdout
148
169
  if p.returncode:
149
170
  code = p.returncode
150
171
  if '--no-fail-fast' not in args: break
151
- return Results(code, text, found, suites, time.monotonic() - start)
172
+ return Results('test', code, text, found, suites, time.monotonic() - start)
152
173
 
153
174
 
154
175
  def sources(root):
@@ -225,15 +246,15 @@ def replace(source, destination):
225
246
  return destination
226
247
 
227
248
 
228
- def develop(root):
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."
249
+ def develop(root, *, profile=None):
250
+ "Serialize this project's cooperating develop runs through build, artifact capture, installation, generator and wheel data. An omitted profile uses tool.fastws.dev-profile, or test. Raw Cargo commands do not take this lock."
230
251
  root = Path(root).expanduser()
231
252
  with store.building(root) as started:
232
253
  tool = settings(root)
233
254
  cfg = tool['maturin']
234
255
  if 'python-source' not in cfg: raise ConfigError(r'cargo develop requires tool.maturin.python-source')
235
256
  binaries = tool.get('fastws', {}).get('native-binaries', False)
236
- artifacts = build(root, binaries=binaries, profile='test')
257
+ artifacts = build(root, binaries=binaries, profile=profile or _dev_profile(root))
237
258
  artifact, = [m for m in artifacts if 'cdylib' in m['target']['crate_types']]
238
259
  lib, = [Path(f) for f in artifact['filenames'] if Path(f).suffix in ('.dylib', '.so', '.dll')]
239
260
  module = cfg.get('module-name', artifact['target']['name'])
@@ -1,6 +1,6 @@
1
1
  "Fast workspace tools for multi-repo management."
2
2
 
3
- __all__ = ["ws_setup", "ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_build", "ws_sync", "ws_add", "ws_remove", "cargo_develop", "cargo_stage", "cargo_wait"]
3
+ __all__ = ["ws_setup", "ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_build", "ws_sync", "ws_add", "ws_remove", "cargo_develop", "cargo_stage", "cargo_wait", "cargo_setup"]
4
4
 
5
5
  import ast, fnmatch, glob, hashlib, json, os, re, shlex, shutil, subprocess, sys, time
6
6
  from pathlib import Path
@@ -14,8 +14,7 @@ from ghapi.core import dep_key, dep_closure
14
14
  from packaging.requirements import Requirement
15
15
  from packaging.utils import canonicalize_name
16
16
 
17
- try: import tomllib
18
- except ModuleNotFoundError: import tomli as tomllib
17
+ from ._toml import tomllib
19
18
 
20
19
  def _repo_lines(repos_file) -> list[str]:
21
20
  p = Path(repos_file)
@@ -571,10 +570,11 @@ def _cli_errors():
571
570
  def cargo_develop(
572
571
  command: str = 'develop', # Subcommand name supplied by Cargo
573
572
  path: str = '.', # Rust/Python project directory
573
+ profile: str = None, # Cargo profile; default: tool.fastws.dev-profile, or test
574
574
  ):
575
575
  r"Build and install a mixed Rust/Python project's extension and opted-in binaries."
576
576
  from .cargo import develop
577
- with _cli_errors(): develop(Path(path).resolve())
577
+ with _cli_errors(): develop(Path(path).resolve(), profile=profile)
578
578
 
579
579
  @call_parse(pos=['command'])
580
580
  def cargo_stage(
@@ -603,12 +603,12 @@ def cargo_wait(
603
603
  patience: int = 1800, # Seconds to wait before giving up
604
604
  ):
605
605
  r"Wait until a Cargo workspace compiles, checking again after each edit to its local packages' sources."
606
- from .cargo import check, diagnostics, sources
606
+ from .cargo import check, sources
607
607
  root, start = Path(path).resolve(), time.monotonic()
608
608
  with _cli_errors(): srcs = sources(root)
609
609
  while (res := check(root)).returncode:
610
- if time.monotonic() - start > patience: raise SystemExit(f'{res.stderr}gave up after {patience} s')
611
- print(next((str(d) for d in diagnostics(res.stderr) if d.level == 'error'), res.stderr.strip() or 'cargo check failed'), file=sys.stderr)
610
+ if time.monotonic() - start > patience: raise SystemExit(f'{res.text}gave up after {patience} s')
611
+ print(next((str(d) for d in res.diagnostics if d.level == 'error'), res.text.strip() or 'cargo check failed'), file=sys.stderr)
612
612
  before = _newest(srcs)
613
613
  while (now := _newest(srcs)) == before and time.monotonic() - start < patience: time.sleep(1)
614
614
  while True:
@@ -721,10 +721,23 @@ def _strip_patch_tables(content: str) -> str:
721
721
  content = content[:m.start()] + content[end:]
722
722
  return content
723
723
 
724
+ def _patch_text(tables: dict) -> str:
725
+ "TOML `[patch.*]` tables for `tables`, which maps each source URL to its crates' source specs"
726
+ return "\n".join(
727
+ (f"[patch.{url}]" if url == "crates-io" else f'[patch."{url}"]') + "\n"
728
+ + "".join(f"{name} = {_fmt_source(spec)}\n" for name, spec in sorted(entries.items()))
729
+ for url, entries in tables.items() if entries)
730
+
724
731
  def _entry_changes(current, new):
725
732
  "Added and removed keys, in the order supplied."
726
733
  return [k for k in new if k not in current], [k for k in current if k not in new]
727
734
 
735
+ def cargo_setup(root):
736
+ "Regenerate local Cargo patches and the compile-lock wrapper without pulling or building."
737
+ root = Path(root).expanduser().resolve()
738
+ _sync_cargo_wrapper(root)
739
+ return _sync_cargo_patches(root)
740
+
728
741
  def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
729
742
  """Regenerate workspace Cargo patches, default to quiet output, and return (added, removed).
730
743
 
@@ -756,10 +769,7 @@ def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
756
769
  for url, entries in desired.items():
757
770
  cur = tables.setdefault(url, {})
758
771
  for name, path in entries.items(): cur.setdefault(name, {"path": path})
759
- body = "\n".join(
760
- (f"[patch.{url}]" if url == "crates-io" else f'[patch."{url}"]') + "\n"
761
- + "".join(f"{name} = {_fmt_source(spec)}\n" for name, spec in sorted(entries.items()))
762
- for url, entries in tables.items() if entries)
772
+ body = _patch_text(tables)
763
773
  new = (_strip_patch_tables(content).rstrip() + "\n\n" + body).lstrip() + "\n" if body else _strip_patch_tables(content)
764
774
  if new == original: return [], []
765
775
  tomllib.loads(new) # never write an unparseable config
@@ -991,8 +1001,7 @@ async def ws_sync(
991
1001
  projects = _build_projects(root, repos_file)
992
1002
  _sync_build_isolation(pyproject_path, projects)
993
1003
 
994
- _sync_cargo_wrapper(root)
995
- added_p, removed_p = _sync_cargo_patches(root)
1004
+ added_p, removed_p = cargo_setup(root)
996
1005
  if added_p: print(f"Cargo patches added: {', '.join(added_p)}")
997
1006
  if removed_p: print(f"Cargo patches removed: {', '.join(removed_p)}")
998
1007
 
@@ -0,0 +1,136 @@
1
+ r"""Build several repos' working trees together as one snapshot, apart from the live trees, and group its compiler messages by owner.
2
+
3
+ Each run snapshots every repo's working tree as a commit, checks the commits out as worktrees under one root, and gives that root the workspace's Cargo config with the root's own crates patched in. Builds there never see a file halfway through an edit, and never touch the files that people and agents are editing."""
4
+ import fnmatch, os, posixpath, re, subprocess, tempfile
5
+ from pathlib import Path
6
+ from ._toml import tomllib
7
+ from .core import _local_crates, _patch_text, _strip_patch_tables, _sync_cargo_patches
8
+ from .cargo import check
9
+
10
+ __all__ = ["snapshot", "integrate", "by_owner", "owners", "errors", "code_count", "code_lines", "record"]
11
+
12
+
13
+ def _git(repo, *args, env=None):
14
+ return subprocess.run(["git", *args], cwd=repo, env=env, stdout=subprocess.PIPE, text=True, check=True).stdout.strip()
15
+
16
+
17
+ def snapshot(repo):
18
+ "A commit of `repo`'s working tree on top of `HEAD`, with untracked files and without ignored ones. `HEAD` and the index don't change."
19
+ repo = Path(repo).expanduser()
20
+ with tempfile.TemporaryDirectory() as d:
21
+ env = {**os.environ, "GIT_INDEX_FILE": str(Path(d)/"index")}
22
+ _git(repo, "read-tree", "HEAD", env=env)
23
+ _git(repo, "add", "-A", env=env)
24
+ tree = _git(repo, "write-tree", env=env)
25
+ return _git(repo, "commit-tree", tree, "-p", "HEAD", "-m", "integration snapshot")
26
+
27
+
28
+ def _checkout(repo, dest, commit):
29
+ "Check `commit` out at `dest`, a detached worktree of `repo` that this adds if it's missing, removing the last run's untracked files."
30
+ if not dest.exists(): return _git(repo, "worktree", "add", "--detach", str(dest), commit)
31
+ _git(dest, "checkout", "--detach", "-f", commit)
32
+ _git(dest, "clean", "-fd")
33
+
34
+
35
+ def _config(root, workspace):
36
+ "Write `root`'s Cargo config: `workspace`'s, with the patches for crates under `root` pointing there."
37
+ held = {pkg["name"] for pkg, _ in _local_crates(root)}
38
+ content = (workspace/".cargo"/"config.toml").read_text()
39
+ kept = {url: {n: s for n, s in entries.items() if n not in held} for url, entries in tomllib.loads(content).get("patch", {}).items()}
40
+ config = root/".cargo"/"config.toml"
41
+ config.parent.mkdir(exist_ok=True)
42
+ config.write_text(_strip_patch_tables(content).rstrip() + "\n\n" + _patch_text(kept) + "\n")
43
+ _sync_cargo_patches(root)
44
+
45
+
46
+ def integrate(
47
+ repos:dict, # Each repo's name and its live checkout; the snapshot goes in `root/<name>`
48
+ root, # Directory holding the snapshot worktrees and their Cargo config
49
+ workspace="~/aai-ws", # Workspace whose Cargo config the root copies
50
+ )->dict:
51
+ "Snapshot each of `repos`, check the snapshots out under `root`, and write `root`'s Cargo config. Returns each repo's snapshot commit."
52
+ root = Path(root).expanduser().resolve()
53
+ commits = {}
54
+ for name, repo in repos.items():
55
+ repo = Path(repo).expanduser()
56
+ commits[name] = snapshot(repo)
57
+ _checkout(repo, root/name, commits[name])
58
+ _config(root, Path(workspace).expanduser().resolve())
59
+ return commits
60
+
61
+
62
+ def by_owner(
63
+ diagnostics:dict, # Each repo's name and its `fastws.cargo.Diagnostic`s, with paths relative to the repo, such as one check's errors
64
+ owners:dict, # Each owner's path globs, matched against `<repo>/<path>`
65
+ )->dict:
66
+ "Group `diagnostics` by owner, then by file, with each path prefixed by its repo's name. A path that no owner's globs match groups under `None`."
67
+ groups = {}
68
+ for repo, ds in diagnostics.items():
69
+ for d in ds:
70
+ d = d._replace(path=posixpath.normpath(f"{repo}/{d.path}"))
71
+ owner = next((o for o, globs in owners.items() if any(fnmatch.fnmatch(d.path, g) for g in globs)), None)
72
+ groups.setdefault(owner, {}).setdefault(d.path, []).append(d)
73
+ return groups
74
+
75
+
76
+ def owners(
77
+ path, # Markdown file with a `Files` heading (expands `~`)
78
+ )->dict:
79
+ "Each owner's path globs from the bullets under `path`'s `Files` heading, one `- Owner: `glob` `glob`` line each, in order, as `by_owner` takes them. The first owner whose globs match a path owns it."
80
+ files = re.split(r"^#+ Files\s*$", Path(path).expanduser().read_text(), maxsplit=1, flags=re.M)[1]
81
+ files = re.split(r"^#", files, maxsplit=1, flags=re.M)[0]
82
+ return {m[1]: re.findall(r"`([^`]+)`", m[2]) for m in re.finditer(r"^- ([^:]+):(.*)$", files, re.M)}
83
+
84
+
85
+ def errors(
86
+ root, # Directory holding the snapshot worktrees (expands `~`)
87
+ repos, # Names of the worktrees under `root` to check, in order
88
+ )->dict:
89
+ "Each repo's compiler errors in its own files, from checking its worktree under `root`, as `by_owner` takes them. A check repeats a dependency's errors under a path outside the repo; the dependency's own check reports those."
90
+ root = Path(root).expanduser()
91
+ return {r: [d for d in check(root/r).diagnostics if d.level == "error" and not d.path.startswith(("/", ".."))] for r in repos}
92
+
93
+
94
+ def code_count(
95
+ lines, # Rust source lines in order, such as one file's or one side of a diff hunk
96
+ )->int:
97
+ "How many of `lines` are code: neither blank nor comments (`//` lines and `/* */` blocks)."
98
+ n, block = 0, False
99
+ for line in lines:
100
+ s = line.strip()
101
+ if block: block = "*/" not in s
102
+ elif s.startswith("/*"): block = "*/" not in s
103
+ elif s and not s.startswith("//"): n += 1
104
+ return n
105
+
106
+
107
+ def code_lines(
108
+ path, # A directory, such as a crate's `src/` (expands `~`)
109
+ )->int:
110
+ "The code lines of the Rust files under `path`, as `code_count` counts them."
111
+ return sum(code_count(f.read_text().splitlines()) for f in Path(path).expanduser().rglob("*.rs"))
112
+
113
+
114
+ def record(
115
+ path, # Markdown file to write
116
+ title:str, # The record's heading, such as "Run 8"
117
+ commits:dict, # Each repo's snapshot commit, as `integrate` returns them
118
+ groups:dict, # Messages by owner and file, as `by_owner` returns them
119
+ edits=(), # Edits made in the snapshot only, one line each
120
+ tests:dict=None, # Each repo's name and its test `Results`, as `fastws.cargo.run_tests` returns them
121
+ sizes:dict=None, # Code lines by label, as `code_lines` counts them, such as each repo's `src/` beside its baselines
122
+ sections:dict=None, # Further sections by heading, each text written as a code block, such as each repo's pytest output
123
+ )->dict:
124
+ "Write a run's record: the snapshots, each owner's messages by file as `line: message`, any snapshot-only edits, each test binary's outcome with its failures' output, code-line sizes, and any further `sections`. Returns each owner's message count."
125
+ counts = {o: sum(map(len, files.values())) for o, files in groups.items()}
126
+ lines = [f"# {title}", "", "Snapshots: " + ", ".join(f"{name} `{c[:9]}`" for name, c in commits.items()), ""]
127
+ for owner, files in groups.items():
128
+ lines += [f"## {owner or 'no owner'}: {counts[owner]}", ""]
129
+ for file, ds in files.items(): lines += [f"- `{file}`", *[f" - {d.line}: {d.message}" for d in ds]]
130
+ lines.append("")
131
+ if edits: lines += ["## Snapshot-only edits", "", *[f"- {e}" for e in edits], ""]
132
+ if tests: lines += ["## Tests", "", *[f"### {name}\n\n```\n{r.summary(None)}\n```\n" for name, r in tests.items()]]
133
+ if sizes: lines += ["## Size (code lines, no comments or blanks)", "", *[f"- {k}: {v:,}" for k, v in sizes.items()], ""]
134
+ for heading, text in (sections or {}).items(): lines += [f"## {heading}", "", f"```\n{text}\n```", ""]
135
+ Path(path).expanduser().write_text("\n".join(lines))
136
+ return counts
@@ -1,12 +1,13 @@
1
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
2
 
3
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
4
+ import hashlib, json, os, re, shutil, time
5
5
  if os.name != 'nt': import fcntl
6
6
  from contextlib import contextmanager
7
7
  from datetime import datetime
8
8
  from pathlib import Path
9
9
  from tempfile import TemporaryDirectory
10
+ from ._toml import tomllib
10
11
 
11
12
  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
13
  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
@@ -32,7 +33,10 @@ def access(exclusive=False):
32
33
  yield
33
34
 
34
35
  def _digest(path):
35
- with open(path, 'rb') as f: return hashlib.file_digest(f, 'sha256').hexdigest()
36
+ digest = hashlib.sha256()
37
+ with open(path, 'rb') as f:
38
+ while chunk := f.read(1024*1024): digest.update(chunk)
39
+ return digest.hexdigest()
36
40
 
37
41
  def _blob(path):
38
42
  "The blob that holds `path`'s contents, read-only and executable where `path` is, added to the store where it's new."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.33
3
+ Version: 0.0.34
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License-Expression: Apache-2.0
@@ -205,9 +205,10 @@ Build a mixed Rust/Python project's extension and opted-in binaries, keep them w
205
205
  ```bash
206
206
  cargo develop
207
207
  cargo develop --path /path/to/project
208
+ cargo develop --profile quick
208
209
  ```
209
210
 
210
- Run from the project root, or give its directory with `--path`. The command reads `python-source`, `module-name`, `manifest-path` and features from `[tool.maturin]`. It always builds with Cargo's `test` profile, matching bare `cargo test`. The project must produce a `cdylib` and set `python-source`. To use release settings without command-line flags, add `[profile.test]` with `inherits = "release"` to `Cargo.toml`. Keep incremental compilation enabled for the local crate in that profile.
211
+ Run from the project root, or give its directory with `--path`. The command reads `python-source`, `module-name`, `manifest-path` and features from `[tool.maturin]`. `[tool.fastws] dev-profile = "quick"` selects a project's default for both `cargo develop` and `fastws.cargo.run_tests`. Without that setting they use `test`. An explicit `--profile NAME` overrides the project default. The project must produce a `cdylib` and set `python-source`. Define the selected profile in `Cargo.toml` and keep incremental compilation enabled for the local crate.
211
212
 
212
213
  Make the initial editable installation through `ws-sync` or maturin. Repeat that installation when package metadata or entry points change. Running Python processes keep the extension they loaded.
213
214
 
@@ -253,11 +254,11 @@ cargo wait --path /path/to/workspace
253
254
 
254
255
  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
 
256
- ### Rust tests from Python
257
+ ### Rust builds and tests from Python
257
258
 
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
+ `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. Each binary gets its own `timeout`, 30 seconds by default, excluding compilation. A binary still running when its `timeout` ends is killed and counts as a failed binary, with its suite's `timed_out` set. 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
 
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
+ The default profile is `[tool.fastws].dev-profile`, or `test` when unset. Explicit `--profile NAME`, `--profile=NAME`, `--release` and `-r` override it. 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
 
262
263
  ```python
263
264
  from fastws.cargo import run_tests
@@ -268,6 +269,34 @@ focused = run_tests('.', '--test', 'core', test_args=('test_name', '--nocapture'
268
269
 
269
270
  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.
270
271
 
272
+ `fastws.cargo.check` runs any Cargo subcommand, `check --all-targets` by default, and returns the same `Results` without test suites. A failing run is a result, not an exception. Each `Diagnostic` in its `diagnostics` holds the message's path, line, column, level and text, and its error code or lint as `code`, such as `E0308` or `clippy::too_many_lines`. The summary lists errors first. `env` sets environment variables for the run, as for `run_tests`. A subcommand that rejects `--message-format`, such as `cargo llvm-lines`, runs with `diagnostics=False`: its result holds the exit status and output, with no diagnostics.
273
+
274
+ ```python
275
+ from fastws.cargo import check
276
+
277
+ lints = check('.', 'clippy', '--all-targets', '--', '-W', 'clippy::too_many_lines', env={'CLIPPY_CONF_DIR': 'review'})
278
+ errors = [d for d in lints.diagnostics if d.level == 'error']
279
+ sizes = check('.', 'llvm-lines', '--lib', diagnostics=False).text
280
+ ```
281
+
282
+ ### Integration snapshots
283
+
284
+ `fastws.integrate` builds several repos together while people or agents keep editing them. `integrate` snapshots each repo's working tree as a commit (untracked files included, ignored files not), checks each commit out as a detached worktree under one root, and writes the root's Cargo config. `HEAD` and the index of each live repo don't change. The config is the workspace's, with the patches for crates under the root pointing at the root's worktrees, so a build there reads the snapshots and every other local crate from the workspace. Keep the root outside the workspace, where `ws-sync` and uv don't treat its worktrees as members.
285
+
286
+ `errors` checks each snapshot in turn and keeps the errors in that repo's own files. A repo's check also reports its dependencies' errors, under paths outside the repo, and each dependency's own check already covers those. `owners` reads each owner's path globs from the bullets under a Markdown file's `Files` heading, one ``- Owner: `glob` `glob` `` line each, in order. `by_owner` groups each repo's compiler messages by owner, then by file, and the first owner whose globs match a path owns it. It prefixes each path with its repo's name, so a path into a sibling repo, such as `../rustymath/src/x.rs` from basedpl's check, groups as `rustymath/src/x.rs`. `run_tests` with `--no-fail-fast` runs every test binary, each within its own `timeout`, so a slow or crashing binary doesn't stop the others. `code_count` counts the Rust code lines in a sequence of lines, leaving out blanks and comments. `code_lines` applies it to each Rust file under a directory. `exhash`'s `Journal.stat(count=code_count)` counts the code lines that edits add and remove. `record` writes a run's messages, each repo's test `Results` with each failure's output, and any sizes, as a Markdown record, and returns each owner's message count.
287
+
288
+ ```python
289
+ from fastws.cargo import run_tests
290
+ from fastws.integrate import by_owner, code_lines, errors, integrate, owners, record
291
+
292
+ root = '~/git/integration'
293
+ commits = integrate({'basedpl': '~/aai-ws/basedpl', 'rustymath': '~/aai-ws/rustymath'}, root)
294
+ errs = errors(root, ['rustymath', 'basedpl'])
295
+ groups = by_owner(errs, owners('~/aai-ws/basedpl/meta/owners.md'))
296
+ tests = {r: run_tests(f'{root}/{r}', '--no-fail-fast') for r in errs if not errs[r]}
297
+ sizes = {r: code_lines(f'{root}/{r}/src') for r in errs}
298
+ counts = record(f'{root}/run8.md', 'Run 8', commits, groups, tests=tests, sizes=sizes)
299
+ ```
271
300
  ### `ws-sync`
272
301
 
273
302
  Syncing proceeds in this order:
@@ -293,6 +322,8 @@ Do not commit a `Cargo.lock` generated under these patches. Its source-less loca
293
322
 
294
323
  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`.
295
324
 
325
+ For an isolated build workspace, `fastws.core.cargo_setup(root)` regenerates these Cargo patches and the same compiler wrapper without pulling, syncing Python or building.
326
+
296
327
  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 `_`.
297
328
 
298
329
  Each sync updates `workspaces` in the root `package.json`. It creates the file when needed. It preserves external paths, globs, and unrelated settings. It reports added and removed packages.
@@ -2,9 +2,11 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  fastws/__init__.py
5
+ fastws/_toml.py
5
6
  fastws/build_backend.py
6
7
  fastws/cargo.py
7
8
  fastws/core.py
9
+ fastws/integrate.py
8
10
  fastws/news.py
9
11
  fastws/procs.py
10
12
  fastws/releases.py
@@ -18,6 +20,7 @@ fastws_cli.egg-info/requires.txt
18
20
  fastws_cli.egg-info/top_level.txt
19
21
  tests/test_build.py
20
22
  tests/test_build_backend.py
23
+ tests/test_integrate.py
21
24
  tests/test_news.py
22
25
  tests/test_procs.py
23
26
  tests/test_releases.py
@@ -1,5 +1,5 @@
1
1
  import subprocess, pytest, fastws.core as core
2
- from fastws.cargo import build, run_tests, diagnostics
2
+ from fastws.cargo import build, check, run_tests, Suite
3
3
 
4
4
 
5
5
  def _mk_proj(root, name, extra=''):
@@ -41,7 +41,9 @@ def test_build_diagnostics(tmp_path, capfd, body, diagnostic):
41
41
  else: assert [m['target']['name'] for m in build(tmp_path)] == ['app']
42
42
  stderr = capfd.readouterr().err
43
43
  if diagnostic: assert f'[dep] dep/src/lib.rs:1:' in stderr and f': {diagnostic}:' in stderr
44
- else: assert not diagnostics(stderr)
44
+ else: assert 'lib.rs:' not in stderr
45
+ checked = check(tmp_path)
46
+ assert checked.ok == (diagnostic != 'error') and [(d.path, d.level) for d in checked.diagnostics] == ([('dep/src/lib.rs', diagnostic)] if diagnostic else [])
45
47
 
46
48
 
47
49
  def test_run_tests(tmp_path):
@@ -64,12 +66,31 @@ pub fn two() -> i32 { 2 }
64
66
  (tmp_path/'.cargo/config.toml').write_text('[term]\nquiet = true\n')
65
67
  r = run_tests(tmp_path, '--no-fail-fast')
66
68
  assert not r.ok
67
- assert [(d.path, d.line, d.level) for d in r.diagnostics] == [('src/lib.rs', 1, 'warning')]
69
+ assert [(d.path, d.line, d.level, d.code) for d in r.diagnostics] == [('src/lib.rs', 1, 'warning', 'unused_variables')]
68
70
  suites = {s.name: s for s in r.suites}
69
71
  unit, more = suites['unittests src/lib.rs'], suites['tests/more.rs']
70
72
  assert len(suites) == 2
71
73
  assert unit.tests == {'t::passes': 'ok', 't::fails': 'FAILED', 't::skipped': 'ignored'} and unit.failed
72
74
  assert 'broken' in unit.failures['t::fails'] and list(unit.failures) == ['t::fails']
73
75
  assert more.tests == {'other': 'ok'} and not more.failed
74
- with pytest.raises(TimeoutError, match='started slow test'):
75
- run_tests(tmp_path, '--lib', test_args=('skipped', '--ignored', '--nocapture'), timeout=.5)
76
+ slow = run_tests(tmp_path, '--lib', test_args=('skipped', '--ignored', '--nocapture'), timeout=.5)
77
+ s, = slow.suites
78
+ assert not slow.ok and s.failed and s.timed_out and 'started slow test' in s.output
79
+ assert str(s) == 'unittests src/lib.rs: ran out of time'
80
+
81
+ (tmp_path/'pyproject.toml').write_text(r'tool.fastws.dev-profile = "missing"')
82
+ r = run_tests(tmp_path, '--test', 'more')
83
+ assert not r.ok and not r.suites and 'missing' in r.text
84
+ for args in [('--profile', 'test'), ('--profile=test',), ('--release',), ('-r',)]:
85
+ assert run_tests(tmp_path, '--test', 'more', *args).ok
86
+
87
+
88
+ def test_captured_test_output():
89
+ for failed in (False, True):
90
+ lines = ['test passes ... ok', 'successes:', '---- passes stdout ----', 'printed', 'successes:', 'passes']
91
+ if failed:
92
+ lines[1:1] = ['test fails ... FAILED']
93
+ lines += ['failures:', '---- fails stdout ----', 'broken', 'failures:', 'fails']
94
+ lines.append(f'test result: {"FAILED" if failed else "ok"}. 1 passed; {int(failed)} failed; 0 ignored')
95
+ s = Suite.read('unit', lines, int(failed))
96
+ assert s.failures == ({'fails': 'broken'} if failed else {}) and 'printed' in s.output
@@ -0,0 +1,46 @@
1
+ import subprocess
2
+ from fastws._toml import tomllib
3
+ from fastws.integrate import _config, code_lines, owners, snapshot
4
+
5
+
6
+ def git(repo, *args): return subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True).stdout
7
+
8
+
9
+ def test_snapshot_takes_the_working_tree_and_leaves_head_and_index(tmp_path):
10
+ git(tmp_path, "init", "-q")
11
+ (tmp_path/".gitignore").write_text("ignored\n")
12
+ (tmp_path/"kept").write_text("old")
13
+ git(tmp_path, "add", "-A"); git(tmp_path, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "base")
14
+ head = git(tmp_path, "rev-parse", "HEAD")
15
+ (tmp_path/"kept").write_text("new"); (tmp_path/"untracked").write_text("u"); (tmp_path/"ignored").write_text("i")
16
+ commit = snapshot(tmp_path)
17
+ assert git(tmp_path, "show", f"{commit}:kept") == "new"
18
+ assert git(tmp_path, "show", f"{commit}:untracked") == "u"
19
+ assert "ignored" not in git(tmp_path, "ls-tree", "--name-only", commit).split()
20
+ assert git(tmp_path, "rev-parse", "HEAD") == head and git(tmp_path, "diff", "--cached", "--name-only") == ""
21
+
22
+
23
+ def test_config_points_only_the_roots_crates_at_the_root(tmp_path):
24
+ workspace, root = tmp_path/"ws", tmp_path/"root"
25
+ (workspace/".cargo").mkdir(parents=True)
26
+ (workspace/".cargo"/"config.toml").write_text(
27
+ f'[build]\nrustc-wrapper = "/lock"\n\n[patch.crates-io]\na = {{ path = "{workspace}/a" }}\nb = {{ path = "{workspace}/b" }}\n')
28
+ (root/"a").mkdir(parents=True)
29
+ (root/"a"/"Cargo.toml").write_text('[package]\nname = "a"\nversion = "0.1.0"\n')
30
+ _config(root, workspace)
31
+ config = tomllib.loads((root/".cargo"/"config.toml").read_text())
32
+ assert config["build"]["rustc-wrapper"] == "/lock"
33
+ assert config["patch"]["crates-io"] == {"a": {"path": str(root/"a")}, "b": {"path": f"{workspace}/b"}}
34
+
35
+
36
+ def test_code_lines_skips_blanks_and_comments(tmp_path):
37
+ (tmp_path/"a").mkdir()
38
+ (tmp_path/"a"/"m.rs").write_text("//! Module doc\n\nfn f() {}\n/* a\n block */\nlet s = \"// not a comment\";\n /// doc\n}\n")
39
+ (tmp_path/"notes.md").write_text("fn f() {}\n")
40
+ assert code_lines(tmp_path) == 3
41
+
42
+
43
+ def test_owners_reads_the_files_section_in_order(tmp_path):
44
+ (tmp_path/"owners.md").write_text(
45
+ "# Owners\n\n- Before: `x/*`\n\n## Files\n\nThe first match owns a path.\n- Structure: `a/src/p.rs` `b/src/s.rs`\n- Numeric: `b/*`\n\n## Order\n\n- After: `c/*`\n")
46
+ assert list(owners(tmp_path/"owners.md").items()) == [("Structure", ["a/src/p.rs", "b/src/s.rs"]), ("Numeric", ["b/*"])]
File without changes
File without changes
File without changes
File without changes
File without changes