From 16695b3fbb021db35a9db45ca73f62d600c5c86e Mon Sep 17 00:00:00 2001 From: Stephanos Kuma Date: Fri, 13 Dec 2024 11:37:55 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20Make=20linting=20stricter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 93 ++++++------------- src/yamk/command/make.py | 63 +++++++------ src/yamk/lib/functions.py | 31 ++++--- src/yamk/lib/types.py | 25 ++++- src/yamk/lib/utils.py | 69 +++++++------- tests/helpers.py | 29 +++++- .../yamk/command/test_make/test_arguments.py | 89 ++++++++++-------- .../yamk/command/test_make/test_exceptions.py | 4 +- tests/yamk/lib/test_parser.py | 18 +++- tests/yamk/lib/test_utils.py | 9 +- 10 files changed, 239 insertions(+), 191 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 179881a..bed03ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ requires-python = ">=3.9" dependencies = [ "dj_settings~=6.0", - "pyutilkit~=0.5", + "pyutilkit~=0.10", ] [project.urls] @@ -55,6 +55,7 @@ lint = [ "black~=24.10", "mypy~=1.13", "ruff~=0.8", + "typing-extensions~=4.12", # upgrade: py3.10: use typing module ] test = [ "pytest~=8.3", @@ -78,7 +79,11 @@ target-version = [ [tool.mypy] check_untyped_defs = true +disallow_any_decorated = true +disallow_any_explicit = true +disallow_any_expr = false # many builtins are Any disallow_any_generics = true +disallow_any_unimported = true disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true @@ -87,13 +92,18 @@ disallow_untyped_defs = true extra_checks = true ignore_missing_imports = true no_implicit_reexport = true +show_column_numbers = true show_error_codes = true strict_equality = true -warn_return_any = true warn_redundant_casts = true +warn_return_any = true +warn_unused_configs = true warn_unused_ignores = true warn_unreachable = true -warn_unused_configs = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_any_decorated = false # mock.MagicMock is Any [tool.ruff] src = [ @@ -103,73 +113,23 @@ target-version = "py39" [tool.ruff.lint] select = [ - "A", - "ANN", - "ARG", - "ASYNC", - "B", - "BLE", - "C4", - "COM", - "DTZ", - "E", - "EM", - "ERA", - "EXE", - "F", - "FA", - "FBT", - "FIX", - "FLY", - "FURB", - "G", - "I", - "ICN", - "INP", - "ISC", - "LOG", - "N", - "PGH", - "PERF", - "PIE", - "PL", - "PT", - "PTH", - "PYI", - "Q", - "RET", - "RSE", - "RUF", - "S", - "SIM", - "SLF", - "SLOT", - "T10", - "TCH", - "TD", - "TID", - "TRY", - "UP", - "W", - "YTT", + "ALL", ] ignore = [ - "ANN401", - "COM812", - "E501", - "FIX002", - "PLR09", - "TD002", - "TD003", - "TRY003", + "C901", # Adding a limit to complexity is too arbitrary + "COM812", # Avoid magic trailing commas + "D10", # Not everything needs a docstring + "D203", # Prefer `no-blank-line-before-class` (D211) + "D213", # Prefer `multi-line-summary-first-line` (D212) + "E501", # Avoid clashes with black + "PLR09", # Adding a limit to complexity is too arbitrary ] [tool.ruff.lint.per-file-ignores] "tests/**" = [ - "FBT001", - "PLR2004", - "PT011", - "S101", + "FBT001", # Test arguments are handled by pytest + "PLR2004", # Tests should contain magic number comparisons + "S101", # Pytest needs assert statements ] [tool.ruff.lint.flake8-tidy-imports] @@ -196,12 +156,15 @@ source = [ "src/", ] data_file = ".cov_cache/coverage.dat" +omit = [ + "src/yamk/lib/types.py", +] [tool.coverage.report] exclude_also = [ "if TYPE_CHECKING:", ] -fail_under = 85 +fail_under = 90 precision = 2 show_missing = true skip_covered = true diff --git a/src/yamk/command/make.py b/src/yamk/command/make.py index 2bf90b4..7145421 100644 --- a/src/yamk/command/make.py +++ b/src/yamk/command/make.py @@ -1,15 +1,16 @@ from __future__ import annotations import itertools +import os import pathlib import re import subprocess import sys from time import sleep -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast from dj_settings import ConfigParser -from pyutilkit.term import SGRCodes, SGRString +from pyutilkit.term import SGRCodes, SGROutput, SGRString from pyutilkit.timing import Stopwatch from yamk.__version__ import __version__ @@ -29,13 +30,13 @@ import argparse from collections.abc import Iterator - from yamk.lib.types import ExistenceCheck + from yamk.lib.types import ExistenceCheck, RawRecipe class MakeCommand: def __init__(self, args: argparse.Namespace) -> None: self.verbosity = args.verbosity - self.regex_recipes: dict[str, Recipe] = {} + self.regex_recipes: dict[re.Pattern[str], Recipe] = {} self.static_recipes: dict[str, Recipe] = {} self.aliases: dict[str, str] = {} self.target = args.target @@ -106,7 +107,7 @@ def _run_command(self, command: str) -> int: if i != self.retries: a, b = b, a + b - print(f"{command} failed. Retrying in {a}s...") + SGRString(f"{command} failed. Retrying in {a}s...").print() sleep(a) report = CommandReport( @@ -129,7 +130,7 @@ def _check_command(self, check: ExistenceCheck) -> bool: return False return result.returncode == check["returncode"] - def _parse_recipes(self, parsed_cookbook: dict[str, dict[str, Any]]) -> None: + def _parse_recipes(self, parsed_cookbook: dict[str, RawRecipe]) -> None: for target, raw_recipe in parsed_cookbook.items(): recipe = Recipe( target, @@ -141,11 +142,11 @@ def _parse_recipes(self, parsed_cookbook: dict[str, dict[str, Any]]) -> None: ) if recipe.alias: - self.aliases[recipe.target] = recipe.alias + self.aliases[cast(str, recipe.target)] = recipe.alias elif recipe.regex: - self.regex_recipes[recipe.target] = recipe + self.regex_recipes[cast(re.Pattern[str], recipe.target)] = recipe else: - self.static_recipes[recipe.target] = recipe + self.static_recipes[cast(str, recipe.target)] = recipe def _preprocess_target(self) -> DAG: recipe = self._extract_recipe(self.target, use_extra=True) @@ -170,7 +171,7 @@ def _preprocess_target(self) -> DAG: msg = f"No recipe to build {requirement}" raise ValueError(msg) else: - requirement = recipe.target + requirement = cast(str, recipe.target) target_recipe.requires[index] = requirement if requirement in dag: @@ -188,13 +189,17 @@ def _preprocess_target(self) -> DAG: dag.sort() self._mark_unchanged(dag) if self.verbosity > 3: # noqa: PLR2004 - print("=== all targets ===") + SGRString("=== all targets ===").print() for node in dag: - print(f"- {node.target}:") - print(f" timestamp: {human_readable_timestamp(node.timestamp)}") - print(f" should_build: {node.should_build}") - print(f" requires: {node.requires}") - print(f" required_by: {node.required_by}") + SGROutput( + [ + f"- {node.target}:", + f" timestamp: {human_readable_timestamp(node.timestamp)}", + f" should_build: {node.should_build}", + f" requires: {node.requires}", + f" required_by: {node.required_by}", + ] + ).print(sep=os.linesep) return dag def _update_ts(self, node: Node) -> None: @@ -207,7 +212,7 @@ def _update_ts(self, node: Node) -> None: self.phony_dir.mkdir(exist_ok=True) path.touch() if not recipe.phony and recipe.update: - pathlib.Path(recipe.target).touch() + pathlib.Path(cast(str, recipe.target)).touch() def _make_target(self, node: Node) -> None: recipe = node.recipe @@ -216,7 +221,7 @@ def _make_target(self, node: Node) -> None: raise ValueError(msg) if self.verbosity > 1: - print(f"=== target: {recipe.target} ===") + SGRString(f"=== target: {recipe.target} ===").print() n = len(recipe.commands) for i, raw_command in enumerate(recipe.commands): @@ -236,7 +241,7 @@ def _make_target(self, node: Node) -> None: print_reports(self.reports) sys.exit(return_code) if i != n - 1: - print() + SGRString("").print() self._update_ts(node) def _extract_recipe(self, target: str, *, use_extra: bool = False) -> Recipe | None: @@ -287,7 +292,7 @@ def _path_exists(self, node: Node) -> bool: if not recipe.exists_only: msg = "Existence commands need exists_only" raise ValueError(msg) - return self._check_command(recipe.existence_check) + return self._check_command(recipe.existence_check) # type: ignore[arg-type] return path.exists() @@ -336,18 +341,20 @@ def _print_reasons(self, recipe: Recipe, options: set[str]) -> Iterator[bool]: yield self.echo_override def _print_command(self, command: str) -> None: - bold_command = SGRString(command, params=[SGRCodes.BOLD]) - print(f"🔧 Running `{bold_command}`") + SGROutput( + ["🔧 Running ", "`", SGRString(command, params=[SGRCodes.BOLD]), "`"] + ).print() def _print_result(self, command: str, return_code: int) -> None: - bold_command = SGRString(command, params=[SGRCodes.BOLD]) if return_code: - prefix = "❌" - suffix = f"failed with exit code {return_code}" + prefix = "❌ " + suffix = f" failed with exit code {return_code}" else: - prefix = "✅" - suffix = "run successfully!" - print(f"{prefix} `{bold_command}` {suffix}") + prefix = "✅ " + suffix = " run successfully!" + SGROutput( + [prefix, "`", SGRString(command, params=[SGRCodes.BOLD]), "`", suffix] + ).print() def _get_version(self) -> Version: return Version.from_string(self.globals["version"]) diff --git a/src/yamk/lib/functions.py b/src/yamk/lib/functions.py index 118fafe..a78e424 100644 --- a/src/yamk/lib/functions.py +++ b/src/yamk/lib/functions.py @@ -1,12 +1,16 @@ from __future__ import annotations from functools import reduce -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast if TYPE_CHECKING: + from collections.abc import Iterable from pathlib import Path - from yamk.lib.types import Pathlike + from yamk.lib.types import Comparable, Pathlike + +S = TypeVar("S") +T = TypeVar("T") class Function: @@ -15,7 +19,7 @@ class Function: def __init__(self, base_dir: Path) -> None: self.base_dir = base_dir - def __call__(self, *args: Any, **kwargs: Any) -> Any: + def __call__(self, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc] # noqa: ANN401 raise NotImplementedError @@ -26,11 +30,11 @@ def __call__(self, pattern: str) -> list[str]: return [path.as_posix() for path in self.base_dir.glob(pattern)] -class Sort(Function): +class Sort(Function, Generic[T]): name = "sort" - def __call__(self, *args: Any) -> list[Any]: - return sorted(*args) + def __call__(self, args: Iterable[Comparable]) -> list[Comparable]: + return sorted(args) class Exists(Function): @@ -109,7 +113,7 @@ def __call__(self) -> str: class FilterOut(Function): name = "filter_out" - def __call__(self, odd: Any, obj: list[Any]) -> list[Any]: + def __call__(self, odd: T, obj: list[T]) -> list[T]: return list(filter(lambda x: x != odd, obj)) @@ -117,8 +121,8 @@ class TernaryIf(Function): name = "ternary_if" def __call__( - self, condition: bool, if_true: Any, if_false: Any # noqa: FBT001 - ) -> Any: + self, condition: bool, if_true: S, if_false: T # noqa: FBT001 + ) -> S | T: return if_true if condition else if_false @@ -135,11 +139,14 @@ class Merge(Function): name = "merge" @staticmethod - def _as_list(obj: Any | list[Any]) -> list[Any]: + def _as_list(obj: T | list[T]) -> list[T]: return obj if isinstance(obj, list) else [obj] - def __call__(self, *args: Any | list[Any]) -> list[Any]: - return reduce(lambda x, y: self._as_list(x) + self._as_list(y), args) + def _concat(self, x: T | list[T], y: T | list[T]) -> list[T]: + return self._as_list(x) + self._as_list(y) + + def __call__(self, *args: T | list[T]) -> list[T]: + return reduce(self._concat, args) # type: ignore[arg-type] functions = {function.name: function for function in Function.__subclasses__()} diff --git a/src/yamk/lib/types.py b/src/yamk/lib/types.py index 13541e7..31ef883 100644 --- a/src/yamk/lib/types.py +++ b/src/yamk/lib/types.py @@ -1,13 +1,36 @@ from __future__ import annotations from pathlib import Path -from typing import TypedDict, Union +from typing import TYPE_CHECKING, Protocol, TypedDict, Union + +if TYPE_CHECKING: + from typing_extensions import Self # upgrade: py3.10: import from typing Pathlike = Union[str, Path] +class Comparable(Protocol): + def __lt__(self, other: Self) -> bool: ... + + class ExistenceCheck(TypedDict): command: str returncode: int stdout: str | None stderr: str | None + + +class RawRecipe(TypedDict, total=False): + update: bool + phony: bool + echo: bool + keep_ts: bool + regex: bool + exists_only: bool + allow_failures: bool + alias: str + existence_command: str + existence_check: ExistenceCheck + requires: list[str] + commands: list[str] + vars: dict[str, str] diff --git a/src/yamk/lib/utils.py b/src/yamk/lib/utils.py index 0a51928..1e7a16c 100644 --- a/src/yamk/lib/utils.py +++ b/src/yamk/lib/utils.py @@ -8,9 +8,9 @@ from dataclasses import dataclass from datetime import datetime, timezone from re import Match -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast -from pyutilkit.term import SGRCodes, SGRString +from pyutilkit.term import SGRCodes, SGROutput, SGRString from yamk.lib.functions import functions @@ -20,6 +20,9 @@ from pyutilkit.timing import Timing + from yamk.lib.types import RawRecipe + +T = TypeVar("T") VAR = re.compile( r"(?P\$+){(?P[a-zA-Z0-9_.]+)(?P:)?(?P[a-zA-Z0-9_.]+)?}" ) @@ -31,18 +34,20 @@ ".yaml": "yaml", ".json": "json", } +FlatVariables = dict[str, Any] # type: ignore[misc] +Variables = dict[str, FlatVariables] class Recipe: def __init__( self, target: str, - raw_recipe: dict[str, Any], + raw_recipe: RawRecipe, base_dir: Path, - file_vars: dict[str, Any], - arg_vars: dict[str, Any], + file_vars: dict[str, str], + arg_vars: dict[str, str], extra: list[str], - original_regex: str | None = None, + original_regex: str | re.Pattern[str] | None = None, *, specified: bool = False, ) -> None: @@ -63,7 +68,7 @@ def __init__( self.existence_check["command"] = existence_command self.recursive = raw_recipe.get("recursive", False) self.update = raw_recipe.get("update", False) - temp_vars = { + temp_vars: Variables = { "global": file_vars, "env": dict(**os.environ), "arg": arg_vars, @@ -81,7 +86,7 @@ def __init__( if original_regex is None: msg = "original_regex must be specified when target is specific" raise RuntimeError(msg) - match_obj = re.fullmatch(original_regex, self.target) + match_obj = re.fullmatch(original_regex, cast(str, self.target)) if match_obj is None: msg = ( f"original_regex {original_regex} does not match {self.target}" @@ -116,9 +121,9 @@ def for_target(self, target: str, extra: list[str]) -> Recipe: specified=True, ) - def _evaluate( - self, obj: Any, variables: dict[str, dict[str, Any]] | None = None - ) -> Any: + def _evaluate( # type: ignore[misc] + self, obj: object, variables: Variables | None = None + ) -> Any: # noqa: ANN401 if variables is None: variables = self.vars flat_vars = flatten_vars(variables, self.base_dir) @@ -134,12 +139,14 @@ def _re_evaluate(self) -> None: self.existence_check.setdefault("stderr", None) self.existence_check.setdefault("returncode", 0) - def _alias(self, alias: str | Literal[False], variables: dict[str, Any]) -> Any: + def _alias( # type: ignore[misc] + self, alias: str | Literal[False], variables: Variables + ) -> Any: # noqa: ANN401 if alias is False: return alias return self._evaluate(alias, variables) - def _target(self, target: str, variables: dict[str, Any]) -> Any: + def _target(self, target: str, variables: Variables) -> str | re.Pattern[str]: if not self._specified: target = self._evaluate(target, variables) if not self.phony and not self.alias: @@ -150,17 +157,19 @@ def _target(self, target: str, variables: dict[str, Any]) -> Any: class Parser: - def __init__(self, variables: dict[str, Any], base_dir: Path) -> None: + def __init__(self, variables: FlatVariables, base_dir: Path) -> None: self.vars = variables self.base_dir = base_dir @staticmethod - def _stringify(value: Any) -> str: + def _stringify(value: object) -> str: if isinstance(value, list): return " ".join(map(str, value)) return str(value) - def expand_function(self, name: str, args: str) -> Any: + def expand_function( # type: ignore[misc] + self, name: str, args: str + ) -> Any: # noqa: ANN401 split_args = shlex.split(args) for i, arg in enumerate(split_args): split_args[i] = self.evaluate(arg) @@ -180,7 +189,7 @@ def repl(self, match_obj: re.Match[str]) -> str: return self._stringify(value[key]) return f"{'$'*(len(dollars)//2)}{{{variable}}}" - def substitute(self, string: str) -> Any: + def substitute(self, string: str) -> Any: # type: ignore[misc] # noqa: ANN401 function = re.fullmatch(FUNCTION, string) if function is not None: return self.expand_function(**function.groupdict()) @@ -195,7 +204,7 @@ def substitute(self, string: str) -> Any: return self.vars[match["variable"]] return re.sub(VAR, self.repl, string) - def evaluate(self, obj: Any) -> Any: + def evaluate(self, obj: object) -> Any: # type: ignore[misc] # noqa: ANN401 if isinstance(obj, str): return self.substitute(obj) if isinstance(obj, list): @@ -375,13 +384,13 @@ def print(self, cols: int) -> None: indicator = "🟢" sgr_code = SGRCodes.GREEN timing = str(self.timing) - padding = " " * (cols - len(self.command) - len(timing) - 7) - print( - indicator, - f"`{self.command}`", - padding, - SGRString(timing, params=[sgr_code]), - ) + padding = " " * (cols - len(self.command) - len(timing) - 4) + SGROutput( + [ + SGRString(f"`{self.command}`", prefix=indicator, suffix=padding), + SGRString(timing, params=[sgr_code]), + ] + ).print() @dataclass(frozen=True, order=True) @@ -401,9 +410,7 @@ def from_string(cls, version: str) -> Version: return cls(major, minor, patch) -def flatten_vars( - variables: dict[str, dict[str, Any]], base_dir: Path -) -> dict[str, Any]: +def flatten_vars(variables: Variables, base_dir: Path) -> FlatVariables: order = [ "env", "arg", @@ -417,7 +424,7 @@ def flatten_vars( "env", "arg", ] - output: dict[str, Any] = {} + output: Variables = {} strong_keys: set[str] = set() for var_type in order: var_block = variables.get(var_type, {}) @@ -456,9 +463,7 @@ def human_readable_timestamp(timestamp: float) -> str: def print_reports(reports: list[CommandReport]) -> None: - SGRString("Yam Report", params=[SGRCodes.BOLD]).header( - padding=SGRString("=", params=[SGRCodes.BOLD]) - ) + SGRString("Yam Report", params=[SGRCodes.BOLD]).header(padding="=") cols = os.get_terminal_size().columns for report in reports: diff --git a/tests/helpers.py b/tests/helpers.py index b0c94b3..c90a2ef 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,11 +1,16 @@ -import pathlib +from __future__ import annotations + from itertools import chain -from typing import Any +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict from unittest import mock from yamk.command.make import MakeCommand -TEST_DATA_ROOT = pathlib.Path(__file__).resolve().parent.joinpath("data") +if TYPE_CHECKING: + from typing_extensions import Unpack # upgrade: py3.10: import from typing + +TEST_DATA_ROOT = Path(__file__).resolve().parent.joinpath("data") TEST_COOKBOOK = TEST_DATA_ROOT.joinpath("mk.toml") DEFAULT_VALUES = { "directory": ".", @@ -21,6 +26,22 @@ } +class MakeCommandArgs(TypedDict, total=False): + directory: str + cookbook: Path + cookbook_name: str + cookbook_type: str | None + verbosity: int + retries: int + bare: bool + time: bool + force: bool + dry_run: bool + extra: list[str] + target: str + variables: list[str] + + def runner_exit_success() -> mock.MagicMock: return mock.MagicMock(return_value=mock.MagicMock(returncode=0)) @@ -29,7 +50,7 @@ def runner_exit_failure() -> mock.MagicMock: return mock.MagicMock(return_value=mock.MagicMock(returncode=42)) -def get_make_command(**kwargs: Any) -> MakeCommand: +def get_make_command(**kwargs: Unpack[MakeCommandArgs]) -> MakeCommand: if cookbook_name := kwargs.pop("cookbook_name"): kwargs["cookbook"] = TEST_DATA_ROOT.joinpath(cookbook_name) diff --git a/tests/yamk/command/test_make/test_arguments.py b/tests/yamk/command/test_make/test_arguments.py index 320de19..63e0084 100644 --- a/tests/yamk/command/test_make/test_arguments.py +++ b/tests/yamk/command/test_make/test_arguments.py @@ -1,3 +1,4 @@ +import os from unittest import mock from tests.helpers import get_make_command, runner_exit_success @@ -5,72 +6,82 @@ COOKBOOK = "arguments.yaml" -@mock.patch("yamk.command.make.print", new_callable=mock.MagicMock) @mock.patch("yamk.command.make.subprocess.run", new_callable=runner_exit_success) -def test_make_dry_run(runner: mock.MagicMock, mock_print: mock.MagicMock) -> None: +def test_make_dry_run( + runner: mock.MagicMock, capsys: mock.MagicMock # upgrade: pytest: check for isatty +) -> None: make_command = get_make_command(cookbook_name=COOKBOOK, target="echo", dry_run=True) make_command.make() assert runner.call_count == 0 - assert mock_print.call_count == 2 - calls = [ - mock.call("🔧 Running `\x1b[1mecho 'echo'\x1b[0m`"), - mock.call("✅ `\x1b[1mecho 'echo'\x1b[0m` run successfully!"), - ] - assert mock_print.call_args_list == calls + + expected_out_lines = ( + "🔧 Running `echo 'echo'`", + "✅ `echo 'echo'` run successfully!", + ) + captured = capsys.readouterr() + assert captured.out == os.linesep.join(expected_out_lines) + os.linesep + assert captured.err == "" -@mock.patch("yamk.command.make.print", new_callable=mock.MagicMock) @mock.patch("yamk.command.make.subprocess.run", new_callable=runner_exit_success) -def test_make_verbosity(runner: mock.MagicMock, mock_print: mock.MagicMock) -> None: +def test_make_verbosity( + runner: mock.MagicMock, capsys: mock.MagicMock # upgrade: pytest: check for isatty +) -> None: make_command = get_make_command(cookbook_name=COOKBOOK, target="echo", verbosity=4) make_command.make() assert runner.call_count == 1 - assert mock_print.call_count == 9 - calls = [ - mock.call("=== all targets ==="), - mock.call("- echo:"), - mock.call(" timestamp: end of time"), - mock.call(" should_build: True"), - mock.call(" requires: []"), - mock.call(" required_by: set()"), - mock.call("=== target: echo ==="), - mock.call("🔧 Running `\x1b[1mecho 'echo'\x1b[0m`"), - mock.call("✅ `\x1b[1mecho 'echo'\x1b[0m` run successfully!"), - ] - assert mock_print.call_args_list == calls + calls = [mock.call("echo 'echo'", **make_command.subprocess_kwargs)] + assert runner.call_args_list == calls + + expected_out_lines = ( + "=== all targets ===", + "- echo:", + " timestamp: end of time", + " should_build: True", + " requires: []", + " required_by: set()", + "=== target: echo ===", + "🔧 Running `echo 'echo'`", + "✅ `echo 'echo'` run successfully!", + ) + captured = capsys.readouterr() + assert captured.out == os.linesep.join(expected_out_lines) + os.linesep + assert captured.err == "" -@mock.patch("yamk.command.make.print", new_callable=mock.MagicMock) @mock.patch("yamk.command.make.subprocess.run", new_callable=runner_exit_success) def test_make_echo_in_recipe( - runner: mock.MagicMock, mock_print: mock.MagicMock + runner: mock.MagicMock, capsys: mock.MagicMock # upgrade: pytest: check for isatty ) -> None: make_command = get_make_command(cookbook_name=COOKBOOK, target="echo") make_command.make() assert runner.call_count == 1 calls = [mock.call("echo 'echo'", **make_command.subprocess_kwargs)] assert runner.call_args_list == calls - assert mock_print.call_count == 2 - calls = [ - mock.call("🔧 Running `\x1b[1mecho 'echo'\x1b[0m`"), - mock.call("✅ `\x1b[1mecho 'echo'\x1b[0m` run successfully!"), - ] - assert mock_print.call_args_list == calls + + expected_out_lines = ( + "🔧 Running `echo 'echo'`", + "✅ `echo 'echo'` run successfully!", + ) + captured = capsys.readouterr() + assert captured.out == os.linesep.join(expected_out_lines) + os.linesep + assert captured.err == "" -@mock.patch("yamk.command.make.print", new_callable=mock.MagicMock) @mock.patch("yamk.command.make.subprocess.run", new_callable=runner_exit_success) def test_make_echo_in_command( - runner: mock.MagicMock, mock_print: mock.MagicMock + runner: mock.MagicMock, capsys: mock.MagicMock # upgrade: pytest: check for isatty ) -> None: make_command = get_make_command(cookbook_name=COOKBOOK, target="echo_in_command") make_command.make() assert runner.call_count == 1 calls = [mock.call("echo 'echo_in_command'", **make_command.subprocess_kwargs)] assert runner.call_args_list == calls - assert mock_print.call_count == 2 - calls = [ - mock.call("🔧 Running `\x1b[1mecho 'echo_in_command'\x1b[0m`"), - mock.call("✅ `\x1b[1mecho 'echo_in_command'\x1b[0m` run successfully!"), - ] - assert mock_print.call_args_list == calls + + expected_out_lines = ( + "🔧 Running `echo 'echo_in_command'`", + "✅ `echo 'echo_in_command'` run successfully!", + ) + captured = capsys.readouterr() + assert captured.out == os.linesep.join(expected_out_lines) + os.linesep + assert captured.err == "" diff --git a/tests/yamk/command/test_make/test_exceptions.py b/tests/yamk/command/test_make/test_exceptions.py index cb2fde6..899ca14 100644 --- a/tests/yamk/command/test_make/test_exceptions.py +++ b/tests/yamk/command/test_make/test_exceptions.py @@ -9,7 +9,7 @@ def test_make_raises_on_missing_target() -> None: make_command = get_make_command(cookbook_name=COOKBOOK, target="missing_target") - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="No recipe to build missing_target"): make_command.make() @@ -17,7 +17,7 @@ def test_make_raises_on_missing_requirement() -> None: make_command = get_make_command( cookbook_name=COOKBOOK, target="missing_requirement" ) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="No recipe to build .*missing_target"): make_command.make() diff --git a/tests/yamk/lib/test_parser.py b/tests/yamk/lib/test_parser.py index ac77e3f..c7ede1b 100644 --- a/tests/yamk/lib/test_parser.py +++ b/tests/yamk/lib/test_parser.py @@ -1,9 +1,21 @@ from unittest import mock +import pytest + from yamk.lib.parser import parse_args -@mock.patch("sys.argv", ["yamk", "phony"]) -def test_parse_args() -> None: +@mock.patch("sys.argv", ["yamk", "target"]) +def test_parse_target() -> None: args = parse_args() - assert args.target == "phony" + assert args.target == "target" + + +@pytest.mark.parametrize( + ("verbose", "expected_verbosity"), [("-v", 1), ("-vv", 2), ("-vvvvv", 5)] +) +def test_yamk_verbosity(verbose: str, expected_verbosity: int) -> None: + with mock.patch("sys.argv", ["yamk", verbose, "target"]): + args = parse_args() + + assert args.verbosity == expected_verbosity diff --git a/tests/yamk/lib/test_utils.py b/tests/yamk/lib/test_utils.py index d4c9316..c745daa 100644 --- a/tests/yamk/lib/test_utils.py +++ b/tests/yamk/lib/test_utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import pathlib -from typing import Any import pytest @@ -90,7 +89,7 @@ def test_topological_sort_detects_cycles() -> None: node.add_requirement(root) dag = DAG(root) dag.add_node(node) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Cyclic dependencies detected"): dag.topological_sort() @@ -101,12 +100,12 @@ def test_c3_sort_detects_cycles() -> None: node.add_requirement(root) dag = DAG(root) dag.add_node(node) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Cannot compute c3_sort"): dag.c3_sort() @pytest.mark.parametrize("obj", [("string in a tuple",), None]) -def test_parser_evaluation_raises(obj: Any) -> None: +def test_parser_evaluation_raises(obj: object) -> None: parser = Parser({}, PATH) with pytest.raises(TypeError): parser.evaluate(obj) @@ -135,7 +134,7 @@ def test_parser_evaluation_raises(obj: Any) -> None: ], ) def test_parser_evaluation( - obj: str | list[str], variables: dict[str, Any], expected: str | list[str] + obj: str | list[str], variables: dict[str, object], expected: str | list[str] ) -> None: parser = Parser(variables, PATH) assert parser.evaluate(obj) == expected