diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0de1bd0..04cfc9f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,23 +40,23 @@ jobs: ignore-test-outcome: false - python-version: "3.10" toxfactor: py3.10 - ignore-typecheck-outcome: true + ignore-typecheck-outcome: false ignore-test-outcome: false - python-version: "3.11" toxfactor: py3.11 - ignore-typecheck-outcome: true + ignore-typecheck-outcome: false ignore-test-outcome: false - python-version: "3.12" toxfactor: py3.12 - ignore-typecheck-outcome: true + ignore-typecheck-outcome: false ignore-test-outcome: false - python-version: "3.13" toxfactor: py3.13 - ignore-typecheck-outcome: true + ignore-typecheck-outcome: false ignore-test-outcome: false - python-version: "3.14" toxfactor: py3.14 - ignore-typecheck-outcome: true + ignore-typecheck-outcome: false ignore-test-outcome: false steps: - uses: actions/checkout@v4 @@ -71,17 +71,17 @@ jobs: python -m pip install poetry==2.0.0 - name: Configure poetry run: | - python -m poetry config virtualenvs.in-project true + poetry config virtualenvs.in-project true - name: Cache the virtualenv id: poetry-dependencies-cache uses: actions/cache@v3 with: path: ./.venv - key: ${{ runner.os }}-venv-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + key: ${{ runner.os }}-venv-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}${{ hashFiles('.github/workflows/**') }} - name: Install dev dependencies if: steps.poetry-dependencies-cache.outputs.cache-hit != 'true' run: | - python -m poetry install --only=dev + poetry install - name: Download artifact uses: actions/download-artifact@v4 with: @@ -91,16 +91,15 @@ jobs: # Ignore errors for older pythons continue-on-error: ${{ matrix.ignore-typecheck-outcome }} run: | - source .venv/bin/activate - tox -e mypy + poetry run mypy pytest_factoryboy - name: Test with tox continue-on-error: ${{ matrix.ignore-test-outcome }} run: | source .venv/bin/activate coverage erase - # Using `--parallel 4` as it's the number of CPUs in the GitHub Actions runner - # Using `installpkg dist/*.whl` because we want to install the pre-built package (want to test against that) - tox run-parallel -f ${{ matrix.toxfactor }} --parallel 4 --parallel-no-spinner --parallel-live --installpkg dist/*.whl + # Using `--parallel 4` as it's the number of CPUs in the GitHub Actions runner + # Using `installpkg dist/*.whl` because we want to install the pre-built package (want to test against that) + tox run-parallel -f ${{ matrix.toxfactor }} --parallel 4 --parallel-no-spinner --parallel-live --installpkg dist/*.whl coverage combine coverage xml - uses: codecov/codecov-action@v4 diff --git a/CHANGES.rst b/CHANGES.rst index 9a9c463..1107c2d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -35,6 +35,7 @@ Added * Declare compatibility with python 3.13. Supported versions are now: 3.9, 3.10, 3.11, 3.12, 3.13. * Test against pytest 8.4 * Test against python 3.14 (beta) +* Run static type checks. Changed +++++++ @@ -51,6 +52,7 @@ Removed Fixed +++++ * Fix compatibility with ``pytest 8.4``. +* Fixed internal type annotations. Security ++++++++ diff --git a/pyproject.toml b/pyproject.toml index 5dd3e2b..a29f173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ check_untyped_defs = true disallow_untyped_decorators = true disallow_any_explicit = false disallow_any_generics = true -disallow_untyped_calls = true +disallow_untyped_calls = false disallow_untyped_defs = true ignore_errors = false ignore_missing_imports = true diff --git a/pytest_factoryboy/compat.py b/pytest_factoryboy/compat.py index b5a2d84..c2555b8 100644 --- a/pytest_factoryboy/compat.py +++ b/pytest_factoryboy/compat.py @@ -15,17 +15,23 @@ try: from factory.declarations import PostGenerationContext except ImportError: # factory_boy < 3.2.0 - from factory.builder import PostGenerationContext + from factory.builder import ( # type: ignore[attr-defined, no-redef] + PostGenerationContext, + ) if pytest_version.release >= (8, 1): - def getfixturedefs(fixturemanager: FixtureManager, fixturename: str, node: Node) -> Sequence[FixtureDef] | None: + def getfixturedefs( + fixturemanager: FixtureManager, fixturename: str, node: Node + ) -> Sequence[FixtureDef[object]] | None: return fixturemanager.getfixturedefs(fixturename, node) else: - def getfixturedefs(fixturemanager: FixtureManager, fixturename: str, node: Node) -> Sequence[FixtureDef] | None: - return fixturemanager.getfixturedefs(fixturename, node.nodeid) + def getfixturedefs( + fixturemanager: FixtureManager, fixturename: str, node: Node + ) -> Sequence[FixtureDef[object]] | None: + return fixturemanager.getfixturedefs(fixturename, node.nodeid) # type: ignore[arg-type] if pytest_version.release >= (8, 4): @@ -35,4 +41,4 @@ def getfixturedefs(fixturemanager: FixtureManager, fixturename: str, node: Node) else: from _pytest.fixtures import FixtureFunction - PytestFixtureT: TypeAlias = FixtureFunction + PytestFixtureT: TypeAlias = FixtureFunction # type: ignore[misc, no-redef] diff --git a/pytest_factoryboy/fixture.py b/pytest_factoryboy/fixture.py index e94b039..983d19c 100644 --- a/pytest_factoryboy/fixture.py +++ b/pytest_factoryboy/fixture.py @@ -13,11 +13,19 @@ from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, cast, overload import factory -import factory.builder -import factory.declarations import factory.enums import inflection -from typing_extensions import ParamSpec, TypeAlias +from factory.base import Factory +from factory.builder import BuildStep, DeclarationSet, StepBuilder +from factory.declarations import ( + NotProvided, + PostGeneration, + PostGenerationDeclaration, + PostGenerationMethodCall, + RelatedFactory, + SubFactory, +) +from typing_extensions import ParamSpec from .compat import PostGenerationContext from .fixturegen import create_fixture @@ -27,9 +35,8 @@ from .plugin import Request as FactoryboyRequest -FactoryType: TypeAlias = type[factory.Factory] -F = TypeVar("F", bound=FactoryType) T = TypeVar("T") +U = TypeVar("U") T_co = TypeVar("T_co", covariant=True) P = ParamSpec("P") @@ -38,9 +45,9 @@ @dataclass(eq=False) -class DeferredFunction: +class DeferredFunction(Generic[T]): name: str - factory: FactoryType + factory: type[Factory[T]] is_related: bool function: Callable[[SubRequest], Any] @@ -67,24 +74,24 @@ def named_model(model_cls: type[T], name: str) -> type[T]: # register(AuthorFactory, ...) # # @register -# class AuthorFactory(factory.Factory): ... +# class AuthorFactory(Factory): ... @overload -def register(factory_class: F, _name: str | None = None, **kwargs: Any) -> F: ... +def register(factory_class: type[Factory[T]], _name: str | None = None, **kwargs: Any) -> type[Factory[T]]: ... # @register(...) -# class AuthorFactory(factory.Factory): ... +# class AuthorFactory(Factory): ... @overload -def register(*, _name: str | None = None, **kwargs: Any) -> Callable[[F], F]: ... +def register(*, _name: str | None = None, **kwargs: Any) -> Callable[[type[Factory[T]]], type[Factory[T]]]: ... def register( - factory_class: F | None = None, + factory_class: type[Factory[T]] | None = None, _name: str | None = None, *, _caller_locals: Box[dict[str, Any]] | None = None, **kwargs: Any, -) -> F | Callable[[F], F]: +) -> type[Factory[T]] | Callable[[type[Factory[T]]], type[Factory[T]]]: r"""Register fixtures for the factory class. :param factory_class: Factory class to register. @@ -97,7 +104,7 @@ def register( if factory_class is None: - def register_(factory_class: F) -> F: + def register_(factory_class: type[Factory[T]]) -> type[Factory[T]]: return register(factory_class, _name=_name, _caller_locals=_caller_locals, **kwargs) return register_ @@ -131,7 +138,7 @@ def register_(factory_class: F) -> F: def generate_fixtures( - factory_class: FactoryType, + factory_class: type[Factory[T]], model_name: str, factory_name: str, overrides: Mapping[str, Any], @@ -193,23 +200,23 @@ def create_fixture_with_related( def make_declaration_fixturedef( attr_name: str, value: Any, - factory_class: FactoryType, + factory_class: type[Factory[T]], related: list[str], ) -> Callable[..., Any]: """Create the FixtureDef for a factory declaration.""" - if isinstance(value, (factory.SubFactory, factory.RelatedFactory)): - subfactory_class = value.get_factory() + if isinstance(value, (SubFactory, RelatedFactory)): + subfactory_class: type[Factory[object]] = value.get_factory() subfactory_deps = get_deps(subfactory_class, factory_class) args = list(subfactory_deps) - if isinstance(value, factory.RelatedFactory): + if isinstance(value, RelatedFactory): related_model = get_model_name(subfactory_class) args.append(related_model) related.append(related_model) related.append(attr_name) related.extend(subfactory_deps) - if isinstance(value, factory.SubFactory): + if isinstance(value, SubFactory): args.append(inflection.underscore(subfactory_class._meta.model.__name__)) return create_fixture_with_related( @@ -219,10 +226,10 @@ def make_declaration_fixturedef( ) deps: list[str] # makes mypy happy - if isinstance(value, factory.PostGeneration): + if isinstance(value, PostGeneration): value = None deps = [] - elif isinstance(value, factory.PostGenerationMethodCall): + elif isinstance(value, PostGenerationMethodCall): value = value.method_arg deps = [] elif isinstance(value, LazyFixture): @@ -258,7 +265,7 @@ def inject_into_caller(name: str, function: Callable[..., Any], locals_: Box[dic locals_.value[name] = function -def get_model_name(factory_class: FactoryType) -> str: +def get_model_name(factory_class: type[Factory[T]]) -> str: """Get model fixture name by factory.""" model_cls = factory_class._meta.model @@ -278,14 +285,14 @@ def get_model_name(factory_class: FactoryType) -> str: return model_name -def get_factory_name(factory_class: FactoryType) -> str: +def get_factory_name(factory_class: type[Factory[T]]) -> str: """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: type[Factory[T]], + parent_factory_class: type[Factory[U]] | None = None, model_name: str | None = None, ) -> list[str]: """Get factory dependencies. @@ -296,11 +303,13 @@ def get_deps( parent_model_name = get_model_name(parent_factory_class) if parent_factory_class is not None else None def is_dep(value: Any) -> bool: - if isinstance(value, factory.RelatedFactory): + if isinstance(value, RelatedFactory): return False - if isinstance(value, factory.SubFactory) and get_model_name(value.get_factory()) == parent_model_name: - return False - if isinstance(value, factory.declarations.PostGenerationDeclaration): + if isinstance(value, SubFactory): + subfactory_class: type[Factory[object]] = value.get_factory() + if get_model_name(subfactory_class) == parent_model_name: + return False + if isinstance(value, PostGenerationDeclaration): # Dependency on extracted value return True @@ -334,7 +343,7 @@ def disable_method(method: MethodType) -> Iterator[None]: setattr(klass, method.__name__, old_method) -def model_fixture(request: SubRequest, factory_name: str) -> Any: +def model_fixture(request: SubRequest, factory_name: str) -> object: """Model fixture implementation.""" factoryboy_request: FactoryboyRequest = request.getfixturevalue("factoryboy_request") @@ -345,21 +354,19 @@ def model_fixture(request: SubRequest, factory_name: str) -> Any: fixture_name = request.fixturename prefix = "".join((fixture_name, SEPARATOR)) - factory_class: FactoryType = request.getfixturevalue(factory_name) + factory_class: type[Factory[object]] = request.getfixturevalue(factory_name) # Create model fixture instance - Factory: FactoryType = cast(FactoryType, type("Factory", (factory_class,), {})) + NewFactory: type[Factory[object]] = cast(type[Factory[object]], type("Factory", (factory_class,), {})) # equivalent to: # class Factory(factory_class): # pass # it just makes mypy understand it. - Factory._meta.base_declarations = { - k: v - for k, v in Factory._meta.base_declarations.items() - if not isinstance(v, factory.declarations.PostGenerationDeclaration) + NewFactory._meta.base_declarations = { + k: v for k, v in NewFactory._meta.base_declarations.items() if not isinstance(v, PostGenerationDeclaration) } - Factory._meta.post_declarations = factory.builder.DeclarationSet() + NewFactory._meta.post_declarations = DeclarationSet() kwargs = {} for key in factory_class._meta.pre_declarations: @@ -368,25 +375,25 @@ def model_fixture(request: SubRequest, factory_name: str) -> Any: kwargs[key] = evaluate(request, request.getfixturevalue(argname)) strategy = factory.enums.CREATE_STRATEGY - builder = factory.builder.StepBuilder(Factory._meta, kwargs, strategy) - step = factory.builder.BuildStep(builder=builder, sequence=Factory._meta.next_sequence()) + builder = StepBuilder(NewFactory._meta, kwargs, strategy) + step = BuildStep(builder=builder, sequence=NewFactory._meta.next_sequence()) # FactoryBoy invokes the `_after_postgeneration` method, but we will instead call it manually later, # once we are able to evaluate all the related fixtures. - with disable_method(Factory._after_postgeneration): - instance = Factory(**kwargs) + with disable_method(NewFactory._after_postgeneration): # type: ignore[arg-type] # https://github.com/python/mypy/issues/14235 + instance = NewFactory(**kwargs) # 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[fixture_name] = request._fixturedef # Defer post-generation declarations - deferred: list[DeferredFunction] = [] + deferred: list[DeferredFunction[object]] = [] for attr in factory_class._meta.post_declarations.sorted(): decl = factory_class._meta.post_declarations.declarations[attr] - if isinstance(decl, factory.RelatedFactory): + if isinstance(decl, RelatedFactory): deferred.append(make_deferred_related(factory_class, fixture_name, attr)) else: argname = "".join((prefix, attr)) @@ -405,7 +412,7 @@ def model_fixture(request: SubRequest, factory_name: str) -> Any: # that `value_provided` should be falsy postgen_value = evaluate(request, request.getfixturevalue(argname)) postgen_context = PostGenerationContext( - value_provided=(postgen_value is not factory.declarations.NotProvided), + value_provided=(postgen_value is not NotProvided), value=postgen_value, extra=extra, ) @@ -420,7 +427,7 @@ def model_fixture(request: SubRequest, factory_name: str) -> Any: return instance -def make_deferred_related(factory: FactoryType, fixture: str, attr: str) -> DeferredFunction: +def make_deferred_related(factory: type[Factory[T]], fixture: str, attr: str) -> DeferredFunction[T]: """Make deferred function for the related factory declaration. :param factory: Factory class. @@ -443,14 +450,14 @@ def deferred_impl(request: SubRequest) -> Any: def make_deferred_postgen( - step: factory.builder.BuildStep, - factory_class: FactoryType, + step: BuildStep, + factory_class: type[Factory[T]], fixture: str, instance: Any, attr: str, - declaration: factory.declarations.PostGenerationDeclaration, + declaration: PostGenerationDeclaration, context: PostGenerationContext, -) -> DeferredFunction: +) -> DeferredFunction[T]: """Make deferred function for the post-generation declaration. :param step: factory_boy builder step. @@ -476,7 +483,7 @@ def deferred_impl(request: SubRequest) -> Any: ) -def factory_fixture(request: SubRequest, factory_class: F) -> F: +def factory_fixture(request: SubRequest, factory_class: type[Factory[T]]) -> type[Factory[T]]: """Factory fixture implementation.""" return factory_class @@ -486,7 +493,7 @@ def attr_fixture(request: SubRequest, value: T) -> T: return value -def subfactory_fixture(request: SubRequest, factory_class: FactoryType) -> Any: +def subfactory_fixture(request: SubRequest, factory_class: type[Factory[object]]) -> Any: """SubFactory/RelatedFactory fixture implementation.""" fixture = inflection.underscore(factory_class._meta.model.__name__) return request.getfixturevalue(fixture) diff --git a/pytest_factoryboy/plugin.py b/pytest_factoryboy/plugin.py index ac2a7c6..ea944ab 100644 --- a/pytest_factoryboy/plugin.py +++ b/pytest_factoryboy/plugin.py @@ -3,22 +3,17 @@ from __future__ import annotations from collections import defaultdict -from typing import TYPE_CHECKING +from typing import Any import pytest +from _pytest.config import PytestPluginManager +from _pytest.fixtures import FixtureRequest, SubRequest +from _pytest.nodes import Item +from _pytest.python import Metafunc +from factory.base import Factory from .compat import getfixturedefs - -if TYPE_CHECKING: - from typing import Any - - from _pytest.config import PytestPluginManager - from _pytest.fixtures import FixtureRequest, SubRequest - from _pytest.nodes import Item - from _pytest.python import Metafunc - from factory import Factory - - from .fixture import DeferredFunction +from .fixture import DeferredFunction class CycleDetected(Exception): @@ -30,12 +25,12 @@ class Request: def __init__(self) -> None: """Create pytest_factoryboy request.""" - self.deferred: list[list[DeferredFunction]] = [] + self.deferred: list[list[DeferredFunction[object]]] = [] self.results: dict[str, dict[str, Any]] = defaultdict(dict) - self.model_factories: dict[str, type[Factory]] = {} - self.in_progress: set[DeferredFunction] = set() + self.model_factories: dict[str, type[Factory[object]]] = {} + self.in_progress: set[DeferredFunction[object]] = set() - def defer(self, functions: list[DeferredFunction]) -> None: + def defer(self, functions: list[DeferredFunction[object]]) -> None: """Defer post-generation declaration execution until the end of the test setup. :param functions: Functions to be deferred. @@ -51,6 +46,8 @@ def get_deps(self, request: SubRequest, fixture: str, deps: set[str] | None = No if fixture == "request": return deps + assert request._pyfuncitem.parent is not None, "Request must have a parent item." + fixturedefs = getfixturedefs(request._fixturemanager, fixture, request._pyfuncitem.parent) for fixturedef in fixturedefs or []: for argname in fixturedef.argnames: @@ -67,7 +64,9 @@ def get_current_deps(self, request: FixtureRequest | SubRequest) -> set[str]: request = request._parent_request return deps - def execute(self, request: SubRequest, function: DeferredFunction, deferred: list[DeferredFunction]) -> None: + def execute( + self, request: SubRequest, function: DeferredFunction[object], deferred: list[DeferredFunction[object]] + ) -> None: """Execute deferred function and store the result.""" if function in self.in_progress: raise CycleDetected() @@ -114,9 +113,7 @@ def factoryboy_request() -> Request: return Request() -# type ignored because pluggy v1.0.0 has no type annotations: -# https://github.com/pytest-dev/pluggy/issues/191 -@pytest.hookimpl(tryfirst=True) # type: ignore[misc] +@pytest.hookimpl(tryfirst=True) def pytest_runtest_call(item: Item) -> None: """Before the test item is called.""" # TODO: We should instead do an `if isinstance(item, Function)`. diff --git a/tox.ini b/tox.ini index 1ef8bf7..3fade08 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,6 @@ distshare = {homedir}/.tox/distshare envlist = py{3.9,3.10,3.11,3.12,3.13,3.14}-pytest{7.3,7.4,8.0,8.1,8.2,8.3,8.4,latest,main} py{3.9,3.10,3.11}-pytest{7.0,7.1,7.2} - mypy [testenv] parallel_show_output = true @@ -25,10 +24,5 @@ deps = coverage[toml] - -[testenv:mypy] -allowlist_externals = mypy -commands = mypy {posargs:.} - [pytest] addopts = -vv -l