Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add pylint package and matchers #11

Merged
merged 2 commits into from
Aug 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -130,7 +135,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)
poetry export --without-hashes --all-extras -o $(or $(OUTFILE),requirements.txt)
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
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"
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"
7 changes: 5 additions & 2 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
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"
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"
Expand All @@ -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"
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==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.0 ; python_version >= "3.10" and python_version < "4.0"
typing-extensions==4.12.2 ; 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"
4 changes: 4 additions & 0 deletions requirements_min.txt
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions src/pytest_matchers/pytest_matchers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
anything,
between,
case,
contains,
different_value,
has_attribute,
if_false,
Expand All @@ -20,3 +21,8 @@
one_of,
same_value,
)

try:
from .pydantic.main import is_pydantic, is_pydantic_v1
except ImportError: # pragma: no cover
pass
12 changes: 9 additions & 3 deletions src/pytest_matchers/pytest_matchers/main.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
from typing import Any, Callable, Type

from pytest_matchers.matchers import (
And,
Anything,
Between,
Case,
Contains,
Datetime,
DatetimeString,
Dict,
DifferentValue,
HasAttribute,
If,
IsInstance,
List,
Number,
String,
JSON,
List,
Matcher,
Number,
Or,
SameValue,
StrictDict,
String,
UUID,
)

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions src/pytest_matchers/pytest_matchers/matchers/has_attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

Expand Down
Empty file.
6 changes: 1 addition & 5 deletions src/pytest_matchers/pytest_matchers/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
"",
Expand Down
Empty file.
43 changes: 43 additions & 0 deletions src/pytest_matchers/pytest_matchers/pydantic/main.py
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .pydantic_model import PydanticModel
Original file line number Diff line number Diff line change
@@ -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(),
)
25 changes: 25 additions & 0 deletions src/tests/examples/test_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
is_instance,
is_list,
is_number,
is_pydantic,
is_strict_dict,
is_string,
is_uuid,
one_of,
same_value,
)
from src.tests.conftest import CustomEqual
from src.tests.pydantic.conftest import BaseModel


def test_deal_with_custom_equals():
Expand Down Expand Up @@ -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))
Loading
Loading