Skip to content

Commit

Permalink
Merge pull request #136 from pytest-dev/debuggability
Browse files Browse the repository at this point in the history
Debuggability
  • Loading branch information
youtux committed Apr 30, 2022
2 parents f883927 + 67ff235 commit eb328cd
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 69 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ Changelog

Unreleased
----------
- 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.


2.1.0
-----
Expand Down
135 changes: 135 additions & 0 deletions pytest_factoryboy/codegen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
from __future__ import annotations

import atexit
import importlib.util
import itertools
import logging
import pathlib
import shutil
import tempfile
import typing
from dataclasses import field, dataclass
from functools import lru_cache
from types import ModuleType

import mako.template
from appdirs import AppDirs

from .compat import path_with_stem

cache_dir = pathlib.Path(AppDirs("pytest-factoryboy").user_cache_dir)

logger = logging.getLogger(__name__)


@dataclass
class FixtureDef:
name: str
function_name: typing.Literal["model_fixture", "attr_fixture", "factory_fixture", "subfactory_fixture"]
function_kwargs: dict = field(default_factory=dict)
deps: list[str] = field(default_factory=list)
related: list[str] = field(default_factory=list)

@property
def kwargs_var_name(self):
return f"_{self.name}__kwargs"


module_template = mako.template.Template(
"""\
import pytest
from pytest_factoryboy.fixture import (
attr_fixture,
factory_fixture,
model_fixture,
subfactory_fixture,
)
def _fixture(related):
def fixture_maker(fn):
fn._factoryboy_related = related
return pytest.fixture(fn)
return fixture_maker
% for fixture_def in fixture_defs:
${ fixture_def.kwargs_var_name } = {}
@_fixture(related=${ repr(fixture_def.related) })
def ${ fixture_def.name }(
% for dep in ["request"] + fixture_def.deps:
${ dep },
% endfor
):
return ${ fixture_def.function_name }(request, **${ fixture_def.kwargs_var_name })
% endfor
"""
)

init_py_content = '''\
"""Pytest-factoryboy generated fixtures.
This module and the other modules in this package are automatically generated by
pytest-factoryboy. They will be rewritten on the next run.
"""
'''


@lru_cache() # This way we reuse the same folder for the whole execution of the program
def make_temp_folder(package_name: str) -> pathlib.Path:
"""Create a temporary folder and automatically delete it when the process exit."""
path = pathlib.Path(tempfile.mkdtemp()) / package_name
path.mkdir(parents=True, exist_ok=True)

atexit.register(shutil.rmtree, str(path))

return path


@lru_cache() # This way we reuse the same folder for the whole execution of the program
def create_package(package_name: str, init_py_content=init_py_content) -> pathlib.Path:
path = cache_dir / package_name
try:
if path.exists():
shutil.rmtree(str(path))

path.mkdir(parents=True, exist_ok=False)
except OSError: # Catch cases where the directory can't be removed or can't be created
logger.warning(f"Can't create the cache directory {path}. Using a temporary directory instead.", exc_info=True)
return make_temp_folder(package_name)

(path / "__init__.py").write_text(init_py_content)

return path


def make_module(code: str, module_name: str, package_name: str) -> ModuleType:
tmp_module_path = create_package(package_name) / f"{module_name}.py"

counter = itertools.count(1)
while tmp_module_path.exists():
count = next(counter)
new_stem = f"{tmp_module_path.stem}_{count}"
tmp_module_path = path_with_stem(tmp_module_path, new_stem)

logger.info(f"Writing content of {module_name!r} into {tmp_module_path}.")

tmp_module_path.write_text(code)

spec = importlib.util.spec_from_file_location(f"{package_name}.{module_name}", tmp_module_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


def make_fixture_model_module(model_name, fixture_defs: list[FixtureDef]):
code = module_template.render(fixture_defs=fixture_defs)
generated_module = make_module(code, module_name=model_name, package_name="_pytest_factoryboy_generated_fixtures")
for fixture_def in fixture_defs:
assert hasattr(generated_module, fixture_def.kwargs_var_name)
setattr(generated_module, fixture_def.kwargs_var_name, fixture_def.function_kwargs)
return generated_module
14 changes: 14 additions & 0 deletions pytest_factoryboy/compat.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
from __future__ import annotations
import sys
import pathlib

try:
from factory.declarations import PostGenerationContext
except ImportError: # factory_boy < 3.2.0
from factory.builder import PostGenerationContext

if sys.version_info >= (3, 9):

def path_with_stem(path: pathlib.Path, stem: str) -> pathlib.Path:
return path.with_stem(stem)

else:

def path_with_stem(path: pathlib.Path, stem: str) -> pathlib.Path:
return path.with_name(stem + path.suffix)
118 changes: 50 additions & 68 deletions pytest_factoryboy/fixture.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,21 @@
"""Factory boy fixture integration."""
from __future__ import annotations

import sys
from inspect import getmodule, signature

import factory
import factory.builder
import factory.declarations
import factory.enums
import inflection
import pytest

from inspect import getmodule, signature

from pytest_factoryboy.compat import PostGenerationContext
from .codegen import make_fixture_model_module, FixtureDef
from .compat import PostGenerationContext

SEPARATOR = "__"


FIXTURE_FUNC_FORMAT = """
def {name}({deps}):
return _fixture_impl(request, **kwargs)
"""


def make_fixture(name, module, func, args=None, related=None, **kwargs):
"""Make fixture function and inject arguments.
:param name: Fixture name.
:param module: Python module to contribute the fixture into.
:param func: Fixture implementation function.
:param args: Argument names.
"""
args = [] if args is None else list(args)
if "request" not in args:
args.insert(0, "request")
deps = ", ".join(args)
context = dict(_fixture_impl=func, kwargs=kwargs)
context.update(kwargs)
exec(FIXTURE_FUNC_FORMAT.format(name=name, deps=deps), context)
fixture_func = context[name]
fixture_func.__module__ = module.__name__

if related:
fixture_func._factoryboy_related = related

fixture = pytest.fixture(fixture_func)
setattr(module, name, fixture)
return fixture


def register(factory_class, _name=None, **kwargs):
r"""Register fixtures for the factory class.
Expand All @@ -58,28 +26,31 @@ def register(factory_class, _name=None, **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)

deps = get_deps(factory_class, model_name=model_name)
related = []
related: list[str] = []

for attr, value in factory_class._meta.declarations.items():
args = None
args = []
attr_name = SEPARATOR.join((model_name, attr))

if isinstance(value, factory.declarations.PostGeneration):
value = kwargs.get(attr, None)
if isinstance(value, LazyFixture):
args = value.args

make_fixture(
name=attr_name,
module=module,
func=attr_fixture,
value=value,
args=args,
fixture_defs.append(
FixtureDef(
name=attr_name,
function_name="attr_fixture",
function_kwargs={"value": value},
deps=args,
)
)
else:
value = kwargs.get(attr, value)
Expand All @@ -99,41 +70,52 @@ def register(factory_class, _name=None, **kwargs):
if isinstance(value, factory.SubFactory):
args.append(inflection.underscore(subfactory_class._meta.model.__name__))

make_fixture(
name=attr_name,
module=module,
func=subfactory_fixture,
args=args,
factory_class=subfactory_class,
fixture_defs.append(
FixtureDef(
name=attr_name,
function_name="subfactory_fixture",
function_kwargs={"factory_class": subfactory_class},
deps=args,
)
)
else:
if isinstance(value, LazyFixture):
args = value.args

make_fixture(
name=attr_name,
module=module,
func=attr_fixture,
value=value,
args=args,
fixture_defs.append(
FixtureDef(
name=attr_name,
function_name="attr_fixture",
function_kwargs={"value": value},
deps=args,
)
)

if not hasattr(module, factory_name):
make_fixture(
name=factory_name,
module=module,
func=factory_fixture,
factory_class=factory_class,
fixture_defs.append(
FixtureDef(
name=factory_name,
function_name="factory_fixture",
function_kwargs={"factory_class": factory_class},
)
)

make_fixture(
name=model_name,
module=module,
func=model_fixture,
args=deps,
factory_name=factory_name,
related=related,
fixture_defs.append(
FixtureDef(
name=model_name,
function_name="model_fixture",
function_kwargs={"factory_name": factory_name},
deps=deps,
related=related,
)
)

generated_module = make_fixture_model_module(model_name, fixture_defs)

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

return factory_class


Expand Down
3 changes: 2 additions & 1 deletion pytest_factoryboy/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def pytest_generate_tests(metafunc):
related = []
for arg2fixturedef in metafunc._arg2fixturedefs.values():
fixturedef = arg2fixturedef[-1]
related.extend(getattr(fixturedef.func, "_factoryboy_related", []))
related_fixtures = getattr(fixturedef.func, "_factoryboy_related", [])
related.extend(related_fixtures)

metafunc.fixturenames.extend(related)
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ install_requires =
inflection
factory_boy>=2.10.0
pytest>=4.6
mako
appdirs
tests_require = tox
packages = pytest_factoryboy
include_package_data = True
Expand Down

0 comments on commit eb328cd

Please sign in to comment.