From 2f5c2df8a50b13d58a3ff16951643816cd42b5ce Mon Sep 17 00:00:00 2001 From: MartinGotelli Date: Sat, 24 Aug 2024 17:27:54 -0300 Subject: [PATCH 1/2] feat: Add pylint package and matchers --- .github/workflows/python-package.yml | 4 + Makefile | 4 +- pyproject.toml | 6 +- requirements.txt | 9 +- requirements_dev.txt | 9 +- requirements_min.txt | 4 + .../pytest_matchers/__init__.py | 6 + src/pytest_matchers/pytest_matchers/main.py | 12 +- .../pytest_matchers/matchers/has_attribute.py | 8 +- .../matchers/pydantic/__init__.py | 0 src/pytest_matchers/pytest_matchers/plugin.py | 6 +- .../pytest_matchers/pydantic/__init__.py | 0 .../pytest_matchers/pydantic/main.py | 43 ++++ .../pydantic/matchers/__init__.py | 1 + .../pydantic/matchers/pydantic_model.py | 110 ++++++++++ src/tests/matchers/test_eq.py | 3 + src/tests/matchers/test_has_attribute.py | 15 +- src/tests/pydantic/__init__.py | 0 src/tests/pydantic/conftest.py | 42 ++++ src/tests/pydantic/matchers/__init__.py | 0 .../pydantic/matchers/test_pydantic_model.py | 192 ++++++++++++++++++ src/tests/pydantic/test_main.py | 89 ++++++++ src/tests/test_main.py | 11 + src/tests/test_plugin.py | 26 +++ vulture/whitelist.py | 4 + 25 files changed, 583 insertions(+), 21 deletions(-) create mode 100644 requirements_min.txt create mode 100644 src/pytest_matchers/pytest_matchers/matchers/pydantic/__init__.py create mode 100644 src/pytest_matchers/pytest_matchers/pydantic/__init__.py create mode 100644 src/pytest_matchers/pytest_matchers/pydantic/main.py create mode 100644 src/pytest_matchers/pytest_matchers/pydantic/matchers/__init__.py create mode 100644 src/pytest_matchers/pytest_matchers/pydantic/matchers/pydantic_model.py create mode 100644 src/tests/pydantic/__init__.py create mode 100644 src/tests/pydantic/conftest.py create mode 100644 src/tests/pydantic/matchers/__init__.py create mode 100644 src/tests/pydantic/matchers/test_pydantic_model.py create mode 100644 src/tests/pydantic/test_main.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 0662f7f..75fea22 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -35,6 +35,10 @@ jobs: - name: Test with pytest run: | make coverage + - name: Min versions tests + run: | + pip install -r requirements_min.txt + make coverage - name: Spelling check with codespell run: | make spell diff --git a/Makefile b/Makefile index 4af5673..13b24f6 100644 --- a/Makefile +++ b/Makefile @@ -130,7 +130,7 @@ validate_env: fi requirements_dev: - poetry export --without-hashes --with=dev -o $(or $(OUTFILE),requirements_dev.txt) + poetry export --without-hashes --with=dev --all-extras -o $(or $(OUTFILE),requirements_dev.txt) requirements_prod: - poetry export --without-hashes -o $(or $(OUTFILE),requirements.txt) \ No newline at end of file + poetry export --without-hashes --all-extras -o $(or $(OUTFILE),requirements.txt) diff --git a/pyproject.toml b/pyproject.toml index c752e68..ff70454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,11 @@ packages = [ [tool.poetry.dependencies] python = "^3.10" -pytest = ">=7.0.0, <9.0.0" +pytest = ">=7.0, <9.0" +pydantic = {version = ">=1.0, <3.0", optional = true} + +[tool.poetry.extras] +pydantic = ["pydantic"] [tool.poetry.group.dev.dependencies] black = "^24.8.0" diff --git a/requirements.txt b/requirements.txt index 59d0720..f8294b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,10 @@ +atomicwrites==1.4.1 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" +attrs==24.2.0 ; python_version >= "3.10" and python_version < "4.0" colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" -exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11" iniconfig==2.0.0 ; python_version >= "3.10" and python_version < "4.0" packaging==24.1 ; python_version >= "3.10" and python_version < "4.0" pluggy==1.5.0 ; python_version >= "3.10" and python_version < "4.0" -pytest==8.3.2 ; python_version >= "3.10" and python_version < "4.0" -tomli==2.0.1 ; python_version >= "3.10" and python_version < "3.11" +py==1.11.0 ; python_version >= "3.10" and python_version < "4.0" +pydantic==1.0 ; python_version >= "3.10" and python_version < "4.0" +pytest==7.0.0 ; python_version >= "3.10" and python_version < "4.0" +tomli==2.0.1 ; python_version >= "3.10" and python_version < "4.0" diff --git a/requirements_dev.txt b/requirements_dev.txt index a8ec9c3..fde1f5a 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,11 +1,12 @@ astroid==3.2.4 ; python_version >= "3.10" and python_version < "4.0" +atomicwrites==1.4.1 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" +attrs==24.2.0 ; python_version >= "3.10" and python_version < "4.0" black==24.8.0 ; python_version >= "3.10" and python_version < "4.0" click==8.1.7 ; python_version >= "3.10" and python_version < "4.0" codespell==2.3.0 ; python_version >= "3.10" and python_version < "4.0" colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and (sys_platform == "win32" or platform_system == "Windows") coverage==7.6.1 ; python_version >= "3.10" and python_version < "4.0" dill==0.3.8 ; python_version >= "3.10" and python_version < "4.0" -exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11" iniconfig==2.0.0 ; python_version >= "3.10" and python_version < "4.0" isort==5.13.2 ; python_version >= "3.10" and python_version < "4.0" mccabe==0.7.0 ; python_version >= "3.10" and python_version < "4.0" @@ -14,9 +15,11 @@ packaging==24.1 ; python_version >= "3.10" and python_version < "4.0" pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0" platformdirs==4.2.2 ; python_version >= "3.10" and python_version < "4.0" pluggy==1.5.0 ; python_version >= "3.10" and python_version < "4.0" +py==1.11.0 ; python_version >= "3.10" and python_version < "4.0" +pydantic==1.0 ; python_version >= "3.10" and python_version < "4.0" pylint==3.2.6 ; python_version >= "3.10" and python_version < "4.0" -pytest==8.3.2 ; python_version >= "3.10" and python_version < "4.0" -tomli==2.0.1 ; python_version >= "3.10" and python_version < "3.11" +pytest==7.0.0 ; python_version >= "3.10" and python_version < "4.0" +tomli==2.0.1 ; python_version >= "3.10" and python_version < "4.0" tomlkit==0.13.0 ; python_version >= "3.10" and python_version < "4.0" typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "3.11" vulture==2.11 ; python_version >= "3.10" and python_version < "4.0" diff --git a/requirements_min.txt b/requirements_min.txt new file mode 100644 index 0000000..4f2ffcd --- /dev/null +++ b/requirements_min.txt @@ -0,0 +1,4 @@ +attrs==24.2.0 ; python_version >= "3.10" and python_version < "4.0" +py==1.11.0 ; python_version >= "3.10" and python_version < "4.0" +pydantic==1.10.16 ; python_version >= "3.10" and python_version < "4.0" +pytest==7.0.0 ; python_version >= "3.10" and python_version < "4.0" \ No newline at end of file diff --git a/src/pytest_matchers/pytest_matchers/__init__.py b/src/pytest_matchers/pytest_matchers/__init__.py index 5f9bc9d..3edb0a1 100644 --- a/src/pytest_matchers/pytest_matchers/__init__.py +++ b/src/pytest_matchers/pytest_matchers/__init__.py @@ -3,6 +3,7 @@ anything, between, case, + contains, different_value, has_attribute, if_false, @@ -20,3 +21,8 @@ one_of, same_value, ) + +try: + from .pydantic.main import is_pydantic, is_pydantic_v1 +except ImportError: # pragma: no cover + pass diff --git a/src/pytest_matchers/pytest_matchers/main.py b/src/pytest_matchers/pytest_matchers/main.py index cc50785..15c81f5 100644 --- a/src/pytest_matchers/pytest_matchers/main.py +++ b/src/pytest_matchers/pytest_matchers/main.py @@ -1,9 +1,11 @@ from typing import Any, Callable, Type from pytest_matchers.matchers import ( + And, Anything, Between, Case, + Contains, Datetime, DatetimeString, Dict, @@ -11,14 +13,14 @@ HasAttribute, If, IsInstance, - List, - Number, - String, JSON, + List, Matcher, + Number, Or, SameValue, StrictDict, + String, UUID, ) @@ -64,6 +66,10 @@ def between( return Between(min_value, max_value, inclusive, min_inclusive, max_inclusive) +def contains(*values: Any) -> And: + return And(*[Contains(value) for value in values]) + + def is_datetime( min_value: Any = None, max_value: Any = None, diff --git a/src/pytest_matchers/pytest_matchers/matchers/has_attribute.py b/src/pytest_matchers/pytest_matchers/matchers/has_attribute.py index d4ac2d6..a1f0f92 100644 --- a/src/pytest_matchers/pytest_matchers/matchers/has_attribute.py +++ b/src/pytest_matchers/pytest_matchers/matchers/has_attribute.py @@ -11,7 +11,9 @@ def __init__(self, attribute_name: str, expected_value: Any = None): super().__init__() self._attribute_name = attribute_name self._expected_value = expected_value - self._expected_value_matcher = as_matcher(expected_value) if expected_value else None + self._expected_value_matcher = ( + as_matcher(expected_value) if expected_value is not None else None + ) def matches(self, value: Any) -> bool: return hasattr(value, self._attribute_name) and matches_or_none( @@ -27,9 +29,9 @@ def __repr__(self) -> str: def concatenated_repr(self) -> str: base_repr = f"with {repr(self._attribute_name)}" - if self._expected_value: + if self._expected_value is not None: if isinstance(self._expected_value, Matcher): - return f"{base_repr} expecting {self._expected_value.concatenated_repr()}" + return f"{base_repr} expected {self._expected_value.concatenated_repr()}" return f"{base_repr} being {repr(self._expected_value)}" return base_repr diff --git a/src/pytest_matchers/pytest_matchers/matchers/pydantic/__init__.py b/src/pytest_matchers/pytest_matchers/matchers/pydantic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pytest_matchers/pytest_matchers/plugin.py b/src/pytest_matchers/pytest_matchers/plugin.py index 5b56e30..db9e683 100644 --- a/src/pytest_matchers/pytest_matchers/plugin.py +++ b/src/pytest_matchers/pytest_matchers/plugin.py @@ -17,11 +17,7 @@ def _matches_operation(operation, actual: Any, expected: Matcher) -> bool: @pytest.hookimpl() def pytest_assertrepr_compare(config, op: str, left: Any, right: Any): del config - if ( - MatcherDetector(left).uses_matchers() - or MatcherDetector(right).uses_matchers() - and _matches_operation(op, left, right) - ): + if MatcherDetector(right).uses_matchers() and _matches_operation(op, left, right): return [ f"{left!r} {op} {right!r}", "", diff --git a/src/pytest_matchers/pytest_matchers/pydantic/__init__.py b/src/pytest_matchers/pytest_matchers/pydantic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pytest_matchers/pytest_matchers/pydantic/main.py b/src/pytest_matchers/pytest_matchers/pydantic/main.py new file mode 100644 index 0000000..5050387 --- /dev/null +++ b/src/pytest_matchers/pytest_matchers/pydantic/main.py @@ -0,0 +1,43 @@ +from typing import Type + +from pydantic import BaseModel +from pydantic.v1 import BaseModel as BaseModelV1 + +from pytest_matchers.pydantic.matchers import PydanticModel + + +def is_pydantic( + model_class: Type[BaseModel | BaseModelV1] | None = None, + *, + strict: bool = None, + exempt_defaults: bool = True, + attributes: dict = None, + **kwargs, +): + model_class = model_class or BaseModel + if strict is None: + strict = bool(attributes) or bool(kwargs) + return PydanticModel( + model_class, + strict=strict, + exempt_defaults=exempt_defaults, + attributes=attributes, + **kwargs, + ) + + +def is_pydantic_v1( + model_class: Type[BaseModelV1] | None = None, + *, + strict: bool = None, + exempt_defaults: bool = True, + attributes: dict = None, + **kwargs, +): + return is_pydantic( + model_class or BaseModelV1, + strict=strict, + exempt_defaults=exempt_defaults, + attributes=attributes, + **kwargs, + ) diff --git a/src/pytest_matchers/pytest_matchers/pydantic/matchers/__init__.py b/src/pytest_matchers/pytest_matchers/pydantic/matchers/__init__.py new file mode 100644 index 0000000..46de65c --- /dev/null +++ b/src/pytest_matchers/pytest_matchers/pydantic/matchers/__init__.py @@ -0,0 +1 @@ +from .pydantic_model import PydanticModel diff --git a/src/pytest_matchers/pytest_matchers/pydantic/matchers/pydantic_model.py b/src/pytest_matchers/pytest_matchers/pydantic/matchers/pydantic_model.py new file mode 100644 index 0000000..81d02b4 --- /dev/null +++ b/src/pytest_matchers/pytest_matchers/pydantic/matchers/pydantic_model.py @@ -0,0 +1,110 @@ +from typing import Any, Type + +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from pydantic.v1 import BaseModel as BaseModelV1 +from pydantic.v1.fields import ModelField + +from pytest_matchers.matchers import HasAttribute, Matcher +from pytest_matchers.utils.matcher_utils import is_instance_matcher +from pytest_matchers.utils.repr_utils import concat_reprs + + +def _model_fields(model_class: Type[BaseModel | BaseModelV1]): + if issubclass(model_class, BaseModelV1): + return model_class.__fields__ + return model_class.model_fields + + +def _is_required(field: FieldInfo | ModelField) -> bool: + if isinstance(field, ModelField): + return field.required + return field.is_required() + + +def _default_value(field: FieldInfo | ModelField) -> Any: + if field.default_factory is None: + return field.default + return field.default_factory() + + +def _version(model_class: Type) -> str | None: + is_pydantic = hasattr(model_class, "__pydantic_complete__") or hasattr( + model_class, "__fields__" + ) + if is_pydantic: + if hasattr(model_class, "__pydantic_complete__"): + return "v2" + return "v1" + return None + + +class PydanticModel(Matcher): + def __init__( + self, + model_class: Type[BaseModel | BaseModelV1], + *, + strict: bool = True, + exempt_defaults: bool = True, + attributes: dict = None, + **kwargs, + ): + super().__init__() + self._model_class = model_class + self._is_instance_matcher = is_instance_matcher(model_class) + self._strict = strict + self._exempt_defaults = exempt_defaults + self._attributes = kwargs + self._attributes.update(attributes or {}) + self._assert_matching_all_attributes() + + def _assert_matching_all_attributes(self): + if not self._strict: + return + fields = _model_fields(self._model_class) + if self._exempt_defaults: + fields = {name: field for name, field in fields.items() if _is_required(field)} + attribute_names = set(self._attributes.keys()) + model_attribute_names = set(fields.keys()) + missing_attributes = model_attribute_names - attribute_names + if missing_attributes: + raise ValueError( + f"Attributes {missing_attributes} are not present in " + f"{self._model_class.__name__}.\n" + "Consider using strict=False" + ) + + def _optional_matchers(self, value: Any = None) -> list[Matcher]: + if value is None: + model_class = self._model_class + else: + model_class = type(value) + if self._strict and self._exempt_defaults: + fields = _model_fields(model_class) + return [ + HasAttribute(name, _default_value(field)) + for name, field in fields.items() + if not _is_required(field) and name not in self._attributes + ] + return [] + + def _attribute_matchers(self, value: Any = None) -> list[Matcher]: + return [ + HasAttribute(name, value) for name, value in self._attributes.items() + ] + self._optional_matchers(value) + + def matches(self, value: Any) -> bool: + return self._matches_instance(value) and all( + matcher == value for matcher in self._attribute_matchers(value) + ) + + def _matches_instance(self, value: Any) -> bool: + expected_model_version = _version(self._model_class) + value_version = _version(type(value)) + return expected_model_version == value_version and self._is_instance_matcher == value + + def __repr__(self) -> str: + return concat_reprs( + f"To be a pydantic model instance of {repr(self._model_class.__name__)}", + *self._attribute_matchers(), + ) diff --git a/src/tests/matchers/test_eq.py b/src/tests/matchers/test_eq.py index 5193eab..23fb316 100644 --- a/src/tests/matchers/test_eq.py +++ b/src/tests/matchers/test_eq.py @@ -11,11 +11,14 @@ def test_matches(): assert 1 == Eq(1) assert 1 != Eq(2) assert Eq(1) == 1 + assert Eq([]) == [] # pylint: disable=use-implicit-booleaness-not-comparison + assert Eq([]) != [1] def test_repr(): assert repr(Eq(1)) == "To be 1" assert repr(Eq("string")) == "To be 'string'" + assert repr(Eq([])) == "To be []" def test_concatenated_repr(): diff --git a/src/tests/matchers/test_has_attribute.py b/src/tests/matchers/test_has_attribute.py index f4dfde6..cb7796e 100644 --- a/src/tests/matchers/test_has_attribute.py +++ b/src/tests/matchers/test_has_attribute.py @@ -36,7 +36,9 @@ def test_concatenated_repr(): matcher = HasAttribute("attribute_name", 42) assert matcher.concatenated_repr() == "with 'attribute_name' being 42" matcher = HasAttribute("attribute_name", is_string()) - assert matcher.concatenated_repr() == "with 'attribute_name' expecting to be a string" + assert matcher.concatenated_repr() == "with 'attribute_name' expected to be a string" + matcher = HasAttribute("attribute_name", []) + assert matcher.concatenated_repr() == "with 'attribute_name' being []" def test_matches(): @@ -70,6 +72,17 @@ def test_matches_with_expect_value_as_matcher(): assert matcher == str_mock +def test_matches_with_empty_values(): + empty_value_mock = MagicMock(empty_value="") + matcher = HasAttribute("empty_value") + assert matcher == empty_value_mock + assert matcher != 3 + matcher = HasAttribute("empty_value", "") + assert matcher == empty_value_mock + matcher = HasAttribute("empty_value", []) + assert matcher != empty_value_mock + + def test_has_attribute_matcher(): matcher = has_attribute_matcher("attribute_name", None) assert isinstance(matcher, HasAttribute) diff --git a/src/tests/pydantic/__init__.py b/src/tests/pydantic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/pydantic/conftest.py b/src/tests/pydantic/conftest.py new file mode 100644 index 0000000..b4c3ad5 --- /dev/null +++ b/src/tests/pydantic/conftest.py @@ -0,0 +1,42 @@ +from typing import Optional + +import pytest + +pydantic = pytest.importorskip("pydantic") + +# pylint: disable=too-few-public-methods +BaseModel = pydantic.BaseModel +Field = pydantic.Field +BaseModelV1 = pydantic.v1.BaseModel + + +class PersonV2(BaseModel): + name: str + age: int + friends: list[str] = [] + + +class MoneyPersonV2(PersonV2): + money: Optional[int] + debts: Optional[int] = Field(default_factory=int) + + +class PersonV1(BaseModelV1): + name: str + age: int + friends: list[str] = [] + + +@pytest.fixture +def palermo(): + return PersonV2(name="Palermo", age=30) + + +@pytest.fixture +def monica(): + return PersonV2(name="Mónica", age=25, friends=["Rachel", "Phoebe"]) + + +@pytest.fixture +def roman(): + return PersonV1(name="Román", age=40) diff --git a/src/tests/pydantic/matchers/__init__.py b/src/tests/pydantic/matchers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/pydantic/matchers/test_pydantic_model.py b/src/tests/pydantic/matchers/test_pydantic_model.py new file mode 100644 index 0000000..4ba4606 --- /dev/null +++ b/src/tests/pydantic/matchers/test_pydantic_model.py @@ -0,0 +1,192 @@ +import pytest +from pydantic import BaseModel + +from pytest_matchers import anything, is_list, is_number, is_string +from pytest_matchers.pydantic.matchers import PydanticModel +from src.tests.pydantic.conftest import MoneyPersonV2, PersonV1, PersonV2 + + +def test_create(): + with pytest.raises(ValueError, match="Consider using strict=False"): + PydanticModel(PersonV2) + matcher = PydanticModel(PersonV2, strict=False) + assert isinstance(matcher, PydanticModel) + matcher = PydanticModel(PersonV2, name="Palermo", age=30) + assert isinstance(matcher, PydanticModel) + with pytest.raises(ValueError, match="Attributes {'age'} are not present"): + PydanticModel(PersonV2, name="Palermo") + matcher = PydanticModel(PersonV2, name="Palermo", strict=False) + assert isinstance(matcher, PydanticModel) + with pytest.raises(ValueError, match="Attributes {'friends'} are not present"): + PydanticModel(PersonV2, name="Palermo", age=30, exempt_defaults=False) + matcher = PydanticModel( + PersonV2, + name="Palermo", + age=30, + exempt_defaults=False, + strict=False, + ) + assert isinstance(matcher, PydanticModel) + + +def test_repr(): + matcher = PydanticModel(PersonV2, strict=False) + assert repr(matcher) == "To be a pydantic model instance of 'PersonV2'" + matcher = PydanticModel(PersonV2, name="Palermo", age=30, strict=False) + assert ( + repr(matcher) == "To be a pydantic model instance of 'PersonV2' " + "with 'name' being 'Palermo' and with 'age' being 30" + ) + matcher = PydanticModel(PersonV2, name="Palermo", age=30) + assert ( + repr(matcher) == "To be a pydantic model instance of 'PersonV2' " + "with 'name' being 'Palermo' and with 'age' being 30 and " + "with 'friends' being []" + ) + matcher = PydanticModel(PersonV2, name="Palermo", age=30, friends=is_list()) + assert repr(matcher) == ( + "To be a pydantic model instance of 'PersonV2' " + "with 'name' being 'Palermo' and with 'age' being 30 and " + "with 'friends' expected of 'list' instance" + ) + matcher = PydanticModel(PersonV1, name=is_string(), age=is_number()) + assert repr(matcher) == ( + "To be a pydantic model instance of 'PersonV1' " + "with 'name' expected to be a string and with 'age' expected to be a number and " + "with 'friends' being []" + ) + + +def test_base_model_repr(): + matcher = PydanticModel(BaseModel, strict=False) + assert repr(matcher) == "To be a pydantic model instance of 'BaseModel'" + matcher = PydanticModel(BaseModel, name="Palermo") + assert ( + repr(matcher) == "To be a pydantic model instance of 'BaseModel' " + "with 'name' being 'Palermo'" + ) + + +def test_matches_strict(palermo, monica, roman): + matcher = PydanticModel(PersonV2, name="Palermo", age=30) + assert matcher == palermo + assert matcher != monica + assert matcher != roman + assert matcher != "Palermo" + matcher = PydanticModel(PersonV2, name=roman.name, age=roman.age) + assert matcher != palermo + assert matcher != monica + assert matcher != roman # Román is a TestModelV1 instance + matcher = PydanticModel(PersonV2, name=is_string(), age=is_number(max_value=28)) + assert matcher != palermo + assert matcher != monica # Mónica has friends + assert matcher != roman + matcher = PydanticModel(PersonV2, name=is_string(), age=is_number(), friends=is_list()) + assert matcher == palermo + assert matcher == monica + assert matcher != roman + matcher = PydanticModel(PersonV1, name=is_string(), age=is_number(), friends=is_list()) + assert matcher != palermo + assert matcher != monica + assert matcher == roman + + +def test_matches_non_strict(palermo, monica, roman): + matcher = PydanticModel(PersonV2, strict=False) + assert matcher == palermo + assert matcher == monica + assert matcher != roman + matcher = PydanticModel(PersonV2, name="Palermo", strict=False) + assert matcher == palermo + assert matcher != monica + assert matcher != roman + matcher = PydanticModel(PersonV2, friends=["Rachel", "Phoebe"], strict=False) + assert matcher != palermo + assert matcher == monica + assert matcher != roman + + +def test_matches_exempt_default(palermo, monica, roman): + matcher = PydanticModel( + PersonV2, + name=is_string(), + age=is_number(), + friends=["Rachel", "Phoebe"], + exempt_defaults=False, + ) + assert matcher != palermo + assert matcher == monica + assert matcher != roman + matcher = PydanticModel( + PersonV2, + name="Palermo", + age=30, + friends=is_list(), + exempt_defaults=False, + ) + assert matcher == palermo + assert matcher != monica + assert matcher != roman + matcher = PydanticModel( + PersonV2, + name=is_string(), + age=is_number(), + exempt_defaults=False, + strict=False, + ) + assert matcher == palermo + assert matcher == monica + assert matcher != roman + + +def test_matches_sub_model(): + seba = MoneyPersonV2(name="Sebastián", age=35, money=1000) + diego = MoneyPersonV2(name="Diego", age=30, money=None, debts=500) + matcher = PydanticModel(MoneyPersonV2, name=seba.name, age=seba.age, money=seba.money) + assert matcher == seba + assert matcher != diego + matcher = PydanticModel(MoneyPersonV2, name=seba.name, age=seba.age, strict=False) + assert matcher == seba + assert matcher != diego + matcher = PydanticModel(MoneyPersonV2, name=is_string(), age=is_number(), money=anything()) + assert matcher == seba + assert matcher != diego + matcher = PydanticModel(MoneyPersonV2, name=is_string(), age=is_number(), strict=False) + assert matcher == seba + assert matcher == diego + + +def test_reserved_attributes(): + class StrictModel(BaseModel): + name: str + strict: str + attributes: list = [] + + model = StrictModel(name="Strict!", strict="No") + matcher = PydanticModel(StrictModel, name=model.name, attributes={"strict": model.strict}) + assert matcher == model + matcher = PydanticModel( + StrictModel, + name=model.name, + attributes={"strict": model.strict, "attributes": model.attributes}, + exempt_defaults=False, + ) + assert matcher == model + matcher = PydanticModel( + StrictModel, + attributes={"strict": model.strict, "name": "Wrong"}, + name=model.name, + ) + assert matcher != model + + +def test_matches_base_model(palermo, monica): + matcher = PydanticModel(BaseModel, name="Palermo", age=30) + assert matcher == palermo + assert matcher != monica + matcher = PydanticModel(BaseModel, strict=False) + assert matcher == palermo + assert matcher == monica + matcher = PydanticModel(BaseModel, name=is_string(), age=is_number()) + assert matcher == palermo + assert matcher != monica diff --git a/src/tests/pydantic/test_main.py b/src/tests/pydantic/test_main.py new file mode 100644 index 0000000..f3f03e6 --- /dev/null +++ b/src/tests/pydantic/test_main.py @@ -0,0 +1,89 @@ +import pydantic +import pytest +from packaging import version + +from pytest_matchers import anything, assert_match, is_string +from pytest_matchers.main import contains +from pytest_matchers.pydantic.main import is_pydantic, is_pydantic_v1 +from pytest_matchers.pydantic.matchers import PydanticModel +from src.tests.pydantic.conftest import PersonV1, PersonV2 + +if version.parse(pydantic.__version__) < version.parse("2.0"): # pragma: no cover + pytest.skip("pydantic version is lower than 2.0", allow_module_level=True) + + +def test_create_is_pydantic(): + with pytest.raises(ValueError, match="Consider using strict=False"): + is_pydantic(PersonV2, strict=True) + matcher = is_pydantic(PersonV2) + assert isinstance(matcher, PydanticModel) + with pytest.raises(ValueError, match="Attributes {'age'} are not present"): + is_pydantic(PersonV2, name="Palermo") + matcher = is_pydantic(PersonV2, name=anything(), age=anything()) + assert isinstance(matcher, PydanticModel) + with pytest.raises(ValueError, match="Attributes {'friends'} are not present"): + is_pydantic(PersonV2, name="Palermo", age=30, exempt_defaults=False) + + +def test_is_pydantic_without_attributes(palermo, monica, roman): + assert palermo == is_pydantic() + assert monica == is_pydantic() + assert roman != is_pydantic() # Román is v1 + assert 3 != is_pydantic() + + +def test_is_pydantic_v1_without_attributes(palermo, monica, roman): + assert palermo != is_pydantic_v1() + assert monica != is_pydantic_v1() + assert_match(roman, is_pydantic_v1()) + + +def test_is_pydantic_with_model_class(palermo, monica, roman): + assert palermo == is_pydantic(model_class=PersonV2) + assert monica == is_pydantic(model_class=PersonV2) + assert roman != is_pydantic(model_class=PersonV2) + assert roman != is_pydantic(model_class=PersonV1) # BaseModel v1 implements equality + assert_match(roman, is_pydantic(model_class=PersonV1)) + + +def test_is_pydantic_no_class_with_attributes_strict(palermo, monica, roman): + assert palermo == is_pydantic(name=is_string(), age=30) + assert monica == is_pydantic(name="Mónica", age=25, friends=["Rachel", "Phoebe"]) + assert roman != is_pydantic(name="Román", age=40) + assert monica != is_pydantic(name="Mónica", age=25) + + +def test_is_pydantic_with_attributes_strict(palermo, monica, roman): + assert palermo == is_pydantic(PersonV2, name="Palermo", age=30) + assert monica == is_pydantic(PersonV2, name="Mónica", age=25, friends=["Rachel", "Phoebe"]) + assert roman != is_pydantic(PersonV2, name="Román", age=40) + assert_match(roman, is_pydantic(PersonV1, name="Román", age=40)) + assert monica != is_pydantic(PersonV2, name="Mónica", age=25) + assert palermo != is_pydantic(PersonV2, name="Palermo", age=30, friends=["Román"]) + + +def test_is_pydantic_no_class_with_attributes_non_strict(palermo, monica, roman): + assert palermo == is_pydantic(name="Palermo", strict=False) + assert monica == is_pydantic(name="Mónica", age=25, strict=False) + assert roman != is_pydantic(name="Román", strict=False) + assert monica == is_pydantic(friends=contains("Phoebe"), strict=False) + + +def test_is_pydantic_with_attributes_non_strict(palermo, monica, roman): + assert palermo == is_pydantic(PersonV2, name="Palermo", strict=False) + assert monica == is_pydantic(PersonV2, name="Mónica", age=25, strict=False) + assert roman != is_pydantic(PersonV2, age=40, strict=False) + assert_match(roman, is_pydantic(PersonV1, age=40, strict=False)) + assert monica == is_pydantic(PersonV2, friends=contains("Phoebe"), strict=False) + + +def test_is_pydantic_no_class_with_not_exempt_defaults(palermo, monica, roman): + assert palermo == is_pydantic(name=is_string(), age=30, friends=[], exempt_defaults=False) + assert monica == is_pydantic( + name="Mónica", + age=25, + friends=["Rachel", "Phoebe"], + exempt_defaults=False, + ) + assert roman != is_pydantic(name="Román", age=40, friends=[], exempt_defaults=False) + assert monica != is_pydantic(name="Mónica", age=25, friends=[], exempt_defaults=False) diff --git a/src/tests/test_main.py b/src/tests/test_main.py index 1d36ffc..fbd6811 100644 --- a/src/tests/test_main.py +++ b/src/tests/test_main.py @@ -11,6 +11,7 @@ assert_not_match, between, case, + contains, different_value, has_attribute, if_false, @@ -140,6 +141,16 @@ def test_between(): assert "c" == between("a", "d") +def test_contains(): + assert ["a", "b", "c"] == contains("a") + assert ["a", "b", "c"] == contains("a", "b") + assert ["a", "b", "c"] == contains("a", "b", "c") + assert ["a", "b", "c"] != contains("d") + assert ["a", "b", "c"] != contains(["a", "b"]) + assert "Hello!" == contains("Hello") + assert "Hello!" != contains("Hi") + + def test_is_datetime(): assert datetime(2021, 1, 1) == is_datetime() assert datetime(2021, 1, 1) == is_datetime(year=2021, month=1, day=1) diff --git a/src/tests/test_plugin.py b/src/tests/test_plugin.py index c65f0b5..9e3dd17 100644 --- a/src/tests/test_plugin.py +++ b/src/tests/test_plugin.py @@ -68,6 +68,19 @@ def test_custom_assert_repr(): assert str(error) == _expected_warning(actual, expected, "==") +def test_custom_assert_repr_not_triggered(): + actual = CustomEqual(3) + fail_matcher = is_instance(str) + try: + assert fail_matcher == actual + except AssertionError as error: + assert "WARNING" not in str(error) + try: + assert actual == fail_matcher + except AssertionError as error: + assert "WARNING" not in str(error) + + def test_custom_assert_repr_not(): actual = CustomEqual(3) expected = is_instance(str) @@ -77,6 +90,19 @@ def test_custom_assert_repr_not(): assert str(error) == _expected_warning(actual, expected, "!=") +def test_custom_assert_repr_not_not_triggered(): + actual = CustomEqual(3) + fail_matcher = is_instance(CustomEqual) + try: + assert fail_matcher != actual + except AssertionError as error: + assert "WARNING" not in str(error) + try: + assert actual != fail_matcher + except AssertionError as error: + assert "WARNING" not in str(error) + + def test_custom_assert_repr_dictionary(): custom = CustomEqual(3) actual = { diff --git a/vulture/whitelist.py b/vulture/whitelist.py index a4425b5..186b51c 100644 --- a/vulture/whitelist.py +++ b/vulture/whitelist.py @@ -1,7 +1,11 @@ from pytest_matchers.plugin import pytest_assertrepr_compare from src.tests.conftest import pytest_configure +from src.tests.pydantic.conftest import MoneyPersonV2 from src.tests.test_plugin import _set_verbosity pytest_configure pytest_assertrepr_compare _set_verbosity + +MoneyPersonV2.friends +MoneyPersonV2.debts From 3f3fac6ed5de0faf2f47551970bcf892603f4b67 Mon Sep 17 00:00:00 2001 From: MartinGotelli Date: Sat, 24 Aug 2024 17:47:30 -0300 Subject: [PATCH 2/2] fix: Add example and support for compatibility tests --- Makefile | 7 ++++++- requirements.txt | 13 +++++++------ requirements_dev.txt | 16 ++++++++-------- src/tests/examples/test_example.py | 25 +++++++++++++++++++++++++ vulture/whitelist.py | 2 ++ 5 files changed, 48 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 13b24f6..7af2af2 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ install_hooks: install_shell_support: @scripts/install_shell_support.sh -quality: lint vulture spell black tests +quality: lint vulture spell black compat-coverage coverage format: black $(FOLDERS) @@ -42,6 +42,11 @@ black: coverage: coverage run && coverage report --skip-covered +compat-coverage: + pip install -r requirements_min.txt > /dev/null 2>&1 + -@make coverage + pip install -r requirements_dev.txt > /dev/null 2>&1 + coverage_html: coverage run && coverage html open htmlcov/index.html diff --git a/requirements.txt b/requirements.txt index f8294b5..dbb36df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ -atomicwrites==1.4.1 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" -attrs==24.2.0 ; python_version >= "3.10" and python_version < "4.0" +annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0" colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" +exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11" iniconfig==2.0.0 ; python_version >= "3.10" and python_version < "4.0" packaging==24.1 ; python_version >= "3.10" and python_version < "4.0" pluggy==1.5.0 ; python_version >= "3.10" and python_version < "4.0" -py==1.11.0 ; python_version >= "3.10" and python_version < "4.0" -pydantic==1.0 ; python_version >= "3.10" and python_version < "4.0" -pytest==7.0.0 ; python_version >= "3.10" and python_version < "4.0" -tomli==2.0.1 ; python_version >= "3.10" and python_version < "4.0" +pydantic-core==2.20.1 ; python_version >= "3.10" and python_version < "4.0" +pydantic==2.8.2 ; python_version >= "3.10" and python_version < "4.0" +pytest==8.3.2 ; python_version >= "3.10" and python_version < "4.0" +tomli==2.0.1 ; python_version >= "3.10" and python_version < "3.11" +typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0" diff --git a/requirements_dev.txt b/requirements_dev.txt index fde1f5a..9584c84 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,12 +1,12 @@ +annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0" astroid==3.2.4 ; python_version >= "3.10" and python_version < "4.0" -atomicwrites==1.4.1 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" -attrs==24.2.0 ; python_version >= "3.10" and python_version < "4.0" black==24.8.0 ; python_version >= "3.10" and python_version < "4.0" click==8.1.7 ; python_version >= "3.10" and python_version < "4.0" codespell==2.3.0 ; python_version >= "3.10" and python_version < "4.0" colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and (sys_platform == "win32" or platform_system == "Windows") coverage==7.6.1 ; python_version >= "3.10" and python_version < "4.0" dill==0.3.8 ; python_version >= "3.10" and python_version < "4.0" +exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11" iniconfig==2.0.0 ; python_version >= "3.10" and python_version < "4.0" isort==5.13.2 ; python_version >= "3.10" and python_version < "4.0" mccabe==0.7.0 ; python_version >= "3.10" and python_version < "4.0" @@ -15,11 +15,11 @@ packaging==24.1 ; python_version >= "3.10" and python_version < "4.0" pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0" platformdirs==4.2.2 ; python_version >= "3.10" and python_version < "4.0" pluggy==1.5.0 ; python_version >= "3.10" and python_version < "4.0" -py==1.11.0 ; python_version >= "3.10" and python_version < "4.0" -pydantic==1.0 ; python_version >= "3.10" and python_version < "4.0" +pydantic-core==2.20.1 ; python_version >= "3.10" and python_version < "4.0" +pydantic==2.8.2 ; python_version >= "3.10" and python_version < "4.0" pylint==3.2.6 ; python_version >= "3.10" and python_version < "4.0" -pytest==7.0.0 ; python_version >= "3.10" and python_version < "4.0" -tomli==2.0.1 ; python_version >= "3.10" and python_version < "4.0" -tomlkit==0.13.0 ; python_version >= "3.10" and python_version < "4.0" -typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "3.11" +pytest==8.3.2 ; python_version >= "3.10" and python_version < "4.0" +tomli==2.0.1 ; python_version >= "3.10" and python_version < "3.11" +tomlkit==0.13.2 ; python_version >= "3.10" and python_version < "4.0" +typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0" vulture==2.11 ; python_version >= "3.10" and python_version < "4.0" diff --git a/src/tests/examples/test_example.py b/src/tests/examples/test_example.py index 86894c3..31fafa4 100644 --- a/src/tests/examples/test_example.py +++ b/src/tests/examples/test_example.py @@ -19,6 +19,7 @@ is_instance, is_list, is_number, + is_pydantic, is_strict_dict, is_string, is_uuid, @@ -26,6 +27,7 @@ same_value, ) from src.tests.conftest import CustomEqual +from src.tests.pydantic.conftest import BaseModel def test_deal_with_custom_equals(): @@ -215,3 +217,26 @@ def test_uuids(): for uuid_value in (uuid3(NAMESPACE_DNS, "python.org"), uuid4()): assert uuid_value == is_uuid(version=one_of(3, 4)) assert uuid5(NAMESPACE_DNS, "python.org") != is_uuid(version=one_of(3, 4)) + + +def test_pydantic(): + class Animal(BaseModel): # pylint: disable=too-few-public-methods + age: int + name: str + + class Human(Animal): # pylint: disable=too-few-public-methods + money: float = 0 + + class Dog(Animal): # pylint: disable=too-few-public-methods + breed: str = "Mongrel" + + messi = Human(age=33, name="Messi") + lassie = Dog(age=3, name="Lassie", breed="Collie") + tweety = Animal(age=1, name="Tweety") + + assert_match(messi, is_pydantic()) + assert_match(lassie, is_pydantic()) + assert_match(messi, is_pydantic(Human)) + assert_match(lassie, is_pydantic(Dog, age=3, name="Lassie", breed="Collie")) + assert_match(tweety, is_pydantic(age=1)) + assert_match(messi, is_pydantic(Human, age=33, strict=False)) diff --git a/vulture/whitelist.py b/vulture/whitelist.py index 186b51c..1b6ebc2 100644 --- a/vulture/whitelist.py +++ b/vulture/whitelist.py @@ -1,5 +1,6 @@ from pytest_matchers.plugin import pytest_assertrepr_compare from src.tests.conftest import pytest_configure +from src.tests.examples.test_example import test_pydantic from src.tests.pydantic.conftest import MoneyPersonV2 from src.tests.test_plugin import _set_verbosity @@ -9,3 +10,4 @@ MoneyPersonV2.friends MoneyPersonV2.debts +test_pydantic().Dog.breed