Skip to content

Commit

Permalink
Merge pull request #120 from binary-butterfly/116-mypy-first-steps
Browse files Browse the repository at this point in the history
Integrate mypy into development process (part of #116)
  • Loading branch information
binaryDiv authored Apr 30, 2024
2 parents ca53a4b + ac853ce commit d0f0d3a
Show file tree
Hide file tree
Showing 52 changed files with 348 additions and 253 deletions.
4 changes: 3 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ show_missing = True
skip_empty = True
skip_covered = True
exclude_lines =
pragma: nocover
pragma: no ?cover
@abstractmethod
@overload
if TYPE_CHECKING:

[html]
directory = reports/coverage_html/
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:

- name: Run unit tests with tox
# Run tox using the version of Python in `PATH`
run: tox run -e clean,py,flake8,report -- --junit-xml=reports/pytest_${{ matrix.python-version }}.xml
run: tox run -e clean,py,report,flake8,mypy -- --junit-xml=reports/pytest_${{ matrix.python-version }}.xml

- name: Upload test result artifacts
uses: actions/upload-artifact@v3
Expand Down
56 changes: 38 additions & 18 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ build:
# Test suite
# ----------

# Run complete tox suite
# Run complete tox suite with local Python interpreter
.PHONY: tox
tox:
tox run
Expand All @@ -37,13 +37,18 @@ venv-tox:
# Only run pytest
.PHONY: test
test:
tox run --skip-env flake8
tox run -e clean,py,report

# Only run flake8 linter
.PHONY: flake8
flake8:
tox run -e flake8

# Only run mypy (via tox; you can also just run "mypy" directly)
.PHONY: mypy
mypy:
tox run -e mypy

# Open HTML coverage report in browser
.PHONY: open-coverage
open-coverage:
Expand All @@ -62,25 +67,40 @@ docker-tox:

# Run partial tox test suites in Docker
.PHONY: docker-tox-py312 docker-tox-py311 docker-tox-py310 docker-tox-py39 docker-tox-py38
docker-tox-py312: TOX_ARGS="-e clean,py312,py312-report"
docker-tox-py312: docker-tox
docker-tox-py311: TOX_ARGS="-e clean,py311,py311-report"
docker-tox-py311: docker-tox
docker-tox-py310: TOX_ARGS="-e clean,py310,py310-report"
docker-tox-py310: docker-tox
docker-tox-py39: TOX_ARGS="-e clean,py39,py39-report"
docker-tox-py39: docker-tox
docker-tox-py38: TOX_ARGS="-e clean,py38,py38-report"
docker-tox-py38: docker-tox
docker-test-py312: TOX_ARGS="-e clean,py312,py312-report"
docker-test-py312: docker-tox
docker-test-py311: TOX_ARGS="-e clean,py311,py311-report"
docker-test-py311: docker-tox
docker-test-py310: TOX_ARGS="-e clean,py310,py310-report"
docker-test-py310: docker-tox
docker-test-py39: TOX_ARGS="-e clean,py39,py39-report"
docker-test-py39: docker-tox
docker-test-py38: TOX_ARGS="-e clean,py38,py38-report"
docker-test-py38: docker-tox

# Run all tox test suites, but separately to check code coverage individually
.PHONY: docker-tox-all
docker-tox-all:
make docker-tox-py38
make docker-tox-py39
make docker-tox-py310
make docker-tox-py311
make docker-tox-py312
docker-test-all:
make docker-test-py38
make docker-test-py39
make docker-test-py310
make docker-test-py311
make docker-test-py312

# Run mypy using all different (or specific) Python versions in Docker
.PHONY: docker-mypy-all docker-mypy-py312 docker-mypy-py311 docker-mypy-py310 docker-mypy-py39 docker-mypy-py38
docker-mypy-all: TOX_ARGS="-e py312-mypy,py311-mypy,py310-mypy,py39-mypy,py38-mypy,py37-mypy"
docker-mypy-all: docker-tox
docker-mypy-py312: TOX_ARGS="-e py312-mypy"
docker-mypy-py312: docker-tox
docker-mypy-py311: TOX_ARGS="-e py311-mypy"
docker-mypy-py311: docker-tox
docker-mypy-py310: TOX_ARGS="-e py310-mypy"
docker-mypy-py310: docker-tox
docker-mypy-py39: TOX_ARGS="-e py39-mypy"
docker-mypy-py39: docker-tox
docker-mypy-py38: TOX_ARGS="-e py38-mypy"
docker-mypy-py38: docker-tox

# Pull the latest image of the multi-python Docker image
.PHONY: docker-pull
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
write_to = "src/validataclass/_version.py"
version_scheme = "post-release"

[tool.mypy]
files = "src/"

# Enable strict type checking
strict = true

# Ignore errors like `Module "validataclass.exceptions" does not explicitly export attribute "..."`
no_implicit_reexport = false
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ testing =
pytest-cov
coverage ~= 6.5
coverage-conditional-plugin ~= 0.5
flake8 ~= 7.0
mypy ~= 1.9
42 changes: 22 additions & 20 deletions src/validataclass/dataclasses/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from copy import copy, deepcopy
from typing import Any, Callable, NoReturn

from typing_extensions import Self

from validataclass.helpers import UnsetValue, UnsetValueType

__all__ = [
Expand All @@ -33,15 +35,15 @@ class Default:
def __init__(self, value: Any = None):
self.value = deepcopy(value)

def __repr__(self):
def __repr__(self) -> str:
return f'{type(self).__name__}({self.value!r})'

def __eq__(self, other):
def __eq__(self, other: Any) -> bool:
if isinstance(self, type(other)):
return self.value == other.value
return bool(self.value == other.value)
return NotImplemented

def __hash__(self):
def __hash__(self) -> int:
return hash(self.value)

def get_value(self) -> Any:
Expand Down Expand Up @@ -74,21 +76,21 @@ class DefaultFactory(Default):
DefaultFactory(lambda: date.today())
```
"""
factory: Callable
factory: Callable[[], Any]

def __init__(self, factory: Callable):
def __init__(self, factory: Callable[[], Any]):
super().__init__()
self.factory = factory

def __repr__(self):
def __repr__(self) -> str:
return f'{type(self).__name__}({self.factory!r})'

def __eq__(self, other):
def __eq__(self, other: Any) -> bool:
if isinstance(self, type(other)):
return isinstance(other, DefaultFactory) and self.factory == other.factory
return isinstance(other, DefaultFactory) and bool(self.factory == other.factory)
return NotImplemented

def __hash__(self):
def __hash__(self) -> int:
return hash(self.factory)

def get_value(self) -> Any:
Expand All @@ -104,10 +106,10 @@ class _DefaultUnset(Default):
Class for creating the sentinel object `DefaultUnset`, which is a shortcut for `Default(UnsetValue)`.
"""

def __init__(self):
def __init__(self) -> None:
super().__init__(UnsetValue)

def __repr__(self):
def __repr__(self) -> str:
return 'DefaultUnset'

def get_value(self) -> UnsetValueType:
Expand All @@ -117,13 +119,13 @@ def needs_factory(self) -> bool:
return False

# For convenience: Allow DefaultUnset to be used as `DefaultUnset()`, returning the sentinel itself.
def __call__(self):
def __call__(self) -> Self:
return self


# Create sentinel object DefaultUnset, redefine __new__ to always return the same instance, and delete temporary class
DefaultUnset = _DefaultUnset()
_DefaultUnset.__new__ = lambda cls: DefaultUnset
_DefaultUnset.__new__ = lambda cls: DefaultUnset # type: ignore
del _DefaultUnset


Expand All @@ -136,29 +138,29 @@ class _NoDefault(Default):
A validataclass field with `NoDefault` is equivalent to a validataclass field without specified default.
"""

def __init__(self):
def __init__(self) -> None:
super().__init__()

def __repr__(self):
def __repr__(self) -> str:
return 'NoDefault'

def __eq__(self, other):
def __eq__(self, other: Any) -> bool:
# Nothing is equal to NoDefault except itself
return type(self) is type(other)

def __hash__(self):
def __hash__(self) -> int:
# Use default implementation
return object.__hash__(self)

def get_value(self) -> NoReturn:
raise ValueError('No default value specified!')

# For convenience: Allow NoDefault to be used as `NoDefault()`, returning the sentinel itself.
def __call__(self):
def __call__(self) -> Self:
return self


# Create sentinel object NoDefault, redefine __new__ to always return the same instance, and delete temporary class
NoDefault = _NoDefault()
_NoDefault.__new__ = lambda cls: NoDefault
_NoDefault.__new__ = lambda cls: NoDefault # type: ignore
del _NoDefault
14 changes: 7 additions & 7 deletions src/validataclass/dataclasses/validataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import dataclasses
import sys
from collections import namedtuple
from typing import Callable, Dict, Optional, Type, TypeVar, Union, overload
from typing import Any, Callable, Dict, Optional, Tuple, Type, TypeVar, Union, overload

from typing_extensions import dataclass_transform

Expand All @@ -31,7 +31,7 @@ def validataclass(cls: Type[_T]) -> Type[_T]:


@overload
def validataclass(cls: None = None, /, **kwargs) -> Callable[[Type[_T]], Type[_T]]:
def validataclass(cls: None = None, /, **kwargs: Any) -> Callable[[Type[_T]], Type[_T]]:
...


Expand All @@ -42,7 +42,7 @@ def validataclass(cls: None = None, /, **kwargs) -> Callable[[Type[_T]], Type[_T
def validataclass(
cls: Optional[Type[_T]] = None,
/,
**kwargs,
**kwargs: Any,
) -> Union[Type[_T], Callable[[Type[_T]], Type[_T]]]:
"""
Decorator that turns a normal class into a `DataclassValidator`-compatible dataclass.
Expand Down Expand Up @@ -103,7 +103,7 @@ def decorator(_cls: Type[_T]) -> Type[_T]:
return decorator if cls is None else decorator(cls)


def _prepare_dataclass_metadata(cls) -> None:
def _prepare_dataclass_metadata(cls: Type[_T]) -> None:
"""
Prepares a soon-to-be dataclass (before it is decorated with `@dataclass`) to be usable with `DataclassValidator`
by checking it for `Validator` objects and setting dataclass metadata.
Expand Down Expand Up @@ -151,7 +151,7 @@ def _prepare_dataclass_metadata(cls) -> None:
# If the field already exists in a superclass, the validator and/or default defined in this class will override
# those of the superclass. E.g. setting a default will override the default, but leave the validator intact.
if name in existing_validator_fields:
existing_field = existing_validator_fields.get(name)
existing_field = existing_validator_fields[name]
if field_validator is None:
field_validator = existing_field.validator
if field_default is None:
Expand All @@ -168,7 +168,7 @@ def _prepare_dataclass_metadata(cls) -> None:
setattr(cls, name, validataclass_field(validator=field_validator, default=field_default, _name=name))


def _get_existing_validator_fields(cls) -> Dict[str, _ValidatorField]:
def _get_existing_validator_fields(cls: Type[_T]) -> Dict[str, _ValidatorField]:
"""
Returns a dictionary containing all fields (as `_ValidatorField` objects) of an existing validataclass that have a
validator set in their metadata, or an empty dictionary if the class is not a dataclass (yet).
Expand Down Expand Up @@ -196,7 +196,7 @@ def _get_existing_validator_fields(cls) -> Dict[str, _ValidatorField]:
return validator_fields


def _parse_validator_tuple(args: Union[tuple, None, Validator, Default]) -> _ValidatorField:
def _parse_validator_tuple(args: Union[Tuple[Any, ...], Validator, Default, None]) -> _ValidatorField:
"""
Parses field arguments (the value of a field in a dataclass that has not been parsed by `@dataclass` yet) to a
tuple of a Validator and a Default object.
Expand Down
10 changes: 5 additions & 5 deletions src/validataclass/dataclasses/validataclass_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import dataclasses
import sys
from typing import Any, NoReturn, Optional
from typing import Any, Dict, NoReturn, Optional

from validataclass.validators import Validator
from .defaults import Default, NoDefault
Expand All @@ -20,10 +20,10 @@ def validataclass_field(
validator: Validator,
default: Any = NoDefault,
*,
metadata: Optional[dict] = None,
metadata: Optional[Dict[str, Any]] = None,
_name: Optional[str] = None, # noqa (undocumented parameter, only used internally)
**kwargs
):
**kwargs: Any,
) -> Any:
"""
Defines a dataclass field compatible with DataclassValidator.
Expand Down Expand Up @@ -84,7 +84,7 @@ def validataclass_field(
return dataclasses.field(metadata=metadata, **kwargs)


def _raise_field_required(name: str) -> NoReturn: # pragma: ignore-py-gte-310
def _raise_field_required(name: Optional[str]) -> NoReturn: # pragma: ignore-py-gte-310
"""
Raises a TypeError exception. Used for required fields (only in Python 3.9 or lower where the kw_only option is not
supported yet).
Expand Down
12 changes: 9 additions & 3 deletions src/validataclass/dataclasses/validataclass_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

import dataclasses
import warnings
from typing import Any, Dict, cast

from typing_extensions import Self

from validataclass.helpers import UnsetValue

Expand All @@ -27,7 +30,7 @@ class ExampleClass(ValidataclassMixin):
```
"""

def to_dict(self, *, keep_unset_values: bool = False) -> dict:
def to_dict(self, *, keep_unset_values: bool = False) -> Dict[str, Any]:
"""
Returns the data of the object as a dictionary (recursively resolving inner dataclasses as well).
Expand All @@ -36,7 +39,10 @@ def to_dict(self, *, keep_unset_values: bool = False) -> dict:
Parameters:
`keep_unset_values`: If true, keep fields with value `UnsetValue` in the dictionary (default: False)
"""
data = dataclasses.asdict(self) # noqa
# Technically, there is no guarantee that this class is used as a mixin in an actual dataclass.
# However, if that's not the case, calling to_dict() doesn't make sense and will just fail with an exception.
# For all intents and purposes, we can safely assume that `self` is a dataclass instance.
data = cast(Dict[str, Any], dataclasses.asdict(self)) # type: ignore[call-overload] # noqa

# Filter out all UnsetValues (unless said otherwise)
if not keep_unset_values:
Expand All @@ -45,7 +51,7 @@ def to_dict(self, *, keep_unset_values: bool = False) -> dict:
return data

@classmethod
def create_with_defaults(cls, **kwargs):
def create_with_defaults(cls, **kwargs: Any) -> Self:
"""
(Deprecated.)
Expand Down
Loading

0 comments on commit d0f0d3a

Please sign in to comment.