@@ -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
540548class 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
563571class 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
0 commit comments