Run the agent skill scripts on Windows (#4247)
diff --git a/.claude/skills/build-doc/scripts/gen_doc.py b/.claude/skills/build-doc/scripts/gen_doc.py
index 60cb7ee..75d8c94 100755
--- a/.claude/skills/build-doc/scripts/gen_doc.py
+++ b/.claude/skills/build-doc/scripts/gen_doc.py
@@ -21,7 +21,7 @@
"""key: value pairs of the /* metadata: ... */ block, {} when the file or block is missing."""
if not path.exists():
return {}
- m = re.search(r'/\*\s*metadata:(.*?)\*/', path.read_text(), re.DOTALL)
+ m = re.search(r'/\*\s*metadata:(.*?)\*/', path.read_text(encoding='utf-8'), re.DOTALL)
if not m:
return {}
return {k: v.strip() for k, v in re.findall(r'^[ \t]*(\w+):[ \t]*(.+)$', m.group(1), re.MULTILINE)}
@@ -30,7 +30,8 @@
def boards(bsp):
"""{manufacturer: {board: [name, family, url, note]}} plus the paths skipped for lack of metadata."""
vendors, skipped = {}, []
- for family_dir in sorted(p for p in bsp.iterdir() if p.is_dir()):
+ # by name: Windows paths compare case-insensitively, which would reorder boards.rst
+ for family_dir in sorted((p for p in bsp.iterdir() if p.is_dir()), key=lambda p: p.name):
family_c = family_dir / 'family.c'
if not family_c.exists():
family_c = family_dir / 'boards' / 'family.c'
@@ -39,7 +40,7 @@
skipped.append(family_dir)
continue
manufacturer = f_meta.get('manufacturer', '')
- for board_dir in sorted(p for p in (family_dir / 'boards').iterdir() if p.is_dir()):
+ for board_dir in sorted((p for p in (family_dir / 'boards').iterdir() if p.is_dir()), key=lambda p: p.name):
b_meta = metadata(board_dir / 'board.h')
if not b_meta:
skipped.append(board_dir)
@@ -107,7 +108,7 @@
def hil_md(root):
out = [HEADER, '']
for title, src in (('ci rig', 'test/hil/tinyusb.json'), ('hfp rig', 'test/hil/hfp.json')):
- entries = json.loads((root / src).read_text()).get('boards', [])
+ entries = json.loads((root / src).read_text(encoding='utf-8')).get('boards', [])
if not entries:
continue
out += [f'### {title}', '', f'{len(entries)} boards, from `{src}`.', '',
@@ -124,14 +125,14 @@
gen_deps_doc(root / 'docs/reference/dependencies.rst')
print(f'wrote {root / "docs/reference/dependencies.rst"}')
vendors, skipped = boards(root / 'hw' / 'bsp')
- (root / 'docs/reference/boards.rst').write_text(boards_rst(vendors))
+ (root / 'docs/reference/boards.rst').write_text(boards_rst(vendors), encoding='utf-8')
print(f'wrote {root / "docs/reference/boards.rst"}')
- (root / 'docs/reference/hil_boards.md').write_text(hil_md(root))
+ (root / 'docs/reference/hil_boards.md').write_text(hil_md(root), encoding='utf-8')
print(f'wrote {root / "docs/reference/hil_boards.md"}')
if skipped:
print('no metadata block, left out of boards.rst:', file=sys.stderr)
for path in skipped:
- print(f' {path.relative_to(root)}', file=sys.stderr)
+ print(f' {path.relative_to(root).as_posix()}', file=sys.stderr)
return 0
diff --git a/.claude/skills/build/scripts/check_build.py b/.claude/skills/build/scripts/check_build.py
index 3e7f6a3..2120f70 100755
--- a/.claude/skills/build/scripts/check_build.py
+++ b/.claude/skills/build/scripts/check_build.py
@@ -432,7 +432,7 @@
configuration does not satisfy is an empty translation unit, whatever built."""
argv = family_json.preprocess_argv(entry) + ['-E', entry['file']]
try:
- r = subprocess.run(argv, capture_output=True, text=True, cwd=entry['directory'])
+ r = subprocess.run(argv, capture_output=True, encoding='utf-8', errors='replace', cwd=entry['directory'])
except OSError:
return None
if r.returncode != 0:
@@ -659,7 +659,7 @@
def roster_variants(boards, config):
"""(board, build name, defines, cflags) for each variant hil_test.py runs of each board."""
try:
- roster = {b['name']: b for b in json.loads(Path(config).read_text())['boards']}
+ roster = {b['name']: b for b in json.loads(Path(config).read_text(encoding='utf-8'))['boards']}
except (OSError, ValueError, KeyError, TypeError) as e:
fail(f'could not read the HIL roster {config}: {e}')
unknown = [b for b in boards if b not in roster]
@@ -741,10 +741,11 @@
def run(cmd, verbose):
# streamed as it arrives: a variant build runs for minutes, and a silent buffer reads as a
- # stall. PYTHONUNBUFFERED, or tools/build.py block-buffers its piped stdout until it exits
+ # stall. PYTHONUNBUFFERED, or tools/build.py block-buffers its piped stdout until it exits;
+ # PYTHONIOENCODING, or on Windows it writes the pipe in the ANSI code page, not as decoded here
out = []
- with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=ROOT,
- env={**os.environ, 'PYTHONUNBUFFERED': '1'}) as p:
+ with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8', errors='replace', cwd=ROOT,
+ env={**os.environ, 'PYTHONUNBUFFERED': '1', 'PYTHONIOENCODING': 'utf-8'}) as p:
for line in p.stdout:
out.append(line)
if verbose:
diff --git a/.claude/skills/build/scripts/coverage_audit.py b/.claude/skills/build/scripts/coverage_audit.py
index 8aac5b4..ff6c094 100755
--- a/.claude/skills/build/scripts/coverage_audit.py
+++ b/.claude/skills/build/scripts/coverage_audit.py
@@ -27,6 +27,7 @@
historical tree, since the current graph cannot know what built the deleted file.
"""
import argparse
+import importlib.util
import json
import os
import re
@@ -37,6 +38,11 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[4]
+# split_command, from this script's checkout under a private name: graph's `import build` must
+# still get the audited checkout's own family_json
+_spec = importlib.util.spec_from_file_location('_coverage_audit_family_json', ROOT / 'tools' / 'family_json.py')
+family_json = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(family_json)
DEPS_HEADER = re.compile(r'^(\S.*): #deps \d+')
ROLES = ('device', 'host', 'dual', 'typec')
FORMAT = 3
@@ -46,11 +52,20 @@
pass
+# Windows: the paths rel() found on another drive than the checkout, outside it as a '..' is on
+# POSIX; graph counts them per board on stderr, so a checkout split across drives shows up
+other_drive = set()
+
+
def rel(path, root, base=None):
"""Repo-relative path of `path` (relative ones resolved against `base`, default `root`),
or None for a file outside the checkout (toolchain headers) or a generated one."""
p = os.path.normpath(path if os.path.isabs(path) else os.path.join(base or root, path))
- r = os.path.relpath(p, root)
+ try:
+ r = Path(os.path.relpath(p, root)).as_posix()
+ except ValueError: # Windows: another drive (a toolchain header) is outside
+ other_drive.add(p)
+ return None
return None if r.startswith('..') or r.startswith('cmake-build') else r
@@ -112,7 +127,7 @@
and the -L dirs - plus the including script's own directory."""
seen = set() if seen is None else seen
try:
- with open(script, errors='replace') as fh:
+ with open(script, encoding='utf-8', errors='replace') as fh:
text = fh.read()
except OSError:
return seen
@@ -199,7 +214,7 @@
def run(cmd, cwd, **kw):
- return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, **kw)
+ return subprocess.run(cmd, cwd=cwd, capture_output=True, encoding='utf-8', errors='replace', **kw)
def run_ok(cmd, cwd):
@@ -219,14 +234,15 @@
if '__gnu_lto_slim' not in r.stdout:
return defines_symbols(r.stdout)
try:
- n = run([compiler + '-nm', obj_path], cwd)
+ nm = compiler[:-4] + '-nm.exe' if compiler.lower().endswith('.exe') else compiler + '-nm'
+ n = run([nm, obj_path], cwd)
except OSError:
return True
return n.returncode != 0 or nm_defines_symbols(n.stdout)
def compiler_of(entry):
- return entry['arguments'][0] if 'arguments' in entry else entry['command'].split()[0]
+ return entry['arguments'][0] if 'arguments' in entry else family_json.split_command(entry['command'])[0]
def extract(build_dir, root, example=None):
@@ -235,11 +251,11 @@
its CMake inputs are its own too. `nodeps` lists live objects ninja recorded no deps
for: only their source is known."""
src_of = {}
- with open(os.path.join(build_dir, 'compile_commands.json')) as fh:
+ with open(os.path.join(build_dir, 'compile_commands.json'), encoding='utf-8') as fh:
cc = json.load(fh)
for e in cc:
out = e['output'] if os.path.isabs(e['output']) else os.path.join(e['directory'], e['output'])
- src_of[os.path.relpath(out, build_dir)] = e
+ src_of[Path(os.path.relpath(out, build_dir)).as_posix()] = e # as ninja names it
targets = run_ok(['ninja', '-C', build_dir, '-t', 'targets', 'all'], root)
known = {line.split(':', 1)[0] for line in targets.splitlines()}
deps = parse_ninja_deps(run_ok(['ninja', '-C', build_dir, '-t', 'deps'], root))
@@ -272,7 +288,11 @@
s.add(f)
continue
a = os.path.normpath(os.path.join(build_dir, d))
- for t in (d, os.path.relpath(a, build_dir), a):
+ try:
+ r = Path(os.path.relpath(a, build_dir)).as_posix()
+ except ValueError: # Windows: on another drive than the build, so no relative ninja name
+ r = d
+ for t in (d, r, a):
if t in known:
s |= generated_inputs(t)
break
@@ -365,6 +385,7 @@
name = f'audit-{os.getpid()}-{board}'
build_dir = os.path.join(root, 'cmake-build', f'cmake-build-{name}')
shutil.rmtree(build_dir, ignore_errors=True)
+ other_drive.clear()
try:
r = run([sys.executable, 'tools/build.py', '-b', board, '--build-name', name], root)
rec = {'format': FORMAT, 'head': head, 'board': board, 'config': 'default',
@@ -378,6 +399,9 @@
finally:
shutil.rmtree(build_dir, ignore_errors=True)
rec['secs'] = round(time.monotonic() - t0, 1)
+ if other_drive:
+ print(f'{board}: {len(other_drive)} path(s) on another drive than the checkout counted as outside it, '
+ f'e.g. {min(other_drive)}', file=sys.stderr)
with open(path, 'w') as fh:
json.dump(rec, fh)
print(f'{board}: {rec["status"]} {rec["secs"]}s '
diff --git a/.claude/skills/hil-contract/SKILL.md b/.claude/skills/hil-contract/SKILL.md
index 50967b1..d9a7c61 100644
--- a/.claude/skills/hil-contract/SKILL.md
+++ b/.claude/skills/hil-contract/SKILL.md
@@ -121,6 +121,13 @@
An agent launches it with `--run-id` and waits on it as Timing says.
+It refuses, before touching the rig, when no `rsync` is on PATH. On a Windows dev PC that must be
+an MSYS2 or Cygwin `rsync.exe` in an absolute PATH dir, and it must find its own distribution's
+`ssh` first on PATH, since with Windows OpenSSH (`System32\OpenSSH`) the argument conversion and
+the pipes rsync relies on break. There it also refuses a config or firmware dir on another drive
+than the checkout, which rsync cannot name, and snapshots receipt-pinned firmware under
+`.hil-remote/`. The Windows path is not yet proved end to end on a rig.
+
One invocation per board is wrong here, not merely slow: each run `rm -rf`s `REMOTE_DIR`
and rewrites the report, so only the last board's rows survive. A second run sharing
`REMOTE_DIR` is refused, before the wipe, while the first holds `<REMOTE_DIR>.lock`. The rig
diff --git a/.claude/skills/hil-contract/scripts/hil_remote.py b/.claude/skills/hil-contract/scripts/hil_remote.py
index 989619e..325b32e 100644
--- a/.claude/skills/hil-contract/scripts/hil_remote.py
+++ b/.claude/skills/hil-contract/scripts/hil_remote.py
@@ -27,7 +27,7 @@
"""
import argparse
import contextlib
-import fcntl
+import errno
import hashlib
import importlib
import json
@@ -41,6 +41,12 @@
import time
from pathlib import Path
+try:
+ import fcntl
+except ImportError: # Windows
+ fcntl = None
+ import msvcrt
+
ROOT = Path(os.environ.get('ROOT_DIR') or Path(__file__).resolve().parents[4]).resolve()
# Everything the rig executes, by repo-relative path. test_hil_bounded's RemoteStaging walks
@@ -168,7 +174,7 @@
config_path = config_path.relative_to(ROOT)
board_args = ' '.join(f'--board {shlex.quote(b)}' for b in boards) or '--board <board>'
line = (f'python3 .claude/skills/build/scripts/check_build.py {board_args} --shared '
- f'--variants {shlex.quote(str(config_path))}')
+ f'--variants {shlex.quote(config_path.as_posix())}')
if build_dir != 'cmake-build':
line += f'\n (it writes under cmake-build/, not {build_dir}/)'
return line
@@ -208,14 +214,14 @@
found = variant_dirs(config, build_dir, name)
present = [d for d in found if d.is_dir()]
if boards and not present:
- missing.append(f' {name}: none of {", ".join(str(d.relative_to(ROOT)) for d in found)}')
+ missing.append(f' {name}: none of {", ".join(d.relative_to(ROOT).as_posix() for d in found)}')
unbuilt.append(name)
for i, d in enumerate(found):
if d not in present and boards:
# hil_test.py logs `Skip (no binary)` and exits 0 for these cells, except that a
# one-test run that flashes fails a later variant's same-PID boundary (HIL contract, Build)
boundary = '' if args.skip_flash or i == 0 else ' or fail the same-PID boundary on a one-test run'
- print(f'warning: {name}: no {d.relative_to(ROOT)} -- its cells will be skipped{boundary}',
+ print(f'warning: {name}: no {d.relative_to(ROOT).as_posix()} -- its cells will be skipped{boundary}',
file=sys.stderr)
dirs += present
if missing:
@@ -231,7 +237,7 @@
"""hil_report.json is not namespaced by rig, so a sidecar from another rig or config
would merge boards that never ran here. Require one row from this roster."""
try:
- rows = json.loads(sidecar.read_text()).get('rows') or []
+ rows = json.loads(sidecar.read_text(encoding='utf-8')).get('rows') or []
except (OSError, ValueError, AttributeError):
return False
variants_of = helper('hil_report').variants_of
@@ -271,16 +277,44 @@
f'exec env {shlex.join([*forwarded_env(), *run])}')
+def local_operand(path):
+ """A local file as rsync takes it. Windows rsync reads a drive letter as a remote host, so
+ there the file is named relative to the checkout, where rsync runs."""
+ if os.name != 'nt':
+ return str(path)
+ try:
+ return Path(os.path.relpath(path, ROOT)).as_posix()
+ except ValueError:
+ fail(f'{path} is not on the drive of the checkout {ROOT}, so rsync cannot name it')
+
+
+def rsync_program():
+ """The rsync to run, None when there is none. Windows: an rsync.exe in an absolute PATH dir,
+ which is all subprocess can start by name, and not one in the cwd, which CreateProcess
+ searches first; a relative dir would name it from the cwd, not from ROOT where it runs."""
+ if os.name != 'nt':
+ return 'rsync' if shutil.which('rsync') else None
+ for d in os.environ.get('PATH', '').split(os.pathsep):
+ if os.path.isabs(d) and os.path.isfile(os.path.join(d, 'rsync.exe')):
+ return os.path.join(d, 'rsync.exe')
+ return None
+
+
def rsync(guard, *args, optional=False):
"""rsync with a remote endpoint under `guard` = (remote_dir, token): the remote rsync
server starts only under a shared hold on the tree's lock with this run's token still
- in place, so a transfer never lands in a tree another run rebuilt after our lease died."""
+ in place, so a transfer never lands in a tree another run rebuilt after our lease died.
+ Local files are Paths; options and host:path specs are str, passed as they are."""
remote_dir, token = guard
+ args = [local_operand(a) if isinstance(a, Path) else a for a in args]
+ program = rsync_program() if os.name == 'nt' else 'rsync'
+ if program is None:
+ fail('rsync.exe is no longer on PATH')
# rsync appends its --server arguments after --rsync-path, so `sh -c '...' rsync` makes
# them "$@" of the guard shell, which execs the real rsync on them
server = f'sh -c {shlex.quote(tree_guard(remote_dir, token) + " && exec rsync \"$@\"")} rsync'
# an optional fetch (a green run writes no .failed) must not print rsync's code-23 error
- return subprocess.run(['rsync', '-e', shlex.join(['ssh', *SSH_OPTS]), f'--rsync-path={server}', *args],
+ return subprocess.run([program, '-e', shlex.join(['ssh', *SSH_OPTS]), f'--rsync-path={server}', *args],
cwd=ROOT, stderr=subprocess.DEVNULL if optional else None).returncode
@@ -293,7 +327,7 @@
md, js = ROOT / report.REPORT_MD, ROOT / report.REPORT_JSON
tmp = [p.with_name(p.name + '.tmp') for p in (md, js)]
guard = (remote_dir, token)
- ok = all(rsync(guard, '-q', f'{remote}:{remote_dir}/{p.name}', str(t), optional=True) == 0 and t.is_file()
+ ok = all(rsync(guard, '-q', f'{remote}:{remote_dir}/{p.name}', t, optional=True) == 0 and t.is_file()
for p, t in zip((md, js), tmp))
if ok:
for p, t in zip((md, js), tmp):
@@ -308,7 +342,7 @@
# A green run writes no .failed, so a stale local spec would re-flash boards that passed.
spec = ROOT / f'{config_name}.failed'
spec.unlink(missing_ok=True)
- if rsync(guard, '-q', f'{remote}:{remote_dir}/{spec.name}', str(spec), optional=True) == 0 and spec.is_file():
+ if rsync(guard, '-q', f'{remote}:{remote_dir}/{spec.name}', spec, optional=True) == 0 and spec.is_file():
copied.append(spec.name)
print(f'==> {spec.name} copied to {spec}')
return copied
@@ -319,14 +353,16 @@
"""Wipe and recreate remote_dir, yielding (its absolute path, this run's token) while this
ssh session holds its lock: another runner sharing REMOTE_DIR would otherwise rm -rf this
run's tree."""
+ if not rsync_program(): # before the wipe: every transfer of the run needs it
+ fail('rsync is not on PATH; the staging and the copy-back need it (on Windows, MSYS2\'s or Cygwin\'s rsync.exe)')
token = f'{os.getpid()}-{time.time_ns()}'
# stderr to a file, not a pipe: nothing drains a pipe while the lease is held, and a
# remote that writes more than the pipe buffer (rm -rf reporting hundreds of entries)
# would block with the lock taken
- err_file = tempfile.TemporaryFile('w+')
+ err_file = tempfile.TemporaryFile('w+', encoding='utf-8', errors='replace')
lease = subprocess.Popen(
['ssh', *SSH_OPTS, remote, shlex.join(['bash', '-c', SETUP_SCRIPT, 'hil-setup', remote_dir, build_dir, token])],
- stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=err_file, text=True)
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=err_file, encoding='utf-8', errors='replace')
marked = next((ln.rstrip('\n').partition('=')[2] for ln in lease.stdout
if ln.startswith('HIL_REMOTE_DIR=')), '')
if not marked.startswith('/'):
@@ -362,7 +398,7 @@
def staged_files(dirs, base=None):
"""{path relative to base (default the checkout): sha256} of what FIRMWARE_FILTER stages from dirs."""
- return {str(f.relative_to(base or ROOT)): digest(f) for f in staged_paths(dirs)}
+ return {f.relative_to(base or ROOT).as_posix(): digest(f) for f in staged_paths(dirs)}
def snapshot(dirs, into):
@@ -382,7 +418,7 @@
def example_firmware(d, files):
"""The <role>/<name> example dirs of variant dir d holding staged <name>.<suffix> firmware,
directly or in one config subdir, where hil_flash.find_firmware() looks."""
- prefix = f'{d.relative_to(ROOT)}/'
+ prefix = f'{d.relative_to(ROOT).as_posix()}/'
return {'/'.join(parts[:2]) for parts in (p[len(prefix):].split('/') for p in files if p.startswith(prefix))
if len(parts) in (3, 4) and parts[0] in EXAMPLE_ROLES
and Path(parts[-1]).stem == parts[1] and Path(parts[-1]).suffix in STAGED_SUFFIXES}
@@ -405,7 +441,7 @@
else:
registered = tools_build.cmake_registered_targets(str(d))
if registered is None:
- fail(f'cmake cannot list the targets of {d.relative_to(ROOT)}, so the firmware its build made cannot be told '
+ fail(f'cmake cannot list the targets of {d.relative_to(ROOT).as_posix()}, so the firmware its build made cannot be told '
f'from a stale one')
built = {e for e in examples if e.split('/')[1] in registered}
return sorted(examples - built)
@@ -419,7 +455,7 @@
args = helper('hil_args').build_parser().parse_args([*argv, str(config_path)])
check_build_dir(args.build_dir)
try:
- config = json.loads(config_path.read_text())
+ config = json.loads(config_path.read_text(encoding='utf-8'))
except (OSError, ValueError) as e:
fail(f'could not read the config {config_path}: {e}')
return args, config, config_path
@@ -438,11 +474,11 @@
files = staged_files([d for _, d in dirs])
# only example firmware: CMake's compiler-ABI probes leave a .bin in a dir never built
examples = {d: example_firmware(d, files) for _, d in dirs}
- unbuilt = [str(d.relative_to(ROOT)) for _, d in dirs if not examples[d]]
+ unbuilt = [d.relative_to(ROOT).as_posix() for _, d in dirs if not examples[d]]
if unbuilt:
fail(f'not built: {", ".join(unbuilt)}; a receipt covers every variant the run selects, build with\n'
f' {build_command(config_path, boards, args.build_dir)}')
- stale = [f'{d.relative_to(ROOT)}/{e}' for b, d in dirs for e in unverified_examples(b, d, examples[d])]
+ stale = [f'{d.relative_to(ROOT).as_posix()}/{e}' for b, d in dirs for e in unverified_examples(b, d, examples[d])]
if stale:
fail(f'firmware of an example the build does not build: {", ".join(stale)}; a receipt pins only firmware '
f'the build verified and a run stages every file there, so remove those dirs and write the receipt again')
@@ -466,13 +502,13 @@
dirs are every variant dir of the run's boards, staged the checkout-relative digests of
the firmware to stage."""
try:
- receipt = json.loads(Path(path).read_text())
+ receipt = json.loads(Path(path).read_text(encoding='utf-8'))
head, pinned = receipt['head'], receipt['files']
config_digest, built = receipt['configDigest'], receipt['boards']
except (OSError, ValueError, KeyError, TypeError) as e:
fail(f'unusable build receipt {path}: {e!r}')
unbuilt = [b for b in boards if b not in built]
- prefixes = tuple(f'{d.relative_to(ROOT)}/' for d in dirs)
+ prefixes = tuple(f'{d.relative_to(ROOT).as_posix()}/' for d in dirs)
differ = sorted({*(p for p in staged if pinned.get(p) != staged[p]),
*(p for p in pinned if p.startswith(prefixes) and p not in staged)})
why = [*checkout_drift(head, config_digest, config_path),
@@ -502,12 +538,17 @@
try:
helper('hil_lock') # the rig refuses an off-pin lib/agentrc too, but only after the wipe
except ImportError as e:
- fail(e)
+ if e.name != 'fcntl': # Windows: agentrc.load() passed the pin check before the POSIX-only lock ran
+ fail(e)
if args.build:
fail(f'--build would build on the rig, which gets binaries only; build locally with\n'
f' {build_command(config_path, select_boards(config, args), args.build_dir)}')
firmware = resolve_firmware(config, config_path, args)
- with tempfile.TemporaryDirectory(prefix='hil-remote-') as snap:
+ snap_parent = None
+ if os.name == 'nt': # in the checkout, where local_operand() can name it to rsync
+ snap_parent = ROOT / RUNS_DIR
+ snap_parent.mkdir(exist_ok=True)
+ with tempfile.TemporaryDirectory(prefix='hil-remote-', dir=snap_parent) as snap:
pinned = None
if receipt is not None:
firmware = snapshot(firmware, Path(snap))
@@ -515,6 +556,8 @@
pinned = check_receipt(receipt, config_path, boards,
[d for b in boards for d in variant_dirs(config, args.build_dir, b)],
staged_files(firmware, Path(snap)))
+ for p in (config_path, *firmware):
+ local_operand(p) # Windows: refuse one rsync cannot name now, not after the lease wiped the rig's tree
print(f'==> Setting up remote {remote}:{remote_dir}')
with remote_lease(remote, remote_dir, args.build_dir) as (remote_dir, token):
@@ -535,16 +578,16 @@
"uploading it; this run's table will REPLACE the previous one", file=sys.stderr)
else:
print('==> Uploading hil_report.json as the --accumulate merge base')
- if rsync(guard, '-q', str(sidecar), f'{remote}:{remote_dir}/') != 0:
+ if rsync(guard, '-q', sidecar, f'{remote}:{remote_dir}/') != 0:
fail('could not upload hil_report.json')
print('==> Copying the harness and config')
if rsync(guard, '-aqR', *HARNESS_FILES, f'{remote}:{remote_dir}/') != 0 or \
- rsync(guard, '-q', str(config_path), f'{remote}:{remote_dir}/test/hil/') != 0:
+ rsync(guard, '-q', config_path, f'{remote}:{remote_dir}/test/hil/') != 0:
fail('could not stage the harness')
print(f'==> Copying firmware from {len(firmware)} build dir(s) under {args.build_dir}/')
# one transfer: every dir lands under the same <build_dir>/, by its own name
- if rsync(guard, '-a', *FIRMWARE_FILTER, *map(str, firmware), f'{remote}:{remote_dir}/{args.build_dir}/') != 0:
+ if rsync(guard, '-a', *FIRMWARE_FILTER, *firmware, f'{remote}:{remote_dir}/{args.build_dir}/') != 0:
fail('could not stage the firmware')
# the harness and roster went from the live checkout, which may have moved since the receipt check
drift = pinned and list(filter(None, checkout_drift(*pinned, config_path)))
@@ -578,19 +621,83 @@
"""Publish a record under an exclusive flock taken before it appears, refusing an existing
one; returns the fd that holds the lock, which the kernel drops when this process dies."""
tmp = path.with_name(f'.{path.name}.{os.getpid()}.tmp')
- tmp.write_text(json.dumps(data) + '\n')
+ try:
+ f = open(tmp, 'x')
+ except FileExistsError:
+ if tmp.stat().st_nlink > 1: # a killed run's hidden name under a reused pid: writing it rewrites that record
+ raise
+ tmp.unlink()
+ f = open(tmp, 'x')
+ with f:
+ f.write(json.dumps(data) + '\n')
fd = os.open(tmp, os.O_RDONLY)
try:
- fcntl.flock(fd, fcntl.LOCK_EX)
+ if fcntl:
+ fcntl.flock(fd, fcntl.LOCK_EX)
+ else:
+ win_lock(fd)
os.link(tmp, path)
except BaseException:
os.close(fd)
+ tmp.unlink(missing_ok=True)
raise
- finally:
+ if fcntl: # Windows cannot remove a name of a file still open: run_recorded does, after the close
tmp.unlink(missing_ok=True)
return fd
+# msvcrt locks are mandatory and per byte: lock one far past the record, so readers still read it
+WIN_LOCK_OFFSET = 0x7FFFFFFE
+
+
+def win_lock(fd, block=True):
+ """The Windows stand-in for flock(LOCK_EX), held until win_unlock() or the handle closes;
+ BlockingIOError when not `block` and another handle holds it."""
+ pos = os.lseek(fd, 0, os.SEEK_CUR)
+ os.lseek(fd, WIN_LOCK_OFFSET, os.SEEK_SET)
+ try:
+ while True:
+ try:
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
+ return
+ except OSError as e:
+ if e.errno not in (errno.EACCES, errno.EDEADLOCK):
+ raise
+ if not block:
+ raise BlockingIOError
+ time.sleep(0.05) # LK_LOCK gives up after about 10 s
+ finally:
+ os.lseek(fd, pos, os.SEEK_SET)
+
+
+def win_unlock(fd):
+ """Release win_lock() now: Windows drops a closed handle's lock only "after a delay"."""
+ pos = os.lseek(fd, 0, os.SEEK_CUR)
+ os.lseek(fd, WIN_LOCK_OFFSET, os.SEEK_SET)
+ try:
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
+ finally:
+ os.lseek(fd, pos, os.SEEK_SET)
+
+
+# a waiter's probe holds the lock for a moment; a run holds it for its whole life
+PROBE_TRIES, PROBE_SECS = 10, 0.02
+
+
+def win_probe(fd):
+ """flock(LOCK_SH | LOCK_NB) for run_alive(): msvcrt has no shared lock, so two waiters' probes
+ contend; BlockingIOError only when the lock stays held across the retries."""
+ for _ in range(PROBE_TRIES):
+ try:
+ win_lock(fd, block=False)
+ except BlockingIOError:
+ time.sleep(PROBE_SECS)
+ continue
+ win_unlock(fd)
+ return
+ raise BlockingIOError
+
+
def pop_option(argv, name):
"""(the option's value or None, argv without it): the rest goes to hil_test.py."""
for i, a in enumerate(argv):
@@ -612,10 +719,12 @@
background run."""
started, done = run_paths(run_id)
started.parent.mkdir(exist_ok=True)
+ if not fcntl:
+ sweep_stale_tmps(started.parent)
try:
lock = publish_locked(started, {'runId': run_id, 'pid': os.getpid(), 'startedAt': time.time()})
except FileExistsError:
- fail(f'run id {run_id} was used before ({started.relative_to(ROOT)}); pick a new one')
+ fail(f'run id {run_id} was used before ({started.relative_to(ROOT).as_posix()}); pick a new one')
rc, reports = 1, []
try:
rc, reports = run(argv, receipt)
@@ -625,14 +734,36 @@
raise
finally:
write_json(done, {'runId': run_id, 'exit': rc, 'reports': reports})
- os.close(lock)
+ try:
+ if not fcntl:
+ win_unlock(lock)
+ finally:
+ os.close(lock)
+ if not fcntl:
+ with contextlib.suppress(OSError): # a waiter's probe holding the file open makes Windows refuse; the next sweep takes it
+ started.with_name(f'.{started.name}.{os.getpid()}.tmp').unlink(missing_ok=True)
+
+
+def sweep_stale_tmps(runs):
+ """Windows: remove the hidden second name of each started record a killed run left behind
+ (run_recorded removes its own after the close, which a kill skips). A live run holds its
+ file open, which Windows refuses to unlink, and a launch not yet linked has one name."""
+ for tmp in runs.glob('.*.started.json.*.tmp'):
+ try:
+ if tmp.stat().st_nlink > 1:
+ tmp.unlink()
+ except OSError:
+ pass
def run_alive(started):
"""Its run still holds the started record's lock."""
fd = os.open(started, os.O_RDONLY)
try:
- fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
+ if fcntl:
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
+ else:
+ win_probe(fd)
return False
except BlockingIOError:
return True
diff --git a/.claude/skills/make-release/scripts/release.py b/.claude/skills/make-release/scripts/release.py
index 8177ecc..ca43dee 100755
--- a/.claude/skills/make-release/scripts/release.py
+++ b/.claude/skills/make-release/scripts/release.py
@@ -13,6 +13,7 @@
ancestor of HEAD, a first-parent commit that is not a merged PR."""
import argparse
import json
+import os
import re
import subprocess
import sys
@@ -58,7 +59,7 @@
raise Refused(f'version must be X.Y.Z, got {version!r}')
major, minor, rev = version.split('.')
option_h = root / 'src/tusb_option.h'
- current = current_version(option_h.read_text())
+ current = current_version(option_h.read_text(encoding='utf-8'))
if current == version:
raise Refused(f'{version} is already the version in src/tusb_option.h')
@@ -68,26 +69,26 @@
(r'(#define TUSB_VERSION_MINOR *) \d+', rf'\g<1> {minor}'),
(r'(#define TUSB_VERSION_REVISION *) \d+', rf'\g<1> {rev}'),
],
- root / 'repository.yml': repository_edits((root / 'repository.yml').read_text(), version, major),
+ root / 'repository.yml': repository_edits((root / 'repository.yml').read_text(encoding='utf-8'), version, major),
root / 'library.json': [(r'( {4}"version":) "\d+\.\d+\.\d+"', rf'\1 "{version}"')],
root / 'sonar-project.properties': [(r'(sonar\.projectVersion=)\d+\.\d+\.\d+', rf'\g<1>{version}')],
}
pending = []
for path, subs in edits.items(): # validate everything first: a refusal writes nothing
- before = path.read_text()
+ before = path.read_text(encoding='utf-8')
after = before
for pattern, repl in subs:
after = re.sub(pattern, repl, after, count=1)
if after == before:
- raise Refused(f'{path.relative_to(root)}: no line matched the version pattern; nothing written')
+ raise Refused(f'{path.relative_to(root).as_posix()}: no line matched the version pattern; nothing written')
pending.append((path, after))
for path, after in pending:
- path.write_text(after)
+ path.write_bytes(after.encode('utf-8')) # as on POSIX, no CRLF; write_text's newline= needs 3.10
return [path for path, _ in pending]
def git(root, *args):
- r = subprocess.run(['git', '-C', str(root), *args], capture_output=True, text=True)
+ r = subprocess.run(['git', '-C', str(root), *args], capture_output=True, encoding='utf-8')
if r.returncode != 0:
raise Refused(f'git {" ".join(args)} failed: {r.stderr.strip()}')
return r.stdout
@@ -120,7 +121,7 @@
"""One PR's number, title, labels and author; a few hundred calls in a row hit transient API errors."""
for attempt in range(attempts):
r = subprocess.run(['gh', 'pr', 'view', str(number), '--json', 'number,title,labels,author'],
- cwd=root, capture_output=True, text=True)
+ cwd=root, capture_output=True, encoding='utf-8')
if r.returncode == 0:
return json.loads(r.stdout)
raise Refused(f'gh pr view {number} failed {attempts} times: {r.stderr.strip()}')
@@ -142,11 +143,14 @@
help='the previous release tag; PRs are those reachable from --head but not from it')
q.add_argument('--head', default='HEAD', help='the release branch (default HEAD)')
a = p.parse_args()
+ if os.name == 'nt': # PR titles are UTF-8 whatever the console's code page
+ sys.stdout.reconfigure(encoding='utf-8')
+ sys.stderr.reconfigure(encoding='utf-8', errors='backslashreplace')
root = a.root.resolve()
try:
if a.cmd == 'bump':
for path in bump(root, a.version):
- print(f'{a.version} -> {path.relative_to(root)}')
+ print(f'{a.version} -> {path.relative_to(root).as_posix()}')
print(f'now add docs/changelog/{a.version}.md and list it first in docs/changelog/index.rst')
return 0
prs = fetch(root, merged_prs(root, a.prev, a.head))
diff --git a/.claude/skills/update-sponsor/update_sponsor.py b/.claude/skills/update-sponsor/update_sponsor.py
index d09c8b2..085713f 100644
--- a/.claude/skills/update-sponsor/update_sponsor.py
+++ b/.claude/skills/update-sponsor/update_sponsor.py
@@ -17,6 +17,7 @@
import difflib
import hashlib
import json
+import os
import re
import subprocess
import tempfile
@@ -61,7 +62,7 @@
def gh(*args, **kw):
- out = subprocess.run(["gh", *args], capture_output=True, text=True, **kw)
+ out = subprocess.run(["gh", *args], capture_output=True, encoding="utf-8", **kw)
if out.returncode:
sys.exit(f"gh {' '.join(args[:2])} failed:\n{out.stderr.strip()}")
return out.stdout
@@ -187,25 +188,29 @@
new_text.splitlines(), n=0) if l.startswith("+")]
if not added:
return []
- with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=True) as probe:
- # outside the repo and not dot-prefixed: codespell skips hidden files unless
- # .codespellrc is picked up from cwd, which would make this guard fail open
- probe.write("\n".join(added) + "\n")
- probe.flush()
- try:
- run = subprocess.run(["codespell", "--ignore-words", str(IGNORE_WORDS), probe.name],
- capture_output=True, text=True)
- except FileNotFoundError:
- print(" note: codespell not on PATH - generated block is UNCHECKED")
- return []
+ # closed before codespell reads it: Windows cannot open a file a second time while it is open
+ probe = tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8")
+ try:
+ with probe:
+ # outside the repo and not dot-prefixed: codespell skips hidden files unless
+ # .codespellrc is picked up from cwd, which would make this guard fail open
+ probe.write("\n".join(added) + "\n")
+ run = subprocess.run(["codespell", "--ignore-words", str(IGNORE_WORDS), probe.name],
+ capture_output=True, encoding="utf-8", errors="replace")
+ except FileNotFoundError:
+ print(" note: codespell not on PATH - generated block is UNCHECKED")
+ return []
+ finally:
+ Path(probe.name).unlink(missing_ok=True)
if run.returncode not in (0, 65): # 65 = typos found; anything else is a tool error
print(f" note: codespell failed (rc={run.returncode}) - generated block is UNCHECKED")
return []
out = run.stdout
+ skip = 2 + probe.name.count(":") # <file>:<line>: <word> ==> <fix>, and a Windows path has a drive colon
# v2.2.4 (the pinned hook) lowercases dictionary keys before testing ignore-words,
# so a cased entry would never match and -w would rewrite the login anyway.
- return sorted({l.split(":", 2)[2].split("==>")[0].strip().lower()
- for l in out.splitlines() if l.count(":") >= 2})
+ return sorted({l.split(":", skip)[skip].split("==>")[0].strip().lower()
+ for l in out.splitlines() if l.count(":") >= skip})
def readme_diff(original, new_text):
@@ -404,6 +409,9 @@
only.add_argument("--readme-only", action="store_true", help="skip the label pass")
only.add_argument("--labels-only", action="store_true", help="skip the README pass")
args = p.parse_args()
+ if os.name == "nt": # labels carry emoji, names and titles any script, whatever the console's code page
+ sys.stdout.reconfigure(encoding="utf-8")
+ sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace")
if args.rules:
return print_rules()
diff --git a/.claude/skills/usbtest/SKILL.md b/.claude/skills/usbtest/SKILL.md
index 5f160bc..55743f1 100644
--- a/.claude/skills/usbtest/SKILL.md
+++ b/.claude/skills/usbtest/SKILL.md
@@ -40,9 +40,10 @@
```
`--after leave` keeps usbtest running for a debug session. `--variant` is required when the board
-has several. It refuses with exit 2 and the reason before touching hardware, including while any
-battery or another `run_case.py` runs on the host; pass `--allow-concurrent` only when another
-battery's load on the board's host controller does not matter for what you are measuring. A wedged device is never parked. The last stdout
+has several. It refuses with exit 2 and the reason before touching hardware, including on a host
+that is not Linux and while any battery or another `run_case.py` runs on the host; pass
+`--allow-concurrent` only when another battery's load on the board's host controller does not
+matter for what you are measuring. A wedged device is never parked. The last stdout
line is its JSON verdict; each of its `cases` carries `usbtest.py`'s `detail`, and `stderr` and
`dmesg` when it captured them.
diff --git a/.claude/skills/usbtest/scripts/kernel_src.py b/.claude/skills/usbtest/scripts/kernel_src.py
index b10db2f..48afa18 100644
--- a/.claude/skills/usbtest/scripts/kernel_src.py
+++ b/.claude/skills/usbtest/scripts/kernel_src.py
@@ -164,6 +164,8 @@
default=Path(os.environ.get('XDG_CACHE_HOME') or Path.home() / '.cache') / 'tinyusb-usbtest',
help='where fetched files are kept (default: %(default)s)')
args = p.parse_args()
+ if os.name == 'nt': # the kernel's source is UTF-8 whatever the console's code page
+ sys.stdout.reconfigure(encoding='utf-8')
if args.tag:
if not RE_TAG.fullmatch(args.tag):
@@ -186,7 +188,7 @@
if args.case is None:
return 0
- lines = usbtest_c.read_text(errors='replace').splitlines()
+ lines = usbtest_c.read_text(encoding='utf-8', errors='replace').splitlines()
print(f'\n== case {args.case} in usbtest_do_ioctl()')
for n, text in case_block(lines, args.case):
print(f'{n:5d} {text}')
diff --git a/.claude/skills/usbtest/scripts/run_case.py b/.claude/skills/usbtest/scripts/run_case.py
index 19ba4bb..0a659df 100644
--- a/.claude/skills/usbtest/scripts/run_case.py
+++ b/.claude/skills/usbtest/scripts/run_case.py
@@ -15,6 +15,7 @@
process is alive on this host, checked before and again after taking the lock. That check is
host-wide and does not see a hil_test.py started after it: --allow-concurrent skips it only when
another battery's load on this board's host controller does not matter for what you are measuring.
+A host that is not Linux is refused first, once argparse has accepted the command line.
Flashes with the roster's flasher from cmake-build/cmake-build-<variant> (the build skill's
--shared --variants layout), waits for cafe:4010 with the board's serial, and runs usbtest.py
@@ -24,7 +25,8 @@
stdout ends with one JSON line: {"pass", "board", "variant", "tests", "cases", "wedged",
"boardState", "error"}, each case with usbtest.py's num, name, status, detail and, when it captured
them, stderr and dmesg. Exit 0 every case passed, 1 a case failed or the run failed after the
-lock was taken, 2 refused before any hardware action. A SIGTERM (`hil_lock.py release` sends one
+lock was taken, 2 refused before any hardware action; a command line argparse rejects exits 2
+with its usage on stderr and no JSON line. A SIGTERM (`hil_lock.py release` sends one
to the lock holder) kills the running flasher or usbtest.py, reports boardState unknown with a
verdict line, then releases the lock.
"""
@@ -37,6 +39,33 @@
import time
from pathlib import Path
+
+def build_parser():
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument('--config', required=True, help="this host's HIL config json (the HIL contract)")
+ p.add_argument('--board', required=True)
+ p.add_argument('--tests', required=True, help='comma-separated usbtest case numbers')
+ p.add_argument('--after', required=True, choices=('park', 'leave'),
+ help='park: flash device/board_test afterwards; leave: keep usbtest running')
+ p.add_argument('--variant', help='roster variant, required when the board has several')
+ p.add_argument('--timeout', type=int, default=60, help='per-case timeout in seconds (default 60, as HIL)')
+ p.add_argument('--allow-concurrent', action='store_true',
+ help='skip the live-battery check; see the description first')
+ return p
+
+
+def new_report(args):
+ return {'pass': False, 'board': args.board, 'variant': args.variant, 'tests': args.tests,
+ 'cases': [], 'wedged': False, 'boardState': 'untouched', 'error': ''}
+
+
+if __name__ == '__main__' and sys.platform != 'linux': # after argparse, so --help works; before the harness imports
+ _report = new_report(build_parser().parse_args())
+ _report['error'] = 'run_case.py needs a Linux host (usbfs, /proc, the usbtest kernel module)'
+ print(f'error: {_report["error"]}', file=sys.stderr)
+ print(json.dumps(_report))
+ sys.exit(2)
+
ROOT = Path(__file__).resolve().parents[4]
HIL_DIR = ROOT / 'test' / 'hil'
sys.path.insert(0, str(HIL_DIR))
@@ -268,20 +297,8 @@
def main():
- p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
- p.add_argument('--config', required=True, help="this host's HIL config json (the HIL contract)")
- p.add_argument('--board', required=True)
- p.add_argument('--tests', required=True, help='comma-separated usbtest case numbers')
- p.add_argument('--after', required=True, choices=('park', 'leave'),
- help='park: flash device/board_test afterwards; leave: keep usbtest running')
- p.add_argument('--variant', help='roster variant, required when the board has several')
- p.add_argument('--timeout', type=int, default=60, help='per-case timeout in seconds (default 60, as HIL)')
- p.add_argument('--allow-concurrent', action='store_true',
- help='skip the live-battery check; see the description first')
- args = p.parse_args()
-
- report = {'pass': False, 'board': args.board, 'variant': args.variant, 'tests': args.tests,
- 'cases': [], 'wedged': False, 'boardState': 'untouched', 'error': ''}
+ args = build_parser().parse_args()
+ report = new_report(args)
def finish(code, error=''):
signal.signal(signal.SIGTERM, signal.SIG_IGN) # one verdict line; the release still follows
diff --git a/.claude/skills/usbtest/scripts/wedge_drill.py b/.claude/skills/usbtest/scripts/wedge_drill.py
index 21644fe..6787355 100755
--- a/.claude/skills/usbtest/scripts/wedge_drill.py
+++ b/.claude/skills/usbtest/scripts/wedge_drill.py
@@ -28,7 +28,8 @@
(the harness did not recover a wedge it was given), or "inconclusive" (no wedge was produced:
the case ended before the halt landed, the halt failed, or no case hung). `cleanup` is the drill's
own reset after a non-pass and never counts as harness recovery. Exit 0 only for a pass with the
-board parked; 1 otherwise; 2 refused.
+board parked; 1 otherwise; 2 refused (a command line argparse rejects: its usage on stderr, no JSON
+line).
"""
import argparse
import json
@@ -43,6 +44,35 @@
import time
from pathlib import Path
+
+def build_parser():
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument('--config', required=True, help="this host's HIL config json (the HIL contract)")
+ p.add_argument('--board', required=True)
+ p.add_argument('--variant', help='roster variant, required when the board has several')
+ p.add_argument('--delay', type=float, default=0.3, help='seconds into case 27 before the halt (default 0.3)')
+ p.add_argument('--timeout', type=int, default=60, help='per-case timeout in seconds (default 60, as HIL)')
+ p.add_argument('--halt-openocd', metavar='ARGS',
+ help='openocd args that halt the core on the board\'s probe, required when its recovery '
+ 'flasher is not openocd, e.g. "-f interface/stlink.cfg -f target/stm32l4x.cfg"')
+ p.add_argument('--allow-concurrent', action='store_true',
+ help="skip the live-battery check; see run_case.py's description first")
+ return p
+
+
+def new_report(args):
+ return {'drill': 'inconclusive', 'board': args.board, 'variant': args.variant, 'reason': '',
+ 'halt': {}, 'cases': [], 'wedged': False, 'recovery': {}, 'device': {},
+ 'smoke': None, 'cleanup': None, 'boardState': 'untouched', 'error': ''}
+
+
+if __name__ == '__main__' and sys.platform != 'linux': # after argparse, so --help works; before run_case's harness imports
+ _report = new_report(build_parser().parse_args())
+ _report['error'] = 'wedge_drill.py needs a Linux host (usbfs, /proc, the usbtest kernel module)'
+ print(f'error: {_report["error"]}', file=sys.stderr)
+ print(json.dumps(_report))
+ sys.exit(2)
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
import run_case # noqa: E402
from run_case import PROC, Refused, Terminated, hil_flash, hil_lock, hil_util, usb_observe, usbtest # noqa: E402
@@ -419,22 +449,8 @@
def main():
- p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
- p.add_argument('--config', required=True, help="this host's HIL config json (the HIL contract)")
- p.add_argument('--board', required=True)
- p.add_argument('--variant', help='roster variant, required when the board has several')
- p.add_argument('--delay', type=float, default=0.3, help='seconds into case 27 before the halt (default 0.3)')
- p.add_argument('--timeout', type=int, default=60, help='per-case timeout in seconds (default 60, as HIL)')
- p.add_argument('--halt-openocd', metavar='ARGS',
- help='openocd args that halt the core on the board\'s probe, required when its recovery '
- 'flasher is not openocd, e.g. "-f interface/stlink.cfg -f target/stm32l4x.cfg"')
- p.add_argument('--allow-concurrent', action='store_true',
- help="skip the live-battery check; see run_case.py's description first")
- args = p.parse_args()
-
- report = {'drill': 'inconclusive', 'board': args.board, 'variant': args.variant, 'reason': '',
- 'halt': {}, 'cases': [], 'wedged': False, 'recovery': {}, 'device': {},
- 'smoke': None, 'cleanup': None, 'boardState': 'untouched', 'error': ''}
+ args = build_parser().parse_args()
+ report = new_report(args)
def finish(code):
signal.signal(signal.SIGTERM, signal.SIG_IGN)
diff --git a/.claude/test/test_build.py b/.claude/test/test_build.py
index 541ca05..552d367 100644
--- a/.claude/test/test_build.py
+++ b/.claude/test/test_build.py
@@ -795,6 +795,12 @@
self.assertEqual((rc, err.getvalue()), (3, ''))
self.assertEqual(sorted(out.split()), ['x', 'y'])
+ def test_the_child_writes_utf8_whatever_its_inherited_encoding(self):
+ # a Windows child writes a pipe in the ANSI code page; latin-1 stands in for it on any host
+ with mock.patch.dict(os.environ, {'PYTHONIOENCODING': 'latin-1'}):
+ rc, out = build.run([sys.executable, '-c', 'print("\u2192 \u00f8")'], False)
+ self.assertEqual((rc, out), (0, '\u2192 \u00f8\n'))
+
UTILS = Path(__file__).resolve().parents[2] / 'tools' / 'build_utils.py'
_uspec = importlib.util.spec_from_file_location('build_utils_under_test', UTILS)
diff --git a/.claude/test/test_build_doc.py b/.claude/test/test_build_doc.py
index 4e21604..121955d 100644
--- a/.claude/test/test_build_doc.py
+++ b/.claude/test/test_build_doc.py
@@ -1,54 +1,110 @@
"""Tests for the build-doc skill's build_doc.py: it runs sphinx-build strictly
by default, forwards the flags it documents, and propagates the build's exit
-code without inventing success."""
+code without inventing success. The docs and build dirs are temp dirs, never
+the checkout's docs/_build, which --clean removes."""
+import importlib.util
+import io
import os
+import shutil
import subprocess
import sys
import tempfile
+import types
import unittest
+from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
+from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / 'skills' / 'build-doc' / 'scripts' / 'build_doc.py'
-ROOT = Path(__file__).resolve().parents[2]
-
-
-def run(args, rc=0):
- """Run build_doc.py with a stub sphinx-build on PATH that logs its argv and exits rc."""
- with tempfile.TemporaryDirectory() as d:
- stub = Path(d) / 'sphinx-build'
- log = Path(d) / 'argv'
- stub.write_text(f'#!/bin/sh\nprintf "%s\\n" "$@" > {log}\nexit {rc}\n')
- stub.chmod(0o755)
- env = dict(os.environ, PATH=f'{d}:{os.environ["PATH"]}')
- r = subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True, env=env)
- argv = log.read_text().split('\n')[:-1] if log.exists() else None
- return r, argv
+spec = importlib.util.spec_from_file_location('build_doc', SCRIPT)
+build_doc = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(build_doc)
class BuildDocTest(unittest.TestCase):
+ def setUp(self):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.docs = Path(tmp.name) / 'docs'
+ self.build = self.docs / '_build'
+ for name, value in (('DOCS', self.docs), ('BUILD', self.build)):
+ patch = mock.patch.object(build_doc, name, value)
+ patch.start()
+ self.addCleanup(patch.stop)
+
+ def run_main(self, args, rc=0):
+ """build_doc.py's main() with its sphinx-build call recorded and answered with rc, in-process:
+ a stub on PATH that a host cannot execute would let the real sphinx-build run."""
+ calls = []
+ out, err = io.StringIO(), io.StringIO()
+
+ def call(cmd):
+ calls.append(cmd)
+ self.build_existed = self.build.exists()
+ return rc
+ with mock.patch.object(build_doc.subprocess, 'call', call), \
+ mock.patch.object(sys, 'argv', [str(SCRIPT), *args]), redirect_stdout(out), redirect_stderr(err):
+ try:
+ code = build_doc.main()
+ except SystemExit as e:
+ code = e.code
+ if calls:
+ assert calls[0][0] == 'sphinx-build', calls
+ return types.SimpleNamespace(returncode=code, stdout=out.getvalue(), stderr=err.getvalue()), \
+ calls[0][1:] if calls else None
+
def test_default_is_html_strict_from_docs_into_docs_build(self):
- r, argv = run([])
+ r, argv = self.run_main([])
self.assertEqual(r.returncode, 0, r.stderr)
- self.assertEqual(argv, ['-b', 'html', '-W', str(ROOT / 'docs'), str(ROOT / 'docs' / '_build')])
+ self.assertEqual(argv, ['-b', 'html', '-W', str(self.docs), str(self.build)])
self.assertIn('Docs built:', r.stdout)
def test_no_strict_drops_W_and_nothing_else(self):
- r, argv = run(['--no-strict'])
+ r, argv = self.run_main(['--no-strict'])
self.assertEqual(r.returncode, 0, r.stderr)
self.assertNotIn('-W', argv)
self.assertEqual(argv[:2], ['-b', 'html'])
+ def test_clean_removes_the_build_dir_first(self):
+ (self.build / 'stale').mkdir(parents=True)
+ r, argv = self.run_main(['-c'])
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertFalse(self.build_existed, 'removed before sphinx-build ran')
+
def test_sphinx_failure_is_the_exit_code_and_no_success_line(self):
- r, argv = run([], rc=2)
+ r, argv = self.run_main([], rc=2)
self.assertEqual(r.returncode, 2)
self.assertIsNotNone(argv)
self.assertNotIn('Docs built:', r.stdout)
def test_unknown_flag_is_refused_before_sphinx_runs(self):
- r, argv = run(['-W'])
+ r, argv = self.run_main(['-W'])
self.assertEqual(r.returncode, 2)
self.assertIsNone(argv)
+@unittest.skipIf(os.name == 'nt', 'the stub is a #! script Windows cannot exec: a miss would run the real sphinx-build')
+class ProcessTest(unittest.TestCase):
+ """The script as a process, copied into a temp checkout so its docs/_build is a temp dir:
+ main()'s status is the exit status."""
+
+ def test_the_exit_status_is_mains(self):
+ for rc in (0, 3):
+ with self.subTest(rc=rc), tempfile.TemporaryDirectory() as d:
+ script = Path(d) / '.claude' / 'skills' / 'build-doc' / 'scripts' / 'build_doc.py'
+ script.parent.mkdir(parents=True)
+ shutil.copy(SCRIPT, script)
+ stub = Path(d) / 'bin' / 'sphinx-build'
+ stub.parent.mkdir()
+ stub.write_text(f'#!/bin/sh\nprintf "%s\\n" "$@" > {d}/argv\nexit {rc}\n')
+ stub.chmod(0o755)
+ env = dict(os.environ, PATH=f'{stub.parent}{os.pathsep}{os.environ["PATH"]}')
+ r = subprocess.run([sys.executable, str(script)], capture_output=True, text=True, env=env, timeout=60)
+ self.assertEqual(r.returncode, rc, r.stderr)
+ self.assertEqual((Path(d) / 'argv').read_text().split('\n')[:-1],
+ ['-b', 'html', '-W', str(Path(d).resolve() / 'docs'), str(Path(d).resolve() / 'docs' / '_build')])
+ self.assertEqual('Docs built:' in r.stdout, rc == 0)
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/.claude/test/test_coverage_audit.py b/.claude/test/test_coverage_audit.py
index 651f978..09b4c61 100644
--- a/.claude/test/test_coverage_audit.py
+++ b/.claude/test/test_coverage_audit.py
@@ -4,10 +4,13 @@
import importlib.util
import json
import os
+import shlex
import subprocess
+import sys
import tempfile
import unittest
from pathlib import Path
+from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / 'skills' / 'build' / 'scripts' / 'coverage_audit.py'
spec = importlib.util.spec_from_file_location('coverage_audit', SCRIPT)
@@ -51,6 +54,16 @@
self.assertEqual(ca.compiler_of({'command': '/x/riscv-none-elf-gcc -c a.c'}), '/x/riscv-none-elf-gcc')
self.assertEqual(ca.compiler_of({'arguments': ['arm-none-eabi-gcc', '-c']}), 'arm-none-eabi-gcc')
+ def test_compiler_of_reads_a_quoted_compiler_path(self):
+ gcc = 'C:/Program Files/Arm GNU Toolchain/bin/arm-none-eabi-gcc.exe' if os.name == 'nt' else '/opt/arm gnu/bin/arm-none-eabi-gcc'
+ join = subprocess.list2cmdline if os.name == 'nt' else shlex.join # as CMake writes it on each host
+ self.assertEqual(ca.compiler_of({'command': join([gcc, '-c', 'a.c'])}), gcc)
+
+ def test_its_family_json_leaves_the_audited_checkouts_to_import_build(self):
+ # graph imports the audited checkout's tools/build.py, whose `import family_json` must find that checkout's
+ self.assertEqual(Path(ca.family_json.__file__), SCRIPT.parents[4] / 'tools' / 'family_json.py')
+ self.assertIsNot(sys.modules.get('family_json'), ca.family_json)
+
def test_global_assembly_label_is_live_local_one_is_not(self):
self.assertTrue(ca.defines_symbols(' 25: 00000000 2 NOTYPE GLOBAL DEFAULT 7 Default_Handler\n'))
self.assertFalse(ca.defines_symbols(' 4: 00000000 0 NOTYPE LOCAL DEFAULT 1 $t\n'))
@@ -100,6 +113,25 @@
self.assertIsNone(ca.rel('/repo/cmake-build/cmake-build-x/gen.h', '/repo'))
self.assertEqual(ca.rel('../../src/tusb.c', '/repo', '/repo/cmake-build/b'), 'src/tusb.c')
+ @unittest.skipUnless(os.name == 'nt', 'drive letters are Windows paths')
+ def test_rel_is_posix_and_another_drive_is_outside(self):
+ self.assertEqual(ca.rel('E:\\repo\\src\\tusb.h', 'E:/repo'), 'src/tusb.h')
+ ca.other_drive.clear()
+ self.assertIsNone(ca.rel('C:/Tool/arm/include/stdint.h', 'E:/repo'))
+ self.assertEqual(ca.other_drive, {os.path.normpath('C:/Tool/arm/include/stdint.h')}, 'counted for the graph note')
+
+ def test_an_lto_object_is_asked_through_its_compilers_own_nm(self):
+ for compiler, nm in (('/x/arm-none-eabi-gcc', '/x/arm-none-eabi-gcc-nm'),
+ ('C:\\x\\arm-none-eabi-gcc.exe', 'C:\\x\\arm-none-eabi-gcc-nm.exe')):
+ calls = []
+
+ def run(cmd, cwd):
+ calls.append(cmd[0])
+ return subprocess.CompletedProcess(cmd, 0, '__gnu_lto_slim\n' if cmd[0] == 'readelf' else '00000000 T f\n', '')
+ with mock.patch.object(ca, 'run', run):
+ self.assertTrue(ca.is_live('x.obj', compiler, '.'))
+ self.assertEqual(calls, ['readelf', nm])
+
class ScopeTest(unittest.TestCase):
def test_example_cmake_reaches_its_example_only(self):
@@ -166,8 +198,9 @@
class FakeBuild:
"""A built tree on disk (sources, compile_commands.json) whose ninja and readelf answers
are canned: two examples, each linking a different library archive."""
- def __init__(self, root, fail=None, built=('device/a', 'device/b')):
+ def __init__(self, root, fail=None, built=('device/a', 'device/b'), extra_deps=()):
self.root, self.bd, self.fail = root, os.path.join(root, 'cmake-build', 'b'), fail
+ self.extra_deps = ''.join(f' {d}\n' for d in extra_deps) # in every object's deps
for f in ('examples/device/a/main.c', 'examples/device/b/main.c', 'lib/os/os.c', 'lib/os/os.h',
'lib/usb/usb.c', 'hw/bsp/f/start.S', 'hw/bsp/f/mem.ld', 'src/dead.c', 'src/prog.pio'):
os.makedirs(os.path.dirname(os.path.join(root, f)), exist_ok=True)
@@ -207,7 +240,7 @@
out = ''.join(f'{t}: x\n' for t in self.query)
elif cmd[4] == 'deps':
out = ''.join(f'{o}: #deps 2, deps mtime 1 (VALID)\n {os.path.join(self.root, s)}\n'
- f' {os.path.join(self.root, "lib/os/os.h")}\n'
+ f' {os.path.join(self.root, "lib/os/os.h")}\n' + self.extra_deps
+ (f' {self.bd}/gen/prog.h\n' if o.endswith('usb.c.obj') else '') + '\n'
for o, s in self.objs.items() if not o.endswith('start.S.obj'))
elif cmd[4] == 'query':
@@ -219,10 +252,10 @@
class ExtractTest(unittest.TestCase):
- def extract(self, fail=None, built=('device/a', 'device/b')):
+ def extract(self, fail=None, built=('device/a', 'device/b'), extra_deps=lambda root: ()):
orig = ca.run
with tempfile.TemporaryDirectory() as root:
- fb = FakeBuild(root, fail, built)
+ fb = FakeBuild(root, fail, built, extra_deps(root))
ca.run = fb
try:
return ca.extract(fb.bd, root)
@@ -243,6 +276,18 @@
with self.assertRaises(ca.ExtractError):
self.extract(built=())
+ @unittest.skipUnless(os.name == 'nt', 'drive letters are Windows paths')
+ def test_a_dependency_on_another_drive_is_outside(self):
+ def header(root):
+ drive = 'D:' if os.path.splitdrive(root)[0].upper() == 'C:' else 'C:'
+ return [f'{drive}/Tool/arm/include/stdint.h']
+ ca.other_drive.clear()
+ g = self.extract(extra_deps=header)
+ self.assertEqual(g['examples']['device/a'], ['examples/device/a/main.c', 'hw/bsp/f/link.ld', 'hw/bsp/f/mem.ld',
+ 'hw/bsp/f/start.S', 'lib/os/os.c', 'lib/os/os.h'])
+ self.assertEqual({os.path.splitdrive(p)[1] for p in ca.other_drive},
+ {os.path.normpath('/Tool/arm/include/stdint.h')}, 'counted for the graph note')
+
def test_a_failing_ninja_tool_fails_the_extraction(self):
for tool in ('targets', 'deps', 'query', 'commands'):
with self.assertRaises(ca.ExtractError, msg=tool):
diff --git a/.claude/test/test_gen_doc.py b/.claude/test/test_gen_doc.py
index 9002fcb..4f9a115 100644
--- a/.claude/test/test_gen_doc.py
+++ b/.claude/test/test_gen_doc.py
@@ -39,7 +39,8 @@
bsp = root / 'hw' / 'bsp'
(bsp / 'acme' / 'boards' / 'acme_one').mkdir(parents=True)
(bsp / 'acme' / 'boards' / 'acme_bare').mkdir()
- (bsp / 'acme' / 'family.c').write_text(METADATA_FAMILY)
+ # UTF-8 beyond the metadata block, as several real family.c have: read as UTF-8 on every host
+ (bsp / 'acme' / 'family.c').write_text('// Acme \u2192 \u2714\n' + METADATA_FAMILY, encoding='utf-8')
(bsp / 'acme' / 'boards' / 'acme_one' / 'board.h').write_text(METADATA_BOARD)
(bsp / 'acme' / 'boards' / 'acme_bare' / 'board.h').write_text('#define BOARD_H_\n')
(bsp / 'nometa' / 'boards' / 'nometa_x').mkdir(parents=True)
@@ -50,9 +51,9 @@
(root / 'test' / 'hil' / 'tinyusb.json').write_text(json.dumps({'boards': [
{'name': 'acme_one', 'tests': {'device': True, 'host': True, 'dual': False},
'flasher': {'name': 'jlink'}, 'variant': [{'name': 'acme_one_hs'}],
- 'note': 'needs a | jumper'},
+ 'note': 'needs a | jumper \u00f8'},
{'name': 'acme_two', 'tests': {'only': ['host/cdc_msc', 'device/hid']}},
- ]}))
+ ]}, ensure_ascii=False), encoding='utf-8')
(root / 'test' / 'hil' / 'hfp.json').write_text(json.dumps({'boards': []}))
@@ -69,6 +70,24 @@
self.assertEqual(gen_doc.metadata(Path(d) / 'absent.h'), {})
+class BoardsTest(unittest.TestCase):
+ def test_families_and_boards_sort_by_name_case_sensitively_on_every_host(self):
+ # the committed boards.rst is Linux's order; paths here compare case-insensitively, as on Windows
+ with tempfile.TemporaryDirectory() as d:
+ bsp = Path(d)
+ for family in ('acme', 'Zeta'):
+ (bsp / family / 'family.c').parent.mkdir()
+ (bsp / family / 'family.c').write_text(METADATA_FAMILY)
+ for board, has_header in (('board_x', True), ('Board_Y', True), ('bare', False), ('Bare_', False)):
+ (bsp / family / 'boards' / f'{family}_{board}').mkdir(parents=True)
+ if has_header:
+ (bsp / family / 'boards' / f'{family}_{board}' / 'board.h').write_text(METADATA_BOARD)
+ with mock.patch.object(type(bsp), '__lt__', lambda a, b: str(a).casefold() < str(b).casefold()):
+ vendors, skipped = gen_doc.boards(bsp)
+ self.assertEqual(list(vendors['Acme']), ['Zeta_Board_Y', 'Zeta_board_x', 'acme_Board_Y', 'acme_board_x'])
+ self.assertEqual([p.name for p in skipped], ['Zeta_Bare_', 'Zeta_bare', 'acme_Bare_', 'acme_bare'])
+
+
class MdTableTest(unittest.TestCase):
def test_github_table_pads_headers_by_two_and_strips_cells(self):
self.assertEqual(gen_doc.md_table(['H', 'Name'], [['a ', 'long value'], ['bb', '']]).split('\n'), [
@@ -86,8 +105,8 @@
fixture(root)
r = subprocess.run([sys.executable, str(GEN_DOC), '--root', str(root)], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
- boards = (root / 'docs/reference/boards.rst').read_text()
- hil = (root / 'docs/reference/hil_boards.md').read_text()
+ boards = (root / 'docs/reference/boards.rst').read_text(encoding='utf-8')
+ hil = (root / 'docs/reference/hil_boards.md').read_text(encoding='utf-8')
deps = (root / 'docs/reference/dependencies.rst').read_text()
self.assertIn('Acme\n----\n\n', boards)
self.assertIn('acme_one Acme One acme https://acme.example/one', boards)
@@ -101,8 +120,8 @@
'',
])
self.assertTrue(hil.startswith(gen_doc.HEADER + '\n\n### ci rig\n\n2 boards, from `test/hil/tinyusb.json`.'))
- self.assertIn('| acme_one | device, host | jlink | acme_one_hs | needs a \\| jumper |', hil)
- self.assertIn('| acme_two | device, host | | | |', hil)
+ self.assertIn('| acme_one | device, host | jlink | acme_one_hs | needs a \\| jumper \u00f8 |', hil)
+ self.assertIn('| acme_two | device, host | | | |', hil)
self.assertNotIn('hfp rig', hil)
self.assertIn('lib/fatfs', deps)
diff --git a/.claude/test/test_hil_remote.py b/.claude/test/test_hil_remote.py
index c3d28d5..6fe07e0 100644
--- a/.claude/test/test_hil_remote.py
+++ b/.claude/test/test_hil_remote.py
@@ -1,10 +1,12 @@
"""hil_remote.py against a local "rig": a fake ssh runs each remote command under sh with a
clean environment and a temp HOME, a fake rsync strips the host prefix, and a fake python3
in that HOME's ~/.local/bin stands in for hil_test.py. So the quoting, the tilde expansion,
-the PATH shim and every refusal-before-wipe execute for real. Plumbing only: the real
-transport is proved by a run on ci.lan."""
-import fcntl
+the PATH shim and every refusal-before-wipe execute for real. Windows cannot exec the #!
+fakes, so there only the Refusals and Units run. Plumbing only: the real transport is proved
+by a run on ci.lan."""
+import contextlib
import importlib.util
+import io
import json
import os
import shlex
@@ -12,8 +14,10 @@
import subprocess
import sys
import tempfile
+import types
import unittest
from pathlib import Path
+from unittest import mock
REPO = Path(__file__).resolve().parents[2]
SCRIPT = REPO / '.claude' / 'skills' / 'hil-contract' / 'scripts' / 'hil_remote.py'
@@ -24,6 +28,8 @@
REAL_RSYNC = shutil.which('rsync')
needs_rsync = unittest.skipUnless(REAL_RSYNC, 'rsync not installed; the fake rsync execs the real one')
needs_flock = unittest.skipUnless(shutil.which('flock', path='/usr/bin:/bin'), 'flock not on the fake rig PATH (/usr/bin:/bin)')
+# the fake ssh, rsync and python3 are #! scripts Windows cannot exec: a run there would reach the real ones
+needs_fake_rig = unittest.skipIf(os.name == 'nt', 'the fake rig is #! scripts and a POSIX shell')
FAKE_SSH = f'''#!{sys.executable}
import json, os, subprocess, sys
@@ -116,6 +122,9 @@
PATH=f'{self.bin}:{os.environ["PATH"]}', FAKE_LOG=str(self.log),
FAKE_HOME=str(self.home), FAKE_RUN=str(self.run_file))
e.pop('CONFIG', None)
+ if os.name == 'nt':
+ # only the refusals run here: a REMOTE_DIR they accept, and no real ssh or rsync on PATH
+ e.update(REMOTE_DIR='/tmp/hil-remote-test', PATH=str(self.bin))
return e
def hil_remote(self, *argv, **env):
@@ -210,6 +219,7 @@
self.refused(self.hil_remote('-b', 'alpha', '-B', bad), '-B must be')
+@needs_fake_rig
@needs_rsync
@needs_flock
class Staging(Rig):
@@ -321,6 +331,7 @@
self.assertFalse((self.remote / 'stale').exists())
+@needs_fake_rig
@needs_rsync
@needs_flock
class TheRun(Rig):
@@ -373,11 +384,13 @@
for a in rsync), rsync)
+@needs_fake_rig
@needs_flock
class RemoteLock(Rig):
"""A second run sharing REMOTE_DIR must not rm -rf the tree the first one is running from."""
def lock(self):
+ import fcntl
lk = open(f'{self.remote}.lock', 'a')
self.addCleanup(lk.close)
fcntl.flock(lk, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -476,6 +489,7 @@
self.assertEqual([c['tool'] for c in self.calls()][-1], 'ssh', 'copy_back ran after the refusal')
+@needs_fake_rig
@needs_rsync
@needs_flock
class Reports(Rig):
@@ -546,6 +560,115 @@
self.assertEqual(hil_remote.sidecar_matches(p, CONFIG), ok, body)
p.write_text('{')
self.assertFalse(hil_remote.sidecar_matches(p, CONFIG))
+ p.write_text(json.dumps({'rows': [{'board': 'alpha', 'note': '\u2714'}]}, ensure_ascii=False), encoding='utf-8')
+ self.assertTrue(hil_remote.sidecar_matches(p, CONFIG), 'the rig writes it as UTF-8')
+
+ def test_staged_files_are_named_as_rsync_and_the_rig_name_them(self):
+ with tempfile.TemporaryDirectory() as td, mock.patch.object(hil_remote, 'ROOT', Path(td)):
+ d = Path(td) / 'cmake-build' / 'cmake-build-alpha'
+ (d / 'device' / 'cdc_msc').mkdir(parents=True)
+ for f in ('cdc_msc.elf', 'cdc_msc.map'):
+ (d / 'device' / 'cdc_msc' / f).write_text(f)
+ files = hil_remote.staged_files([d])
+ self.assertEqual(list(files), ['cmake-build/cmake-build-alpha/device/cdc_msc/cdc_msc.elf'])
+ self.assertEqual(hil_remote.example_firmware(d, files), {'device/cdc_msc'})
+
+ def test_no_rsync_is_refused_before_the_lease(self):
+ def popen(*a, **k):
+ raise AssertionError('ssh started')
+ cases = [('none', None, None)]
+ if os.name == 'nt': # subprocess cannot start a .cmd by bare name, and the cwd is not PATH
+ cases += [('a .cmd on PATH', 'path', 'rsync.cmd'), ('an .exe in the cwd', 'cwd', 'rsync.exe')]
+ for case, where, name in cases:
+ with self.subTest(case), tempfile.TemporaryDirectory() as path, tempfile.TemporaryDirectory() as cwd:
+ if name:
+ (Path(path if where == 'path' else cwd) / name).write_bytes(b'')
+ old = os.getcwd()
+ os.chdir(cwd)
+ try:
+ with mock.patch.dict(os.environ, {'PATH': os.pathsep.join(['', path])}), \
+ mock.patch.object(hil_remote.subprocess, 'Popen', popen), \
+ contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as e:
+ with hil_remote.remote_lease('rig', '/tmp/x', 'cmake-build'):
+ pass
+ finally:
+ os.chdir(old)
+ self.assertIn('rsync is not on PATH', str(e.exception.code))
+
+ def test_windows_takes_rsync_exe_only_from_an_absolute_path_dir(self):
+ # the os module as Windows' rsync_program sees it, on any host
+ with tempfile.TemporaryDirectory() as path, tempfile.TemporaryDirectory() as cwd:
+ for d in (path, cwd):
+ (Path(d) / 'rsync.exe').write_bytes(b'')
+ old = os.getcwd()
+ os.chdir(cwd)
+ try:
+ for entries, found in ((['.', 'bin', ''], None), (['.', path], os.path.join(path, 'rsync.exe'))):
+ nt = types.SimpleNamespace(name='nt', path=os.path, pathsep=os.pathsep,
+ environ={'PATH': os.pathsep.join(entries)})
+ with self.subTest(entries), mock.patch.object(hil_remote, 'os', nt):
+ self.assertEqual(hil_remote.rsync_program(), found)
+ finally:
+ os.chdir(old)
+
+ @unittest.skipUnless(os.name == 'nt', 'rsync.exe is the Windows name')
+ def test_windows_runs_the_rsync_exe_found_on_path(self):
+ calls = []
+ with tempfile.TemporaryDirectory() as path:
+ (Path(path) / 'rsync.exe').write_bytes(b'')
+ with mock.patch.dict(os.environ, {'PATH': path}), \
+ mock.patch.object(hil_remote.subprocess, 'run', lambda argv, **kw: calls.append(argv) or mock.Mock(returncode=0)):
+ hil_remote.rsync(('/tmp/t', 'tok'), '-q', 'rig:/tmp/t/x', hil_remote.ROOT / 'x')
+ self.assertEqual(calls[0][0], os.path.join(path, 'rsync.exe'))
+
+ def test_rsync_names_local_files_from_the_checkout_and_passes_remote_specs_as_they_are(self):
+ # a one-letter ssh host must not be read as a drive letter, the checkout's own or another
+ calls = []
+ local = hil_remote.ROOT / 'hil_report.json'
+ hosts = ('rig', hil_remote.ROOT.drive[:1].lower() or 'e', 'r')
+ with mock.patch.object(hil_remote, 'rsync_program', return_value='rsync'), \
+ mock.patch.object(hil_remote.subprocess, 'run', lambda argv, **kw: calls.append((argv, kw)) or mock.Mock(returncode=0)):
+ for host in hosts:
+ hil_remote.rsync(('/tmp/t', 'tok'), '-q', local, f'{host}:/tmp/t/')
+ hil_remote.rsync(('/tmp/t', 'tok'), '-q', f'{host}:/tmp/t/hil_report.json', local)
+ name = 'hil_report.json' if os.name == 'nt' else str(local) # Windows rsync reads C: as a host
+ self.assertEqual([argv[-2:] for argv, _ in calls],
+ [x for h in hosts for x in ([name, f'{h}:/tmp/t/'], [f'{h}:/tmp/t/hil_report.json', name])])
+ self.assertEqual({kw['cwd'] for _, kw in calls}, {hil_remote.ROOT})
+
+ @unittest.skipUnless(os.name == 'nt', 'drive letters are Windows paths')
+ def test_a_config_rsync_cannot_name_is_refused_before_the_lease(self):
+ leased = []
+
+ @contextlib.contextmanager
+ def lease(*a):
+ leased.append(a)
+ yield '/tmp/t', 'tok'
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ other = 'Q:' if root.drive.upper() != 'Q:' else 'R:'
+ args = types.SimpleNamespace(build=False, build_dir='cmake-build', accumulate=False)
+ with mock.patch.object(hil_remote, 'ROOT', root), mock.patch.object(hil_remote, 'helper'), \
+ mock.patch.object(hil_remote, 'parse_run', return_value=(args, CONFIG, Path(f'{other}/rig/tinyusb.json'))), \
+ mock.patch.object(hil_remote, 'resolve_firmware', return_value=[root / 'cmake-build' / 'cmake-build-alpha']), \
+ mock.patch.object(hil_remote, 'remote_lease', lease), \
+ mock.patch.object(hil_remote.subprocess, 'run', side_effect=AssertionError('rsync or ssh ran')), \
+ contextlib.redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as e:
+ hil_remote.run([])
+ self.assertIn('is not on the drive of the checkout', str(e.exception.code))
+ self.assertEqual(leased, [], 'refused after the lease wiped the rig tree')
+
+ def test_the_pin_check_passes_where_the_lock_primitive_cannot_import_fcntl(self):
+ # agentrc.load() checks the pin before running agentrc's hil_lock, whose fcntl Windows lacks
+ args = types.SimpleNamespace(build=True, board=['alpha'], flasher=None, exclude_flasher=None,
+ build_dir='cmake-build')
+ for error, refusal in ((ModuleNotFoundError("No module named 'fcntl'", name='fcntl'), '--build would build'),
+ (ImportError('lib/agentrc must be at the pin'), 'lib/agentrc must be at the pin')):
+ with self.subTest(error), mock.patch.object(hil_remote, 'helper', side_effect=error), \
+ mock.patch.object(hil_remote, 'parse_run', return_value=(args, CONFIG, hil_remote.ROOT / 'x.json')), \
+ self.assertRaises(SystemExit) as e:
+ hil_remote.run([])
+ self.assertIn(refusal, str(e.exception.code))
if __name__ == '__main__':
diff --git a/.claude/test/test_release.py b/.claude/test/test_release.py
index d5d32f1..179bc12 100644
--- a/.claude/test/test_release.py
+++ b/.claude/test/test_release.py
@@ -3,13 +3,16 @@
refuses a previous tag that is not an ancestor and any first-parent commit
that is not a merged PR; gh failures surface instead of thinning the list."""
import importlib.util
+import io
import json
import os
import subprocess
import sys
import tempfile
+import types
import unittest
from pathlib import Path
+from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / 'skills' / 'make-release' / 'scripts' / 'release.py'
spec = importlib.util.spec_from_file_location('release', SCRIPT)
@@ -86,6 +89,20 @@
self.assertEqual(text.count('"0.22.0": "0.22.0"'), 1)
self.assertIn('"0-latest": "0.22.0"', text)
+ def test_files_are_read_and_written_as_utf8_with_lf_whatever_the_locale(self):
+ # bump runs no git or gh, so the script can run as a process here, outside UTF-8 mode and,
+ # on POSIX, in an ASCII locale
+ with tempfile.TemporaryDirectory() as d:
+ root = Path(d)
+ fixture(root)
+ lib = LIBRARY_JSON.replace('"name": "TinyUSB",', '"name": "TinyUSB",\n "authors": "ø ✔",')
+ (root / 'library.json').write_bytes(lib.encode('utf-8'))
+ env = {**{k: v for k, v in os.environ.items() if k != 'PYTHONUTF8'}, 'LC_ALL': 'C', 'PYTHONCOERCECLOCALE': '0'}
+ r = subprocess.run([sys.executable, '-X', 'utf8=0', str(SCRIPT), '--root', d, 'bump', '0.22.1'],
+ capture_output=True, env=env, timeout=60)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertEqual((root / 'library.json').read_bytes(), lib.replace('0.21.0', '0.22.1').encode('utf-8'))
+
class PrNumbersTest(unittest.TestCase):
def test_merge_button_and_squash_subjects_dedupe_and_sort(self):
@@ -99,34 +116,49 @@
class CliTest(unittest.TestCase):
- """git and gh are stubs on PATH: git answers the ancestor check and the log, gh answers per PR."""
+ """main() in-process with git and gh answered by a stand-in for subprocess.run, so no real
+ git or gh can run: git answers the ancestor check and the log, gh answers per PR. Their output
+ is UTF-8 bytes, decoded the way subprocess.run would with the caller's arguments under a
+ legacy code page's locale, and stdout and stderr are a console in a legacy code page."""
def run_cli(self, args, ancestor=True, gh_fail=()):
- with tempfile.TemporaryDirectory() as d:
- bin_dir = Path(d) / 'bin'
- bin_dir.mkdir()
- (bin_dir / 'git').write_text(f'''#!/bin/sh
-case "$*" in
- *merge-base*) exit {0 if ancestor else 1} ;;
- *log*) printf 'Merge pull request #10 from a/b\\nSquashed (#11)\\n' ;;
-esac
-''')
- (bin_dir / 'gh').write_text(f'''#!/bin/sh
-n=$3
-case " {' '.join(map(str, gh_fail))} " in *" $n "*) echo "boom $n" >&2; exit 1 ;; esac
-author=hathach; [ "$n" = 11 ] && author='dependabot[bot]'
-printf '{{"number": %s, "title": "PR %s", "labels": [{{"name": "Port"}}], "author": {{"login": "%s"}}}}' "$n" "$n" "$author"
-''')
- for f in (bin_dir / 'git', bin_dir / 'gh'):
- f.chmod(0o755)
- env = dict(os.environ, PATH=f'{bin_dir}:{os.environ["PATH"]}')
- return subprocess.run([sys.executable, str(SCRIPT), '--root', d, *args],
- capture_output=True, text=True, env=env)
+ def run(argv, capture_output=False, text=False, encoding=None, cwd=None):
+ out, err, rc = '', '', 0
+ if argv[0] == 'git':
+ rc = 0 if 'merge-base' not in argv or ancestor else 1
+ out = 'Merge pull request #10 from a/b\nSquashed (#11)\n' if 'log' in argv else ''
+ elif argv[0] == 'gh':
+ n = int(argv[3])
+ if n in gh_fail:
+ err, rc = f'boom {n}\n', 1
+ else:
+ out = json.dumps({'number': n, 'title': f'PR {n} \u2714', 'labels': [{'name': 'Port \U0001f6a8'}],
+ 'author': {'login': 'dependabot[bot]' if n == 11 else 'hathach'}}, ensure_ascii=False)
+ else:
+ raise AssertionError(f'unexpected command {argv}')
+ out, err = out.encode('utf-8'), err.encode('utf-8')
+ if encoding or text:
+ out, err = (s.decode(encoding or 'cp1252') for s in (out, err))
+ return subprocess.CompletedProcess(argv, rc, out, err)
+
+ # Windows prints UTF-8 whatever the console; elsewhere stdout keeps the encoding and errors it was given
+ console = [io.TextIOWrapper(io.BytesIO(), encoding='cp1252' if os.name == 'nt' else 'ascii',
+ errors='strict' if os.name == 'nt' else 'backslashreplace', newline='\n')
+ for _ in range(2)]
+ with tempfile.TemporaryDirectory() as d, mock.patch.object(release.subprocess, 'run', run), \
+ mock.patch.object(sys, 'argv', [str(SCRIPT), '--root', d, *args]), \
+ mock.patch.object(sys, 'stdout', console[0]), mock.patch.object(sys, 'stderr', console[1]):
+ code = release.main()
+ for c in console:
+ c.flush()
+ return types.SimpleNamespace(returncode=code, stdout=console[0].buffer.getvalue().decode('utf-8'),
+ stderr=console[1].buffer.getvalue().decode('utf-8'))
def test_prs_lists_number_title_and_labels(self):
r = self.run_cli(['prs', '--prev', '0.20.0'])
self.assertEqual(r.returncode, 0, r.stderr)
- self.assertEqual(r.stdout, '#10\tPR 10\t[Port]\n#11\tPR 11\t[Port]\n')
+ title, label = ('PR {} \u2714', 'Port \U0001f6a8') if os.name == 'nt' else ('PR {} \\u2714', 'Port \\U0001f6a8')
+ self.assertEqual(r.stdout, f'#10\t{title.format(10)}\t[{label}]\n#11\t{title.format(11)}\t[{label}]\n')
def test_contributors_drops_bots(self):
r = self.run_cli(['contributors', '--prev', '0.20.0'])
@@ -145,5 +177,36 @@
self.assertEqual(r.stdout, '')
+@unittest.skipIf(os.name == 'nt', 'the stubs are #! scripts Windows cannot exec: a miss would run the real git or gh')
+class ProcessTest(unittest.TestCase):
+ """The script as a process, git and gh stubs on PATH: main()'s status is the exit status."""
+
+ def run_script(self, args, ancestor=True):
+ with tempfile.TemporaryDirectory() as d:
+ bin_dir = Path(d) / 'bin'
+ bin_dir.mkdir()
+ (bin_dir / 'git').write_text(f'''#!/bin/sh
+case "$*" in
+ *merge-base*) exit {0 if ancestor else 1} ;;
+ *log*) printf 'Merge pull request #10 from a/b\\n' ;;
+esac
+''')
+ (bin_dir / 'gh').write_text('''#!/bin/sh
+printf '{"number": %s, "title": "PR %s", "labels": [], "author": {"login": "hathach"}}' "$3" "$3"
+''')
+ for f in (bin_dir / 'git', bin_dir / 'gh'):
+ f.chmod(0o755)
+ env = dict(os.environ, PATH=f'{bin_dir}{os.pathsep}{os.environ["PATH"]}')
+ return subprocess.run([sys.executable, str(SCRIPT), '--root', d, *args],
+ capture_output=True, text=True, env=env, timeout=60)
+
+ def test_the_exit_status_is_mains(self):
+ ok = self.run_script(['prs', '--prev', '0.20.0'])
+ self.assertEqual((ok.returncode, ok.stdout), (0, '#10\tPR 10\t[]\n'), ok.stderr)
+ refused = self.run_script(['prs', '--prev', '0.20.0'], ancestor=False)
+ self.assertEqual(refused.returncode, 1)
+ self.assertIn('not an ancestor of HEAD', refused.stderr)
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/.claude/test/test_update_sponsor.py b/.claude/test/test_update_sponsor.py
new file mode 100644
index 0000000..af3e694
--- /dev/null
+++ b/.claude/test/test_update_sponsor.py
@@ -0,0 +1,75 @@
+"""Tests for the update-sponsor skill's update_sponsor.py, with gh and codespell answered
+in-process (no real CLI runs): gh's JSON is read as UTF-8 whatever the locale, and the codespell
+guard reads the words codespell reports from a probe file it could open."""
+import importlib.util
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+SCRIPT = Path(__file__).resolve().parents[1] / 'skills' / 'update-sponsor' / 'update_sponsor.py'
+spec = importlib.util.spec_from_file_location('update_sponsor', SCRIPT)
+update_sponsor = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(update_sponsor)
+
+
+def completed(argv, out, kw, rc=0):
+ """What subprocess.run returns for UTF-8 output `out`, decoded as the caller asked under a
+ legacy code page's locale: bytes when it asked for no text."""
+ out = out.encode('utf-8')
+ if kw.get('encoding') or kw.get('text'):
+ out = out.decode(kw.get('encoding') or 'cp1252', kw.get('errors') or 'strict')
+ return subprocess.CompletedProcess(argv, rc, out, out[:0])
+
+
+class GhTest(unittest.TestCase):
+ def test_label_names_come_back_as_github_sent_them(self):
+ def run(argv, **kw):
+ self.assertEqual(argv[0], 'gh')
+ return completed(argv, f'{update_sponsor.L_PRIO_TOP}\n', kw)
+ with mock.patch.object(update_sponsor.subprocess, 'run', run):
+ self.assertEqual(update_sponsor.gh('label', 'list'), f'{update_sponsor.L_PRIO_TOP}\n')
+
+
+TYPO = 'te' + 'h' # spelled out so the repository's own codespell hook leaves it alone
+
+
+class CodespellTest(unittest.TestCase):
+ def test_the_words_codespell_flags_in_the_probe(self):
+ seen = []
+
+ def run(argv, **kw):
+ self.assertEqual(argv[0], 'codespell')
+ probe = argv[-1]
+ seen.append(probe)
+ # codespell opens the probe itself: it must be closed and still there
+ self.assertIn(f'+{TYPO} \u00f8sterberg\n', Path(probe).read_text(encoding='utf-8'))
+ return completed(argv, f'{probe}:1: {TYPO} ==> the\n', kw, rc=65)
+ with mock.patch.object(update_sponsor.subprocess, 'run', run):
+ self.assertEqual(update_sponsor.codespell_collisions('a\n', f'{TYPO} \u00f8sterberg\n'), [TYPO])
+ self.assertFalse(Path(seen[0]).exists(), 'the probe is removed afterwards')
+
+ def test_a_probe_that_could_not_be_written_is_removed(self):
+ made, real = [], tempfile.NamedTemporaryFile
+
+ def full(*a, **kw):
+ f = real(*a, **kw)
+ made.append(f.name)
+ f.write = mock.Mock(side_effect=OSError(28, 'No space left on device'))
+ return f
+ with mock.patch.object(update_sponsor.tempfile, 'NamedTemporaryFile', full), self.assertRaises(OSError):
+ update_sponsor.codespell_collisions('a\n', 'b\n')
+ self.assertFalse(Path(made[0]).exists())
+
+ def test_no_codespell_is_a_note_not_a_failure(self):
+ def run(argv, **kw):
+ raise FileNotFoundError(argv[0])
+ with mock.patch.object(update_sponsor.subprocess, 'run', run), \
+ mock.patch('builtins.print') as note:
+ self.assertEqual(update_sponsor.codespell_collisions('a\n', 'b\n'), [])
+ self.assertIn('UNCHECKED', note.call_args[0][0])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/.claude/test/test_usbtest_host.py b/.claude/test/test_usbtest_host.py
new file mode 100644
index 0000000..eda1fff
--- /dev/null
+++ b/.claude/test/test_usbtest_host.py
@@ -0,0 +1,47 @@
+"""run_case.py and wedge_drill.py drive usbfs, /proc and the usbtest kernel module: on a host
+that is not Linux they parse their arguments (so --help works) and then refuse through their
+verdict line, exit 2 (refused before any hardware action), before importing the harness."""
+import json
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+
+SCRIPTS = Path(__file__).resolve().parents[1] / 'skills' / 'usbtest' / 'scripts'
+ARGS = {'run_case.py': ['--config', 'rig.json', '--board', 'x', '--tests', '1', '--after', 'leave'],
+ 'wedge_drill.py': ['--config', 'rig.json', '--board', 'x']}
+
+
+@unittest.skipIf(sys.platform == 'linux', 'the refusal is for hosts that are not Linux')
+class NotLinux(unittest.TestCase):
+ def run_script(self, name, *args):
+ return subprocess.run([sys.executable, str(SCRIPTS / name), *args],
+ capture_output=True, encoding='utf-8', timeout=60)
+
+ def test_each_script_refuses_with_its_verdict_line(self):
+ for name, args in ARGS.items():
+ with self.subTest(name):
+ r = self.run_script(name, *args)
+ error = f'{name} needs a Linux host (usbfs, /proc, the usbtest kernel module)'
+ self.assertEqual(r.returncode, 2, r.stderr)
+ self.assertEqual(r.stderr, f'error: {error}\n')
+ verdict = json.loads(r.stdout.splitlines()[-1])
+ self.assertEqual((verdict['board'], verdict['boardState'], verdict['error']), ('x', 'untouched', error))
+
+ def test_help_still_works(self):
+ for name in ARGS:
+ with self.subTest(name):
+ r = self.run_script(name, '--help')
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn('--board', r.stdout)
+
+ def test_a_bad_command_line_is_argparse_s_error(self):
+ for name in ARGS:
+ with self.subTest(name):
+ r = self.run_script(name, '--board', 'x')
+ self.assertEqual((r.returncode, r.stdout), (2, ''))
+ self.assertIn('the following arguments are required: --config', r.stderr)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/.claude/test/test_usbtest_kernel_src.py b/.claude/test/test_usbtest_kernel_src.py
index d1cbaa6..17aed7a 100644
--- a/.claude/test/test_usbtest_kernel_src.py
+++ b/.claude/test/test_usbtest_kernel_src.py
@@ -177,10 +177,10 @@
self.server = Server(src)
self.addCleanup(self.server.close)
- def run_script(self, *args):
- env = dict(os.environ, KERNEL_SRC_BASE_URL=self.server.url, PYTHONDONTWRITEBYTECODE='1')
+ def run_script(self, *args, **env):
+ env = dict(os.environ, KERNEL_SRC_BASE_URL=self.server.url, PYTHONDONTWRITEBYTECODE='1', **env)
return subprocess.run([sys.executable, str(SCRIPT), '--cache', str(self.tmp / 'cache'), *args],
- capture_output=True, text=True, env=env, timeout=60)
+ capture_output=True, encoding='utf-8', env=env, timeout=60)
def test_fetch_prints_case_and_waits(self):
r = self.run_script('--release', '6.12.107+deb13-amd64', '--case', '16')
@@ -198,6 +198,25 @@
self.assertEqual(sorted(self.server.hits),
['/drivers/usb/misc/usbtest.c?h=v6.12.107', '/tools/usb/testusb.c?h=v6.12.107'])
+ def test_utf8_source_prints_as_utf8_whatever_the_locale(self):
+ # a comment in the case block that no legacy code page decodes or encodes
+ text = USBTEST_C.replace('\tcase 16:\n', '\tcase 16:\t/* \u2192 \u2714 \u00f8 */\n')
+ (self.src / 'drivers/usb/misc/usbtest.c').write_text(text, encoding='utf-8')
+ env = {'PYTHONUTF8': '0', 'LC_ALL': 'C', 'PYTHONCOERCECLOCALE': '0'} # an ASCII locale on POSIX
+ if os.name != 'nt': # stdout stays as given there, so pin it; Windows reconfigures its own
+ env['PYTHONIOENCODING'] = 'utf-8'
+ r = self.run_script('--tag', 'v6.12.107', '--case', '16', **env)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn('\tcase 16:\t/* \u2192 \u2714 \u00f8 */', r.stdout)
+
+ @unittest.skipIf(os.name == 'nt', 'Windows prints UTF-8 whatever the console; elsewhere stdout stays as given')
+ def test_posix_stdout_keeps_the_encoding_and_errors_it_was_given(self):
+ text = USBTEST_C.replace('\tcase 16:\n', '\tcase 16:\t/* \u2714 */\n')
+ (self.src / 'drivers/usb/misc/usbtest.c').write_text(text, encoding='utf-8')
+ r = self.run_script('--tag', 'v6.12.107', '--case', '16', PYTHONIOENCODING='ascii:backslashreplace')
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn('\tcase 16:\t/* \\u2714 */', r.stdout)
+
def test_cache_is_reused(self):
self.assertEqual(self.run_script('--tag', 'v6.12.107').returncode, 0)
self.assertEqual(self.run_script('--tag', 'v6.12.107').returncode, 0)
diff --git a/.claude/test/test_usbtest_run_case.py b/.claude/test/test_usbtest_run_case.py
index 24cf9f0..7d790c7 100644
--- a/.claude/test/test_usbtest_run_case.py
+++ b/.claude/test/test_usbtest_run_case.py
@@ -15,6 +15,8 @@
from pathlib import Path
from unittest import mock
+if os.name == 'nt':
+ raise unittest.SkipTest('run_case.py imports test/hil/usbtest.py, which needs fcntl')
REPO = Path(__file__).resolve().parents[2]
SCRIPT = REPO / '.claude' / 'skills' / 'usbtest' / 'scripts' / 'run_case.py'
spec = importlib.util.spec_from_file_location('run_case', SCRIPT)
diff --git a/.claude/test/test_usbtest_wedge_drill.py b/.claude/test/test_usbtest_wedge_drill.py
index eaeba9a..b4f843f 100644
--- a/.claude/test/test_usbtest_wedge_drill.py
+++ b/.claude/test/test_usbtest_wedge_drill.py
@@ -16,6 +16,8 @@
from pathlib import Path
from unittest import mock
+if os.name == 'nt':
+ raise unittest.SkipTest('wedge_drill.py imports test/hil/usbtest.py, which needs fcntl')
REPO = Path(__file__).resolve().parents[2]
SCRIPTS = REPO / '.claude' / 'skills' / 'usbtest' / 'scripts'
sys.path.insert(0, str(SCRIPTS))
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bdf1ec8..58ba920 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -115,7 +115,7 @@
# relying on it being installed system-wide.
- id: claude-scripts
name: claude-scripts
- files: ^(\.claude/(skills/.*/(scripts/|SKILL\.md)|test/)|tools/get_deps\.py$)
+ files: ^(\.claude/(skills/.*/(scripts/|SKILL\.md|[^/]*\.py$)|test/)|tools/(build_utils|family_json|get_deps)\.py$)
entry: python3 -m unittest discover -s .claude/test -p 'test_*.py'
pass_filenames: false
language: python
diff --git a/test/hil/test/test_family_json.py b/test/hil/test/test_family_json.py
index 259f655..2aae7af 100644
--- a/test/hil/test/test_family_json.py
+++ b/test/hil/test/test_family_json.py
@@ -3,12 +3,15 @@
No cmake here: the build dirs are fixtures. The last test is the check itself over the
real tree, which is what the pre-commit hook guarantees."""
import contextlib
+import errno
import io
import json
import os
import pathlib
+import subprocess
import sys
import tempfile
+import types
import unittest
from unittest import mock
@@ -115,6 +118,75 @@
self.assertEqual(fj.defines_of(fj.preprocess_argv(args)), {'A': '2'})
+class SplitCommand(unittest.TestCase):
+ """CMake on Windows writes `command` for CreateProcess, which hands it to the compiler's C
+ runtime to split: backslashes are path separators, only quotes are special."""
+ split = staticmethod(fj.split_command)
+
+ def windows(self, cmd):
+ with mock.patch.object(fj.os, 'name', 'nt'):
+ return self.split(cmd)
+
+ def test_the_c_runtime_rules(self):
+ for args in (['C:\\Tool\\bin\\arm-none-eabi-gcc.exe', '-IE:/mcu/tinyusb/src', '-c', 'E:\\mcu\\src\\tusb.c'],
+ ['C:\\Program Files\\gcc.exe', '-DX=a b', ''],
+ ['-DCFG_TUD_MEM_SECTION=__attribute__((section("m_usb_global")))'],
+ ['a\\\\b', 'trailing\\', 'quoted trailing\\', 'q\\"', '\\\\"x"', '\t']):
+ self.assertEqual(self.windows(subprocess.list2cmdline(args)), args)
+ # as lpc55's configure writes it, and the doubled quote inside quotes
+ self.assertEqual(self.windows('gcc -DS=__attribute__((section(\\"m_usb_global\\"))) "a""b"'),
+ ['gcc', '-DS=__attribute__((section("m_usb_global")))', 'a"b'])
+
+ def test_posix_commands_stay_shell_words(self):
+ with mock.patch.object(fj.os, 'name', 'posix'):
+ self.assertEqual(fj.split_command("gcc '-DA=1 2' -DB=\\\"x\\\""), ['gcc', '-DA=1 2', '-DB="x"'])
+
+ def test_a_windows_command_preprocesses_with_its_own_compiler_and_source(self):
+ with tempfile.TemporaryDirectory() as d:
+ src = str(pathlib.Path(d, 'src', 'tusb.c'))
+ e = {'directory': d, 'file': src, 'output': 'x.obj',
+ 'command': subprocess.list2cmdline(['C:\\Tool\\bin\\gcc.exe', '-DCFG_TUSB_MCU=OPT_MCU_STM32F4',
+ '-o', 'dir\\x.obj', '-c', src])}
+ with mock.patch.object(fj, 'split_command', self.windows): # pathlib stays native
+ argv = fj.preprocess_argv(e)
+ self.assertEqual(argv, ['C:\\Tool\\bin\\gcc.exe', '-DCFG_TUSB_MCU=OPT_MCU_STM32F4'])
+
+
+MSVCRT = types.SimpleNamespace(LK_UNLCK=0, LK_LOCK=1, LK_NBLCK=2)
+
+
+class WindowsLock(unittest.TestCase):
+ """_locked's msvcrt path on any host: no fcntl to import, and an msvcrt whose locking() is the test's."""
+ def rewrite(self, locking):
+ msvcrt = types.SimpleNamespace(**vars(MSVCRT), locking=locking)
+ with tempfile.TemporaryDirectory() as d:
+ root = pathlib.Path(d)
+ (root / 'family.json').write_text('{}\n', encoding='utf-8')
+ with mock.patch.object(fj, 'ROOT', root), mock.patch.dict(sys.modules, {'fcntl': None, 'msvcrt': msvcrt}), \
+ mock.patch.object(fj.time, 'sleep'):
+ return fj.rewrite(lambda data: data.update(x={}), root / 'family.json')
+
+ def test_a_writer_waits_for_the_lock_as_long_as_it_is_held(self):
+ calls = []
+
+ def locking(fd, mode, n):
+ calls.append(mode)
+ if mode == MSVCRT.LK_LOCK: # it gives up after about 10 s, and the retry loop would spin on that
+ raise AssertionError('LK_LOCK')
+ if mode == MSVCRT.LK_UNLCK:
+ os.fstat(fd) # EBADF once the file is closed
+ elif calls.count(mode) < 30:
+ raise OSError(errno.EACCES, 'Permission denied')
+ self.assertEqual(self.rewrite(locking), 'updated')
+ self.assertEqual(calls, [MSVCRT.LK_NBLCK] * 30 + [MSVCRT.LK_UNLCK], 'released before the close')
+
+ def test_a_lock_error_that_is_not_contention_is_raised(self):
+ def locking(fd, mode, n):
+ raise OSError(errno.EBADF, 'Bad file descriptor')
+ with self.assertRaises(OSError):
+ self.rewrite(locking)
+
+
class Observe(unittest.TestCase):
def test_a_configured_dir_yields_the_row(self):
with tempfile.TemporaryDirectory() as d:
@@ -301,6 +373,18 @@
path.write_text(fj.canonical_text(data))
return path
+ @unittest.skipIf(os.name == 'nt', 'a backslash separates there, so no one file has this name')
+ def test_a_backslash_portable_entry_is_refused_on_windows_even_when_posix_finds_the_file(self):
+ with tempfile.TemporaryDirectory() as d:
+ root = fixture_root(d)
+ p = 'synopsys\\dwc2\\dcd_dwc2.c'
+ (root / 'src' / 'portable' / p).write_text('')
+ row = dict(ROW, portable=[p])
+ self.assertEqual(fj.row_violations(row, {'OPT_MCU_STM32F4'}, root), [])
+ with mock.patch.object(fj, 'os', types.SimpleNamespace(name='nt')): # row_violations' os, as on Windows
+ self.assertEqual(fj.row_violations(row, {'OPT_MCU_STM32F4'}, root),
+ [f'portable entry {p!r} is not a relative path to a file under src/portable/'])
+
def test_a_complete_catalog_passes_and_every_gap_is_named_with_its_remedy(self):
with tempfile.TemporaryDirectory() as d:
root = fixture_root(d)
@@ -328,6 +412,7 @@
({**good, 'stm32f4': {'stm32f407disco': {'cmake': dict(ROW, roles=[{}])}}}, 'roles must be a sorted list'),
({**good, 'stm32f4': {'stm32f407disco': {'cmake': dict(ROW, portable=[str(root / 'src' / 'portable' / 'synopsys/dwc2/dcd_dwc2.c')])}}}, 'is not a relative path'),
({**good, 'stm32f4': {'stm32f407disco': {'cmake': dict(ROW, portable=['../portable/synopsys/dwc2/dcd_dwc2.c'])}}}, 'is not a relative path'),
+ ({**good, 'stm32f4': {'stm32f407disco': {'cmake': dict(ROW, portable=['synopsys\\dwc2\\dcd_dwc2.c'])}}}, 'is not a relative path'),
({**good, 'stm32f4': {'stm32f407disco': {'cmake': dict(ROW, mcu='OPT_MCU_BOGUS')}}}, 'is not an OPT_MCU_* defined in src/tusb_option.h'),
({**good, 'stm32f4': {'stm32f407disco': {'cmake': ROW, 'make': 1}}}, 'is {"cmake": ...} and nothing else'),
({**good, 'nrf': {'x': {'cmake': ROW}}}, 'nrf: rows for a family dir that does not exist'),
diff --git a/test/hil/test/test_hil_remote.py b/test/hil/test/test_hil_remote.py
index cf0fcaf..c1c8ad8 100644
--- a/test/hil/test/test_hil_remote.py
+++ b/test/hil/test/test_hil_remote.py
@@ -116,11 +116,61 @@
self.assertEqual(self.wait('r6', '--timeout', '20'), (0, {'state': 'done', 'runId': 'r6', 'exit': 0, 'reports': ['hil_report.md']}))
def test_a_wrapper_killed_before_its_done_record_is_dead(self):
- self.launch('os.kill(os.getpid(), signal.SIGKILL)', 'r7').wait()
+ # Windows has no SIGKILL; its SIGTERM is TerminateProcess, as abrupt
+ self.launch('os.kill(os.getpid(), getattr(signal, "SIGKILL", signal.SIGTERM))', 'r7').wait()
rc, status = self.wait('r7', '--timeout', '5')
self.assertEqual((rc, status['state']), (hil_remote.DEAD, 'dead'))
self.assertFalse(hil_remote.run_paths('r7')[1].exists())
+ @unittest.skipUnless(os.name == 'nt', 'msvcrt has no shared lock: two waiters probes contend there')
+ def test_another_waiters_probe_does_not_read_as_a_live_run(self):
+ self.started('r12')
+ started = hil_remote.run_paths('r12')[0]
+ fd = os.open(started, os.O_RDONLY)
+ self.addCleanup(os.close, fd)
+ hil_remote.win_lock(fd, block=False) # the other waiter, mid-probe
+ other = threading.Timer(0.05, hil_remote.win_unlock, (fd,))
+ other.start()
+ self.addCleanup(other.join) # before the close: it seeks and unlocks fd
+ # a 5 s retry budget at the usual step, so a late Timer on a loaded host still lands inside it
+ with mock.patch.object(hil_remote, 'PROBE_TRIES', int(5 / hil_remote.PROBE_SECS)), \
+ mock.patch.object(hil_remote, 'win_unlock', wraps=hil_remote.win_unlock) as unlock:
+ self.assertFalse(hil_remote.run_alive(started))
+ unlock.assert_called_once() # the probe released its hold, not left it to the close
+
+ def test_a_refused_unlink_of_the_hidden_name_keeps_the_runs_exit(self):
+ # Windows refuses it while a waiter's probe holds the record open; the next sweep takes it
+ self.stub_run(lambda argv, receipt=None: (3, []))
+ real_unlink = Path.unlink
+
+ def unlink(path, missing_ok=False):
+ if path.name.startswith('.r14.started.json.'):
+ raise PermissionError(13, 'The process cannot access the file')
+ return real_unlink(path, missing_ok=missing_ok)
+ with mock.patch.object(hil_remote, 'fcntl', None), mock.patch.object(hil_remote, 'win_lock'), \
+ mock.patch.object(hil_remote, 'win_unlock'), mock.patch.object(Path, 'unlink', unlink):
+ self.assertEqual(hil_remote.main(['--run-id', 'r14']), 3)
+ self.assertEqual(self.wait('r14')[1], {'state': 'done', 'runId': 'r14', 'exit': 3, 'reports': []})
+
+ @unittest.skipUnless(os.name == 'nt', 'only Windows defers the unlink of the hidden name')
+ def test_a_killed_runs_hidden_record_name_is_swept_at_the_next_run(self):
+ runs = self.root / hil_remote.RUNS_DIR
+ runs.mkdir()
+ hidden = {}
+ for run_id, pid in (('killed', 4242), ('live', 4343)):
+ started = runs / f'{run_id}.started.json'
+ started.write_text('{}')
+ hidden[run_id] = runs / f'.{started.name}.{pid}.tmp'
+ os.link(started, hidden[run_id])
+ fd = os.open(hidden['live'], os.O_RDONLY) # its run still holds it
+ self.addCleanup(os.close, fd)
+ writing = runs / '.new.started.json.4444.tmp' # a launch that has not linked its record yet
+ writing.write_text('{}')
+ self.stub_run(lambda argv, receipt=None: (0, []))
+ self.assertEqual(hil_remote.main(['--run-id', 'r13']), 0)
+ self.assertEqual(sorted(p.name for p in runs.glob('.*.tmp')), sorted([hidden['live'].name, writing.name]))
+ self.assertTrue((runs / 'killed.started.json').is_file(), 'the record itself stays')
+
def test_a_wait_begun_before_the_launch_wrote_its_record_waits_for_it(self):
self.stub_run(lambda argv, receipt=None: (0, []))
timer = threading.Timer(0.1, hil_remote.main, [['--run-id', 'r8']])
@@ -136,6 +186,22 @@
self.assertIn('no run never was started', e.exception.code)
self.assertLess(time.monotonic() - begun, 1, 'neither the grace nor its poll outlasts --timeout')
+ def test_a_killed_runs_hidden_name_under_a_reused_pid_keeps_its_record(self):
+ self.started('r13')
+ started = hil_remote.run_paths('r13')[0]
+ os.link(started, started.with_name(f'.{started.name}.{os.getpid()}.tmp')) # left by a kill on Windows
+ before = started.read_bytes()
+ with self.assertRaises(FileExistsError):
+ hil_remote.publish_locked(started, {'runId': 'r13', 'pid': os.getpid(), 'startedAt': 1})
+ self.assertEqual(started.read_bytes(), before)
+
+ def test_an_unlinked_leftover_hidden_name_is_replaced(self):
+ started = hil_remote.run_paths('r14')[0]
+ started.parent.mkdir(exist_ok=True)
+ started.with_name(f'.{started.name}.{os.getpid()}.tmp').write_text('stale')
+ os.close(hil_remote.publish_locked(started, {'runId': 'r14', 'pid': os.getpid(), 'startedAt': 1}))
+ self.assertEqual(json.loads(started.read_text())['runId'], 'r14')
+
def test_a_timeout_past_the_tool_cap_is_refused(self):
for bad in ('571', '-1', 'nan', 'inf'):
with self.subTest(timeout=bad), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as e:
@@ -338,8 +404,8 @@
def rsync(guard, *args, optional=False):
if '--prune-empty-dirs' not in args:
return 0
- for src in (Path(a) for a in args if not a.startswith('-') and ':' not in a):
- sent.update({str(f.relative_to(src.parent.parent)): hil_remote.digest(f) for f in src.rglob('*')
+ for src in (a for a in args if isinstance(a, Path)): # local files are Paths
+ sent.update({f.relative_to(src.parent.parent).as_posix(): hil_remote.digest(f) for f in src.rglob('*')
if f.suffix in hil_remote.STAGED_SUFFIXES or f.name in hil_remote.STAGED_NAMES})
return 1 # stop the run once the firmware transfer is seen
with mock.patch.object(hil_remote, 'remote_lease', lease), mock.patch.object(hil_remote, 'rsync', rsync), \
@@ -363,7 +429,7 @@
def rsync(guard, *args, optional=False):
if '--prune-empty-dirs' in args:
- state['sources'] += [Path(a) for a in args if not a.startswith('-') and ':' not in a]
+ state['sources'] += [a for a in args if isinstance(a, Path)]
return 0
def run_command(*a, **k):
diff --git a/tools/build_utils.py b/tools/build_utils.py
index 7124d95..fa814d4 100755
--- a/tools/build_utils.py
+++ b/tools/build_utils.py
@@ -561,9 +561,9 @@
if not hits:
raise BoardInfoError(f'unknown board {board}')
if len(hits) > 1:
- raise BoardInfoError(f'{board} exists in several families: ' + ', '.join(map(str, hits)))
+ raise BoardInfoError(f'{board} exists in several families: ' + ', '.join(h.as_posix() for h in hits))
board_dir = hits[0]
- return board_dir, (str(board_dir / 'board.cmake'), str(board_dir.parent.parent / 'family.cmake'))
+ return board_dir, ((board_dir / 'board.cmake').as_posix(), (board_dir.parent.parent / 'family.cmake').as_posix())
def _checked_device(value, source):
@@ -610,8 +610,8 @@
hits = sorted((board_dir / 'ozone').glob('*.jdebug'))
if len(hits) > 1:
raise BoardInfoError(f'{board}: several reference projects: '
- + ', '.join(str(h) for h in hits))
- return str(hits[0]) if hits else None
+ + ', '.join(h.as_posix() for h in hits))
+ return hits[0].as_posix() if hits else None
def main(argv=None):
diff --git a/tools/family_json.py b/tools/family_json.py
index 13a056a..f400bde 100644
--- a/tools/family_json.py
+++ b/tools/family_json.py
@@ -25,6 +25,8 @@
tools/build.py get_examples) is not detected.
"""
import argparse
+import contextlib
+import errno
import json
import os
import re
@@ -32,6 +34,7 @@
import shutil
import subprocess
import sys
+import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
@@ -66,9 +69,9 @@
try:
data = json.loads(text)
except ValueError as e:
- raise Failure(f'{path.relative_to(ROOT)} is not valid JSON ({e}); fix it by hand')
+ raise Failure(f'{path.relative_to(ROOT).as_posix()} is not valid JSON ({e}); fix it by hand')
if not isinstance(data, dict):
- raise Failure(f'{path.relative_to(ROOT)} is not a JSON object')
+ raise Failure(f'{path.relative_to(ROOT).as_posix()} is not a JSON object')
for family, rows in data.items():
if not isinstance(rows, dict):
kind = 'null' if rows is None else f'a {type(rows).__name__}'
@@ -93,9 +96,54 @@
return names
+def split_command(cmd):
+ """A compile database `command` as its compiler's argv: shell words, or on Windows, where
+ the command line reaches CreateProcess as it is, the argv rules of the Microsoft C runtime
+ (msvcr90 and later, the UCRT): backslashes are literal except before a quote, 2k of them
+ before a quote are k and the quote delimits, 2k+1 are k and a literal quote, and `""`
+ inside quotes is a literal quote. CMake writes an embedded quote as `\\"`. The program name
+ is split by these rules too, though the CRT reads it with quotes only toggling; the two
+ differ only on a backslash before a quote or a `""`, which no compiler path holds.
+ The dialect follows the Python reading the file (os.name), which suits native and MinGW
+ Pythons on Windows; a Cygwin or MSYS-runtime Python (os.name 'posix') reading a Windows
+ build's file is not supported."""
+ if os.name != 'nt':
+ return shlex.split(cmd)
+ out, cur, quoted, i, n = [], None, False, 0, len(cmd)
+ while i < n:
+ c = cmd[i]
+ if c == '\\':
+ j = i
+ while j < n and cmd[j] == '\\':
+ j += 1
+ if j < n and cmd[j] == '"':
+ # 2k backslashes before a quote are k and the quote delimits; 2k+1 escape it
+ cur = (cur or '') + '\\' * ((j - i) // 2) + ('"' if (j - i) % 2 else '')
+ i = j + (j - i) % 2
+ else:
+ cur, i = (cur or '') + cmd[i:j], j
+ elif c == '"':
+ if quoted and cmd[i + 1:i + 2] == '"': # "" inside quotes is a literal quote
+ cur, i = cur + '"', i + 2
+ else:
+ quoted, cur, i = not quoted, cur or '', i + 1
+ elif c in ' \t' and not quoted:
+ if cur is not None:
+ out.append(cur)
+ cur = None
+ i += 1
+ else:
+ cur, i = (cur or '') + c, i + 1
+ if cur is not None:
+ out.append(cur)
+ return out
+
+
def compile_tokens(entry):
- """One compile command's tokens, response files expanded."""
- tokens = list(entry['arguments']) if 'arguments' in entry else shlex.split(entry['command'])
+ """One compile command's tokens, response files expanded. gcc reads a response file with
+ libiberty's buildargv on every host; shlex is an approximation of that, close enough for
+ the files CMake writes, which name paths with forward slashes on Windows too."""
+ tokens = list(entry['arguments']) if 'arguments' in entry else split_command(entry['command'])
out = []
for t in tokens:
if t.startswith('@') and len(t) > 1:
@@ -277,8 +325,8 @@
each other's change. Returns 'updated' or 'unchanged'."""
lock = ROOT / 'cmake-build' / '.family.json.lock'
lock.parent.mkdir(exist_ok=True)
- with open(lock, 'w') as lf:
- _lock(lf)
+ # not 'w': a second opener must not truncate the holder's file
+ with open(lock, 'a') as lf, _locked(lf):
data = load(path)
edit(data)
text = canonical_text(data)
@@ -294,13 +342,33 @@
return 'updated'
-def _lock(f):
+@contextlib.contextmanager
+def _locked(f):
+ """Hold the empty lock file f exclusively."""
try:
import fcntl
- fcntl.flock(f, fcntl.LOCK_EX)
- except ImportError:
+ except ImportError: # Windows
+ fcntl = None
import msvcrt
- msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
+ if fcntl:
+ fcntl.flock(f, fcntl.LOCK_EX)
+ yield # the close releases it
+ return
+ fd = f.fileno()
+ os.lseek(fd, 0, os.SEEK_SET) # every holder locks the first byte
+ while True:
+ try:
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
+ break
+ except OSError as e:
+ if e.errno not in (errno.EACCES, errno.EDEADLOCK):
+ raise
+ time.sleep(0.05) # LK_LOCK gives up after about 10 s
+ try:
+ yield
+ finally: # Windows drops a closed handle's lock only "after a delay"
+ os.lseek(fd, 0, os.SEEK_SET)
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
def join(build_dirs):
@@ -360,9 +428,9 @@
data = load(path)
text = path.read_text(encoding='utf-8')
except (Failure, OSError) as e:
- return [f'{path.relative_to(root)}: {e}']
+ return [f'{path.relative_to(root).as_posix()}: {e}']
if text != canonical_text(data):
- out.append(f'{path.relative_to(root)} is not in canonical form')
+ out.append(f'{path.relative_to(root).as_posix()} is not in canonical form')
tree = inventory(root)
option_h = (root / 'src' / 'tusb_option.h').read_text(encoding='utf-8')
mcus = set(re.findall(r'^#define (OPT_MCU_\w+)', option_h, re.M))
@@ -545,6 +613,7 @@
for p in row['portable']:
# a plain relative path: joining an absolute one would discard the base
if not isinstance(p, str) or p.startswith('/') or '..' in p.split('/') \
+ or (os.name == 'nt' and ('\\' in p or Path(p).anchor)) \
or not (root / 'src' / 'portable' / p).is_file():
out.append(f'portable entry {p!r} is not a relative path to a file under src/portable/')
if isinstance(row['roles'], list) and all(isinstance(x, str) for x in row['roles']):
@@ -579,7 +648,7 @@
for line in problems:
print(line)
if changed:
- print(f'{CATALOG.relative_to(ROOT)} updated: stage it and commit again')
+ print(f'{CATALOG.relative_to(ROOT).as_posix()} updated: stage it and commit again')
return 1 if changed or problems else 0
if a.cmd == 'refresh':
asked, failed, problems = refresh(CATALOG, ROOT, subprocess.run)
diff --git a/tools/get_deps.py b/tools/get_deps.py
index 49e141d..3abace0 100755
--- a/tools/get_deps.py
+++ b/tools/get_deps.py
@@ -436,7 +436,7 @@
# argparse error here reds the Get Dependencies step of every scoped PR
parser.add_argument('-e', '--example', action='append', default=[], help='Have no effect')
parser.add_argument('--gen-doc', nargs='?', const=DEPS_RST, metavar='RST',
- help=f'write the dependency table for the docs ({DEPS_RST.relative_to(TOP)}) and exit, fetching nothing')
+ help=f'write the dependency table for the docs ({DEPS_RST.relative_to(TOP).as_posix()}) and exit, fetching nothing')
args = parser.parse_args()
if args.gen_doc: