fastws-cli 0.0.26__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.
Files changed (23) hide show
  1. {fastws_cli-0.0.26/fastws_cli.egg-info → fastws_cli-0.0.27}/PKG-INFO +1 -1
  2. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/fastws/__init__.py +2 -1
  3. fastws_cli-0.0.27/fastws/cargo.py +261 -0
  4. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/fastws/core.py +11 -9
  5. {fastws_cli-0.0.26 → fastws_cli-0.0.27/fastws_cli.egg-info}/PKG-INFO +1 -1
  6. fastws_cli-0.0.27/tests/test_build.py +75 -0
  7. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/tests/test_sync.py +11 -0
  8. fastws_cli-0.0.26/fastws/cargo.py +0 -143
  9. fastws_cli-0.0.26/tests/test_build.py +0 -25
  10. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/LICENSE +0 -0
  11. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/README.md +0 -0
  12. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/fastws/build_backend.py +0 -0
  13. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/fastws/releases.py +0 -0
  14. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/fastws_cli.egg-info/SOURCES.txt +0 -0
  15. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/fastws_cli.egg-info/dependency_links.txt +0 -0
  16. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/fastws_cli.egg-info/entry_points.txt +0 -0
  17. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/fastws_cli.egg-info/requires.txt +0 -0
  18. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/fastws_cli.egg-info/top_level.txt +0 -0
  19. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/pyproject.toml +0 -0
  20. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/setup.cfg +0 -0
  21. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/tests/test_build_backend.py +0 -0
  22. {fastws_cli-0.0.26 → fastws_cli-0.0.27}/tests/test_releases.py +0 -0
  23. {fastws_cli-0.0.26 → 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.26
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
@@ -1,4 +1,5 @@
1
- __version__ = "0.0.26"
1
+ __version__ = "0.0.27"
2
+
2
3
 
3
4
 
4
5
 
@@ -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')]
@@ -558,11 +558,13 @@ def _build_projects(root: Path, repos_file: str, project: str = None) -> list[tu
558
558
  return [(n,d) for n,d in res if d in dirs]
559
559
 
560
560
  @contextmanager
561
- def _quietly():
562
- "Exit without a traceback where a command fails or a project lacks a setting. A failed command exits with its status. Use this only where the command printed its own errors. A missing setting prints its message and exits with status 1."
561
+ def _cli_errors():
562
+ "Exit without a traceback on command failures or missing settings, printing captured stderr and preserving command exit status."
563
563
  from .cargo import ConfigError
564
564
  try: yield
565
- except subprocess.CalledProcessError as e: raise SystemExit(e.returncode) from None
565
+ except subprocess.CalledProcessError as e:
566
+ if e.stderr: print(e.stderr, file=sys.stderr, end='')
567
+ raise SystemExit(e.returncode) from None
566
568
  except ConfigError as e: raise SystemExit(str(e)) from None
567
569
 
568
570
  @call_parse(pos=['command'])
@@ -572,7 +574,7 @@ def cargo_develop(
572
574
  ):
573
575
  r"Build and install a mixed Rust/Python project's extension and opted-in binaries."
574
576
  from .cargo import develop
575
- with _quietly(): develop(Path(path).resolve())
577
+ with _cli_errors(): develop(Path(path).resolve())
576
578
 
577
579
  @call_parse(pos=['command'])
578
580
  def cargo_stage(
@@ -583,7 +585,7 @@ def cargo_stage(
583
585
  ):
584
586
  r"Stage Python-free native binaries for maturin wheel builds."
585
587
  from .cargo import stage_binaries
586
- with _quietly():
588
+ with _cli_errors():
587
589
  for dest in stage_binaries(Path(path).resolve(), profile or None, target or None): print(f'Staged {dest}')
588
590
 
589
591
  def _newest(paths):
@@ -597,12 +599,12 @@ def cargo_wait(
597
599
  patience: int = 1800, # Seconds to wait before giving up
598
600
  ):
599
601
  r"Wait until a Cargo workspace compiles, checking again after each edit to its local packages' sources."
600
- from .cargo import check, sources
602
+ from .cargo import check, diagnostics, sources
601
603
  root, start = Path(path).resolve(), time.monotonic()
602
- with _quietly(): srcs = sources(root)
604
+ with _cli_errors(): srcs = sources(root)
603
605
  while (res := check(root)).returncode:
604
606
  if time.monotonic() - start > patience: raise SystemExit(f'{res.stderr}gave up after {patience} s')
605
- print(next((l for l in res.stderr.splitlines() if 'error' in l), 'cargo check failed'), file=sys.stderr)
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)
606
608
  before = _newest(srcs)
607
609
  while (now := _newest(srcs)) == before and time.monotonic() - start < patience: time.sleep(1)
608
610
  while True:
@@ -933,7 +935,7 @@ async def ws_sync(
933
935
  upgrade: bool = False, # Force the once-daily dependency upgrade pass
934
936
  ):
935
937
  "Pull the workspace baseline, clone missing shared/local repos, then pull projects and sync; upgrade dependencies daily."
936
- with _quietly():
938
+ with _cli_errors():
937
939
  root = _ws_root(workspace, repos_file, pyproject_file, template_file)
938
940
  repos_path = _resolve_path(root, repos_file)
939
941
  pyproject_path = _resolve_path(root, pyproject_file)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.26
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
@@ -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,143 +0,0 @@
1
- r"Build and install Cargo artifacts for mixed Rust/Python projects."
2
- import fcntl, json, os, shutil, subprocess, sys, sysconfig, time
3
- from contextlib import contextmanager
4
- from pathlib import Path
5
-
6
- try: import tomllib
7
- except ModuleNotFoundError: import tomli as tomllib
8
-
9
- # The file whose lock keeps timings and builds apart on this machine. Timings take it exclusively. Builds and test runs take it shared.
10
- _TIMING_LOCK = '/tmp/aai-ws-timing.lock'
11
-
12
-
13
- class ConfigError(ValueError):
14
- "A project's `pyproject.toml` lacks a setting that a command needs."
15
-
16
-
17
- def settings(root):
18
- "Read the project's tool configuration."
19
- return tomllib.loads((root/'pyproject.toml').read_text()).get('tool', {})
20
-
21
-
22
- def cargo(root, *args):
23
- "Run Cargo in the project and return its standard output."
24
- return subprocess.run(['cargo', *args], cwd=root, stdout=subprocess.PIPE, text=True, check=True).stdout
25
-
26
-
27
- def members(root):
28
- "The manifests of the workspace's member packages."
29
- return {Path(p['manifest_path']) for p in json.loads(cargo(root, 'metadata', '--no-deps', '--format-version', '1'))['packages']}
30
-
31
-
32
- def _lock_users():
33
- "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."
34
- users, pid = [], ''
35
- for line in subprocess.run(['lsof', '+c', '0', '-F', 'pc', _TIMING_LOCK], capture_output=True, text=True).stdout.splitlines():
36
- if line[:1] == 'p': pid = line[1:]
37
- elif line[:1] == 'c' and pid != str(os.getpid()): users.append(f'{pid} {line[1:]}')
38
- return users
39
-
40
-
41
- @contextmanager
42
- def timing_lock(
43
- exclusive=False, # Take the lock exclusively, as a timing does; False: take it shared, as a build or a test run does
44
- ):
45
- "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."
46
- with open(_TIMING_LOCK, 'a') as f:
47
- start, waiting, reported = time.monotonic(), False, False
48
- while True:
49
- try:
50
- fcntl.flock(f, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
51
- break
52
- except BlockingIOError: pass
53
- waited = time.monotonic() - start
54
- if waited > 300:
55
- if exclusive: raise TimeoutError(f'a build or another timing has held {_TIMING_LOCK} for 5 minutes')
56
- print(f'going ahead although a timing still holds {_TIMING_LOCK}', file=sys.stderr)
57
- break
58
- if not waiting: print(f'waiting for {"a build or another timing" if exclusive else "a timing"} to release {_TIMING_LOCK}', file=sys.stderr)
59
- waiting = True
60
- if exclusive and not reported and waited > 30:
61
- print(f'{_TIMING_LOCK} is still taken after 30 seconds. Processes with it open: {", ".join(_lock_users()) or "none"}', file=sys.stderr)
62
- reported = True
63
- time.sleep(0.25)
64
- yield
65
-
66
-
67
- def check(root):
68
- "Check every target of the workspace with Cargo, holding the timing lock shared. Cargo's messages are in the result's `stderr`."
69
- with timing_lock(): return subprocess.run(['cargo', 'check', '--all-targets', '--message-format=short'], cwd=root, stderr=subprocess.PIPE, text=True)
70
-
71
-
72
- def sources(root):
73
- "The manifests, and the target directories or files, of the local packages that the workspace builds: its members and its path dependencies."
74
- res = set()
75
- for p in json.loads(cargo(root, 'metadata', '--format-version', '1'))['packages']:
76
- if p['source'] is not None: continue
77
- manifest = Path(p['manifest_path'])
78
- res.add(manifest)
79
- for t in p['targets']:
80
- src = Path(t['src_path'])
81
- res.add(src if src.parent == manifest.parent else src.parent)
82
- return res
83
-
84
-
85
- def build(root, *, library=True, binaries=False, profile=None, target=None, no_default_features=False):
86
- "Build with Cargo and return the compiler artifacts of the workspace's packages."
87
- cfg = settings(root)['maturin']
88
- cmd = ['build', '--manifest-path', str((root/'Cargo.toml').resolve()), '--message-format=json-diagnostic-short,json-render-diagnostics']
89
- if library: cmd.append('--lib')
90
- if binaries: cmd.append('--bins')
91
- if profile := profile or cfg.get('profile'): cmd += ['--profile', profile]
92
- if target: cmd += ['--target', target]
93
- if no_default_features: cmd.append('--no-default-features')
94
- else:
95
- if features := cfg.get('features'): cmd += ['--features', ','.join(features)]
96
- for flag in ('all-features', 'no-default-features'):
97
- if cfg.get(flag): cmd.append('--' + flag)
98
- with timing_lock(): messages = [json.loads(line) for line in cargo(root, *cmd).splitlines()]
99
- workspace = members(root)
100
- return [m for m in messages if m.get('reason') == 'compiler-artifact' and Path(m['manifest_path']) in workspace]
101
-
102
-
103
- def replace(source, destination):
104
- "Replace an installed artifact without changing the file held by running processes."
105
- destination.parent.mkdir(parents=True, exist_ok=True)
106
- tmp = destination.with_name(destination.name + '.tmp')
107
- shutil.copy2(source, tmp)
108
- os.replace(tmp, destination)
109
- return destination
110
-
111
-
112
- def develop(root):
113
- "Install a Python extension, opted-in binaries and wheel data files from one Cargo build."
114
- tool = settings(root)
115
- cfg = tool['maturin']
116
- if 'python-source' not in cfg: raise ConfigError(r'cargo develop requires tool.maturin.python-source')
117
- binaries = tool.get('fastws', {}).get('native-binaries', False)
118
- artifacts = build(root, binaries=binaries, profile='test')
119
- artifact, = [m for m in artifacts if 'cdylib' in m['target']['crate_types']]
120
- lib, = [f for f in artifact['filenames'] if Path(f).suffix in ('.dylib', '.so', '.dll')]
121
- module = cfg.get('module-name', artifact['target']['name'])
122
- dest = root/cfg['python-source']/Path(*module.split('.'))
123
- replace(lib, dest.with_name(dest.name + sysconfig.get_config_var('EXT_SUFFIX')))
124
- env = Path(os.environ.get('VIRTUAL_ENV', sys.prefix))
125
- if binaries:
126
- scripts = env/('Scripts' if os.name == 'nt' else 'bin')
127
- for m in artifacts:
128
- if m.get('executable'): replace(m['executable'], scripts/Path(m['executable']).name)
129
- # Editable builds through `build_backend` don't install the wheel's data files, which a wheel install puts under the prefix.
130
- if 'data' in cfg:
131
- data = root/cfg['data']/'data'
132
- for f in data.rglob('*'):
133
- if f.is_file(): replace(f, env/f.relative_to(data))
134
-
135
-
136
- def stage_binaries(root, profile=None, target=None):
137
- "Stage Python-free Cargo binaries in maturin's wheel-data directory."
138
- cfg = settings(root)['maturin']
139
- if 'data' not in cfg: raise ConfigError(r'staging native binaries requires tool.maturin.data')
140
- dest = root/cfg['data']/'scripts'
141
- artifacts = build(root, library=False, binaries=True, profile=profile, target=target, no_default_features=True)
142
- if dest.exists(): shutil.rmtree(dest)
143
- 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