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

Add named_model function and raise warning for factories using generic models #167

Merged
merged 13 commits into from
Jun 11, 2022
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Changelog

Unreleased
----------
- TODO: Add changelog entry for named_model and warnings.
- Fix ``Factory._after_postgeneration`` being invoked twice. `#164 <https://github.com/pytest-dev/pytest-factoryboy/pull/164>`_ `#156 <https://github.com/pytest-dev/pytest-factoryboy/issues/156>`_

2.4.0
Expand Down
23 changes: 23 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,29 @@ LazyFixture constructor accepts either existing fixture name or callable with de
register(BookFactory, "another_book", author=LazyFixture("another_author"))


Generic container classes as models
-----------------------------------
It's often useful to create factories for ``dict``s or other common generic container classes.
In that case, you should wrap the container class around ``named_model(...)``, so that pytest-factoryboy can correctly determine the model name when using it in a SubFactory or RelatedFactory.
Pytest-factoryboy will otherwise raise a warning.

For example:

.. code-block:: python

import factory
from pytest_factoryboy import named_model, register

@register
class JSONPayload(factory.Factory):
class Meta:
model = named_model("JSONPayload", dict)
# instead of
# model = dict

...


Post-generation dependencies
============================

Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ typing_extensions = "*"
[tool.poetry.dev-dependencies]
mypy = "^0.960"
tox = "^3.25.0"
packaging = "^21.3"
importlib-metadata = "^4.11.4"

[build-system]
requires = ["poetry-core>=1.0.0"]
Expand Down
4 changes: 2 additions & 2 deletions pytest_factoryboy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""pytest-factoryboy public API."""
from .fixture import LazyFixture, register
from .fixture import LazyFixture, named_model, register

__all__ = ("register", "LazyFixture")
__all__ = ("register", "named_model", "LazyFixture")
28 changes: 23 additions & 5 deletions pytest_factoryboy/fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import contextlib
import functools
import sys
import warnings
from dataclasses import dataclass
from inspect import signature
from types import MethodType
Expand Down Expand Up @@ -43,6 +44,7 @@
P = ParamSpec("P")

SEPARATOR = "__"
WARN_FOR_MODEL_TYPES = frozenset({dict, list, set, tuple, frozenset})


@dataclass(eq=False)
Expand All @@ -56,6 +58,11 @@ def __call__(self, request: SubRequest) -> Any:
return self.function(request)


def named_model(model_cls: type[T], name: str) -> type[T]:
"""Return a model class with a given name."""
return type(name, (model_cls,), {})


# register(AuthorFactory, ...)
#
# @register
Expand Down Expand Up @@ -239,11 +246,22 @@ def inject_into_caller(name: str, function: Callable[..., Any], locals_: dict[st

def get_model_name(factory_class: FactoryType) -> str:
"""Get model fixture name by factory."""
return (
inflection.underscore(factory_class._meta.model.__name__)
if not isinstance(factory_class._meta.model, str)
else factory_class._meta.model
)
model_cls = factory_class._meta.model

if isinstance(model_cls, str):
return model_cls

model_name = inflection.underscore(model_cls.__name__)
if model_cls in WARN_FOR_MODEL_TYPES:
warnings.warn(
f"Using a {model_cls} as model type for {factory_class} is discouraged by pytest-factoryboy, "
f"as it assumes that the model name is {model_name!r} when using it as SubFactory or RelatedFactory, "
"which is too generic and probably not what you want.\n"
"You can giving an explicit name to the model by using:\n"
f'model = named_model({model_cls.__name__}, "Foo")',
)

return model_name


def get_factory_name(factory_class: FactoryType) -> str:
Expand Down
55 changes: 55 additions & 0 deletions tests/compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

import sys

from _pytest.pytester import RunResult
from packaging.version import Version

if sys.version_info >= (3, 8):
from importlib import metadata
else:
import importlib_metadata as metadata

PYTEST_VERSION = Version(metadata.version("pytest"))

if PYTEST_VERSION >= Version("6.0.0"):

def assert_outcomes(
result: RunResult,
passed: int = 0,
skipped: int = 0,
failed: int = 0,
errors: int = 0,
xpassed: int = 0,
xfailed: int = 0,
) -> None:
"""Compatibility function for result.assert_outcomes"""
result.assert_outcomes(
errors=errors,
passed=passed,
skipped=skipped,
failed=failed,
xpassed=xpassed,
xfailed=xfailed,
)

else:

def assert_outcomes(
result: RunResult,
passed: int = 0,
skipped: int = 0,
failed: int = 0,
errors: int = 0,
xpassed: int = 0,
xfailed: int = 0,
) -> None:
"""Compatibility function for result.assert_outcomes"""
result.assert_outcomes(
error=errors, # Pytest < 6 uses the singular form
passed=passed,
skipped=skipped,
failed=failed,
xpassed=xpassed,
xfailed=xfailed,
) # type: ignore[call-arg]
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest_plugins = "pytester"
176 changes: 176 additions & 0 deletions tests/test_model_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import warnings

import factory
import pytest

from pytest_factoryboy.fixture import get_model_name, named_model
from tests.compat import assert_outcomes


def make_class(name: str):
"""Create a class with the given name."""
return type(name, (object,), {})


@pytest.mark.parametrize("model_cls", [dict, set, list, frozenset, tuple])
def test_get_model_name_warns_for_common_containers(model_cls):
"""Test that a warning is raised when common containers are used as models."""

class ModelFactory(factory.Factory):
class Meta:
model = model_cls

with pytest.warns(
UserWarning,
match=rf"Using a .*{model_cls.__name__}.* as model type for .*ModelFactory.* is discouraged",
):
assert get_model_name(ModelFactory)


def test_get_model_name_does_not_warn_for_user_defined_models():
"""Test that no warning is raised for when using user-defined models"""

class Foo:
pass

class ModelFactory(factory.Factory):
class Meta:
model = Foo

with warnings.catch_warnings():
warnings.simplefilter("error")
assert get_model_name(ModelFactory) == "foo"
Comment on lines +40 to +42
Copy link
Contributor

Choose a reason for hiding this comment

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

NIP: to make this test more explicit and obvious we can check emitted warnings in an assert statement explicitly:

    with pytest.warns(None) as recorded_warnings:
        assert get_model_name(ModelFactory) == "foo"
        assert not recorded_warnings

We can also pass the exact warning class (UserWarning in our case) instead of None.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh nice, somehow I missed it in their docs, thanks!



@pytest.mark.parametrize(
["model_cls", "expected"],
[
(make_class("Foo"), "foo"),
(make_class("TwoWords"), "two_words"),
(make_class("HTTPHeader"), "http_header"),
(make_class("C3PO"), "c3_po"),
],
)
def test_get_model_name(model_cls, expected):
"""Test normal cases for ``get_model_name``."""

class ModelFactory(factory.Factory):
class Meta:
model = model_cls

assert get_model_name(ModelFactory) == expected


def test_named_model():
"""Assert behaviour of ``named_model``."""
cls = named_model(dict, "Foo")

assert cls.__name__ == "Foo"
assert issubclass(cls, dict)


def test_generic_model_with_custom_name_no_warning(testdir):
testdir.makepyfile(
"""
Copy link
Contributor

Choose a reason for hiding this comment

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

NIP: let's indent whole string content or just this line to make code more readable

from factory import Factory
from pytest_factoryboy import named_model, register

@register
class JSONPayloadFactory(Factory):
class Meta:
model = named_model(dict, "JSONPayload")
foo = "bar"


def test_payload(json_payload: dict):
assert isinstance(json_payload, dict)
assert json_payload["foo"] == "bar"
"""
)
result = testdir.runpytest("-Werror") # Warnings become errors
assert_outcomes(result, passed=1)


def test_generic_model_name_raises_warning(testdir):
testdir.makepyfile(
"""
import builtins
from factory import Factory
from pytest_factoryboy import register

@register
class JSONPayloadFactory(Factory):
class Meta:
model = dict
foo = "bar"


def test_payload(dict):
assert isinstance(dict, builtins.dict)
assert dict["foo"] == "bar"
"""
)
result = testdir.runpytest()
assert_outcomes(result, passed=1)
result.stdout.fnmatch_lines(
"*UserWarning: Using a *class*dict* as model type for *JSONPayloadFactory* is discouraged*"
)


def test_generic_model_with_register_override_no_warning(testdir):
testdir.makepyfile(
"""
from factory import Factory
from pytest_factoryboy import named_model, register

@register(_name="json_payload")
class JSONPayloadFactory(Factory):
class Meta:
model = dict
foo = "bar"


def test_payload(json_payload: dict):
assert isinstance(json_payload, dict)
assert json_payload["foo"] == "bar"

"""
)
result = testdir.runpytest("-Werror") # Warnings become errors
assert_outcomes(result, passed=1)


def test_using_generic_model_name_for_subfactory_raises_warning(testdir):
testdir.makepyfile(
"""
import builtins
from factory import Factory, SubFactory
from pytest_factoryboy import register

@register(_name="JSONPayload")
class JSONPayloadFactory(Factory):
class Meta:
model = dict # no warning raised here, since we override the name at the @register(...)
foo = "bar"

class HTTPRequest:
def __init__(self, json: dict):
self.json = json

@register
class HTTPRequestFactory(Factory):
class Meta:
model = HTTPRequest

json = SubFactory(JSONPayloadFactory) # this will raise a warning

def test_payload(http_request):
assert http_request.json["foo"] == "bar"
"""
)

result = testdir.runpytest()
assert_outcomes(result, errors=1)
result.stdout.fnmatch_lines(
"*UserWarning: Using *class*dict* as model type for *JSONPayloadFactory* is discouraged*"
)