From a521edc17b7407469e1eafc1b1388770fc6bffc1 Mon Sep 17 00:00:00 2001 From: antazoey Date: Fri, 27 Sep 2024 15:09:12 -0500 Subject: [PATCH] fix: `ape test -vvv` was not using correct pytest verbosity (#2303) --- src/ape/cli/options.py | 38 +++++++++++++++++--- src/ape/pytest/plugin.py | 6 ++-- src/ape_test/_cli.py | 58 +++++++++++++++++++++++++----- tests/integration/cli/test_test.py | 35 ++++++++++++++++-- 4 files changed, 120 insertions(+), 17 deletions(-) diff --git a/src/ape/cli/options.py b/src/ape/cli/options.py index 0daacd9c3a..e0a1468151 100644 --- a/src/ape/cli/options.py +++ b/src/ape/cli/options.py @@ -37,6 +37,10 @@ def __init__(self): self.logger = logger super().__init__({}) + def __repr__(self) -> str: + # Customizing this because otherwise it uses `dict` repr, which is confusing. + return f"<{self.__class__.__name__}>" + @staticmethod def abort(msg: str, base_error: Optional[Exception] = None) -> NoReturn: """ @@ -56,18 +60,37 @@ def abort(msg: str, base_error: Optional[Exception] = None) -> NoReturn: def verbosity_option( - cli_logger: Optional[ApeLogger] = None, default: Union[str, int, LogLevel] = DEFAULT_LOG_LEVEL + cli_logger: Optional[ApeLogger] = None, + default: Union[str, int, LogLevel] = DEFAULT_LOG_LEVEL, + callback: Optional[Callable] = None, + **kwargs, ) -> Callable: """A decorator that adds a `--verbosity, -v` option to the decorated command. + + Args: + cli_logger (:class:`~ape.logging.ApeLogger` | None): Optionally pass + a custom logger object. + default (str | int | :class:`~ape.logging.LogLevel`): The default log-level + for this command. + callback (Callable | None): A callback handler for passed-in verbosity values. + **kwargs: Additional click overrides. + + Returns: + click option """ _logger = cli_logger or logger - kwarguments = _create_verbosity_kwargs(_logger=_logger, default=default) + kwarguments = _create_verbosity_kwargs( + _logger=_logger, default=default, callback=callback, **kwargs + ) return lambda f: click.option(*_VERBOSITY_VALUES, **kwarguments)(f) def _create_verbosity_kwargs( - _logger: Optional[ApeLogger] = None, default: Union[str, int, LogLevel] = DEFAULT_LOG_LEVEL + _logger: Optional[ApeLogger] = None, + default: Union[str, int, LogLevel] = DEFAULT_LOG_LEVEL, + callback: Optional[Callable] = None, + **kwargs, ) -> dict: cli_logger = _logger or logger @@ -77,6 +100,9 @@ def set_level(ctx, param, value): if value.startswith("LOGLEVEL."): value = value.split(".")[-1].strip() + if callback is not None: + value = callback(ctx, param, value) + cli_logger._load_from_sys_argv(default=value) level_names = [lvl.name for lvl in LogLevel] @@ -89,6 +115,7 @@ def set_level(ctx, param, value): "help": f"One of {names_str}", "is_eager": True, "type": Noop(), + **kwargs, } @@ -102,13 +129,16 @@ def ape_cli_context( such as logging or accessing managers. Args: - default_log_level (Union[str, int, :class:`~ape.logging.LogLevel`]): The log-level + default_log_level (str | int | :class:`~ape.logging.LogLevel`): The log-level value to pass to :meth:`~ape.cli.options.verbosity_option`. obj_type (Type): The context object type. Defaults to :class:`~ape.cli.options.ApeCliContextObject`. Sub-class the context to extend its functionality in your CLIs, such as if you want to add additional manager classes to the context. + + Returns: + click option """ def decorator(f): diff --git a/src/ape/pytest/plugin.py b/src/ape/pytest/plugin.py index ecb10b7e0e..182dda1e9e 100644 --- a/src/ape/pytest/plugin.py +++ b/src/ape/pytest/plugin.py @@ -80,8 +80,10 @@ def is_module(v): gas_tracker = GasTracker(config_wrapper) coverage_tracker = CoverageTracker(config_wrapper) - # Enable verbose output if stdout capture is disabled - config.option.verbose = config.getoption("capture") == "no" + if not config.option.verbose: + # Enable verbose output if stdout capture is disabled + config.option.verbose = config.getoption("capture") == "no" + # else: user has already changes verbosity to an equal or higher level; avoid downgrading. # Register the custom Ape test runner runner = PytestApeRunner(config_wrapper, receipt_capture, gas_tracker, coverage_tracker) diff --git a/src/ape_test/_cli.py b/src/ape_test/_cli.py index 9495e43155..97431e4976 100644 --- a/src/ape_test/_cli.py +++ b/src/ape_test/_cli.py @@ -4,9 +4,11 @@ from datetime import datetime, timedelta from pathlib import Path from subprocess import run as run_subprocess +from typing import Any import click import pytest +from click import Command from watchdog import events from watchdog.observers import Observer @@ -89,9 +91,8 @@ def _validate_pytest_args(*pytest_args) -> list[str]: # Ensure this is a pytest -v and not ape's -v. next_arg = next(args_iter) lvl_name = _get_level(next_arg) - if lvl_name in [x.name for x in LogLevel]: - # Ape log level found, cannot use. - continue + if not _is_ape_loglevel(lvl_name): + valid_args.append(argument) else: valid_args.append(argument) @@ -99,13 +100,50 @@ def _validate_pytest_args(*pytest_args) -> list[str]: return valid_args +def _is_ape_loglevel(value: Any) -> bool: + if isinstance(value, (int, LogLevel)): + return True + + elif isinstance(value, str): + return ( + value.upper() in [x.name for x in LogLevel] + or (value.isnumeric() and int(value) in LogLevel) + or value.lower().startswith("loglevel.") + ) + + return False + + +class ApeTestCommand(Command): + def parse_args(self, ctx, args: list[str]) -> list[str]: + num_args = len(args) + for idx, argument in enumerate(args): + if not argument.startswith("-v"): + continue + elif (idx == num_args - 1) or argument in ("-vv", "-vvv"): + # Definitely for pytest. + ctx.obj["pytest_verbosity"] = argument + args = [a for a in args if a != argument] + else: + # -v with a following arg; ensure not Ape's. + next_arg = args[idx + 1] + if not _is_ape_loglevel(next_arg): + ctx.obj["pytest_verbosity"] = "-v" + args = [a for a in args if a != argument] + + return super().parse_args(ctx, args) + + @click.command( add_help_option=False, # NOTE: This allows pass-through to pytest's help short_help="Launches pytest and runs the tests for a project", context_settings=dict(ignore_unknown_options=True), + cls=ApeTestCommand, ) # NOTE: Using '.value' because more performant. -@ape_cli_context(default_log_level=LogLevel.WARNING.value) +@ape_cli_context( + default_log_level=LogLevel.WARNING.value, +) @click.option( "-w", "--watch", @@ -131,6 +169,11 @@ def _validate_pytest_args(*pytest_args) -> list[str]: ) @click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED) def cli(cli_ctx, watch, watch_folders, watch_delay, pytest_args): + pytest_arg_ls = [*pytest_args] + if pytest_verbosity := cli_ctx.get("pytest_verbosity"): + pytest_arg_ls.append(pytest_verbosity) + + pytest_arg_ls = _validate_pytest_args(*pytest_arg_ls) if watch: event_handler = _create_event_handler() observer = _create_observer() @@ -142,19 +185,18 @@ def cli(cli_ctx, watch, watch_folders, watch_delay, pytest_args): cli_ctx.logger.warning(f"Folder '{folder}' doesn't exist or isn't a folder.") observer.start() - pytest_args = _validate_pytest_args(*pytest_args) try: - _run_ape_test(*pytest_args) + _run_ape_test(*pytest_arg_ls) while True: - _run_main_loop(watch_delay, *pytest_args) + _run_main_loop(watch_delay, *pytest_arg_ls) finally: observer.stop() observer.join() else: - return_code = pytest.main([*pytest_args], ["ape_test"]) + return_code = pytest.main([*pytest_arg_ls], ["ape_test"]) if return_code: # only exit with non-zero status to make testing easier sys.exit(return_code) diff --git a/tests/integration/cli/test_test.py b/tests/integration/cli/test_test.py index a96e999fb6..f7c09a615f 100644 --- a/tests/integration/cli/test_test.py +++ b/tests/integration/cli/test_test.py @@ -228,18 +228,47 @@ def test_verbosity(runner, ape_cli): for some reason. """ # NOTE: Only using `--fixtures` flag to avoid running tests (just prints fixtures). - result = runner.invoke(ape_cli, ("test", "--verbosity", "DEBUG", "--fixtures")) + cmd = ("test", "--verbosity", "DEBUG", "--fixtures") + result = runner.invoke(ape_cli, cmd, catch_exceptions=False) assert result.exit_code == 0, result.output @skip_projects_except("test") -def test_vvv(runner, ape_cli): +@pytest.mark.parametrize("v_arg", ("-v", "-vv", "-vvv")) +def test_vvv(runner, ape_cli, integ_project, v_arg): """ Showing you can somehow use pytest's -v flag without messing up Ape. """ - result = runner.invoke(ape_cli, ("test", "-vvv", "--fixtures"), catch_exceptions=False) + here = integ_project.path + os.chdir(integ_project.path) + name = f"test_{v_arg.replace('-', '_')}" + + TEST = f""" + def {name}(): + assert True + """.lstrip() + + # Have to create a new test each time to avoid .pycs issues + new_test_file = integ_project.tests_folder / f"{name}.py" + new_test_file.write_text(TEST) + + try: + # NOTE: v_arg purposely at the end because testing doesn't interfere + # with click's option parsing "requires value" error. + result = runner.invoke( + ape_cli, + ("test", f"tests/{new_test_file.name}::{name}", v_arg), + catch_exceptions=False, + ) + finally: + new_test_file.unlink(missing_ok=True) + os.chdir(here) + assert result.exit_code == 0, result.output + # Prove `-vvv` worked via the output. + # It shows PASSED instead of the little green dot. + assert "PASSED" in result.output @skip_projects_except("test", "with-contracts")