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

ci: introduce mypy to the project #145

Merged
merged 11 commits into from May 6, 2022
25 changes: 25 additions & 0 deletions pyproject.toml
Expand Up @@ -8,3 +8,28 @@ target-version = ['py37', 'py38', 'py39', 'py310']

[tool.isort]
profile = 'black'

[tool.mypy]
exclude = ['docs/']
allow_redefinition = false
check_untyped_defs = true
disallow_untyped_decorators = true
disallow_any_explicit = false
disallow_any_generics = true
disallow_untyped_calls = true
ignore_errors = false
ignore_missing_imports = true
implicit_reexport = false
strict_optional = true
no_implicit_optional = true
warn_unused_ignores = true
warn_redundant_casts = true
warn_unused_configs = true
warn_unreachable = true
warn_no_return = true
youtux marked this conversation as resolved.
Show resolved Hide resolved
pretty = true
show_error_codes = true

[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_decorators = false
youtux marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 6 additions & 3 deletions pytest_factoryboy/codegen.py
Expand Up @@ -7,13 +7,14 @@
import pathlib
import shutil
import tempfile
import typing
from dataclasses import dataclass, field
from functools import lru_cache
from types import ModuleType
from typing import Any

import mako.template
from appdirs import AppDirs
from typing_extensions import Literal

from .compat import path_with_stem

Expand All @@ -25,8 +26,8 @@
@dataclass
class FixtureDef:
name: str
function_name: typing.Literal["model_fixture", "attr_fixture", "factory_fixture", "subfactory_fixture"]
function_kwargs: dict = field(default_factory=dict)
function_name: Literal["model_fixture", "attr_fixture", "factory_fixture", "subfactory_fixture"]
function_kwargs: dict[str, Any] = field(default_factory=dict)
deps: list[str] = field(default_factory=list)
related: list[str] = field(default_factory=list)

Expand Down Expand Up @@ -122,7 +123,9 @@ def make_module(code: str, module_name: str, package_name: str) -> ModuleType:
tmp_module_path.write_text(code)
name = f"{package_name}.{module_name}"
spec = importlib.util.spec_from_file_location(name, tmp_module_path)
assert spec # NOTE: satisfy `mypy`
mod = importlib.util.module_from_spec(spec)
assert spec.loader # NOTE: satisfy `mypy`
spec.loader.exec_module(mod)
return mod

Expand Down
2 changes: 2 additions & 0 deletions pytest_factoryboy/compat.py
Expand Up @@ -3,6 +3,8 @@
import pathlib
import sys

__all__ = ("PostGenerationContext", "path_with_stem")

try:
from factory.declarations import PostGenerationContext
except ImportError: # factory_boy < 3.2.0
Expand Down
53 changes: 28 additions & 25 deletions pytest_factoryboy/fixture.py
Expand Up @@ -5,7 +5,7 @@
import sys
from dataclasses import dataclass
from inspect import signature
from typing import TYPE_CHECKING, overload
from typing import TYPE_CHECKING, cast, overload

import factory
import factory.builder
Expand All @@ -20,7 +20,7 @@
if TYPE_CHECKING:
from typing import Any, Callable, TypeVar

from _pytest.fixtures import FixtureRequest
from _pytest.fixtures import FixtureFunction, FixtureRequest, SubRequest
youtux marked this conversation as resolved.
Show resolved Hide resolved
from factory.builder import BuildStep
from factory.declarations import PostGeneration, PostGenerationContext

Expand All @@ -37,9 +37,9 @@ class DeferredFunction:
name: str
factory: FactoryType
is_related: bool
function: Callable[[FixtureRequest], Any]
function: Callable[[SubRequest], Any]

def __call__(self, request: FixtureRequest) -> Any:
def __call__(self, request: SubRequest) -> Any:
return self.function(request)


Expand Down Expand Up @@ -177,7 +177,7 @@ def register(
return factory_class


def inject_into_caller(name: str, function: Callable, locals_: dict[str, Any]) -> None:
def inject_into_caller(name: str, function: Callable[..., Any], locals_: dict[str, Any]) -> None:
"""Inject a function into the caller's locals, making sure that the function will work also within classes."""
# We need to check if the caller frame is a class, since in that case the first argument is the class itself.
# In that case, we can apply the staticmethod() decorator to the injected function, so that the first param
Expand All @@ -191,12 +191,12 @@ def inject_into_caller(name: str, function: Callable, locals_: dict[str, Any]) -
# Therefore, we can just check for __qualname__ to figure out if we are in a class, and apply the @staticmethod.
is_class_or_function = "__qualname__" in locals_
if is_class_or_function:
function = staticmethod(function)
function = staticmethod(function) # type: ignore[assignment]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the build is now failing because of a bug of mypy and this line, I would say to remove the type: ignore[assignment] from here, and change the mypy builds to do a "ignore_outcome = true" so that we won't block on a failure on mypy for now.


locals_[name] = function


def get_model_name(factory_class: FactoryType) -> str:
def get_model_name(factory_class: F) -> str:
skarzi marked this conversation as resolved.
Show resolved Hide resolved
"""Get model fixture name by factory."""
return (
inflection.underscore(factory_class._meta.model.__name__)
Expand All @@ -205,14 +205,14 @@ def get_model_name(factory_class: FactoryType) -> str:
)


def get_factory_name(factory_class: FactoryType) -> str:
def get_factory_name(factory_class: F) -> str:
skarzi marked this conversation as resolved.
Show resolved Hide resolved
"""Get factory fixture name by factory."""
return inflection.underscore(factory_class.__name__)


def get_deps(
factory_class: FactoryType,
parent_factory_class: FactoryType | None = None,
factory_class: F,
parent_factory_class: F | None = None,
skarzi marked this conversation as resolved.
Show resolved Hide resolved
model_name: str | None = None,
) -> list[str]:
"""Get factory dependencies.
Expand All @@ -238,23 +238,26 @@ def is_dep(value: Any) -> bool:
]


def evaluate(request: FixtureRequest, value: LazyFixture | Any) -> Any:
def evaluate(request: SubRequest, value: LazyFixture | Any) -> Any:
"""Evaluate the declaration (lazy fixtures, etc)."""
return value.evaluate(request) if isinstance(value, LazyFixture) else value


def model_fixture(request: FixtureRequest, factory_name: str) -> Any:
def model_fixture(request: SubRequest, factory_name: str) -> Any:
"""Model fixture implementation."""
factoryboy_request = request.getfixturevalue("factoryboy_request")

# Try to evaluate as much post-generation dependencies as possible
factoryboy_request.evaluate(request)

factory_class: FactoryType = request.getfixturevalue(factory_name)
prefix = "".join((request.fixturename, SEPARATOR))
assert request.fixturename # NOTE: satisfy mypy
fixture_name = request.fixturename
prefix = "".join((fixture_name, SEPARATOR))
# NOTE: following type hinting is required, because of `mypy` bug.
# Reference: https://github.com/python/mypy/issues/2477
factory_class: factory.FactoryMetaClass = request.getfixturevalue(factory_name)
youtux marked this conversation as resolved.
Show resolved Hide resolved

# Create model fixture instance

class Factory(factory_class):
pass

Expand All @@ -279,7 +282,7 @@ class Factory(factory_class):

# Cache the instance value on pytest level so that the fixture can be resolved before the return
request._fixturedef.cached_result = (instance, 0, None)
request._fixture_defs[request.fixturename] = request._fixturedef
request._fixture_defs[fixture_name] = request._fixturedef

# Defer post-generation declarations
deferred: list[DeferredFunction] = []
Expand All @@ -289,7 +292,7 @@ class Factory(factory_class):
decl = factory_class._meta.post_declarations.declarations[attr]

if isinstance(decl, factory.RelatedFactory):
deferred.append(make_deferred_related(factory_class, request.fixturename, attr))
deferred.append(make_deferred_related(factory_class, fixture_name, attr))
else:
argname = "".join((prefix, attr))
extra = {}
Expand All @@ -309,7 +312,7 @@ class Factory(factory_class):
extra=extra,
)
deferred.append(
make_deferred_postgen(step, factory_class, request.fixturename, instance, attr, decl, postgen_context)
make_deferred_postgen(step, factory_class, fixture_name, instance, attr, decl, postgen_context)
)
factoryboy_request.defer(deferred)

Expand All @@ -329,7 +332,7 @@ def make_deferred_related(factory: FactoryType, fixture: str, attr: str) -> Defe
"""
name = SEPARATOR.join((fixture, attr))

def deferred_impl(request: FixtureRequest) -> Any:
def deferred_impl(request: SubRequest) -> Any:
return request.getfixturevalue(name)

return DeferredFunction(
Expand Down Expand Up @@ -362,7 +365,7 @@ def make_deferred_postgen(
"""
name = SEPARATOR.join((fixture, attr))

def deferred_impl(request: FixtureRequest) -> Any:
def deferred_impl(request: SubRequest) -> Any:
return declaration.call(instance, step, context)

return DeferredFunction(
Expand All @@ -373,17 +376,17 @@ def deferred_impl(request: FixtureRequest) -> Any:
)


def factory_fixture(request: FixtureRequest, factory_class: F) -> F:
def factory_fixture(request: SubRequest, factory_class: F) -> F:
"""Factory fixture implementation."""
return factory_class


def attr_fixture(request: FixtureRequest, value: T) -> T:
def attr_fixture(request: SubRequest, value: T) -> T:
"""Attribute fixture implementation."""
return value


def subfactory_fixture(request: FixtureRequest, factory_class: FactoryType) -> Any:
def subfactory_fixture(request: SubRequest, factory_class: FactoryType) -> Any:
"""SubFactory/RelatedFactory fixture implementation."""
fixture = inflection.underscore(factory_class._meta.model.__name__)
return request.getfixturevalue(fixture)
Expand All @@ -397,7 +400,7 @@ def get_caller_locals(depth: int = 2) -> dict[str, Any]:
class LazyFixture:
"""Lazy fixture."""

def __init__(self, fixture: Callable | str) -> None:
def __init__(self, fixture: FixtureFunction | str) -> None:
"""Lazy pytest fixture wrapper.

:param fixture: Fixture name or callable with dependencies.
Expand All @@ -409,7 +412,7 @@ def __init__(self, fixture: Callable | str) -> None:
else:
self.args = [self.fixture]

def evaluate(self, request: FixtureRequest) -> Any:
def evaluate(self, request: SubRequest) -> Any:
"""Evaluate the lazy fixture.

:param request: pytest request object.
Expand Down
18 changes: 9 additions & 9 deletions pytest_factoryboy/plugin.py
Expand Up @@ -10,7 +10,7 @@
from typing import Any

from _pytest.config import PytestPluginManager
from _pytest.fixtures import FixtureRequest
from _pytest.fixtures import FixtureRequest, SubRequest
from _pytest.nodes import Item
from _pytest.python import Metafunc
from factory import Factory
Expand All @@ -30,7 +30,7 @@ def __init__(self) -> None:
self.deferred: list[list[DeferredFunction]] = []
self.results: dict[str, dict[str, Any]] = defaultdict(dict)
self.model_factories: dict[str, type[Factory]] = {}
self.in_progress: set = set()
self.in_progress: set[DeferredFunction] = set()

def defer(self, functions: list[DeferredFunction]) -> None:
"""Defer post-generation declaration execution until the end of the test setup.
Expand All @@ -40,7 +40,7 @@ def defer(self, functions: list[DeferredFunction]) -> None:
"""
self.deferred.append(functions)

def get_deps(self, request: FixtureRequest, fixture: str, deps: set[str] | None = None) -> set[str]:
def get_deps(self, request: SubRequest, fixture: str, deps: set[str] | None = None) -> set[str]:
request = request.getfixturevalue("request")

if deps is None:
Expand All @@ -55,15 +55,15 @@ def get_deps(self, request: FixtureRequest, fixture: str, deps: set[str] | None
deps.update(self.get_deps(request, argname, deps))
return deps

def get_current_deps(self, request: FixtureRequest) -> set[str]:
def get_current_deps(self, request: FixtureRequest | SubRequest) -> set[str]:
youtux marked this conversation as resolved.
Show resolved Hide resolved
deps = set()
while hasattr(request, "_parent_request"):
if request.fixturename and request.fixturename not in getattr(request, "_fixturedefs", {}):
deps.add(request.fixturename)
request = request._parent_request
request = request._parent_request # type: ignore[union-attr]
return deps

def execute(self, request: FixtureRequest, function: DeferredFunction, deferred: list[DeferredFunction]) -> None:
def execute(self, request: SubRequest, function: DeferredFunction, deferred: list[DeferredFunction]) -> None:
"""Execute deferred function and store the result."""
if function in self.in_progress:
raise CycleDetected()
Expand All @@ -80,15 +80,15 @@ def execute(self, request: FixtureRequest, function: DeferredFunction, deferred:
deferred.remove(function)
self.in_progress.remove(function)

def after_postgeneration(self, request: FixtureRequest) -> None:
def after_postgeneration(self, request: SubRequest) -> None:
"""Call _after_postgeneration hooks."""
for model in list(self.results.keys()):
results = self.results.pop(model)
obj = request.getfixturevalue(model)
factory = self.model_factories[model]
factory._after_postgeneration(obj, create=True, results=results)

def evaluate(self, request: FixtureRequest) -> None:
def evaluate(self, request: SubRequest) -> None:
"""Finalize, run deferred post-generation actions, etc."""
while self.deferred:
try:
Expand All @@ -115,7 +115,7 @@ def pytest_runtest_call(item: Item) -> None:
"""Before the test item is called."""
# TODO: We should instead do an `if isinstance(item, Function)`.
try:
request = item._request
request = item._request # type: ignore[attr-defined]
except AttributeError:
# pytest-pep8 plugin passes Pep8Item here during tests.
return
Expand Down
13 changes: 10 additions & 3 deletions tests/test_postgen_dependencies.py
@@ -1,7 +1,7 @@
"""Test post-generation dependencies."""
from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

import factory
Expand All @@ -20,6 +20,12 @@ class Foo:
value: int
expected: int

bar: Bar | None = None

# NOTE: following attributes are used internally only for assertions
_create: bool | None = None
_postgeneration_results: dict[str, Any] = field(default_factory=dict)


@dataclass
class Bar:
Expand Down Expand Up @@ -60,7 +66,7 @@ def set1(foo: Foo, create: bool, value: Any, **kwargs: Any) -> str:

@classmethod
def _after_postgeneration(cls, obj: Foo, create: bool, results: dict[str, Any] | None = None) -> None:
obj._postgeneration_results = results
obj._postgeneration_results = results or {}
obj._create = create


Expand Down Expand Up @@ -112,8 +118,9 @@ def test_after_postgeneration(foo: Foo):
assert len(foo._postgeneration_results) == 2


@dataclass
class Ordered:
value = None
value: str | None = None


@register
Expand Down
8 changes: 8 additions & 0 deletions tox.ini
Expand Up @@ -2,6 +2,7 @@
distshare = {homedir}/.tox/distshare
envlist = py38-pytest{50,51,52,53,54,60,61,62,70,71,latest,main},
py{37,39,310,311}-pytestlatest
py{37,38,39,310,311}-mypy

[testenv]
commands = pytest --junitxml={envlogdir}/junit-{envname}.xml {posargs:tests}
Expand Down Expand Up @@ -29,6 +30,13 @@ ignore_outcome = true
# allow failures of tests run with unstable python 3.11
ignore_outcome = true

[testenv:py{37,38,39,310,311}-mypy]
commands = mypy {posargs:.}
deps =
mypy~=0.950

-r{toxinidir}/requirements-testing.txt
youtux marked this conversation as resolved.
Show resolved Hide resolved

[pytest]
addopts = -vv -l

Expand Down