[+] 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;
This commit is contained in:
parent
bf3fd46953
commit
12c9ad8fe0
@ -236,44 +236,11 @@ 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',
|
||||
)
|
||||
|
||||
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,
|
||||
self.tests(
|
||||
project_name=options.project,
|
||||
argv=args,
|
||||
)
|
||||
elif options.command is Command.module_switch:
|
||||
assert not options.project is None
|
||||
|
||||
23
python/m.py
23
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])
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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])
|
||||
|
||||
229
python/online/fxreader/pr34/commands_typed/tests.py
Normal file
229
python/online/fxreader/pr34/commands_typed/tests.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user