Skip to content

Commit 84067b8

Browse files
nicopaussclaude
andcommitted
static-checks: type-check wscript files via waf pyrefly
Pyrefly's `project-includes` filters glob matches by `.py`/`.pyi` extension, so `**/wscript*` patterns are silently dropped in project-checking mode. Add a `waf pyrefly` command and switch the full-codebase branch of `static-checks.py` from `pyrefly check` to `waf pyrefly` to pick up wscripts on the bot. Factorize `run_ruff`, `run_mypy`, and `run_pyrefly` on top of `run_python_checker`, which now handles the shared file-list discovery and dispatches to either a bundled invocation (ruff, pyrefly) or a per-file invocation (mypy, which can't accept multiple `wscript*` files at once because they all map to module `__main__`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Change-Id: I5ba4f90df7677e96dcd91ffe56f5c55d535d962e Priv-Id: 822b0dcf38c1f0359676e0fd67dc167cc5999af2
1 parent 71319c8 commit 84067b8

2 files changed

Lines changed: 134 additions & 54 deletions

File tree

build/waftools/common.py

Lines changed: 133 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -461,40 +461,74 @@ def add_custom_install(self: TaskGen) -> None:
461461
# {{{ python checkers
462462

463463

464-
def run_python_checker(ctx: BuildContext, checker_exec: str) -> None:
465-
# Reset the build
466-
groups: List[List[TaskGen]] = []
467-
ctx.groups = groups
468-
469-
# Get the launch directory
470-
path = ctx.launch_node()
471-
464+
def _get_python_files(ctx: BuildContext) -> List[str]:
465+
"""
466+
Return the file list to check: positional CLI args, or all tracked
467+
python and ``wscript*`` files.
468+
"""
472469
# Steal the optional list of files passed as arguments.
473470
# Waf store the arguments in `Options.commands` and use them to run each
474471
# individual commands.
475472
# But here, the remaining arguments are not commands, but files.
476473
# So we need to steal the remaining arguments in `Options.commands`.
477-
files_args = Options.commands[:]
474+
files_args = cast(List[str], Options.commands[:])
478475
Options.commands.clear()
479476

480477
if files_args:
481-
# If we have some files passed as arguments, use them.
482-
files_list = files_args
483-
else:
484-
# Else, get list of committed python files under the launch directory
485-
files_str = ctx.cmd_and_log(
478+
return files_args
479+
480+
files_str = cast(
481+
str,
482+
ctx.cmd_and_log(
486483
'git ls-files "*.py" "**/*.py" "*.pyi" "**/*.pyi" '
487484
'"wscript*" "**/wscript*"',
488-
cwd=path,
485+
cwd=ctx.launch_node(),
489486
quiet=Context.BOTH,
490-
).strip()
491-
files_list = files_str.splitlines()
487+
),
488+
).strip()
489+
return files_str.splitlines()
492490

493-
# Create tasks to check them using the checker
494-
rule = checker_exec + ' ${SRC}'
495-
for f in files_list:
496-
node = path.make_node(f)
497-
ctx(rule=rule, source=node, path=path, cwd=ctx.srcnode, always=True)
491+
492+
def run_python_checker(
493+
ctx: BuildContext, checker_cmd: List[str], *, per_file: bool = False
494+
) -> None:
495+
"""
496+
Run ``checker_cmd`` on tracked Python files and ``wscript*`` files.
497+
498+
``checker_cmd`` is the argv prefix (executable plus flags); files are
499+
appended as additional argv entries — no shell interpolation. By
500+
default the file list is passed in a single invocation. Set
501+
``per_file=True`` to spawn one invocation per file (needed for mypy,
502+
which can't accept multiple ``wscript*`` files at once: they all map
503+
to module ``__main__`` and collide).
504+
"""
505+
# Reset the build: we don't want waf to perform its normal build.
506+
groups: List[List[TaskGen]] = []
507+
ctx.groups = groups
508+
509+
files_list = _get_python_files(ctx)
510+
if not files_list:
511+
return
512+
513+
if per_file:
514+
path = ctx.launch_node()
515+
rule = ' '.join(checker_cmd) + ' ${SRC}'
516+
for f in files_list:
517+
node = path.make_node(f)
518+
ctx(
519+
rule=rule,
520+
source=node,
521+
path=path,
522+
cwd=ctx.srcnode,
523+
always=True,
524+
)
525+
return
526+
527+
argv = [*checker_cmd, *files_list]
528+
if ctx.exec_command(
529+
argv, cwd=ctx.launch_node(), stdout=None, stderr=None
530+
):
531+
ctx.fatal(f'`{checker_cmd[0]}` reported errors')
498532

499533

500534
# }}}
@@ -505,36 +539,10 @@ def run_ruff(ctx: BuildContext) -> None:
505539
if ctx.cmd not in {'ruff', 'ruff-fix'}:
506540
return
507541

508-
# Reset the build
509-
groups: List[List[TaskGen]] = []
510-
ctx.groups = groups
511-
512-
# Steal the optional list of files passed as arguments.
513-
# Waf store the arguments in `Options.commands` and use them to run each
514-
# individual commands.
515-
# But here, the remaining arguments are not commands, but files.
516-
# So we need to steal the remaining arguments in `Options.commands`.
517-
files_args = Options.commands[:]
518-
Options.commands.clear()
519-
520-
fix = '--fix --unsafe-fixes' if ctx.cmd == 'ruff-fix' else ''
521-
522-
if files_args:
523-
# If files are passed manually, use them directly
524-
file_args = ' '.join(f'"{f}"' for f in files_args)
525-
rule = f'ruff check {fix} --force-exclude {file_args}'
526-
else:
527-
# Use shell pipeline to get files and check them
528-
rule = (
529-
'git ls-files "*.py" "**/*.py" "*.pyi" "**/*.pyi" '
530-
f'"wscript*" "**/wscript*" | '
531-
f'xargs ruff check --force-exclude {fix}'
532-
)
533-
534-
# One task, run everything at once
535-
ctx.cmd_and_log(
536-
cmd=rule, cwd=ctx.launch_node(), shell=True, stdout=None, stderr=None
537-
)
542+
cmd = ['ruff', 'check', '--force-exclude']
543+
if ctx.cmd == 'ruff-fix':
544+
cmd += ['--fix', '--unsafe-fixes']
545+
run_python_checker(ctx, cmd)
538546

539547

540548
class RuffClass(BuildContext): # type: ignore[misc]
@@ -557,7 +565,7 @@ def run_mypy(ctx: BuildContext) -> None:
557565
if ctx.cmd != 'mypy':
558566
return
559567

560-
run_python_checker(ctx, 'mypy')
568+
run_python_checker(ctx, ['mypy'], per_file=True)
561569

562570

563571
class MypyClass(BuildContext): # type: ignore[misc]
@@ -566,6 +574,77 @@ class MypyClass(BuildContext): # type: ignore[misc]
566574
cmd = 'mypy'
567575

568576

577+
# }}}
578+
# {{{ pyrefly
579+
580+
581+
def _read_pyrefly_project_excludes(pyproject_path: str) -> List[str]:
582+
"""
583+
Return the `project-excludes` patterns from the `[tool.pyrefly]`
584+
section of ``pyproject_path``, or an empty list if absent.
585+
"""
586+
# Deferred to keep `common.py` importable on Python 3.6 (waf
587+
# bootstrap); `waf pyrefly` only runs in the 3.9+ phase.
588+
try:
589+
import tomllib # noqa: PLC0415
590+
except ImportError:
591+
import tomli as tomllib # type: ignore[no-redef, import-not-found] # noqa: PLC0415
592+
try:
593+
with open(pyproject_path, 'rb') as f:
594+
data = tomllib.load(f)
595+
except FileNotFoundError:
596+
return []
597+
pyrefly_cfg = data.get('tool', {}).get('pyrefly', {})
598+
return list(pyrefly_cfg.get('project-excludes', []))
599+
600+
601+
def run_pyrefly(ctx: BuildContext) -> None:
602+
if ctx.cmd != 'pyrefly':
603+
return
604+
605+
# `run_python_checker` passes the file list as argv, putting pyrefly
606+
# in single-file mode. In that mode pyrefly silently:
607+
# - drops the config's `project-excludes`, so explicitly-listed
608+
# files get checked even when the config excludes them (e.g.
609+
# the intentionally-broken `one/ci/data/web-api/invalid.py`);
610+
# - does config-finding *per-file*, which for a project that
611+
# vendors `lib-common` as a submodule means lib-common's own
612+
# `[tool.pyrefly]` section gets applied to files reached via
613+
# import resolution. The two configs each pick their own
614+
# search-path for `iopy` and the resulting two `iopy.Channel`
615+
# classes fail to unify, producing spurious
616+
# `Channel is not assignable to Channel` errors.
617+
# Workarounds:
618+
# `-c <pyproject>` pins the config and disables the
619+
# per-file config-finding;
620+
# `--project-excludes <pat>` re-applies the config's excludes,
621+
# which `-c` alone does not.
622+
# The excludes need to be absolute: pyrefly resolves
623+
# `--project-excludes` patterns against its own cwd, so a TOML
624+
# pattern like `one/ci/data/web-api/invalid.py` would silently fail
625+
# to match when waf is invoked from `one/` (cwd=one/, file shows up
626+
# as `ci/data/web-api/invalid.py`).
627+
cmd = ['pyrefly', 'check']
628+
pyproject_node = ctx.srcnode.find_node('pyproject.toml')
629+
if pyproject_node is not None:
630+
pyproject_path = pyproject_node.abspath()
631+
cmd += ['-c', pyproject_path]
632+
config_dir = os.path.dirname(pyproject_path)
633+
for pattern in _read_pyrefly_project_excludes(pyproject_path):
634+
cmd += ['--project-excludes', os.path.join(config_dir, pattern)]
635+
636+
# Pyrefly's `project-includes` only matches `.py`/`.pyi` paths, so
637+
# `wscript*` files are silently skipped in project-checking mode. The
638+
# file list passed by `run_python_checker` includes them explicitly.
639+
run_python_checker(ctx, cmd)
640+
641+
642+
class PyreflyClass(BuildContext): # type: ignore[misc]
643+
"""run pyrefly checks on committed python files"""
644+
645+
cmd = 'pyrefly'
646+
647+
569648
# }}}
570649
# {{{ git hooks
571650

@@ -654,6 +733,7 @@ def build(ctx: BuildContext) -> None:
654733
ctx.add_pre_fun(add_scan_in_signature)
655734
ctx.add_pre_fun(run_ruff)
656735
ctx.add_pre_fun(run_mypy)
736+
ctx.add_pre_fun(run_pyrefly)
657737
ctx.add_post_fun(run_checks)
658738

659739

static-checks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ def main() -> None:
174174
run_cmd('waf', 'ruff')
175175
run_cmd('ruff', 'format', '--check')
176176
run_cmd('waf', 'mypy')
177-
run_cmd('pyrefly', 'check')
177+
run_cmd('waf', 'pyrefly')
178178
run_cmd('ast-grep', 'scan')
179179
run_cmd('ast-grep', 'test')
180180

0 commit comments

Comments
 (0)