diff --git a/poetry.lock b/poetry.lock index 0bd8831..69c4d79 100644 --- a/poetry.lock +++ b/poetry.lock @@ -536,6 +536,17 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "virtualenv" version = "20.26.3" @@ -559,4 +570,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "c7e77528e9eca8213b1d2a0d7d353b7f47900832569b2396f9026300939dac14" +content-hash = "334a7105baa770bfd69d0391602995e317ccc0faa3d34539acbf7e9aa5355fee" diff --git a/pyproject.toml b/pyproject.toml index da0e17e..f975398 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ packages = [{ include = "src" }] [tool.poetry.dependencies] python = "^3.11" +typing-extensions = "^4.12.2" [tool.poetry.group.dev.dependencies] poethepoet = "^0.25.0" @@ -41,7 +42,6 @@ ignore = [ "CPY", # flake8-copyright "EM", # flake8-errmsg "SLF", # flake8-self - "TCH", # flake8-type-checking "ARG", # flake8-unused-arguments "TD", # flake8-todos "FIX", # flake8-fixme @@ -85,6 +85,7 @@ ignore = [ "PGH003", # Using specific rule codes in type ignores "E731", # Don't asign a lambda expression, use a def "S311", # Use `secrets` for random number generation, not `random` + "TRY003", # Avoid specifying long messages outside the exception class # Redundant rules with ruff-format: "E111", # Indentation of a non-multiple of 4 spaces @@ -148,3 +149,67 @@ reportUnnecessaryContains = "error" reportUnnecessaryTypeIgnoreComment = "error" reportImplicitOverride = "error" reportShadowedImports = "error" + +[tool.pytest.ini_options] +minversion = "6.0" +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "--strict-markers --cov --no-cov-on-fail" + +[tool.coverage.report] +precision = 2 +fail_under = 20 +show_missing = true +skip_covered = false +skip_empty = false +sort = "cover" +exclude_lines = [ + "\\#\\s*pragma: no cover", + "^\\s*if (typing\\.)?TYPE_CHECKING:", + "^\\s*@(abc\\.)?abstractmethod", + "^\\s*@(typing\\.)?overload", + "^\\s*def __repr__\\(", + "^\\s*class .*\\bProtocol\\):", + "^\\s*raise NotImplementedError", + "^\\s*return NotImplemented", + "^\\s*\\.\\.\\.", +] + +[tool.coverage.run] +relative_files = true +parallel = true +branch = true +timid = false +source = ["src"] + +[tool.poe.tasks.precommit] +cmd = "pre-commit install" +help = "install pre-commit hooks" + +[tool.poe.tasks.lint] +cmd = "pre-commit run --all-files" +help = "Run all project linters (via pre-commit)" + +[tool.poe.tasks.ruff] +cmd = "ruff check --fix ." +help = "Run ruff linter, with automatic issue fixing" + +[tool.poe.tasks.ruff-format] +cmd = "ruff format ." +help = "Run ruff formatter" + +[tool.poe.tasks.test] +cmd = "pytest -v --failed-first" +help = "Run pytest tests" + +[tool.poe.tasks.retest] +cmd = "pytest -v --last-failed" +help = "Run previously failed tests using pytest" + +[tool.poe.tasks.test-nocov] +cmd = "pytest -v --no-cov --failed-first" +help = "Run pytest tests without coverage" + +[tool.poe.tasks.retest-nocov] +cmd = "pytest -v --no-cov --last-failed" +help = "Run previously failed tests using pytest without coverage" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..d21d6c6 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio +import unittest.mock +from typing import Any, Generic, TYPE_CHECKING, TypeVar + +from typing_extensions import ParamSpec + +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + +T = TypeVar("T") +P = ParamSpec("P") +T_Mock = TypeVar("T_Mock", bound=unittest.mock.Mock) + +__all__ = [ + "synchronize", + "UnpropagatingMockMixin", + "CustomMockMixin", +] + + +def synchronize(f: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]: + """Take an asynchronous function, and return a synchronous alternative. + + This is needed because we sometimes want to test asynchronous behavior in a synchronous test function, + where we can't simply await something. This function uses `asyncio.run` and generates a wrapper + around the original asynchronous function, that awaits the result in a blocking synchronous way, + returning the obtained value. + """ + + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +class UnpropagatingMockMixin(Generic[T_Mock]): + """Provides common functionality for our :class:`~unittest.mock.Mock` classes. + + By default, mock objects propagate themselves by returning a new instance of the same mock + class, with same initialization attributes. This is done whenever we're accessing new + attributes that mock class. + + This propagation makes sense for simple mocks without any additional restrictions, however when + dealing with limited mocks to some ``spec_set``, it doesn't usually make sense to propagate + those same ``spec_set`` restrictions, since we generally don't have attributes/methods of a + class be of/return the same class. + + This mixin class stops this propagation, and instead returns instances of specified mock class, + defined in :attr:`.child_mock_type` class variable, which is by default set to + :class:`~unittest.mock.MagicMock`, as it can safely represent most objects. + + .. note: + This propagation handling will only be done for the mock classes that inherited from this + mixin class. That means if the :attr:`.child_mock_type` is one of the regular mock classes, + and the mock is propagated, a regular mock class is returned as that new attribute. This + regular class then won't have the same overrides, and will therefore propagate itself, like + any other mock class would. + + If you wish to counteract this, you can set the :attr:`.child_mock_type` to a mock class + that also inherits from this mixin class, perhaps to your class itself, overriding any + propagation recursively. + """ + + child_mock_type: T_Mock = unittest.mock.MagicMock + + # Since this is a mixin class, we can access some attributes defined in mock classes safely. + # Define the types of these variables here, for proper static type analysis. + _mock_sealed: bool + _extract_mock_name: Callable[[], str] + + def _get_child_mock(self, **kwargs) -> T_Mock: + """Make :attr:`.child_mock_type`` instances instead of instances of the same class. + + By default, this method creates a new mock instance of the same original class, and passes + over the same initialization arguments. This overrides that behavior to instead create an + instance of :attr:`.child_mock_type` class. + """ + # Mocks can be sealed, in which case we wouldn't want to allow propagation of any kind + # and rather raise an AttributeError, informing that given attr isn't accessible + if self._mock_sealed: + mock_name = self._extract_mock_name() + obj_name = f"{mock_name}.{kwargs['name']}" if "name" in kwargs else f"{mock_name}()" + raise AttributeError(f"Can't access {obj_name}, mock is sealed.") + + # Propagate any other children as simple `unittest.mock.Mock` instances + # rather than `self.__class__` instances + return self.child_mock_type(**kwargs) + + +class CustomMockMixin(UnpropagatingMockMixin): + """Provides common functionality for our custom mock types. + + * Stops propagation of same ``spec_set`` restricted mock in child mocks + (see :class:`.UnpropagatingMockMixin` for more info) + * Allows using the ``spec_set`` attribute as class attribute + """ + + spec_set = None + + def __init__(self, **kwargs): + if "spec_set" in kwargs: + self.spec_set = kwargs.pop("spec_set") + super().__init__(spec_set=self.spec_set, **kwargs) # type: ignore # Mixin class, this __init__ is valid