fastws-cli 0.0.25__tar.gz → 0.0.27__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastws_cli-0.0.25/fastws_cli.egg-info → fastws_cli-0.0.27}/PKG-INFO +14 -1
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/README.md +13 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/fastws/__init__.py +3 -1
- fastws_cli-0.0.27/fastws/cargo.py +261 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/fastws/core.py +94 -55
- {fastws_cli-0.0.25 → fastws_cli-0.0.27/fastws_cli.egg-info}/PKG-INFO +14 -1
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/fastws_cli.egg-info/entry_points.txt +1 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/pyproject.toml +1 -0
- fastws_cli-0.0.27/tests/test_build.py +75 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/tests/test_sync.py +11 -0
- fastws_cli-0.0.25/fastws/cargo.py +0 -81
- fastws_cli-0.0.25/tests/test_build.py +0 -25
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/LICENSE +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/fastws/build_backend.py +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/fastws/releases.py +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/fastws_cli.egg-info/SOURCES.txt +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/fastws_cli.egg-info/dependency_links.txt +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/fastws_cli.egg-info/requires.txt +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/fastws_cli.egg-info/top_level.txt +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/setup.cfg +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/tests/test_build_backend.py +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/tests/test_releases.py +0 -0
- {fastws_cli-0.0.25 → fastws_cli-0.0.27}/tests/test_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.27
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -217,6 +217,8 @@ Set `native-binaries = true` under `[tool.fastws]` to build and install the bina
|
|
|
217
217
|
|
|
218
218
|
`cargo develop` also copies the `data/` subdirectory of `[tool.maturin].data` into the active venv, as a wheel install does. For example, `data/share/jupyter/kernels/<name>/kernel.json` is installed as `share/jupyter/kernels/<name>/kernel.json`, where Jupyter finds the kernel. `cargo develop` installs the binaries it builds, not the staged copies in `scripts/`.
|
|
219
219
|
|
|
220
|
+
Each Cargo build that fastws runs holds a shared `flock` on `/tmp/aai-ws-timing.lock`. A speed test that takes that lock exclusively then never times code while a build runs. A build waits up to 5 minutes for such a timing to finish, then goes ahead without the lock. Python code takes the same lock with `fastws.cargo.timing_lock`: `with timing_lock(exclusive=True):` around a timing, and `with timing_lock():` around a test run. An exclusive hold that has waited 30 seconds prints the processes that have the lock's file open. It raises `TimeoutError` after 5 minutes.
|
|
221
|
+
|
|
220
222
|
Use `fastws.build_backend` as the build backend of a mixed project, and include `fastws-cli>=0.0.25` alongside maturin in `[build-system].requires`. Its editable builds install only Python metadata and the source path, and compile no Rust. `ws-sync` then runs `cargo develop` to build and install the extension, the binaries and the wheel data files. After standalone `uv sync`, run `cargo develop` yourself.
|
|
221
223
|
|
|
222
224
|
For distribution wheels containing the binaries, also set `[tool.maturin].data` to a generated wheel-data directory. With `native-binaries = true`, wheel builds through the backend replace its `scripts/` subdirectory with the current Python-free binaries, then delegate packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries. Without `native-binaries`, wheel builds go straight to maturin, and `[tool.maturin].data` is optional.
|
|
@@ -228,6 +230,17 @@ cargo stage --profile dist
|
|
|
228
230
|
maturin build --profile dist
|
|
229
231
|
```
|
|
230
232
|
|
|
233
|
+
### `cargo wait`
|
|
234
|
+
|
|
235
|
+
Wait until a Cargo workspace compiles:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
cargo wait
|
|
239
|
+
cargo wait --path /path/to/workspace
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Use it when another process's unfinished edits stop the workspace compiling, such as a second agent working in the same tree. Each check runs `cargo check --all-targets` while holding the timing lock shared, as builds do. After a failed check, `cargo wait` prints Cargo's first error line. It checks again once a source file of a local package changes and then stays unchanged for 2 seconds. Local packages are the workspace's members and its path dependencies, such as a sibling crate that `ws-sync` patches in. When a check passes, it prints `compiles` and exits. After `--patience` seconds, 1800 by default, it prints the last check's errors and exits with status 1.
|
|
243
|
+
|
|
231
244
|
### `ws-sync`
|
|
232
245
|
|
|
233
246
|
Syncing proceeds in this order:
|
|
@@ -191,6 +191,8 @@ Set `native-binaries = true` under `[tool.fastws]` to build and install the bina
|
|
|
191
191
|
|
|
192
192
|
`cargo develop` also copies the `data/` subdirectory of `[tool.maturin].data` into the active venv, as a wheel install does. For example, `data/share/jupyter/kernels/<name>/kernel.json` is installed as `share/jupyter/kernels/<name>/kernel.json`, where Jupyter finds the kernel. `cargo develop` installs the binaries it builds, not the staged copies in `scripts/`.
|
|
193
193
|
|
|
194
|
+
Each Cargo build that fastws runs holds a shared `flock` on `/tmp/aai-ws-timing.lock`. A speed test that takes that lock exclusively then never times code while a build runs. A build waits up to 5 minutes for such a timing to finish, then goes ahead without the lock. Python code takes the same lock with `fastws.cargo.timing_lock`: `with timing_lock(exclusive=True):` around a timing, and `with timing_lock():` around a test run. An exclusive hold that has waited 30 seconds prints the processes that have the lock's file open. It raises `TimeoutError` after 5 minutes.
|
|
195
|
+
|
|
194
196
|
Use `fastws.build_backend` as the build backend of a mixed project, and include `fastws-cli>=0.0.25` alongside maturin in `[build-system].requires`. Its editable builds install only Python metadata and the source path, and compile no Rust. `ws-sync` then runs `cargo develop` to build and install the extension, the binaries and the wheel data files. After standalone `uv sync`, run `cargo develop` yourself.
|
|
195
197
|
|
|
196
198
|
For distribution wheels containing the binaries, also set `[tool.maturin].data` to a generated wheel-data directory. With `native-binaries = true`, wheel builds through the backend replace its `scripts/` subdirectory with the current Python-free binaries, then delegate packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries. Without `native-binaries`, wheel builds go straight to maturin, and `[tool.maturin].data` is optional.
|
|
@@ -202,6 +204,17 @@ cargo stage --profile dist
|
|
|
202
204
|
maturin build --profile dist
|
|
203
205
|
```
|
|
204
206
|
|
|
207
|
+
### `cargo wait`
|
|
208
|
+
|
|
209
|
+
Wait until a Cargo workspace compiles:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
cargo wait
|
|
213
|
+
cargo wait --path /path/to/workspace
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Use it when another process's unfinished edits stop the workspace compiling, such as a second agent working in the same tree. Each check runs `cargo check --all-targets` while holding the timing lock shared, as builds do. After a failed check, `cargo wait` prints Cargo's first error line. It checks again once a source file of a local package changes and then stays unchanged for 2 seconds. Local packages are the workspace's members and its path dependencies, such as a sibling crate that `ws-sync` patches in. When a check passes, it prints `compiles` and exits. After `--patience` seconds, 1800 by default, it prints the last check's errors and exits with status 1.
|
|
217
|
+
|
|
205
218
|
### `ws-sync`
|
|
206
219
|
|
|
207
220
|
Syncing proceeds in this order:
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
r"Build, test and install Cargo artifacts for mixed Rust/Python projects."
|
|
2
|
+
import fcntl, json, os, re, shutil, subprocess, sys, sysconfig, time
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
try: import tomllib
|
|
10
|
+
except ModuleNotFoundError: import tomli as tomllib
|
|
11
|
+
|
|
12
|
+
# The file whose lock keeps timings and builds apart on this machine. Timings take it exclusively. Builds and test runs take it shared.
|
|
13
|
+
_TIMING_LOCK = '/tmp/aai-ws-timing.lock'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(ValueError):
|
|
17
|
+
"A project's `pyproject.toml` lacks a setting that a command needs."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def settings(root):
|
|
21
|
+
"Read the project's tool configuration."
|
|
22
|
+
return tomllib.loads((Path(root).expanduser()/'pyproject.toml').read_text()).get('tool', {})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cargo(root, *args):
|
|
26
|
+
"Run Cargo in the project and return its standard output."
|
|
27
|
+
return subprocess.run(['cargo', *args], cwd=Path(root).expanduser(), stdout=subprocess.PIPE, text=True, check=True).stdout
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def members(root):
|
|
31
|
+
"The manifests of the workspace's member packages."
|
|
32
|
+
return {Path(p['manifest_path']) for p in json.loads(cargo(root, 'metadata', '--no-deps', '--format-version', '1'))['packages']}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _lock_users():
|
|
36
|
+
"The other processes that have the timing lock's file open, as `pid command` strings from `lsof`. On macOS, where `lsof` doesn't show `flock` holds, the list also includes any process waiting for the lock."
|
|
37
|
+
users, pid = [], ''
|
|
38
|
+
for line in subprocess.run(['lsof', '+c', '0', '-F', 'pc', _TIMING_LOCK], capture_output=True, text=True).stdout.splitlines():
|
|
39
|
+
if line[:1] == 'p': pid = line[1:]
|
|
40
|
+
elif line[:1] == 'c' and pid != str(os.getpid()): users.append(f'{pid} {line[1:]}')
|
|
41
|
+
return users
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def timing_lock(
|
|
46
|
+
exclusive=False, # Take the lock exclusively, as a timing does; False: take it shared, as a build or a test run does
|
|
47
|
+
):
|
|
48
|
+
"Hold the lock that keeps this machine's timings apart from its builds and test runs. A hold waits up to 5 minutes for the lock. After that, a shared hold goes ahead without it. An exclusive hold raises `TimeoutError`. An exclusive hold that has waited 30 seconds prints, once, the other processes that have the lock's file open."
|
|
49
|
+
with open(_TIMING_LOCK, 'a') as f:
|
|
50
|
+
start, waiting, reported = time.monotonic(), False, False
|
|
51
|
+
while True:
|
|
52
|
+
try:
|
|
53
|
+
fcntl.flock(f, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
|
|
54
|
+
break
|
|
55
|
+
except BlockingIOError: pass
|
|
56
|
+
waited = time.monotonic() - start
|
|
57
|
+
if waited > 300:
|
|
58
|
+
if exclusive: raise TimeoutError(f'a build or another timing has held {_TIMING_LOCK} for 5 minutes')
|
|
59
|
+
print(f'going ahead although a timing still holds {_TIMING_LOCK}', file=sys.stderr)
|
|
60
|
+
break
|
|
61
|
+
if not waiting: print(f'waiting for {"a build or another timing" if exclusive else "a timing"} to release {_TIMING_LOCK}', file=sys.stderr)
|
|
62
|
+
waiting = True
|
|
63
|
+
if exclusive and not reported and waited > 30:
|
|
64
|
+
print(f'{_TIMING_LOCK} is still taken after 30 seconds. Processes with it open: {", ".join(_lock_users()) or "none"}', file=sys.stderr)
|
|
65
|
+
reported = True
|
|
66
|
+
time.sleep(0.25)
|
|
67
|
+
yield
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check(root):
|
|
71
|
+
"Check every target of the workspace with Cargo, holding the timing lock shared. Cargo's messages are in the result's `stderr`, which `diagnostics` reads."
|
|
72
|
+
with timing_lock(): return subprocess.run(['cargo', 'check', '--all-targets', '--message-format=short'], cwd=Path(root).expanduser(), stderr=subprocess.PIPE, text=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Cargo's line that starts a test binary: `Running` with its target and executable, or `Doc-tests` with its package.
|
|
76
|
+
_SUITE = re.compile(r'^\s+(?:Running (.+) \([^)]*\)|(Doc-tests .+))$')
|
|
77
|
+
_TEST = re.compile(r'^test (.+) \.\.\. (ok|FAILED|ignored)')
|
|
78
|
+
_FAILURE = re.compile(r'^---- (.+) std(?:out|err) ----$')
|
|
79
|
+
_DIAGNOSTIC = re.compile(r'^(\S.*?):(\d+):(\d+): (warning|error)(?:\[\w+\])?: (.*)$')
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Diagnostic(NamedTuple):
|
|
83
|
+
"A compiler message, read from Cargo's short format."
|
|
84
|
+
path:str; line:int; col:int; level:str; message:str
|
|
85
|
+
def __str__(self): return f'{self.level}: {self.path}:{self.line}: {self.message}'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def diagnostics(text):
|
|
89
|
+
"Each `Diagnostic` in `text`, which holds Cargo's short-format messages, such as `check`'s `stderr`."
|
|
90
|
+
return [Diagnostic(m[1], int(m[2]), int(m[3]), m[4], m[5]) for l in text.splitlines() if (m := _DIAGNOSTIC.match(l))]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _lines(text, n=20, tail=False):
|
|
94
|
+
"Up to `n` lines of `text`, indented, from its end if `tail`, with a count of the lines left out."
|
|
95
|
+
lines = text.splitlines()
|
|
96
|
+
shown = [f' {l}' for l in (lines[-n:] if tail else lines[:n])]
|
|
97
|
+
if len(lines) <= n: return shown
|
|
98
|
+
more = f' … {len(lines)-n} more lines'
|
|
99
|
+
return [more, *shown] if tail else [*shown, more]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class Suite:
|
|
104
|
+
"What one test binary printed when `cargo test` ran it."
|
|
105
|
+
name:str # Cargo's name for the binary, such as `tests/core.rs`, `unittests src/lib.rs` or `Doc-tests basedpl`
|
|
106
|
+
output:str # Everything printed after Cargo's line that starts the binary, until the next binary starts
|
|
107
|
+
tests:dict # Each test's outcome: `ok`, `FAILED` or `ignored`
|
|
108
|
+
failures:dict # Each failing test's captured output
|
|
109
|
+
summary:str # libtest's `test result:` line, empty for a binary that crashed or has its own harness
|
|
110
|
+
failed:bool # Did Cargo report the binary as failing?
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def read(cls, name, lines):
|
|
114
|
+
"Read one binary's lines."
|
|
115
|
+
failures, current = {}, None
|
|
116
|
+
for line in lines:
|
|
117
|
+
if m := _FAILURE.match(line): current = failures.setdefault(m[1], [])
|
|
118
|
+
elif line == 'failures:': current = None
|
|
119
|
+
elif current is not None: current.append(line)
|
|
120
|
+
return cls(name, '\n'.join(lines), {m[1]: m[2] for l in lines if (m := _TEST.match(l))},
|
|
121
|
+
{k: '\n'.join(v).strip('\n') for k,v in failures.items()},
|
|
122
|
+
next((l for l in lines if l.startswith('test result: ')), ''), any(l.startswith('error: test failed') for l in lines))
|
|
123
|
+
|
|
124
|
+
def __str__(self):
|
|
125
|
+
n = Counter(self.tests.values())
|
|
126
|
+
status = ', '.join(f'{n[o]} {w}' for o,w in [('ok', 'passed'), ('FAILED', 'failed'), ('ignored', 'ignored')] if n[o])
|
|
127
|
+
if self.failed and not n['FAILED']: status = f'{status}, then failed' if status else 'failed'
|
|
128
|
+
time = f' in {m[1]}' if (m := re.search(r'finished in (\S+)$', self.summary)) else ''
|
|
129
|
+
return f'{self.name}: {status or "ok"}{time}'
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Results:
|
|
134
|
+
"What `cargo test` printed, read into the build's compiler messages and each test binary's results. It displays as a summary: the compiler messages, each binary that ran tests, and each failure's output."
|
|
135
|
+
returncode:int
|
|
136
|
+
text:str # Everything Cargo and the tests printed, standard output and standard error in the order they were written
|
|
137
|
+
diagnostics:list # Each `Diagnostic` from the build, before the first binary ran
|
|
138
|
+
suites:list # Each binary's `Suite`, in the order Cargo ran them
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def ok(self): return self.returncode == 0
|
|
142
|
+
|
|
143
|
+
def __repr__(self):
|
|
144
|
+
res = ['cargo test passed' if self.ok else f'cargo test failed with exit status {self.returncode}']
|
|
145
|
+
diags = sorted(self.diagnostics, key=lambda d: d.level != 'error')
|
|
146
|
+
res += [str(d) for d in diags[:20]]
|
|
147
|
+
if len(diags) > 20: res.append(f'{len(diags)-20} more compiler messages in `diagnostics`')
|
|
148
|
+
ran = [s for s in self.suites if s.tests or not s.summary]
|
|
149
|
+
for s in ran:
|
|
150
|
+
res.append(str(s))
|
|
151
|
+
for t,out in s.failures.items(): res += [f' FAILED {t}', *_lines(out)]
|
|
152
|
+
if s.failed and not s.failures: res += _lines(s.output, tail=True)
|
|
153
|
+
if idle := len(self.suites) - len(ran): res.append(f'{idle} test binaries ran no tests')
|
|
154
|
+
if not (self.ok or self.suites or diags): res += _lines(self.text, tail=True)
|
|
155
|
+
return '\n'.join(res)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _blocks(text):
|
|
159
|
+
"Split `text` at Cargo's lines that start a test binary, into `(name, lines)` pairs. The first pair, named `None`, holds the build's lines."
|
|
160
|
+
res = [(None, [])]
|
|
161
|
+
for line in text.splitlines():
|
|
162
|
+
if m := _SUITE.match(line): res.append((m[1] or m[2], []))
|
|
163
|
+
else: res[-1][1].append(line)
|
|
164
|
+
return res
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_tests(
|
|
168
|
+
root, # A Cargo package or workspace (expands `~`)
|
|
169
|
+
*args, # Arguments for `cargo test`, such as `'--test', 'core'`, a test name to filter by, or `'--', '--nocapture'`
|
|
170
|
+
env:dict=None, # Environment variables to set for the run, such as `{'BASEDPL_COSTS': '1'}`
|
|
171
|
+
timeout:float=None # Seconds before the run stops, raising `subprocess.TimeoutExpired`
|
|
172
|
+
)->Results:
|
|
173
|
+
"Run `cargo test` in `root` and read what it prints. Compiler messages come in Cargo's short format. It doesn't hold the timing lock as `check` and `build` do: tests that time take the lock exclusively themselves, and a shared hold here would block them."
|
|
174
|
+
# A quiet Cargo config hides the `Running` lines that divide the binaries' output.
|
|
175
|
+
p = subprocess.run(['cargo', 'test', '--message-format=short', *args], cwd=Path(root).expanduser(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=timeout,
|
|
176
|
+
env=os.environ | {'CARGO_TERM_QUIET': 'false', 'CARGO_TERM_COLOR': 'never'} | (env or {}))
|
|
177
|
+
(_, build), *runs = _blocks(p.stdout)
|
|
178
|
+
return Results(p.returncode, p.stdout, diagnostics('\n'.join(build)), [Suite.read(*b) for b in runs])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def sources(root):
|
|
182
|
+
"The manifests, and the target directories or files, of the local packages that the workspace builds: its members and its path dependencies."
|
|
183
|
+
res = set()
|
|
184
|
+
for p in json.loads(cargo(root, 'metadata', '--format-version', '1'))['packages']:
|
|
185
|
+
if p['source'] is not None: continue
|
|
186
|
+
manifest = Path(p['manifest_path'])
|
|
187
|
+
res.add(manifest)
|
|
188
|
+
for t in p['targets']:
|
|
189
|
+
src = Path(t['src_path'])
|
|
190
|
+
res.add(src if src.parent == manifest.parent else src.parent)
|
|
191
|
+
return res
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build(root, *, library=True, binaries=False, profile=None, target=None, no_default_features=False):
|
|
195
|
+
"Build with Cargo and return the compiler artifacts of the workspace's packages."
|
|
196
|
+
root = Path(root).expanduser()
|
|
197
|
+
cfg = settings(root)['maturin']
|
|
198
|
+
cmd = ['build', '--manifest-path', str((root/'Cargo.toml').resolve()), '--message-format=json-diagnostic-short']
|
|
199
|
+
if library: cmd.append('--lib')
|
|
200
|
+
if binaries: cmd.append('--bins')
|
|
201
|
+
if profile := profile or cfg.get('profile'): cmd += ['--profile', profile]
|
|
202
|
+
if target: cmd += ['--target', target]
|
|
203
|
+
if no_default_features: cmd.append('--no-default-features')
|
|
204
|
+
else:
|
|
205
|
+
if features := cfg.get('features'): cmd += ['--features', ','.join(features)]
|
|
206
|
+
for flag in ('all-features', 'no-default-features'):
|
|
207
|
+
if cfg.get(flag): cmd.append('--' + flag)
|
|
208
|
+
with timing_lock(): result = subprocess.run(['cargo', *cmd], cwd=root, stdout=subprocess.PIPE, text=True)
|
|
209
|
+
messages = [json.loads(line) for line in result.stdout.splitlines()]
|
|
210
|
+
for m in messages:
|
|
211
|
+
if m.get('reason') != 'compiler-message': continue
|
|
212
|
+
name = tomllib.loads(Path(m['manifest_path']).read_text())['package']['name']
|
|
213
|
+
for line in m['message']['rendered'].splitlines(): print(f'[{name}] {line}', file=sys.stderr)
|
|
214
|
+
result.check_returncode()
|
|
215
|
+
workspace = members(root)
|
|
216
|
+
return [m for m in messages if m.get('reason') == 'compiler-artifact' and Path(m['manifest_path']) in workspace]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def replace(source, destination):
|
|
220
|
+
"Replace an installed artifact without changing the file held by running processes."
|
|
221
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
tmp = destination.with_name(f'{destination.name}.{os.getpid()}.tmp')
|
|
223
|
+
shutil.copy2(source, tmp)
|
|
224
|
+
os.replace(tmp, destination)
|
|
225
|
+
return destination
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def develop(root):
|
|
229
|
+
"Install a Python extension, opted-in binaries and wheel data files from one Cargo build."
|
|
230
|
+
root = Path(root).expanduser()
|
|
231
|
+
tool = settings(root)
|
|
232
|
+
cfg = tool['maturin']
|
|
233
|
+
if 'python-source' not in cfg: raise ConfigError(r'cargo develop requires tool.maturin.python-source')
|
|
234
|
+
binaries = tool.get('fastws', {}).get('native-binaries', False)
|
|
235
|
+
artifacts = build(root, binaries=binaries, profile='test')
|
|
236
|
+
artifact, = [m for m in artifacts if 'cdylib' in m['target']['crate_types']]
|
|
237
|
+
lib, = [f for f in artifact['filenames'] if Path(f).suffix in ('.dylib', '.so', '.dll')]
|
|
238
|
+
module = cfg.get('module-name', artifact['target']['name'])
|
|
239
|
+
dest = root/cfg['python-source']/Path(*module.split('.'))
|
|
240
|
+
replace(lib, dest.with_name(dest.name + sysconfig.get_config_var('EXT_SUFFIX')))
|
|
241
|
+
env = Path(os.environ.get('VIRTUAL_ENV', sys.prefix))
|
|
242
|
+
if binaries:
|
|
243
|
+
scripts = env/('Scripts' if os.name == 'nt' else 'bin')
|
|
244
|
+
for m in artifacts:
|
|
245
|
+
if m.get('executable'): replace(m['executable'], scripts/Path(m['executable']).name)
|
|
246
|
+
# Editable builds through `build_backend` don't install the wheel's data files, which a wheel install puts under the prefix.
|
|
247
|
+
if 'data' in cfg:
|
|
248
|
+
data = root/cfg['data']/'data'
|
|
249
|
+
for f in data.rglob('*'):
|
|
250
|
+
if f.is_file(): replace(f, env/f.relative_to(data))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def stage_binaries(root, profile=None, target=None):
|
|
254
|
+
"Stage Python-free Cargo binaries in maturin's wheel-data directory."
|
|
255
|
+
root = Path(root).expanduser()
|
|
256
|
+
cfg = settings(root)['maturin']
|
|
257
|
+
if 'data' not in cfg: raise ConfigError(r'staging native binaries requires tool.maturin.data')
|
|
258
|
+
dest = root/cfg['data']/'scripts'
|
|
259
|
+
artifacts = build(root, library=False, binaries=True, profile=profile, target=target, no_default_features=True)
|
|
260
|
+
if dest.exists(): shutil.rmtree(dest)
|
|
261
|
+
return [replace(m['executable'], dest/Path(m['executable']).name) for m in artifacts if m.get('executable')]
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"Fast workspace tools for multi-repo management."
|
|
2
2
|
|
|
3
|
-
__all__ = ["ws_setup", "ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_build", "ws_sync", "ws_add", "ws_remove", "cargo_develop", "cargo_stage"]
|
|
3
|
+
__all__ = ["ws_setup", "ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_build", "ws_sync", "ws_add", "ws_remove", "cargo_develop", "cargo_stage", "cargo_wait"]
|
|
4
4
|
|
|
5
5
|
import ast, fnmatch, glob, hashlib, json, os, re, shlex, shutil, subprocess, sys, time
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from contextlib import contextmanager
|
|
7
8
|
from concurrent.futures import ThreadPoolExecutor
|
|
8
9
|
|
|
9
10
|
from fastcore.parallel import parallel_async_gen
|
|
@@ -556,6 +557,16 @@ def _build_projects(root: Path, repos_file: str, project: str = None) -> list[tu
|
|
|
556
557
|
dirs = dep_closure(key, graph)
|
|
557
558
|
return [(n,d) for n,d in res if d in dirs]
|
|
558
559
|
|
|
560
|
+
@contextmanager
|
|
561
|
+
def _cli_errors():
|
|
562
|
+
"Exit without a traceback on command failures or missing settings, printing captured stderr and preserving command exit status."
|
|
563
|
+
from .cargo import ConfigError
|
|
564
|
+
try: yield
|
|
565
|
+
except subprocess.CalledProcessError as e:
|
|
566
|
+
if e.stderr: print(e.stderr, file=sys.stderr, end='')
|
|
567
|
+
raise SystemExit(e.returncode) from None
|
|
568
|
+
except ConfigError as e: raise SystemExit(str(e)) from None
|
|
569
|
+
|
|
559
570
|
@call_parse(pos=['command'])
|
|
560
571
|
def cargo_develop(
|
|
561
572
|
command: str = 'develop', # Subcommand name supplied by Cargo
|
|
@@ -563,7 +574,7 @@ def cargo_develop(
|
|
|
563
574
|
):
|
|
564
575
|
r"Build and install a mixed Rust/Python project's extension and opted-in binaries."
|
|
565
576
|
from .cargo import develop
|
|
566
|
-
develop(Path(path).resolve())
|
|
577
|
+
with _cli_errors(): develop(Path(path).resolve())
|
|
567
578
|
|
|
568
579
|
@call_parse(pos=['command'])
|
|
569
580
|
def cargo_stage(
|
|
@@ -574,7 +585,33 @@ def cargo_stage(
|
|
|
574
585
|
):
|
|
575
586
|
r"Stage Python-free native binaries for maturin wheel builds."
|
|
576
587
|
from .cargo import stage_binaries
|
|
577
|
-
|
|
588
|
+
with _cli_errors():
|
|
589
|
+
for dest in stage_binaries(Path(path).resolve(), profile or None, target or None): print(f'Staged {dest}')
|
|
590
|
+
|
|
591
|
+
def _newest(paths):
|
|
592
|
+
"Newest file mtime among `paths`, walking each directory as `_src_mtime` does"
|
|
593
|
+
return max((_src_mtime(p) if p.is_dir() else p.stat().st_mtime) for p in paths if p.exists())
|
|
594
|
+
|
|
595
|
+
@call_parse(pos=['command'])
|
|
596
|
+
def cargo_wait(
|
|
597
|
+
command: str = 'wait', # Subcommand name supplied by Cargo
|
|
598
|
+
path: str = '.', # Cargo workspace directory
|
|
599
|
+
patience: int = 1800, # Seconds to wait before giving up
|
|
600
|
+
):
|
|
601
|
+
r"Wait until a Cargo workspace compiles, checking again after each edit to its local packages' sources."
|
|
602
|
+
from .cargo import check, diagnostics, sources
|
|
603
|
+
root, start = Path(path).resolve(), time.monotonic()
|
|
604
|
+
with _cli_errors(): srcs = sources(root)
|
|
605
|
+
while (res := check(root)).returncode:
|
|
606
|
+
if time.monotonic() - start > patience: raise SystemExit(f'{res.stderr}gave up after {patience} s')
|
|
607
|
+
print(next((str(d) for d in diagnostics(res.stderr) if d.level == 'error'), res.stderr.strip() or 'cargo check failed'), file=sys.stderr)
|
|
608
|
+
before = _newest(srcs)
|
|
609
|
+
while (now := _newest(srcs)) == before and time.monotonic() - start < patience: time.sleep(1)
|
|
610
|
+
while True:
|
|
611
|
+
time.sleep(2)
|
|
612
|
+
if (later := _newest(srcs)) == now: break
|
|
613
|
+
now = later
|
|
614
|
+
print('compiles')
|
|
578
615
|
|
|
579
616
|
@call_parse
|
|
580
617
|
def ws_build(
|
|
@@ -898,58 +935,60 @@ async def ws_sync(
|
|
|
898
935
|
upgrade: bool = False, # Force the once-daily dependency upgrade pass
|
|
899
936
|
):
|
|
900
937
|
"Pull the workspace baseline, clone missing shared/local repos, then pull projects and sync; upgrade dependencies daily."
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
938
|
+
with _cli_errors():
|
|
939
|
+
root = _ws_root(workspace, repos_file, pyproject_file, template_file)
|
|
940
|
+
repos_path = _resolve_path(root, repos_file)
|
|
941
|
+
pyproject_path = _resolve_path(root, pyproject_file)
|
|
942
|
+
template_path = _resolve_path(root, template_file)
|
|
943
|
+
await _pull_workspace(root)
|
|
944
|
+
entries = _load_repo_entries(repos_path, root)
|
|
945
|
+
await _clone_repos(entries, workers)
|
|
946
|
+
repos = await _discover_ws_repos(root)
|
|
947
|
+
|
|
948
|
+
if missing_repos := _update_repos_file(repos_path, repos): print(f"Added local repos: {', '.join(missing_repos)}")
|
|
949
|
+
entries = _load_repo_entries(repos_path, root)
|
|
950
|
+
ext_dirs = [d for _,d,_ in entries if d.resolve().parent != root.resolve()]
|
|
951
|
+
dirs = [d for _,d,_ in entries]
|
|
952
|
+
try: dirs = await _changed_dirs(dirs)
|
|
953
|
+
except Exception: pass
|
|
954
|
+
await _pull(dirs, workers=workers)
|
|
955
|
+
|
|
956
|
+
if not pyproject_path.exists(): _sync_ws_pyproject(pyproject_path, template_path, [])
|
|
957
|
+
added_ex, removed_ex = _sync_ws_excludes(pyproject_path, root, {d.name for _,d,_ in entries if d.resolve().parent == root.resolve()})
|
|
958
|
+
if added_ex: print(f"Auto-excluded from the workspace: {', '.join(added_ex)}")
|
|
959
|
+
if removed_ex: print(f"No longer excluded from the workspace: {', '.join(removed_ex)}")
|
|
960
|
+
|
|
961
|
+
missing_projects = _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root), _external_projects(root, ext_dirs),
|
|
962
|
+
_repo_extras(root, entries))
|
|
963
|
+
if missing_projects: print(f"Added workspace projects: {', '.join(missing_projects)}")
|
|
964
|
+
projects = _build_projects(root, repos_file)
|
|
965
|
+
_sync_build_isolation(pyproject_path, projects)
|
|
966
|
+
|
|
967
|
+
wrapper_added = _sync_cargo_wrapper(root)
|
|
968
|
+
added_p, removed_p = _sync_cargo_patches(root)
|
|
969
|
+
if added_p: print(f"Cargo patches added: {', '.join(added_p)}")
|
|
970
|
+
if removed_p: print(f"Cargo patches removed: {', '.join(removed_p)}")
|
|
971
|
+
if wrapper_added: print("Cargo builds now use sccache")
|
|
972
|
+
|
|
973
|
+
js_members = _npm_dirs(root)
|
|
974
|
+
if _native_js(js_members): _check_rustup()
|
|
975
|
+
added_j, removed_j = _sync_ws_package_json(root, js_members)
|
|
976
|
+
if added_j: print(f"JS workspace packages added: {', '.join(added_j)}")
|
|
977
|
+
if removed_j: print(f"JS workspace packages removed: {', '.join(removed_j)}")
|
|
978
|
+
|
|
979
|
+
if bad := _pending_dirs(root):
|
|
980
|
+
print(f"⚠️ Skipping uv sync, not Python projects yet (scaffold with e.g. nbdev-new or ship-new, or remove): {', '.join(bad)}")
|
|
981
|
+
return
|
|
982
|
+
up = upgrade or _should_upgrade(root)
|
|
983
|
+
if up: _cargo_update(root, workers=workers)
|
|
984
|
+
_sync_cargo_keys(root)
|
|
985
|
+
subprocess.run(["uv", "sync", "-U"] if up else ["uv", "sync"], check=True, cwd=root)
|
|
986
|
+
from .cargo import develop
|
|
987
|
+
for _,d in projects:
|
|
988
|
+
cfg = tomllib.loads((d/'pyproject.toml').read_text()).get('tool', {}).get('maturin', {})
|
|
989
|
+
if 'python-source' in cfg and cfg.get('bindings') != 'bin': develop(d.resolve())
|
|
990
|
+
if up: _upgrade_stamp(root).touch()
|
|
991
|
+
if js_members: _sync_js(root, js_members)
|
|
953
992
|
|
|
954
993
|
@call_parse
|
|
955
994
|
async def ws_add(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.27
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -217,6 +217,8 @@ Set `native-binaries = true` under `[tool.fastws]` to build and install the bina
|
|
|
217
217
|
|
|
218
218
|
`cargo develop` also copies the `data/` subdirectory of `[tool.maturin].data` into the active venv, as a wheel install does. For example, `data/share/jupyter/kernels/<name>/kernel.json` is installed as `share/jupyter/kernels/<name>/kernel.json`, where Jupyter finds the kernel. `cargo develop` installs the binaries it builds, not the staged copies in `scripts/`.
|
|
219
219
|
|
|
220
|
+
Each Cargo build that fastws runs holds a shared `flock` on `/tmp/aai-ws-timing.lock`. A speed test that takes that lock exclusively then never times code while a build runs. A build waits up to 5 minutes for such a timing to finish, then goes ahead without the lock. Python code takes the same lock with `fastws.cargo.timing_lock`: `with timing_lock(exclusive=True):` around a timing, and `with timing_lock():` around a test run. An exclusive hold that has waited 30 seconds prints the processes that have the lock's file open. It raises `TimeoutError` after 5 minutes.
|
|
221
|
+
|
|
220
222
|
Use `fastws.build_backend` as the build backend of a mixed project, and include `fastws-cli>=0.0.25` alongside maturin in `[build-system].requires`. Its editable builds install only Python metadata and the source path, and compile no Rust. `ws-sync` then runs `cargo develop` to build and install the extension, the binaries and the wheel data files. After standalone `uv sync`, run `cargo develop` yourself.
|
|
221
223
|
|
|
222
224
|
For distribution wheels containing the binaries, also set `[tool.maturin].data` to a generated wheel-data directory. With `native-binaries = true`, wheel builds through the backend replace its `scripts/` subdirectory with the current Python-free binaries, then delegate packaging to maturin. Explicit profile and target arguments from maturin's build settings also apply to the binaries. Without `native-binaries`, wheel builds go straight to maturin, and `[tool.maturin].data` is optional.
|
|
@@ -228,6 +230,17 @@ cargo stage --profile dist
|
|
|
228
230
|
maturin build --profile dist
|
|
229
231
|
```
|
|
230
232
|
|
|
233
|
+
### `cargo wait`
|
|
234
|
+
|
|
235
|
+
Wait until a Cargo workspace compiles:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
cargo wait
|
|
239
|
+
cargo wait --path /path/to/workspace
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Use it when another process's unfinished edits stop the workspace compiling, such as a second agent working in the same tree. Each check runs `cargo check --all-targets` while holding the timing lock shared, as builds do. After a failed check, `cargo wait` prints Cargo's first error line. It checks again once a source file of a local package changes and then stays unchanged for 2 seconds. Local packages are the workspace's members and its path dependencies, such as a sibling crate that `ws-sync` patches in. When a check passes, it prints `compiles` and exits. After `--patience` seconds, 1800 by default, it prints the last check's errors and exits with status 1.
|
|
243
|
+
|
|
231
244
|
### `ws-sync`
|
|
232
245
|
|
|
233
246
|
Syncing proceeds in this order:
|
|
@@ -36,6 +36,7 @@ dev = [
|
|
|
36
36
|
[project.scripts]
|
|
37
37
|
cargo-develop = "fastws.core:cargo_develop"
|
|
38
38
|
cargo-stage = "fastws.core:cargo_stage"
|
|
39
|
+
cargo-wait = "fastws.core:cargo_wait"
|
|
39
40
|
ws-setup = "fastws.core:ws_setup"
|
|
40
41
|
ws-clone = "fastws.core:ws_clone"
|
|
41
42
|
ws-pull = "fastws.core:ws_pull"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import pytest
|
|
3
|
+
import fastws.core as core
|
|
4
|
+
from fastws.cargo import build, run_tests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _mk_proj(root, name, extra=''):
|
|
8
|
+
d = root/name
|
|
9
|
+
d.mkdir()
|
|
10
|
+
(d/'pyproject.toml').write_text(fr'''[project]
|
|
11
|
+
name = "{name}"
|
|
12
|
+
{extra}
|
|
13
|
+
''')
|
|
14
|
+
return d
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_build_dependency_selection(tmp_path):
|
|
18
|
+
root = tmp_path/'ws'
|
|
19
|
+
root.mkdir()
|
|
20
|
+
_mk_proj(root, 'app', r'''dependencies = ["My.Lib>=1"]''')
|
|
21
|
+
_mk_proj(root, 'my-lib', r'''[build-system]
|
|
22
|
+
requires = ["external"]''')
|
|
23
|
+
external = _mk_proj(tmp_path, 'external', r'''dependencies = ["app", "published-only"]''')
|
|
24
|
+
_mk_proj(root, 'unrelated')
|
|
25
|
+
(root/'repos-local.txt').write_text(f'owner/external {external}')
|
|
26
|
+
assert {n for n,d in core._build_projects(root, 'repos.txt', 'APP')} == {'app', 'my-lib', 'external'}
|
|
27
|
+
with pytest.raises(SystemExit, match='No workspace project'): core._build_projects(root, 'repos.txt', 'missing')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize('body, diagnostic', [('', ''), ('let unused = 1;', 'warning'), ('compile_error!("broken");', 'error')])
|
|
31
|
+
def test_build_diagnostics(tmp_path, capfd, body, diagnostic):
|
|
32
|
+
(tmp_path/'pyproject.toml').write_text('[tool.maturin]\n')
|
|
33
|
+
(tmp_path/'Cargo.toml').write_text('[package]\nname = "app"\nversion = "0.1.0"\n[dependencies]\ndep = { path = "dep" }\n')
|
|
34
|
+
(tmp_path/'src').mkdir()
|
|
35
|
+
(tmp_path/'src/lib.rs').write_text('extern crate dep; pub use dep::run;')
|
|
36
|
+
(tmp_path/'dep/src').mkdir(parents=True)
|
|
37
|
+
(tmp_path/'dep/Cargo.toml').write_text('[package]\nname = "dep"\nversion = "0.1.0"\n')
|
|
38
|
+
(tmp_path/'dep/src/lib.rs').write_text(f'pub fn run() {{ {body} }}')
|
|
39
|
+
(tmp_path/'.cargo').mkdir()
|
|
40
|
+
(tmp_path/'.cargo/config.toml').write_text('[term]\nquiet = true\n')
|
|
41
|
+
if diagnostic == 'error':
|
|
42
|
+
with pytest.raises(subprocess.CalledProcessError): build(tmp_path)
|
|
43
|
+
else: assert [m['target']['name'] for m in build(tmp_path)] == ['app']
|
|
44
|
+
stderr = capfd.readouterr().err
|
|
45
|
+
if diagnostic: assert f'[dep] dep/src/lib.rs:1:' in stderr and f': {diagnostic}:' in stderr
|
|
46
|
+
else: assert not stderr
|
|
47
|
+
|
|
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; }
|
|
53
|
+
/// ```
|
|
54
|
+
/// assert_eq!(app::two(), 2);
|
|
55
|
+
/// ```
|
|
56
|
+
pub fn two() -> i32 { 2 }
|
|
57
|
+
#[cfg(test)] mod t {
|
|
58
|
+
#[test] fn passes() {}
|
|
59
|
+
#[test] fn fails() { panic!("broken"); }
|
|
60
|
+
#[test] #[ignore] fn skipped() {}
|
|
61
|
+
}
|
|
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')
|
|
67
|
+
r = run_tests(tmp_path, '--no-fail-fast')
|
|
68
|
+
assert not r.ok
|
|
69
|
+
assert [(d.path, d.line, d.level) for d in r.diagnostics] == [('src/lib.rs', 1, 'warning')]
|
|
70
|
+
unit, more, docs = r.suites
|
|
71
|
+
assert [s.name for s in r.suites] == ['unittests src/lib.rs', 'tests/more.rs', 'Doc-tests app']
|
|
72
|
+
assert unit.tests == {'t::passes': 'ok', 't::fails': 'FAILED', 't::skipped': 'ignored'} and unit.failed
|
|
73
|
+
assert 'broken' in unit.failures['t::fails'] and list(unit.failures) == ['t::fails']
|
|
74
|
+
assert more.tests == {'other': 'ok'} and not more.failed
|
|
75
|
+
assert list(docs.tests.values()) == ['ok']
|
|
@@ -24,6 +24,17 @@ def mk_repo(d, origin=None):
|
|
|
24
24
|
return g
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_sync_reports_workspace_pull_failure(tmp_path, capsys):
|
|
29
|
+
g = mk_repo(tmp_path, origin=tmp_path/'origin')
|
|
30
|
+
g.config('pull.rebase', 'true')
|
|
31
|
+
g.config('rebase.autoStash', 'false')
|
|
32
|
+
(tmp_path/'f.txt').write_text('dirty')
|
|
33
|
+
with pytest.raises(SystemExit) as e: await core.ws_sync(workspace=str(tmp_path))
|
|
34
|
+
assert e.value.code != 0
|
|
35
|
+
assert 'cannot pull with rebase' in capsys.readouterr().err
|
|
36
|
+
|
|
37
|
+
|
|
27
38
|
def test_repos_file_roundtrip(tmp_path):
|
|
28
39
|
repos_path = tmp_path/'repos.txt'
|
|
29
40
|
repos_path.write_text('# header\nAnswerDotAI/fastws[dev]\njph00/private[docs] ~/private\nfastai/fastai sub/dir\n')
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
r"Build and install Cargo artifacts for mixed Rust/Python projects."
|
|
2
|
-
import json, os, shutil, subprocess, sys, sysconfig
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
try: import tomllib
|
|
6
|
-
except ModuleNotFoundError: import tomli as tomllib
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def settings(root):
|
|
10
|
-
"Read the project's tool configuration."
|
|
11
|
-
return tomllib.loads((root/'pyproject.toml').read_text()).get('tool', {})
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def cargo(root, *args):
|
|
15
|
-
"Run Cargo in the project and return its standard output."
|
|
16
|
-
return subprocess.run(['cargo', *args], cwd=root, stdout=subprocess.PIPE, text=True, check=True).stdout
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def members(root):
|
|
20
|
-
"The manifests of the workspace's member packages."
|
|
21
|
-
return {Path(p['manifest_path']) for p in json.loads(cargo(root, 'metadata', '--no-deps', '--format-version', '1'))['packages']}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def build(root, *, library=True, binaries=False, profile=None, target=None, no_default_features=False):
|
|
25
|
-
"Build with Cargo and return the compiler artifacts of the workspace's packages."
|
|
26
|
-
cfg = settings(root)['maturin']
|
|
27
|
-
cmd = ['build', '--manifest-path', str((root/'Cargo.toml').resolve()), '--message-format=json-render-diagnostics']
|
|
28
|
-
if library: cmd.append('--lib')
|
|
29
|
-
if binaries: cmd.append('--bins')
|
|
30
|
-
if profile := profile or cfg.get('profile'): cmd += ['--profile', profile]
|
|
31
|
-
if target: cmd += ['--target', target]
|
|
32
|
-
if no_default_features: cmd.append('--no-default-features')
|
|
33
|
-
else:
|
|
34
|
-
if features := cfg.get('features'): cmd += ['--features', ','.join(features)]
|
|
35
|
-
for flag in ('all-features', 'no-default-features'):
|
|
36
|
-
if cfg.get(flag): cmd.append('--' + flag)
|
|
37
|
-
messages = [json.loads(line) for line in cargo(root, *cmd).splitlines()]
|
|
38
|
-
workspace = members(root)
|
|
39
|
-
return [m for m in messages if m.get('reason') == 'compiler-artifact' and Path(m['manifest_path']) in workspace]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def replace(source, destination):
|
|
43
|
-
"Replace an installed artifact without changing the file held by running processes."
|
|
44
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
-
tmp = destination.with_name(destination.name + '.tmp')
|
|
46
|
-
shutil.copy2(source, tmp)
|
|
47
|
-
os.replace(tmp, destination)
|
|
48
|
-
return destination
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def develop(root):
|
|
52
|
-
"Install a Python extension, opted-in binaries and wheel data files from one Cargo build."
|
|
53
|
-
tool = settings(root)
|
|
54
|
-
cfg = tool['maturin']
|
|
55
|
-
if 'python-source' not in cfg: raise SystemExit(r'cargo develop requires tool.maturin.python-source')
|
|
56
|
-
binaries = tool.get('fastws', {}).get('native-binaries', False)
|
|
57
|
-
artifacts = build(root, binaries=binaries, profile='test')
|
|
58
|
-
artifact, = [m for m in artifacts if 'cdylib' in m['target']['crate_types']]
|
|
59
|
-
lib, = [f for f in artifact['filenames'] if Path(f).suffix in ('.dylib', '.so', '.dll')]
|
|
60
|
-
module = cfg.get('module-name', artifact['target']['name'])
|
|
61
|
-
dest = root/cfg['python-source']/Path(*module.split('.'))
|
|
62
|
-
replace(lib, dest.with_name(dest.name + sysconfig.get_config_var('EXT_SUFFIX')))
|
|
63
|
-
env = Path(os.environ.get('VIRTUAL_ENV', sys.prefix))
|
|
64
|
-
if binaries:
|
|
65
|
-
scripts = env/('Scripts' if os.name == 'nt' else 'bin')
|
|
66
|
-
for m in artifacts:
|
|
67
|
-
if m.get('executable'): replace(m['executable'], scripts/Path(m['executable']).name)
|
|
68
|
-
# Editable builds through `build_backend` don't install the wheel's data files, which a wheel install puts under the prefix.
|
|
69
|
-
if 'data' in cfg:
|
|
70
|
-
data = root/cfg['data']/'data'
|
|
71
|
-
for f in data.rglob('*'):
|
|
72
|
-
if f.is_file(): replace(f, env/f.relative_to(data))
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def stage_binaries(root, profile=None, target=None):
|
|
76
|
-
"Stage Python-free Cargo binaries in maturin's wheel-data directory."
|
|
77
|
-
cfg = settings(root)['maturin']
|
|
78
|
-
dest = root/cfg['data']/'scripts'
|
|
79
|
-
artifacts = build(root, library=False, binaries=True, profile=profile, target=target, no_default_features=True)
|
|
80
|
-
if dest.exists(): shutil.rmtree(dest)
|
|
81
|
-
return [replace(m['executable'], dest/Path(m['executable']).name) for m in artifacts if m.get('executable')]
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
import fastws.core as core
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def _mk_proj(root, name, extra=''):
|
|
6
|
-
d = root/name
|
|
7
|
-
d.mkdir()
|
|
8
|
-
(d/'pyproject.toml').write_text(fr'''[project]
|
|
9
|
-
name = "{name}"
|
|
10
|
-
{extra}
|
|
11
|
-
''')
|
|
12
|
-
return d
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_build_dependency_selection(tmp_path):
|
|
16
|
-
root = tmp_path/'ws'
|
|
17
|
-
root.mkdir()
|
|
18
|
-
_mk_proj(root, 'app', r'''dependencies = ["My.Lib>=1"]''')
|
|
19
|
-
_mk_proj(root, 'my-lib', r'''[build-system]
|
|
20
|
-
requires = ["external"]''')
|
|
21
|
-
external = _mk_proj(tmp_path, 'external', r'''dependencies = ["app", "published-only"]''')
|
|
22
|
-
_mk_proj(root, 'unrelated')
|
|
23
|
-
(root/'repos-local.txt').write_text(f'owner/external {external}')
|
|
24
|
-
assert {n for n,d in core._build_projects(root, 'repos.txt', 'APP')} == {'app', 'my-lib', 'external'}
|
|
25
|
-
with pytest.raises(SystemExit, match='No workspace project'): core._build_projects(root, 'repos.txt', 'missing')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|