Compare commits

...

6 commits

Author SHA1 Message Date
ItsDrike bb920f9474
Add test helpers.py 2024-06-30 23:02:19 +02:00
ItsDrike 04ca4a486d
Enable flake-type-checking 2024-06-30 22:52:36 +02:00
ItsDrike 7c95528c6a
Add typing-extensions dependency 2024-06-30 22:52:27 +02:00
ItsDrike 80d42cd68c
Ignore TRY003 2024-06-30 22:49:13 +02:00
ItsDrike 8cb7ad48bc
Add poe commands 2024-06-30 22:42:27 +02:00
ItsDrike 5a167c358e
Add pytest & coverage settings 2024-06-30 22:35:17 +02:00
3 changed files with 183 additions and 2 deletions

13
poetry.lock generated
View file

@ -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"

View file

@ -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"

105
tests/helpers.py Normal file
View file

@ -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