From 12c9ad8fe0460c03ed207d7859823ea12c0f53f4 Mon Sep 17 00:00:00 2001 From: LLM Date: Thu, 9 Apr 2026 09:00:00 +0000 Subject: [PATCH] [+] refactor test runner into commands_typed/tests.py with pyproject.toml config 1. add commands_typed/tests.py with run_tests(), collect_tests(), filter_tests(); 2. support test_names as dotted prefixes or * glob patterns via fnmatch; 3. add dry-run mode via collect subcommand in __main__ with argparse; 4. add [tool.online-fxreader-pr34.tests] section: search_paths, test_names, discovery_paths; 5. extend PyProject dataclass with Tests nested class in cli_bootstrap.py; 6. parse tests config in pyproject_load via check_dict/check_list; 7. add tests() method to commands_typed/cli.py base CLI class; 8. simplify python/cli.py to delegate to self.tests(project_name, argv); 9. auto-patch PYTHONPATH from search_paths before running tests; 10. narrow unittest discover start_dir from common prefix of test_names; --- python/cli.py | 41 +--- python/m.py | 23 +- .../fxreader/pr34/commands_typed/cli.py | 38 +++ .../pr34/commands_typed/cli_bootstrap.py | 45 +++- .../fxreader/pr34/commands_typed/tests.py | 229 ++++++++++++++++++ 5 files changed, 327 insertions(+), 49 deletions(-) create mode 100644 python/online/fxreader/pr34/commands_typed/tests.py diff --git a/python/cli.py b/python/cli.py index 794ad80..c8269bf 100644 --- a/python/cli.py +++ b/python/cli.py @@ -236,45 +236,12 @@ class CLI(_cli.CLI): argv=args, ) elif options.command is Command.tests: - from online.fxreader.pr34.commands_typed import argparse as pr34_argparse + assert not options.project is None - tests_parser = argparse.ArgumentParser() - tests_parser.add_argument( - '--timeout', - default=16, - type=int, - help='test timeout in seconds, default = 16', + self.tests( + project_name=options.project, + argv=args, ) - - tests_options, tests_args = pr34_argparse.parse_args(tests_parser, args) - - for k, v in self.projects.items(): - if len(tests_args) > 0: - cmd = [ - sys.executable, - '-m', - 'unittest', - *tests_args, - ] - else: - cmd = [ - sys.executable, - '-m', - 'unittest', - 'discover', - '-s', - str(v.source_dir), - '-p', - 'test_*.py', - '-t', - str(v.source_dir), - ] - - subprocess.check_call( - cmd, - cwd=str(v.source_dir), - timeout=tests_options.timeout, - ) elif options.command is Command.module_switch: assert not options.project is None diff --git a/python/m.py b/python/m.py index 9a73599..7bc1cba 100755 --- a/python/m.py +++ b/python/m.py @@ -595,7 +595,10 @@ def whl_cache_download( if parsed is None: continue if py_tag_prefix is not None and parsed.python_tag is not None: - if not parsed.python_tag.startswith(py_tag_prefix) and parsed.python_tag not in ('py3', 'py2.py3'): + if not parsed.python_tag.startswith(py_tag_prefix) and parsed.python_tag not in ( + 'py3', + 'py2.py3', + ): continue cached_pkgs.add((parsed.name, parsed.version)) @@ -796,9 +799,7 @@ def env_bootstrap( with contextlib.ExitStack() as stack: if overrides and len(constraint_args) > 0: patched = stack.enter_context( - tempfile.NamedTemporaryFile( - mode='w', prefix='constraints_', suffix='.txt' - ) + tempfile.NamedTemporaryFile(mode='w', prefix='constraints_', suffix='.txt') ) packaging_t.apply_overrides_to_constraints( requirements_path, overrides, patched @@ -925,8 +926,18 @@ class argv_extract_t: if action is not None: matched_argv.append(argv[i]) i += 1 - if action.nargs in (None, 1) and action.const is None and not isinstance( - action, (_argparse._StoreTrueAction, _argparse._StoreFalseAction, _argparse._CountAction, _argparse._HelpAction) + if ( + action.nargs in (None, 1) + and action.const is None + and not isinstance( + action, + ( + _argparse._StoreTrueAction, + _argparse._StoreFalseAction, + _argparse._CountAction, + _argparse._HelpAction, + ), + ) ): if i < len(argv): matched_argv.append(argv[i]) diff --git a/python/online/fxreader/pr34/commands_typed/cli.py b/python/online/fxreader/pr34/commands_typed/cli.py index 6257899..f822821 100644 --- a/python/online/fxreader/pr34/commands_typed/cli.py +++ b/python/online/fxreader/pr34/commands_typed/cli.py @@ -213,6 +213,44 @@ class CLI(abc.ABC): cwd=str(project.source_dir), ) + def tests( + self, + project_name: str, + argv: list[str], + ) -> None: + from . import argparse as pr34_argparse + from . import cli_bootstrap + from . import tests as _tests + + tests_parser = argparse.ArgumentParser() + tests_parser.add_argument( + '--timeout', + default=16, + type=int, + help='test timeout in seconds, default = 16', + ) + tests_parser.add_argument( + '--dry-run', + default=False, + action=argparse.BooleanOptionalAction, + help='list tests to run without executing them', + ) + + tests_options, tests_args = pr34_argparse.parse_args(tests_parser, argv) + + project = self.projects[project_name] + pyproject = cli_bootstrap.pyproject_load(project.source_dir / 'pyproject.toml') + + _tests.run_tests( + source_dir=project.source_dir, + tests_args=tests_args, + timeout=tests_options.timeout, + search_paths=pyproject.tests.search_paths if pyproject.tests else None, + test_names=pyproject.tests.test_names if pyproject.tests else None, + discovery_paths=pyproject.tests.discovery_paths if pyproject.tests else None, + dry_run=tests_options.dry_run, + ) + def pip_sync( self, project: str, diff --git a/python/online/fxreader/pr34/commands_typed/cli_bootstrap.py b/python/online/fxreader/pr34/commands_typed/cli_bootstrap.py index f18c4f7..3660277 100644 --- a/python/online/fxreader/pr34/commands_typed/cli_bootstrap.py +++ b/python/online/fxreader/pr34/commands_typed/cli_bootstrap.py @@ -93,6 +93,14 @@ class PyProject: default_factory=lambda: [], ) + @dataclasses.dataclass + class Tests: + search_paths: list[pathlib.Path] = dataclasses.field(default_factory=list) + test_names: list[str] = dataclasses.field(default_factory=list) + discovery_paths: list[pathlib.Path] = dataclasses.field(default_factory=list) + + tests: Optional[Tests] = None + tool: dict[str, Any] = dataclasses.field( default_factory=lambda: dict(), ) @@ -311,6 +319,20 @@ def pyproject_load( for k, v in check_dict(pr34_tool['requirements'], str, str).items() } + if 'tests' in pr34_tool: + tests_section = check_dict(pr34_tool['tests'], str) + res.tests = PyProject.Tests( + search_paths=[ + d.parent / pathlib.Path(o) + for o in check_list(tests_section.get('search_paths', []), str) + ], + test_names=check_list(tests_section.get('test_names', []), str), + discovery_paths=[ + d.parent / pathlib.Path(o) + for o in check_list(tests_section.get('discovery_paths', []), str) + ], + ) + if 'modules' in pr34_tool: modules = check_list(pr34_tool['modules']) # res.modules = [] @@ -595,7 +617,10 @@ def whl_cache_download( if parsed is None: continue if py_tag_prefix is not None and parsed.python_tag is not None: - if not parsed.python_tag.startswith(py_tag_prefix) and parsed.python_tag not in ('py3', 'py2.py3'): + if not parsed.python_tag.startswith(py_tag_prefix) and parsed.python_tag not in ( + 'py3', + 'py2.py3', + ): continue cached_pkgs.add((parsed.name, parsed.version)) @@ -796,9 +821,7 @@ def env_bootstrap( with contextlib.ExitStack() as stack: if overrides and len(constraint_args) > 0: patched = stack.enter_context( - tempfile.NamedTemporaryFile( - mode='w', prefix='constraints_', suffix='.txt' - ) + tempfile.NamedTemporaryFile(mode='w', prefix='constraints_', suffix='.txt') ) packaging_t.apply_overrides_to_constraints( requirements_path, overrides, patched @@ -925,8 +948,18 @@ class argv_extract_t: if action is not None: matched_argv.append(argv[i]) i += 1 - if action.nargs in (None, 1) and action.const is None and not isinstance( - action, (_argparse._StoreTrueAction, _argparse._StoreFalseAction, _argparse._CountAction, _argparse._HelpAction) + if ( + action.nargs in (None, 1) + and action.const is None + and not isinstance( + action, + ( + _argparse._StoreTrueAction, + _argparse._StoreFalseAction, + _argparse._CountAction, + _argparse._HelpAction, + ), + ) ): if i < len(argv): matched_argv.append(argv[i]) diff --git a/python/online/fxreader/pr34/commands_typed/tests.py b/python/online/fxreader/pr34/commands_typed/tests.py new file mode 100644 index 0000000..ecbc309 --- /dev/null +++ b/python/online/fxreader/pr34/commands_typed/tests.py @@ -0,0 +1,229 @@ +import argparse +import fnmatch +import logging +import os +import pathlib +import subprocess +import sys +import unittest + +from typing import Optional + +logger = logging.getLogger(__name__) + + +def _build_discover_cmd( + source_dir: pathlib.Path, + test_names: Optional[list[str]] = None, + discovery_paths: Optional[list[pathlib.Path]] = None, +) -> list[str]: + top_level = str(source_dir) + start_dir = str(source_dir) + + if discovery_paths is not None and len(discovery_paths) > 0: + top_level = str(discovery_paths[0].resolve()) + start_dir = top_level + + # narrow start_dir to common prefix of test_names + if test_names is not None and len(test_names) > 0: + # find the first non-glob prefix + prefixes = [n for n in test_names if '*' not in n] + if len(prefixes) > 0: + # find common prefix of all dotted prefixes + common = prefixes[0].split('.') + for p in prefixes[1:]: + parts = p.split('.') + common = common[: min(len(common), len(parts))] + for i in range(len(common)): + if common[i] != parts[i]: + common = common[:i] + break + + if len(common) > 0: + start_dir = str(pathlib.Path(top_level) / '/'.join(common)) + + return [ + sys.executable, + '-m', + 'unittest', + 'discover', + '-s', + start_dir, + '-p', + 'test_*.py', + '-t', + top_level, + ] + + +def _build_env( + search_paths: Optional[list[pathlib.Path]] = None, +) -> dict[str, str]: + env = dict(os.environ) + + if search_paths is not None and len(search_paths) > 0: + extra = [str(p.resolve()) for p in search_paths] + existing = env.get('PYTHONPATH', '') + env['PYTHONPATH'] = os.pathsep.join(extra + ([existing] if existing else [])) + + return env + + +def _parse_discover_args(cmd: list[str]) -> tuple[str, str, str]: + start_dir = '.' + top_level = '.' + pattern = 'test_*.py' + + for i, arg in enumerate(cmd): + if arg == '-s' and i + 1 < len(cmd): + start_dir = cmd[i + 1] + elif arg == '-t' and i + 1 < len(cmd): + top_level = cmd[i + 1] + elif arg == '-p' and i + 1 < len(cmd): + pattern = cmd[i + 1] + + return start_dir, top_level, pattern + + +def collect_tests( + start_dir: str, + top_level: str, + pattern: str = 'test_*.py', +) -> list[str]: + loader = unittest.TestLoader() + suite = loader.discover(start_dir, pattern=pattern, top_level_dir=top_level) + + result: list[str] = [] + + def _collect(s: unittest.TestSuite) -> None: + for t in s: + if isinstance(t, unittest.TestSuite): + _collect(t) + else: + result.append(t.id()) + + _collect(suite) + return result + + +def filter_tests( + all_tests: list[str], + test_names: list[str], +) -> list[str]: + """Filter tests by prefix or glob pattern. + + Each entry in test_names is either: + - a dotted prefix (e.g. 'pkg.tests.test_models') — matches tests starting with it + - a glob with * (e.g. '*TestResolver*') — matched via fnmatch against full test id + """ + result: list[str] = [] + for t in all_tests: + for name in test_names: + if '*' in name: + if fnmatch.fnmatch(t, name): + result.append(t) + break + else: + if t.startswith(name): + result.append(t) + break + return result + + +def _collect_via_subprocess( + cmd: list[str], + env: dict[str, str], + source_dir: pathlib.Path, +) -> list[str]: + start_dir, top_level, pattern = _parse_discover_args(cmd) + collect_cmd = [ + sys.executable, + '-m', + __name__, + 'collect', + '-s', + start_dir, + '-t', + top_level, + '-p', + pattern, + ] + logger.info(dict(collect_cmd=collect_cmd, cwd=str(source_dir))) + result = subprocess.run( + collect_cmd, + cwd=str(source_dir), + env=env, + capture_output=True, + text=True, + ) + logger.info(dict(collect_rc=result.returncode, stdout_lines=len(result.stdout.splitlines()))) + if result.returncode != 0: + logger.error(dict(stderr=result.stderr)) + raise RuntimeError('test collection failed') + return [line for line in result.stdout.splitlines() if line.strip()] + + +def run_tests( + source_dir: pathlib.Path, + tests_args: list[str], + timeout: int = 16, + search_paths: Optional[list[pathlib.Path]] = None, + test_names: Optional[list[str]] = None, + discovery_paths: Optional[list[pathlib.Path]] = None, + dry_run: bool = False, +) -> None: + env = _build_env(search_paths) + + if len(tests_args) > 0: + cmd = [sys.executable, '-m', 'unittest', *tests_args] + else: + discover_cmd = _build_discover_cmd(source_dir, test_names, discovery_paths) + all_tests = _collect_via_subprocess(discover_cmd, env, source_dir) + + if test_names is not None and len(test_names) > 0: + all_tests = filter_tests(all_tests, test_names) + + if len(all_tests) == 0: + logger.warning('no tests matched') + return + + cmd = [sys.executable, '-m', 'unittest', *all_tests] + + logger.info(dict(cmd=cmd, PYTHONPATH=env.get('PYTHONPATH', ''))) + + if dry_run: + for t in cmd[3:]: + print(t) + return + + subprocess.check_call( + cmd, + cwd=str(source_dir), + timeout=timeout, + env=env, + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + prog='online.fxreader.pr34.commands_typed.tests', + ) + subparsers = parser.add_subparsers(dest='command') + + collect_parser = subparsers.add_parser('collect', help='discover and list test names') + collect_parser.add_argument('-s', dest='start_dir', default='.', help='start directory') + collect_parser.add_argument('-t', dest='top_level', default='.', help='top level directory') + collect_parser.add_argument('-p', dest='pattern', default='test_*.py', help='file pattern') + + args = parser.parse_args() + + if args.command == 'collect': + for t in collect_tests(args.start_dir, args.top_level, args.pattern): + print(t) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == '__main__': + main()