Skip to content

Commit

Permalink
Merge branch 'master' into feature/introduce-mypy-and-isort
Browse files Browse the repository at this point in the history
  • Loading branch information
skarzi committed May 2, 2022
2 parents d0703c1 + ba268c8 commit c63914b
Show file tree
Hide file tree
Showing 10 changed files with 195 additions and 29 deletions.
6 changes: 2 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
---
name: Main testing workflow

on:
push:
pull_request:
workflow_dispatch:

jobs:
build:

runs-on: ubuntu-latest
strategy:
matrix:
Expand All @@ -25,5 +24,4 @@ jobs:
pip install -U setuptools
pip install tox tox-gh-actions
- name: Test with tox
run: |
tox
run: tox
16 changes: 14 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,22 @@ Changelog

Unreleased
----------

2.2.0
----------
- Drop support for Python 3.6. We now support only python >= 3.7.
- Improve "debuggability". Internal pytest-factoryboy calls are now visible when using a debugger like PDB or PyCharm.
- Add type annotations. Now `register` and `LazyFixture` are type annotated.
- Fix `_after_postgeneration` not getting the evaluated post_generations and RelatedFactory results correctly in the `result` param.
- Add type annotations. Now ``register`` and ``LazyFixture`` are type annotated.
- Fix `Factory._after_postgeneration <https://factoryboy.readthedocs.io/en/stable/reference.html#factory.Factory._after_postgeneration>`_ method not getting the evaluated ``post_generations`` and ``RelatedFactory`` results correctly in the ``result`` param.
- Factories can now be registered inside classes (even nested classes) and they won't pollute the module namespace.
- Allow the ``@register`` decorator to be called with parameters:

.. code-block:: python
@register
@register("other_author")
class AuthorFactory(Factory):
...
2.1.0
Expand Down
15 changes: 15 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# How to make a release

```shell
python -m pip install --upgrade build twine

# cleanup the ./dist folder
rm -rf ./dist

# Build the distributions
python -m build

# Upload them

twine check dist/* && twine upload dist/*
```
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
[build-system]
requires = ["setuptools>=58", "wheel"]
build-backend = "setuptools.build_meta"

[tool.black]
line-length = 120
target-version = ['py37', 'py38', 'py39', 'py310', 'py310']
target-version = ['py37', 'py38', 'py39', 'py310']

[tool.mypy]
allow_redefinition = false
Expand Down
2 changes: 1 addition & 1 deletion pytest_factoryboy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""pytest-factoryboy public API."""
from .fixture import register, LazyFixture

__version__ = "2.1.0"
__version__ = "2.2.0"


__all__ = ("register", "LazyFixture")
74 changes: 60 additions & 14 deletions pytest_factoryboy/fixture.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Factory boy fixture integration."""
from __future__ import annotations

import functools
import sys
from dataclasses import dataclass
from inspect import getmodule, signature
from inspect import signature

import factory
import factory.builder
Expand All @@ -13,15 +14,15 @@

from .codegen import make_fixture_model_module, FixtureDef
from .compat import PostGenerationContext
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, overload
from typing_extensions import Protocol

if TYPE_CHECKING:
from typing import Any, Callable, TypeVar
from _pytest.fixtures import FixtureRequest
from factory.builder import BuildStep
from factory.declarations import PostGeneration
from factory.declarations import PostGenerationContext
from types import ModuleType

FactoryType = type[factory.Factory]
T = TypeVar("T")
Expand All @@ -42,19 +43,47 @@ def __call__(self, request: FixtureRequest) -> Any:
return self.function(request)


class RegisterProtocol(Protocol):
"""Protocol for ``register`` function called with ``factory_class``."""

def __call__(self, factory_class: F, _name: str | None = None, **kwargs: Any) -> F:
"""``register`` fuction called with ``factory_class``."""


@overload
def register(
factory_class: None = None,
_name: str | None = None,
**kwargs: Any,
) -> RegisterProtocol:
...


@overload
def register(factory_class: F, _name: str | None = None, **kwargs: Any) -> F:
...


def register(
factory_class: F | None = None,
_name: str | None = None,
**kwargs: Any,
) -> F | RegisterProtocol:
r"""Register fixtures for the factory class.
:param factory_class: Factory class to register.
:param _name: Name of the model fixture. By default is lowercase-underscored model name.
:param \**kwargs: Optional keyword arguments that override factory attributes.
"""

if factory_class is None:
return functools.partial(register, _name=_name, **kwargs)

assert not factory_class._meta.abstract, "Can't register abstract factories."
assert factory_class._meta.model is not None, "Factory model class is not specified."

fixture_defs: list[FixtureDef] = []

module = get_caller_module()
model_name = get_model_name(factory_class) if _name is None else _name
factory_name = get_factory_name(factory_class)

Expand Down Expand Up @@ -117,7 +146,9 @@ def register(factory_class: F, _name: str | None = None, **kwargs: Any) -> F:
)
)

if not hasattr(module, factory_name):
caller_locals = get_caller_locals()

if factory_name not in caller_locals:
fixture_defs.append(
FixtureDef(
name=factory_name,
Expand All @@ -140,11 +171,31 @@ def register(factory_class: F, _name: str | None = None, **kwargs: Any) -> F:

for fixture_def in fixture_defs:
exported_name = fixture_def.name
setattr(module, exported_name, getattr(generated_module, exported_name))
fixture_function = getattr(generated_module, exported_name)
inject_into_caller(exported_name, fixture_function, caller_locals)

return factory_class


def inject_into_caller(name: str, function: Callable, 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
# will be disregarded.
# To figure out if the caller frame is a class, we can check if the __qualname__ attribute is present.

# According to the python docs, __qualname__ is available for both **classes and functions**.
# However, it seems that for functions it is not yet available in the function namespace before it's defined.
# This could change in the future, but it shouldn't be too much of a problem since registering a factory
# in a function namespace would not make it usable anyway.
# 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)

locals_[name] = function


def get_model_name(factory_class: FactoryType) -> str:
"""Get model fixture name by factory."""
return (
Expand Down Expand Up @@ -338,14 +389,9 @@ def subfactory_fixture(request: FixtureRequest, factory_class: FactoryType) -> A
return request.getfixturevalue(fixture)


def get_caller_module(depth: int = 2) -> ModuleType:
"""Get the module of the caller."""
frame = sys._getframe(depth)
module = getmodule(frame)
# Happens when there's no __init__.py in the folder
if module is None:
return get_caller_module(depth=depth) # pragma: no cover
return module
def get_caller_locals(depth: int = 2) -> dict[str, Any]:
"""Get the local namespace of the caller frame."""
return sys._getframe(depth).f_locals


class LazyFixture:
Expand Down
4 changes: 3 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,17 @@ classifiers =
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11

[options]
python_requires = >=3.7
install_requires =
inflection
factory_boy>=2.10.0
pytest>=4.6
pytest>=5.0.0
mako
appdirs
typing_extensions
tests_require = tox
packages = pytest_factoryboy
include_package_data = True
Expand Down
29 changes: 29 additions & 0 deletions tests/test_factory_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ class Meta:


@register
@register(
_name="harry_potter_author",
name="J.K. Rowling",
register_user="jk_rowling",
)
class AuthorFactory(factory.Factory):
"""Author factory."""

Expand Down Expand Up @@ -150,6 +155,7 @@ def test_parametrized(book: Book):
@pytest.mark.parametrize("author__register_user", ["admin"])
def test_post_generation(author: Author):
"""Test post generation declaration."""
assert author.user
assert author.user.username == "admin"
assert author.user.is_active is True

Expand All @@ -170,6 +176,7 @@ def test_second_author(author: Author, second_author: Author):
def test_partial(partial_author: Author):
"""Test fixture partial specialization."""
assert partial_author.name == "John Doe"
assert partial_author.user
assert partial_author.user.username == "jd@jd.com"


Expand Down Expand Up @@ -199,4 +206,26 @@ def test_lazy_fixture_callable(book: Book, another_author: Author) -> None:
def test_lazy_fixture_post_generation(author: Author):
"""Test that post-generation values are replaced with lazy fixtures."""
# assert author.user.username == "lazyfixture"
assert author.user
assert author.user.password == "asdasd"


def test_register_class_decorator_with_kwargs_only(harry_potter_author: Author):
"""Ensure ``register`` decorator called with kwargs only works normally."""
assert harry_potter_author.name == "J.K. Rowling"
assert harry_potter_author.user
assert harry_potter_author.user.username == "jk_rowling"


register(_name="the_chronicles_of_narnia_author", name="C.S. Lewis")(
AuthorFactory,
register_user="cs_lewis",
register_user__password="Aslan1",
)


def test_register_function_with_kwargs_only(the_chronicles_of_narnia_author: Author):
"""Ensure ``register`` function called with kwargs only works normally."""
assert the_chronicles_of_narnia_author.name == "C.S. Lewis"
assert the_chronicles_of_narnia_author.user
assert the_chronicles_of_narnia_author.user.password == "Aslan1"
62 changes: 62 additions & 0 deletions tests/test_namespaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from dataclasses import dataclass

import pytest
from _pytest.fixtures import FixtureLookupError
from factory import Factory

from pytest_factoryboy import register


@dataclass
class Foo:
value: str


@register
class FooFactory(Factory):
class Meta:
model = Foo

value = "module_foo"


def test_module_namespace(foo):
assert foo.value == "module_foo"


class TestClassNamespace:
@register
class FooFactory(Factory):
class Meta:
model = Foo

value = "class_foo"

register(FooFactory, "class_foo")

def test_class_namespace(self, class_foo, foo):
assert foo.value == class_foo.value == "class_foo"

class TestNestedClassNamespace:
@register
class FooFactory(Factory):
class Meta:
model = Foo

value = "nested_class_foo"

register(FooFactory, "nested_class_foo")

def test_nested_class_namespace(self, foo, nested_class_foo):
assert foo.value == nested_class_foo.value == "nested_class_foo"

def test_nested_class_factories_dont_pollute_the_class(self, request):
with pytest.raises(FixtureLookupError):
request.getfixturevalue("nested_class_foo")


def test_class_factories_dont_pollute_the_module(request):
with pytest.raises(FixtureLookupError):
request.getfixturevalue("class_foo")
with pytest.raises(FixtureLookupError):
request.getfixturevalue("nested_class_foo")
10 changes: 4 additions & 6 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[tox]
distshare = {homedir}/.tox/distshare
envlist = py38-pytest{46,50,51,52,53,54,60,61,62,70,71,latest,main},
py{37,39,310,311}-pytestlatest,
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]
Expand All @@ -19,16 +19,15 @@ deps =
pytest52: pytest~=5.2.0
pytest51: pytest~=5.1.0
pytest50: pytest~=5.0.0
pytest46: pytest~=4.6.0

-r{toxinidir}/requirements-testing.txt

[testenv:py38-pytestmain]
# allow failures of tests run for `pytest` installed from `main` branch
ignore_outcome = true

[testenv:py310-pytestlatest]
# allow failures of tests run with unstable python 3.10
[testenv:py311-pytestlatest]
# allow failures of tests run with unstable python 3.11
ignore_outcome = true

[testenv:py{37,38,39,310,311}-mypy]
Expand All @@ -43,7 +42,6 @@ addopts = -vv -l

[gh-actions]
python =
3.6: py36
3.7: py37
3.8: py38
3.9: py39
Expand Down

0 comments on commit c63914b

Please sign in to comment.