[+] 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,45 +236,12 @@ class CLI(_cli.CLI):
|
|||||||
argv=args,
|
argv=args,
|
||||||
)
|
)
|
||||||
elif options.command is Command.tests:
|
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()
|
self.tests(
|
||||||
tests_parser.add_argument(
|
project_name=options.project,
|
||||||
'--timeout',
|
argv=args,
|
||||||
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,
|
|
||||||
)
|
|
||||||
elif options.command is Command.module_switch:
|
elif options.command is Command.module_switch:
|
||||||
assert not options.project is None
|
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:
|
if parsed is None:
|
||||||
continue
|
continue
|
||||||
if py_tag_prefix is not None and parsed.python_tag is not None:
|
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
|
continue
|
||||||
cached_pkgs.add((parsed.name, parsed.version))
|
cached_pkgs.add((parsed.name, parsed.version))
|
||||||
|
|
||||||
@ -796,9 +799,7 @@ def env_bootstrap(
|
|||||||
with contextlib.ExitStack() as stack:
|
with contextlib.ExitStack() as stack:
|
||||||
if overrides and len(constraint_args) > 0:
|
if overrides and len(constraint_args) > 0:
|
||||||
patched = stack.enter_context(
|
patched = stack.enter_context(
|
||||||
tempfile.NamedTemporaryFile(
|
tempfile.NamedTemporaryFile(mode='w', prefix='constraints_', suffix='.txt')
|
||||||
mode='w', prefix='constraints_', suffix='.txt'
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
packaging_t.apply_overrides_to_constraints(
|
packaging_t.apply_overrides_to_constraints(
|
||||||
requirements_path, overrides, patched
|
requirements_path, overrides, patched
|
||||||
@ -925,8 +926,18 @@ class argv_extract_t:
|
|||||||
if action is not None:
|
if action is not None:
|
||||||
matched_argv.append(argv[i])
|
matched_argv.append(argv[i])
|
||||||
i += 1
|
i += 1
|
||||||
if action.nargs in (None, 1) and action.const is None and not isinstance(
|
if (
|
||||||
action, (_argparse._StoreTrueAction, _argparse._StoreFalseAction, _argparse._CountAction, _argparse._HelpAction)
|
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):
|
if i < len(argv):
|
||||||
matched_argv.append(argv[i])
|
matched_argv.append(argv[i])
|
||||||
|
|||||||
@ -213,6 +213,44 @@ class CLI(abc.ABC):
|
|||||||
cwd=str(project.source_dir),
|
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(
|
def pip_sync(
|
||||||
self,
|
self,
|
||||||
project: str,
|
project: str,
|
||||||
|
|||||||
@ -93,6 +93,14 @@ class PyProject:
|
|||||||
default_factory=lambda: [],
|
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(
|
tool: dict[str, Any] = dataclasses.field(
|
||||||
default_factory=lambda: dict(),
|
default_factory=lambda: dict(),
|
||||||
)
|
)
|
||||||
@ -311,6 +319,20 @@ def pyproject_load(
|
|||||||
for k, v in check_dict(pr34_tool['requirements'], str, str).items()
|
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:
|
if 'modules' in pr34_tool:
|
||||||
modules = check_list(pr34_tool['modules'])
|
modules = check_list(pr34_tool['modules'])
|
||||||
# res.modules = []
|
# res.modules = []
|
||||||
@ -595,7 +617,10 @@ def whl_cache_download(
|
|||||||
if parsed is None:
|
if parsed is None:
|
||||||
continue
|
continue
|
||||||
if py_tag_prefix is not None and parsed.python_tag is not None:
|
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
|
continue
|
||||||
cached_pkgs.add((parsed.name, parsed.version))
|
cached_pkgs.add((parsed.name, parsed.version))
|
||||||
|
|
||||||
@ -796,9 +821,7 @@ def env_bootstrap(
|
|||||||
with contextlib.ExitStack() as stack:
|
with contextlib.ExitStack() as stack:
|
||||||
if overrides and len(constraint_args) > 0:
|
if overrides and len(constraint_args) > 0:
|
||||||
patched = stack.enter_context(
|
patched = stack.enter_context(
|
||||||
tempfile.NamedTemporaryFile(
|
tempfile.NamedTemporaryFile(mode='w', prefix='constraints_', suffix='.txt')
|
||||||
mode='w', prefix='constraints_', suffix='.txt'
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
packaging_t.apply_overrides_to_constraints(
|
packaging_t.apply_overrides_to_constraints(
|
||||||
requirements_path, overrides, patched
|
requirements_path, overrides, patched
|
||||||
@ -925,8 +948,18 @@ class argv_extract_t:
|
|||||||
if action is not None:
|
if action is not None:
|
||||||
matched_argv.append(argv[i])
|
matched_argv.append(argv[i])
|
||||||
i += 1
|
i += 1
|
||||||
if action.nargs in (None, 1) and action.const is None and not isinstance(
|
if (
|
||||||
action, (_argparse._StoreTrueAction, _argparse._StoreFalseAction, _argparse._CountAction, _argparse._HelpAction)
|
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):
|
if i < len(argv):
|
||||||
matched_argv.append(argv[i])
|
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