fastws-cli 0.0.34__tar.gz → 0.0.35__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.34 → fastws_cli-0.0.35}/PKG-INFO +8 -3
  2. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/README.md +7 -2
  3. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/__init__.py +2 -1
  4. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/cargo.py +108 -23
  5. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/core.py +32 -15
  6. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws_cli.egg-info/PKG-INFO +8 -3
  7. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/tests/test_build.py +50 -9
  8. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/tests/test_integrate.py +2 -0
  9. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/tests/test_procs.py +11 -4
  10. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/tests/test_sync.py +11 -8
  11. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/LICENSE +0 -0
  12. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/_toml.py +0 -0
  13. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/build_backend.py +0 -0
  14. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/integrate.py +0 -0
  15. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/news.py +0 -0
  16. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/procs.py +0 -0
  17. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/releases.py +0 -0
  18. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/store.py +0 -0
  19. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws/timelock.py +0 -0
  20. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws_cli.egg-info/SOURCES.txt +0 -0
  21. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws_cli.egg-info/dependency_links.txt +0 -0
  22. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws_cli.egg-info/entry_points.txt +0 -0
  23. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws_cli.egg-info/requires.txt +0 -0
  24. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/fastws_cli.egg-info/top_level.txt +0 -0
  25. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/pyproject.toml +0 -0
  26. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/setup.cfg +0 -0
  27. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/tests/test_build_backend.py +0 -0
  28. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/tests/test_news.py +0 -0
  29. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/tests/test_releases.py +0 -0
  30. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/tests/test_setup.py +0 -0
  31. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/tests/test_store.py +0 -0
  32. {fastws_cli-0.0.34 → fastws_cli-0.0.35}/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.34
3
+ Version: 0.0.35
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License-Expression: Apache-2.0
@@ -256,7 +256,11 @@ Use it when another process's unfinished edits stop the workspace compiling, suc
256
256
 
257
257
  ### Rust builds and tests from Python
258
258
 
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
+ `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. Outside `FASTWS_JOB`, a timed-out binary is reported as a failed suite with `timed_out` set. Inside a marked job, its timeout raises so the job owner can clean up descendants. 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.
260
+
261
+ Where a root has `.config/nextest.toml`, `cargo nextest run` runs the built tests instead: each test in its own process, every binary at once, with the config's slow-test timeouts and groups. `profiles` lists the nextest profiles to run one after another, `('default',)` unless given. `timeout` bounds each profile's run, and each binary's results come from nextest's JUnit report, which lists no ignored tests. `--profile NAME` passes to nextest as `--cargo-profile NAME`, and `test_args` follow `--`.
262
+
263
+ `root` can also be a list of roots, which build one after another. Roots without nextest then run their binaries in turn. Each profile then runs at the same time in every root whose config defines it: tests in different roots share a phase, and no phase starts before the previous one ends. Suite names start with the root's directory name, and with several profiles also with the profile, as in `app timed: tests/speed.rs`. A failure ends the run after its profile unless `--no-fail-fast` is given.
260
264
 
261
265
  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.
262
266
 
@@ -265,6 +269,7 @@ from fastws.cargo import run_tests
265
269
 
266
270
  native = run_tests('.', '--no-fail-fast')
267
271
  focused = run_tests('.', '--test', 'core', test_args=('test_name', '--nocapture'))
272
+ phased = run_tests(['app', 'lib'], profiles=('untimed', 'timed'))
268
273
  ```
269
274
 
270
275
  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.
@@ -314,7 +319,7 @@ Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level dir
314
319
 
315
320
  Existing globs, entries for missing directories, and entries for checkouts that are still not Python projects are retained. Use `exclude = [...]` under `[tool.fastws]` to specify exclusions the scan cannot infer, such as keeping a valid project out of the workspace. Adding members preserves hand-written `[tool.uv.sources]` entries, including path and Git sources.
316
321
 
317
- 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.
322
+ 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, that some local manifest requires from crates.io: by version alone, without `path`, `git` or `workspace`, in any dependency table or `[workspace.dependencies]`. 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.
318
323
 
319
324
  Sync also defaults `[term] quiet = true` in that file to suppress routine Cargo output and warnings. An explicit `quiet = false` is preserved.
320
325
 
@@ -228,7 +228,11 @@ Use it when another process's unfinished edits stop the workspace compiling, suc
228
228
 
229
229
  ### Rust builds and tests from Python
230
230
 
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
+ `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. Outside `FASTWS_JOB`, a timed-out binary is reported as a failed suite with `timed_out` set. Inside a marked job, its timeout raises so the job owner can clean up descendants. 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.
232
+
233
+ Where a root has `.config/nextest.toml`, `cargo nextest run` runs the built tests instead: each test in its own process, every binary at once, with the config's slow-test timeouts and groups. `profiles` lists the nextest profiles to run one after another, `('default',)` unless given. `timeout` bounds each profile's run, and each binary's results come from nextest's JUnit report, which lists no ignored tests. `--profile NAME` passes to nextest as `--cargo-profile NAME`, and `test_args` follow `--`.
234
+
235
+ `root` can also be a list of roots, which build one after another. Roots without nextest then run their binaries in turn. Each profile then runs at the same time in every root whose config defines it: tests in different roots share a phase, and no phase starts before the previous one ends. Suite names start with the root's directory name, and with several profiles also with the profile, as in `app timed: tests/speed.rs`. A failure ends the run after its profile unless `--no-fail-fast` is given.
232
236
 
233
237
  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.
234
238
 
@@ -237,6 +241,7 @@ from fastws.cargo import run_tests
237
241
 
238
242
  native = run_tests('.', '--no-fail-fast')
239
243
  focused = run_tests('.', '--test', 'core', test_args=('test_name', '--nocapture'))
244
+ phased = run_tests(['app', 'lib'], profiles=('untimed', 'timed'))
240
245
  ```
241
246
 
242
247
  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.
@@ -286,7 +291,7 @@ Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level dir
286
291
 
287
292
  Existing globs, entries for missing directories, and entries for checkouts that are still not Python projects are retained. Use `exclude = [...]` under `[tool.fastws]` to specify exclusions the scan cannot infer, such as keeping a valid project out of the workspace. Adding members preserves hand-written `[tool.uv.sources]` entries, including path and Git sources.
288
293
 
289
- 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.
294
+ 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, that some local manifest requires from crates.io: by version alone, without `path`, `git` or `workspace`, in any dependency table or `[workspace.dependencies]`. 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.
290
295
 
291
296
  Sync also defaults `[term] quiet = true` in that file to suppress routine Cargo output and warnings. An explicit `quiet = false` is preserved.
292
297
 
@@ -1,4 +1,5 @@
1
- __version__ = "0.0.34"
1
+ __version__ = "0.0.35"
2
+
2
3
 
3
4
 
4
5
 
@@ -1,11 +1,13 @@
1
1
  r"Build, test and install Cargo artifacts for mixed Rust/Python projects."
2
- import json, os, re, shutil, signal, subprocess, sys, sysconfig, time
2
+ import json, os, re, shutil, signal, subprocess, sys, sysconfig, tempfile, time
3
3
  from collections import Counter
4
+ from concurrent.futures import ThreadPoolExecutor
4
5
  from dataclasses import dataclass, field
5
6
  from pathlib import Path
6
7
  from typing import NamedTuple
8
+ from xml.etree import ElementTree
7
9
  from . import store
8
- from .procs import bounded
10
+ from .procs import bounded, JOB_ENV
9
11
  from ._toml import tomllib
10
12
 
11
13
 
@@ -143,32 +145,115 @@ def check(
143
145
  return Results(args[0], *_run(Path(root).expanduser(), args, env, diagnostics)[:3])
144
146
 
145
147
 
146
- def run_tests(
147
- root, # A Cargo package or workspace (expands `~`)
148
- *args, # Cargo build/selection options, such as `'--test', 'core'` or `'--profile', 'checked'`; `--no-fail-fast` runs every binary
149
- test_args=(), # Arguments passed directly to each test binary, such as `('name', '--nocapture')`
150
- env:dict=None, # Environment variables to set for the run, such as `{'BASEDPL_COSTS': '1'}`
151
- timeout:float=30 # Seconds that each native test binary may run after building; None: no limit
152
- )->Results:
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')]
160
- suites, code, start = [], 0, time.monotonic()
148
+ def _bounded(cmd, timeout, **kwargs):
149
+ "`bounded(cmd, timeout, merge=True, **kwargs)`, and whether it ran out of time. Outside `FASTWS_JOB`, a timeout gives a killed process's result; inside a marked job, it raises so the job's owner can clean up descendants."
150
+ try: return bounded(cmd, timeout, merge=True, **kwargs), False
151
+ except TimeoutError as e:
152
+ if os.environ.get(JOB_ENV): raise
153
+ return subprocess.CompletedProcess(cmd, -signal.SIGKILL, f'{e}\n'), True
154
+
155
+
156
+ def _suite_name(m):
157
+ "The `Suite` name of the test binary that Cargo artifact `m` describes: its target's source, prefixed `unittests ` unless it's an integration test."
158
+ name = Path(m['target']['src_path']).relative_to(Path(m['manifest_path']).parent).as_posix()
159
+ return name if 'test' in m['target']['kind'] else 'unittests ' + name
160
+
161
+
162
+ def _binary_id(m, packages):
163
+ "nextest's id for the test binary that Cargo artifact `m` describes, such as `app`, `app::more` or `app::bin/app`. `packages` maps package ids to names."
164
+ pkg, kind, name = packages[m['package_id']], m['target']['kind'][0], m['target']['name']
165
+ if kind == 'test': return f'{pkg}::{name}'
166
+ return f'{pkg}::{kind}/{name}' if kind in ('bin', 'example', 'bench') else pkg
167
+
168
+
169
+ def _junit(path, names):
170
+ "Each binary's `Suite` from nextest's JUnit report at `path`. `names` maps nextest's binary ids to suite names. Ignored tests don't appear."
171
+ suites = []
172
+ for ts in ElementTree.parse(path).getroot().iter('testsuite'):
173
+ tests, failures, late = {}, {}, False
174
+ for tc in ts.iter('testcase'):
175
+ t, bad = tc.get('name'), next(iter(tc.findall('failure') + tc.findall('error')), None)
176
+ tests[t] = 'ok' if bad is None else 'FAILED'
177
+ if bad is None: continue
178
+ failures[t] = '\n'.join(s for s in (tc.findtext('system-out', '').strip('\n'), tc.findtext('system-err', '').strip('\n') or bad.text or '') if s)
179
+ late |= 'timeout' in bad.get('type', '')
180
+ suites.append(Suite(names.get(ts.get('name'), ts.get('name')), '\n\n'.join(failures.values()), tests, failures, '', bool(failures), late))
181
+ return suites
182
+
183
+
184
+ def _nextest(root, args, test_args, env, timeout, artifacts, profile='default'):
185
+ "Run the built tests with `cargo nextest run --profile profile` under `timeout` as a whole: its exit status, its output, each binary's `Suite` and the run's seconds. Cargo's `--profile` in `args` becomes nextest's `--cargo-profile`."
186
+ packages = {p['id']: p['name'] for p in json.loads(cargo(root, 'metadata', '--format-version', '1', '--no-deps'))['packages']}
187
+ names = {_binary_id(m, packages): _suite_name(m) for m in artifacts}
188
+ args = [re.sub(r'^--profile(?==|$)', '--cargo-profile', a) for a in map(str, args)]
189
+ with tempfile.TemporaryDirectory() as d:
190
+ report, tool = Path(d)/'junit.xml', Path(d)/'fastws.toml'
191
+ tool.write_text(f'[profile.{profile}.junit]\npath = {json.dumps(str(report))}\n')
192
+ cmd = ['cargo', 'nextest', 'run', '--tool-config-file', f'fastws:{tool}', '--profile', profile, '--show-progress=none', '--color=never', *args, *(('--', *test_args) if test_args else ())]
193
+ start = time.monotonic()
194
+ p, late = _bounded(cmd, timeout, cwd=root, env=os.environ | env)
195
+ suites = _junit(report, names) if report.exists() else [Suite('nextest', p.stdout, {}, {}, '', True, late)]
196
+ return p.returncode, p.stdout, suites, time.monotonic() - start
197
+
198
+
199
+ def _libtest(root, args, test_args, env, timeout, artifacts):
200
+ "Run each built test binary in turn from its package directory under `timeout`: the first failing exit status, the output and each binary's `Suite`. Without `--no-fail-fast` in `args`, the first failing binary ends the run."
201
+ code, text, suites = 0, '', []
161
202
  for m in artifacts:
162
- package = Path(m['manifest_path']).parent
163
- name = Path(m['target']['src_path']).relative_to(package).as_posix()
164
- if 'test' not in m['target']['kind']: name = 'unittests ' + name
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
203
+ package, name = Path(m['manifest_path']).parent, _suite_name(m)
204
+ p, late = _bounded([m['executable'], *test_args], timeout, cwd=package, env=os.environ | env | {'CARGO_MANIFEST_DIR': str(package)})
167
205
  suites.append(Suite.read(name, p.stdout.splitlines(), p.returncode, late))
168
206
  text += f' Running {name} ({m["executable"]})\n' + p.stdout
169
207
  if p.returncode:
170
- code = p.returncode
208
+ code = code or p.returncode
171
209
  if '--no-fail-fast' not in args: break
210
+ return code, text, suites
211
+
212
+
213
+ def _nextest_profiles(root):
214
+ "The nextest profiles that `root`'s `.config/nextest.toml` defines, `default` included, or None without that file."
215
+ config = root/'.config/nextest.toml'
216
+ return {'default', *tomllib.loads(config.read_text()).get('profile', {})} if config.exists() else None
217
+
218
+
219
+ def run_tests(
220
+ root, # A Cargo package or workspace (expands `~`), or a list of them
221
+ *args, # Cargo build/selection options, such as `'--test', 'core'` or `'--profile', 'checked'`; `--no-fail-fast` runs every binary
222
+ test_args=(), # Arguments passed directly to each test binary, such as `('name', '--nocapture')`; nextest gets them after `--`
223
+ env:dict=None, # Environment variables to set for the run, such as `{'BASEDPL_COSTS': '1'}`
224
+ timeout:float=30, # Seconds that each native test binary, or each nextest run of one profile in one root, may take after building; None: no limit
225
+ profiles:tuple=('default',) # The nextest profiles to run in turn, each at once in every root whose `.config/nextest.toml` defines it
226
+ )->Results:
227
+ "Build native tests once per root, with no time limit, then run Cargo's reported executables directly from their package directories. Where a root has `.config/nextest.toml`, `cargo nextest run` runs them instead, each test in its own process, once for each of `profiles` that its config defines, and each binary's results come from nextest's JUnit report. Several roots build in turn. Roots without nextest then run in turn, and each profile then runs in every root that defines it at once. Without `--no-fail-fast`, a failure ends the run after its binary, or after its profile. With several roots, each suite's name starts with its root's directory name, and with several profiles, with the profile. Source edits after building cannot trigger another build; results describe the compiled build, not the latest working tree. `timeout` bounds each binary, or each root's run of a profile, excluding compilation. Outside `FASTWS_JOB`, a run that runs out of time is reported as a failed suite with `timed_out` set. Inside a marked job, the timeout raises so its owner can clean up descendants. An omitted Cargo profile uses each root's 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."
228
+ roots = [Path(r).expanduser().resolve() for r in (root if isinstance(root, (list, tuple)) else [root])]
229
+ env = {'CARGO_TERM_QUIET': 'false', 'CARGO_TERM_COLOR': 'never'} | (env or {})
230
+ has_profile = any(a in ('-r', '--release', '--profile') or isinstance(a, str) and a.startswith('--profile=') for a in args)
231
+ text, found, built = '', [], []
232
+ for r in roots:
233
+ rargs = args if has_profile else (*args, '--profile', _dev_profile(r))
234
+ code, out, diags, rest = _run(r, ('test', '--no-run', *rargs), env)
235
+ text, found = text + out, found + diags
236
+ if code: return Results('test', code, text, found)
237
+ artifacts = [m for m in rest if m['reason'] == 'compiler-artifact' and m['profile']['test'] and m.get('executable')]
238
+ built.append((r, rargs, artifacts, _nextest_profiles(r)))
239
+ def named(r, p, suites):
240
+ "`suites`, freshly read, renamed for root `r` and profile `p` where several roots or profiles ran"
241
+ t = ' '.join(x for x in (r.name if len(roots) > 1 else '', p if p and len(profiles) > 1 else '') if x)
242
+ for s in suites: s.name = f'{t}: {s.name}' if t else s.name
243
+ return t, suites
244
+ fail_fast, code, suites, start = '--no-fail-fast' not in args, 0, [], time.monotonic()
245
+ for r, rargs, artifacts, defined in built:
246
+ if defined is not None: continue
247
+ c, out, ss = _libtest(r, rargs, test_args, env, timeout, artifacts)
248
+ text, suites, code = text + out, suites + named(r, None, ss)[1], code or c
249
+ if code and fail_fast: return Results('test', code, text, found, suites, time.monotonic() - start)
250
+ for p in profiles:
251
+ jobs = [(r, rargs, artifacts) for r, rargs, artifacts, defined in built if p in (defined or ())]
252
+ with ThreadPoolExecutor(max(len(jobs), 1)) as ex: runs = list(ex.map(lambda j: _nextest(j[0], j[1], test_args, env, timeout, j[2], p), jobs))
253
+ for (r, _, _), (c, out, ss, _) in zip(jobs, runs):
254
+ t, ss = named(r, p, ss)
255
+ text, suites, code = text + (f' Nextest profile {p} in {r}\n' if t else '') + out, suites + ss, code or c
256
+ if code and fail_fast: break
172
257
  return Results('test', code, text, found, suites, time.monotonic() - start)
173
258
 
174
259
 
@@ -684,20 +684,31 @@ def _cargo_patches(root: Path):
684
684
  patches[(_repo_key(url), name)] = path if path.is_absolute() else (config.parent/path).resolve()
685
685
  return patches, config
686
686
 
687
- def _crate_pkgs(d: Path):
688
- "(`[package]` table, dir) for the crate at `d` and its cargo workspace members"
687
+ def _crate_manifests(d: Path):
688
+ "(parsed `Cargo.toml`, dir) for the crate or workspace at `d` and each of its cargo workspace members"
689
689
  try: data = tomllib.loads((d/"Cargo.toml").read_text())
690
690
  except tomllib.TOMLDecodeError: return
691
- if "name" in (pkg := data.get("package", {})): yield pkg, d
691
+ yield data, d
692
692
  for m in _project_dirs(d, "Cargo.toml", data.get("workspace", {}).get("members", [])):
693
693
  try: sub = tomllib.loads((m/"Cargo.toml").read_text())
694
694
  except tomllib.TOMLDecodeError: continue
695
- if "name" in (pkg := sub.get("package", {})): yield pkg, m
695
+ yield sub, m
696
+
697
+ def _crate_pkgs(d: Path):
698
+ "(`[package]` table, dir) for the crate at `d` and its cargo workspace members"
699
+ for data, m in _crate_manifests(d):
700
+ if "name" in (pkg := data.get("package", {})): yield pkg, m
696
701
 
697
702
  def _local_crates(root: Path) -> list[tuple[dict, Path]]:
698
703
  "(`[package]` table, dir) for every crate under `root`, nested cargo workspace members included"
699
704
  return [p for crate in _crate_dirs(root) for p in _crate_pkgs(crate)]
700
705
 
706
+ def _registry_requirements(root: Path) -> set[str]:
707
+ "Packages that some manifest under `root` requires from crates.io: a version with no `path`, `git`, `registry` or `workspace` key"
708
+ return {spec.get("package", name) if isinstance(spec, dict) else name
709
+ for crate in _crate_dirs(root) for data, _ in _crate_manifests(crate) for deps in _cargo_dep_tables(data, every=True)
710
+ for name, spec in deps.items() if isinstance(spec, str) or isinstance(spec, dict) and not {"path", "git", "registry", "workspace"} & spec.keys()}
711
+
701
712
  def _git_dep_tables(root: Path, crates: dict[str, Path]) -> dict[str, dict[str, Path]]:
702
713
  "Git URL -> {package: local dir} for members' git deps that name a local crate"
703
714
  res = {}
@@ -741,15 +752,16 @@ def cargo_setup(root):
741
752
  def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
742
753
  """Regenerate workspace Cargo patches, default to quiet output, and return (added, removed).
743
754
 
744
- Every local crate gets a `[patch.crates-io]` entry, except one marked `publish = false`, which never comes
745
- from crates.io. A member's git dep on a local crate gets an entry under that URL. Builds anywhere under `root`
746
- then use the checkouts: the cargo analog of editable installs. Only entries whose path is inside `root` are
747
- managed; entries pointing elsewhere (and any other config sections) are kept as-is, and a kept entry wins
748
- over a generated one of the same name."""
755
+ A local crate gets a `[patch.crates-io]` entry only where some local manifest requires it from crates.io
756
+ (`_registry_requirements`); Cargo uses no other patch. A member's git dep on a local crate gets an entry under
757
+ that URL. Builds anywhere under `root` then use the checkouts: the cargo analog of editable installs. Only
758
+ entries whose path is inside `root` are managed; entries pointing elsewhere (and any other config sections) are
759
+ kept as-is, and a kept entry wins over a generated one of the same name."""
749
760
  root = root.resolve()
750
761
  pkgs = _local_crates(root)
751
762
  crates = {pkg["name"]: d for pkg, d in pkgs}
752
- desired = {"crates-io": {pkg["name"]: str(d) for pkg, d in pkgs if pkg.get("publish") is not False}}
763
+ required = _registry_requirements(root)
764
+ desired = {"crates-io": {name: str(d) for name, d in crates.items() if name in required}}
753
765
  for url, entries in _git_dep_tables(root, crates).items(): desired[url] = {name: str(d) for name, d in entries.items()}
754
766
  config = root/".cargo"/"config.toml"
755
767
  original = content = config.read_text() if config.exists() else ""
@@ -816,10 +828,13 @@ def _sync_cargo_wrapper(root: Path):
816
828
  config.parent.mkdir(exist_ok=True)
817
829
  config.write_text(new)
818
830
 
819
- def _cargo_dep_tables(data):
820
- for name in "dependencies","build-dependencies": yield data.get(name, {})
831
+ def _cargo_dep_tables(data, every=False):
832
+ "A manifest's dependency and build-dependency tables, top-level and per target; with `every`, its dev-dependency tables and `[workspace.dependencies]` too"
833
+ names = ("dependencies", "build-dependencies", "dev-dependencies")[:3 if every else 2]
834
+ for name in names: yield data.get(name, {})
821
835
  for target in data.get("target", {}).values():
822
- for name in "dependencies","build-dependencies": yield target.get(name, {})
836
+ for name in names: yield target.get(name, {})
837
+ if every: yield data.get("workspace", {}).get("dependencies", {})
823
838
 
824
839
  def _patched_cargo_deps(crate: Path, patches):
825
840
  data = tomllib.loads((crate/"Cargo.toml").read_text())
@@ -942,8 +957,10 @@ def _npm_install(root: Path):
942
957
  except json.JSONDecodeError:
943
958
  print(res.stdout, file=sys.stderr, end="")
944
959
  raise
945
- if data.get("audit", {}).get("vulnerabilities", {}).get("total") or data.get("unreviewedScripts"):
946
- print(f"npm install findings ({root}):\n{res.stdout}", file=sys.stderr, end="")
960
+ findings = {}
961
+ if data.get("audit", {}).get("vulnerabilities", {}).get("total"): findings["audit"] = data["audit"]
962
+ if data.get("unreviewedScripts"): findings["unreviewedScripts"] = data["unreviewedScripts"]
963
+ if findings: print(f"npm install findings ({root}):\n{json.dumps(findings, indent=2)}", file=sys.stderr)
947
964
 
948
965
  def _sync_js(root: Path, members: list[Path]) -> list[Path]:
949
966
  "Install the JS workspace, run every native member's build script, and return those members. Cargo handles incremental compilation."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.34
3
+ Version: 0.0.35
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License-Expression: Apache-2.0
@@ -256,7 +256,11 @@ Use it when another process's unfinished edits stop the workspace compiling, suc
256
256
 
257
257
  ### Rust builds and tests from Python
258
258
 
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
+ `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. Outside `FASTWS_JOB`, a timed-out binary is reported as a failed suite with `timed_out` set. Inside a marked job, its timeout raises so the job owner can clean up descendants. 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.
260
+
261
+ Where a root has `.config/nextest.toml`, `cargo nextest run` runs the built tests instead: each test in its own process, every binary at once, with the config's slow-test timeouts and groups. `profiles` lists the nextest profiles to run one after another, `('default',)` unless given. `timeout` bounds each profile's run, and each binary's results come from nextest's JUnit report, which lists no ignored tests. `--profile NAME` passes to nextest as `--cargo-profile NAME`, and `test_args` follow `--`.
262
+
263
+ `root` can also be a list of roots, which build one after another. Roots without nextest then run their binaries in turn. Each profile then runs at the same time in every root whose config defines it: tests in different roots share a phase, and no phase starts before the previous one ends. Suite names start with the root's directory name, and with several profiles also with the profile, as in `app timed: tests/speed.rs`. A failure ends the run after its profile unless `--no-fail-fast` is given.
260
264
 
261
265
  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.
262
266
 
@@ -265,6 +269,7 @@ from fastws.cargo import run_tests
265
269
 
266
270
  native = run_tests('.', '--no-fail-fast')
267
271
  focused = run_tests('.', '--test', 'core', test_args=('test_name', '--nocapture'))
272
+ phased = run_tests(['app', 'lib'], profiles=('untimed', 'timed'))
268
273
  ```
269
274
 
270
275
  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.
@@ -314,7 +319,7 @@ Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level dir
314
319
 
315
320
  Existing globs, entries for missing directories, and entries for checkouts that are still not Python projects are retained. Use `exclude = [...]` under `[tool.fastws]` to specify exclusions the scan cannot infer, such as keeping a valid project out of the workspace. Adding members preserves hand-written `[tool.uv.sources]` entries, including path and Git sources.
316
321
 
317
- 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.
322
+ 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, that some local manifest requires from crates.io: by version alone, without `path`, `git` or `workspace`, in any dependency table or `[workspace.dependencies]`. 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.
318
323
 
319
324
  Sync also defaults `[term] quiet = true` in that file to suppress routine Cargo output and warnings. An explicit `quiet = false` is preserved.
320
325
 
@@ -1,4 +1,4 @@
1
- import subprocess, pytest, fastws.core as core
1
+ import shutil, subprocess, pytest, fastws.core as core
2
2
  from fastws.cargo import build, check, run_tests, Suite
3
3
 
4
4
 
@@ -46,10 +46,10 @@ def test_build_diagnostics(tmp_path, capfd, body, diagnostic):
46
46
  assert checked.ok == (diagnostic != 'error') and [(d.path, d.level) for d in checked.diagnostics] == ([('dep/src/lib.rs', diagnostic)] if diagnostic else [])
47
47
 
48
48
 
49
- def test_run_tests(tmp_path):
50
- (tmp_path/'Cargo.toml').write_text('[package]\nname = "app"\nversion = "0.1.0"\nedition = "2021"\n')
51
- (tmp_path/'src').mkdir()
52
- (tmp_path/'src/lib.rs').write_text('''pub fn unused() { let x = 1; }
49
+ def _test_crate(root):
50
+ (root/'Cargo.toml').write_text('[package]\nname = "app"\nversion = "0.1.0"\nedition = "2021"\n')
51
+ (root/'src').mkdir()
52
+ (root/'src/lib.rs').write_text('''pub fn unused() { let x = 1; }
53
53
  pub fn two() -> i32 { 2 }
54
54
  #[cfg(test)] mod t {
55
55
  #[test] fn passes() {}
@@ -60,10 +60,14 @@ pub fn two() -> i32 { 2 }
60
60
  }
61
61
  }
62
62
  ''')
63
- (tmp_path/'tests').mkdir()
64
- (tmp_path/'tests/more.rs').write_text('#[test] fn other() { assert_eq!(app::two(), 2); }\n')
65
- (tmp_path/'.cargo').mkdir()
66
- (tmp_path/'.cargo/config.toml').write_text('[term]\nquiet = true\n')
63
+ (root/'tests').mkdir()
64
+ (root/'tests/more.rs').write_text('#[test] fn other() { assert_eq!(app::two(), 2); }\n')
65
+ (root/'.cargo').mkdir()
66
+ (root/'.cargo/config.toml').write_text('[term]\nquiet = true\n')
67
+
68
+
69
+ def test_run_tests(tmp_path):
70
+ _test_crate(tmp_path)
67
71
  r = run_tests(tmp_path, '--no-fail-fast')
68
72
  assert not r.ok
69
73
  assert [(d.path, d.line, d.level, d.code) for d in r.diagnostics] == [('src/lib.rs', 1, 'warning', 'unused_variables')]
@@ -85,6 +89,43 @@ pub fn two() -> i32 { 2 }
85
89
  assert run_tests(tmp_path, '--test', 'more', *args).ok
86
90
 
87
91
 
92
+ @pytest.mark.skipif(not shutil.which('cargo-nextest'), reason='needs cargo-nextest')
93
+ def test_run_tests_nextest(tmp_path):
94
+ _test_crate(tmp_path)
95
+ (tmp_path/'.config').mkdir()
96
+ (tmp_path/'.config/nextest.toml').write_text('[profile.default]\nfail-fast = false\n')
97
+ r = run_tests(tmp_path)
98
+ assert not r.ok and [(d.path, d.level) for d in r.diagnostics] == [('src/lib.rs', 'warning')]
99
+ suites = {s.name: s for s in r.suites}
100
+ unit, more = suites['unittests src/lib.rs'], suites['tests/more.rs']
101
+ assert unit.tests == {'t::passes': 'ok', 't::fails': 'FAILED'} and unit.failed and 'broken' in unit.failures['t::fails']
102
+ assert more.tests == {'other': 'ok'} and not more.failed
103
+ assert run_tests(tmp_path, '--test', 'more', '--profile', 'test').ok
104
+ slow = run_tests(tmp_path, '--lib', test_args=('skipped', '--ignored'), timeout=5)
105
+ s, = slow.suites
106
+ assert not slow.ok and s.timed_out and str(s) == 'nextest: ran out of time'
107
+
108
+
109
+ @pytest.mark.skipif(not shutil.which('cargo-nextest'), reason='needs cargo-nextest')
110
+ def test_run_tests_profiles(tmp_path):
111
+ a, b = tmp_path/'a', tmp_path/'b'
112
+ for d in a, b: (d/'.config').mkdir(parents=True)
113
+ _test_crate(a)
114
+ (a/'.config/nextest.toml').write_text('[profile.one]\ndefault-filter = "test(passes) | test(other)"\n[profile.two]\ndefault-filter = "test(fails)"\n')
115
+ (b/'Cargo.toml').write_text('[package]\nname = "b"\nversion = "0.1.0"\nedition = "2021"\n')
116
+ (b/'src').mkdir()
117
+ (b/'src/lib.rs').write_text('')
118
+ (b/'tests').mkdir()
119
+ (b/'tests/fine.rs').write_text('#[test] fn fine() {}\n')
120
+ (b/'.config/nextest.toml').write_text('[profile.one]\n')
121
+ r = run_tests([a, b], profiles=('one', 'two'))
122
+ tests = {s.name: s.tests for s in r.suites if s.tests}
123
+ assert not r.ok and tests == {'a one: unittests src/lib.rs': {'t::passes': 'ok'}, 'a one: tests/more.rs': {'other': 'ok'},
124
+ 'b one: tests/fine.rs': {'fine': 'ok'}, 'a two: unittests src/lib.rs': {'t::fails': 'FAILED'}}
125
+ first = run_tests([a, b], profiles=('two', 'one'))
126
+ assert not first.ok and {s.name for s in first.suites if s.tests} == {'a two: unittests src/lib.rs'}
127
+
128
+
88
129
  def test_captured_test_output():
89
130
  for failed in (False, True):
90
131
  lines = ['test passes ... ok', 'successes:', '---- passes stdout ----', 'printed', 'successes:', 'passes']
@@ -27,6 +27,8 @@ def test_config_points_only_the_roots_crates_at_the_root(tmp_path):
27
27
  f'[build]\nrustc-wrapper = "/lock"\n\n[patch.crates-io]\na = {{ path = "{workspace}/a" }}\nb = {{ path = "{workspace}/b" }}\n')
28
28
  (root/"a").mkdir(parents=True)
29
29
  (root/"a"/"Cargo.toml").write_text('[package]\nname = "a"\nversion = "0.1.0"\n')
30
+ (root/"c").mkdir()
31
+ (root/"c"/"Cargo.toml").write_text('[package]\nname = "c"\nversion = "0.1.0"\n\n[dependencies]\na = "0.1"\n')
30
32
  _config(root, workspace)
31
33
  config = tomllib.loads((root/".cargo"/"config.toml").read_text())
32
34
  assert config["build"]["rustc-wrapper"] == "/lock"
@@ -1,4 +1,4 @@
1
- import fcntl, os, subprocess, sys, pytest
1
+ import fcntl, os, subprocess, sys, threading, pytest
2
2
  from fastws.procs import JOB_ENV, kill_group
3
3
 
4
4
 
@@ -25,10 +25,17 @@ time.sleep(60)
25
25
  '''
26
26
  with subprocess.Popen([sys.executable, '-c', job], start_new_session=True, env=os.environ | {JOB_ENV: '1'},
27
27
  stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) as p:
28
- try:
29
- with pytest.raises(subprocess.TimeoutExpired): p.communicate(timeout=.8)
28
+ lines, survived = [], threading.Event()
29
+ def read():
30
+ for line in p.stdout:
31
+ lines.append(line)
32
+ if line.startswith('job survived inner timeout'): survived.set()
33
+ reader = threading.Thread(target=read, daemon=True)
34
+ reader.start()
35
+ try: assert survived.wait(10), ''.join(lines)
30
36
  finally: kill_group(p)
31
- output, _ = p.communicate(timeout=2)
37
+ reader.join(2)
38
+ output = ''.join(lines)
32
39
  group = output.split('job group ')[1].split()[0]
33
40
  assert f'child group {group} 1' in output and f'descendant group {group}' in output
34
41
  assert 'job survived inner timeout' in output
@@ -171,24 +171,27 @@ def test_sync_cargo_patches_generates_and_preserves(tmp_path):
171
171
  config = tmp_path/'.cargo'/'config.toml'
172
172
  config.write_text('[term]\nquiet = true\n\n[patch.crates-io]\n'
173
173
  f'foreign = {{ path = "/elsewhere/foreign" }}\ngone = {{ path = "{tmp_path}/gone" }}\n')
174
- crate1 = tmp_path/'crate1'
175
- crate1.mkdir()
176
- (crate1/'Cargo.toml').write_text('[package]\nname = "crate1"\nversion = "0.1.0"\n\n[dependencies]\nfamily = { git = "https://example.com/family" }\n')
174
+ crate1, helper = tmp_path/'crate1', tmp_path/'helper'
175
+ crate1.mkdir(); helper.mkdir()
176
+ (crate1/'Cargo.toml').write_text('[package]\nname = "crate1"\nversion = "0.1.0"\n\n[dependencies]\nfamily = { git = "https://example.com/family" }\n'
177
+ '\n[dev-dependencies]\nfamily-sub = { version = "0.1" }\n')
178
+ (helper/'Cargo.toml').write_text('[package]\nname = "helper"\nversion = "0.1.0"\n')
177
179
  family = tmp_path/'family'
178
180
  (family/'sub').mkdir(parents=True)
179
- (family/'Cargo.toml').write_text('[package]\nname = "family"\nversion = "0.1.0"\npublish = false\n\n[workspace]\nmembers = ["sub"]\n')
181
+ (family/'Cargo.toml').write_text('[package]\nname = "family"\nversion = "0.1.0"\npublish = false\n\n[workspace]\nmembers = ["sub"]\n'
182
+ '\n[workspace.dependencies]\nhelper = "0.1"\ncrate1 = { path = "../crate1", version = "0.1" }\n')
180
183
  (family/'sub'/'Cargo.toml').write_text('[package]\nname = "family-sub"\nversion = "0.1.0"\n')
181
184
 
182
185
  added, removed = core._sync_cargo_patches(tmp_path)
183
186
  data = core.tomllib.loads(config.read_text())
184
187
  cio = data['patch']['crates-io']
185
- assert set(cio) == {'foreign', 'crate1', 'family-sub'} # `publish = false` crates never come from crates.io
188
+ assert set(cio) == {'foreign', 'family-sub', 'helper'} # only crates that a local manifest requires from crates.io; `crate1` is required by path
186
189
  assert cio['foreign']['path'] == '/elsewhere/foreign' # entries pointing outside the root are kept as-is
187
- assert cio['crate1']['path'] == str(crate1)
188
- assert cio['family-sub']['path'] == str(family/'sub')
190
+ assert cio['family-sub']['path'] == str(family/'sub') # required as a dev-dependency
191
+ assert cio['helper']['path'] == str(helper) # required in `[workspace.dependencies]`
189
192
  assert data['patch']['https://example.com/family']['family']['path'] == str(family) # git deps on local crates get their URL table, published or not
190
193
  assert data['term']['quiet'] is True # other sections untouched
191
- assert set(added) == {'crate1', 'family', 'family-sub'} and removed == ['gone']
194
+ assert set(added) == {'family', 'family-sub', 'helper'} and removed == ['gone']
192
195
 
193
196
 
194
197
  def test_sync_ws_package_json_preserves_local_and_merges_shared(tmp_path):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes