From 3960266f714c95c0fcf228efcd68b1cb27f361bd Mon Sep 17 00:00:00 2001 From: Tim Klein Date: Wed, 14 Oct 2020 12:09:12 -0400 Subject: [PATCH 1/6] [52] Add ability to load foreign_key recipe argument from another module - This also refactors the `foreign_key` and `RecipeForeignKey` logic slightly to reduce duplicate code for loading `Recipe` objects from other modules. --- model_bakery/recipe.py | 40 +++++++++++++++++++++++++++++++--------- model_bakery/utils.py | 20 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/model_bakery/recipe.py b/model_bakery/recipe.py index 998c3838..83448bd9 100644 --- a/model_bakery/recipe.py +++ b/model_bakery/recipe.py @@ -7,7 +7,7 @@ from . import baker from .exceptions import RecipeNotFound -from .utils import seq # NoQA: Enable seq to be imported from recipes +from .utils import seq, get_calling_module # NoQA: Enable seq to be imported from recipes finder = baker.ModelFinder() @@ -68,18 +68,28 @@ def extend(self, **attrs) -> "Recipe": return type(self)(self._model, **attr_mapping) +def _load_recipe_from_calling_module(recipe: str) -> Recipe: + """Load `Recipe` from the string attribute given from the calling module. + + Args: + recipe (str): the name of the recipe attribute within the module from + which it should be loaded + + Returns: + (Recipe): recipe resolved from calling module + """ + recipe = getattr(get_calling_module(2), recipe) + if recipe: + return cast(Recipe, recipe) + else: + raise RecipeNotFound + + class RecipeForeignKey(object): + """A `Recipe` to use for making ManyToOne related objects.""" def __init__(self, recipe: Union[str, Recipe]) -> None: if isinstance(recipe, Recipe): self.recipe = recipe - elif isinstance(recipe, str): - frame = inspect.stack()[2] - caller_module = inspect.getmodule(frame[0]) - recipe = getattr(caller_module, recipe) - if recipe: - self.recipe = cast(Recipe, recipe) - else: - raise RecipeNotFound else: raise TypeError("Not a recipe") @@ -89,7 +99,19 @@ def foreign_key(recipe: Union[Recipe, str]) -> RecipeForeignKey: Return the callable, so that the associated `_model` will not be created during the recipe definition. + + This resolves recipes supplied as strings from other module paths or from + the calling code's module. """ + if isinstance(recipe, str): + # Load `Recipe` from string before handing off to `RecipeFOreignKey` + try: + # Try to load from another module + recipe = baker._recipe(recipe) + except (AttributeError, ImportError, ValueError): + # Probably not in another module, so load it from calling module + recipe = _load_recipe_from_calling_module(recipe) + return RecipeForeignKey(recipe) diff --git a/model_bakery/utils.py b/model_bakery/utils.py index 04d0ea55..96b3bac4 100644 --- a/model_bakery/utils.py +++ b/model_bakery/utils.py @@ -1,7 +1,10 @@ import datetime import importlib +import inspect import itertools import warnings + +from types import ModuleType from typing import Any, Callable, Optional, Union from .timezone import tz_aware @@ -21,6 +24,23 @@ def import_from_str(import_string: Optional[Union[Callable, str]]) -> Any: return import_string +def get_calling_module(levels_back: int) -> ModuleType: + """Get the module some number of stack frames back from the current one. + + Make sure to account for the number of stacks between the "calling" code + and the one that calls this function. + + Args: + levels_back (int): Number of stack frames back from the current + + Returns: + (ModuleType): the module from which the code was called + """ + frame = inspect.stack()[levels_back + 1][0] + return inspect.getmodule(frame) + + + def seq(value, increment_by=1, start=None, suffix=None): """Generate a sequence of values based on a running count. From 70260061742058000e4924d27e34797420f4bf5d Mon Sep 17 00:00:00 2001 From: Tim Klein Date: Wed, 14 Oct 2020 13:52:43 -0400 Subject: [PATCH 2/6] [52] Add unit tests for get_calling_module --- tests/test_utils.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 3efeb763..215978ba 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ import pytest +from inspect import getmodule -from model_bakery.utils import import_from_str +from model_bakery.utils import import_from_str, get_calling_module from tests.generic.models import User @@ -13,3 +14,36 @@ def test_import_from_str(): assert import_from_str("tests.generic.models.User") == User assert import_from_str(User) == User + + +def test_get_calling_module(): + # Reference to this very module + this_module = getmodule(test_get_calling_module) + + # Once removed is the `pytest` module calling this function + pytest_module = get_calling_module(1) + assert pytest_module != this_module + assert "pytest" in pytest_module.__name__ + + # Test functions + def dummy_secondary_method(): + return get_calling_module(2), get_calling_module(3) + + def dummy_method(): + return ( + *dummy_secondary_method(), + get_calling_module(1), + get_calling_module(2) + ) + + # Unpack results from the function chain + sec_mod, sec_pytest_mod, dummy_mod, pytest_mod = dummy_method() + + assert sec_mod == this_module + assert "pytest" in sec_pytest_mod.__name__ + assert dummy_mod == this_module + assert "pytest" in pytest_mod.__name__ + + # Raise an `IndexError` when attempting to access too many frames removed + with pytest.raises(IndexError): + assert get_calling_module(100) From fe0540121d597fbd30cc129b01ca651a185dc9b0 Mon Sep 17 00:00:00 2001 From: Tim Klein Date: Wed, 14 Oct 2020 17:18:53 -0400 Subject: [PATCH 3/6] [52] Add unit tests for loading recipe from other module - Fix lint issues, clean up imports --- model_bakery/recipe.py | 15 ++++++++------- model_bakery/utils.py | 2 -- tests/test_recipes.py | 12 ++++++++++++ tests/test_utils.py | 11 ++++------- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/model_bakery/recipe.py b/model_bakery/recipe.py index 83448bd9..29c809e3 100644 --- a/model_bakery/recipe.py +++ b/model_bakery/recipe.py @@ -1,4 +1,3 @@ -import inspect import itertools from typing import Any, Dict, List, Type, Union, cast @@ -7,7 +6,10 @@ from . import baker from .exceptions import RecipeNotFound -from .utils import seq, get_calling_module # NoQA: Enable seq to be imported from recipes +from .utils import ( # NoQA: Enable seq to be imported from recipes + get_calling_module, + seq, +) finder = baker.ModelFinder() @@ -87,7 +89,8 @@ def _load_recipe_from_calling_module(recipe: str) -> Recipe: class RecipeForeignKey(object): """A `Recipe` to use for making ManyToOne related objects.""" - def __init__(self, recipe: Union[str, Recipe]) -> None: + + def __init__(self, recipe: Recipe) -> None: if isinstance(recipe, Recipe): self.recipe = recipe else: @@ -104,7 +107,7 @@ def foreign_key(recipe: Union[Recipe, str]) -> RecipeForeignKey: the calling code's module. """ if isinstance(recipe, str): - # Load `Recipe` from string before handing off to `RecipeFOreignKey` + # Load `Recipe` from string before handing off to `RecipeForeignKey` try: # Try to load from another module recipe = baker._recipe(recipe) @@ -122,9 +125,7 @@ def __init__(self, *args) -> None: if isinstance(recipe, Recipe): self.related.append(recipe) elif isinstance(recipe, str): - frame = inspect.stack()[1] - caller_module = inspect.getmodule(frame[0]) - recipe = getattr(caller_module, recipe) + recipe = _load_recipe_from_calling_module(recipe) if recipe: self.related.append(recipe) else: diff --git a/model_bakery/utils.py b/model_bakery/utils.py index 96b3bac4..7c5bca53 100644 --- a/model_bakery/utils.py +++ b/model_bakery/utils.py @@ -3,7 +3,6 @@ import inspect import itertools import warnings - from types import ModuleType from typing import Any, Callable, Optional, Union @@ -40,7 +39,6 @@ def get_calling_module(levels_back: int) -> ModuleType: return inspect.getmodule(frame) - def seq(value, increment_by=1, start=None, suffix=None): """Generate a sequence of values based on a running count. diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 15e56ff3..55c61e66 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -341,6 +341,18 @@ def test_not_accept_other_type(self): exception = c.value assert str(exception) == "Not a recipe" + def test_load_from_other_module_recipe(self): + dog = Recipe(Dog, owner=foreign_key("tests.generic.person")).make() + assert dog.owner.name == "John Doe" + + def test_fail_load_invalid_recipe(self): + with pytest.raises(AttributeError): + foreign_key("tests.generic.nonexisting_recipe") + + def test_class_directly_with_string(self): + with pytest.raises(TypeError): + RecipeForeignKey("foo") + def test_do_not_create_related_model(self): """It should not create another object when passing the object as argument.""" person = baker.make_recipe("tests.generic.person") diff --git a/tests/test_utils.py b/tests/test_utils.py index 215978ba..cecad4cd 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,8 @@ -import pytest from inspect import getmodule -from model_bakery.utils import import_from_str, get_calling_module +import pytest + +from model_bakery.utils import get_calling_module, import_from_str from tests.generic.models import User @@ -30,11 +31,7 @@ def dummy_secondary_method(): return get_calling_module(2), get_calling_module(3) def dummy_method(): - return ( - *dummy_secondary_method(), - get_calling_module(1), - get_calling_module(2) - ) + return (*dummy_secondary_method(), get_calling_module(1), get_calling_module(2)) # Unpack results from the function chain sec_mod, sec_pytest_mod, dummy_mod, pytest_mod = dummy_method() From 6dff184dd7e4c359fb415bf7bec2fa9b64606b58 Mon Sep 17 00:00:00 2001 From: Tim Klein Date: Wed, 14 Oct 2020 17:31:00 -0400 Subject: [PATCH 4/6] [52] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 734e8416..cb1fe3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - [dev] Add instructions and script for running `postgres` and `postgis` tests. +- Add ability to pass `str` values to `foreign_key` for recipes from other modules [PR #120](https://github.com/model-bakers/model_bakery/pull/120) ### Changed - Fixed _model parameter annotations [PR #115](https://github.com/model-bakers/model_bakery/pull/115) From c048cad038e397fec0d618d946bba0ef75b77189 Mon Sep 17 00:00:00 2001 From: Tim Klein Date: Wed, 14 Oct 2020 18:32:20 -0400 Subject: [PATCH 5/6] [52] Add some type `cast` calls to appease mypy --- model_bakery/recipe.py | 4 ++-- model_bakery/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/model_bakery/recipe.py b/model_bakery/recipe.py index 29c809e3..556c91ef 100644 --- a/model_bakery/recipe.py +++ b/model_bakery/recipe.py @@ -113,9 +113,9 @@ def foreign_key(recipe: Union[Recipe, str]) -> RecipeForeignKey: recipe = baker._recipe(recipe) except (AttributeError, ImportError, ValueError): # Probably not in another module, so load it from calling module - recipe = _load_recipe_from_calling_module(recipe) + recipe = _load_recipe_from_calling_module(cast(str, recipe)) - return RecipeForeignKey(recipe) + return RecipeForeignKey(cast(Recipe, recipe)) class related(object): # FIXME diff --git a/model_bakery/utils.py b/model_bakery/utils.py index 7c5bca53..fe37db25 100644 --- a/model_bakery/utils.py +++ b/model_bakery/utils.py @@ -23,7 +23,7 @@ def import_from_str(import_string: Optional[Union[Callable, str]]) -> Any: return import_string -def get_calling_module(levels_back: int) -> ModuleType: +def get_calling_module(levels_back: int) -> Optional[ModuleType]: """Get the module some number of stack frames back from the current one. Make sure to account for the number of stacks between the "calling" code From 175e3ea8c4b46a2d1ad13d8be9b2e2080ea9680f Mon Sep 17 00:00:00 2001 From: Tim Klein Date: Wed, 14 Oct 2020 18:37:38 -0400 Subject: [PATCH 6/6] [52] Change wording: `stacks` -> `frames` --- model_bakery/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model_bakery/utils.py b/model_bakery/utils.py index fe37db25..aa07963e 100644 --- a/model_bakery/utils.py +++ b/model_bakery/utils.py @@ -26,7 +26,7 @@ def import_from_str(import_string: Optional[Union[Callable, str]]) -> Any: def get_calling_module(levels_back: int) -> Optional[ModuleType]: """Get the module some number of stack frames back from the current one. - Make sure to account for the number of stacks between the "calling" code + Make sure to account for the number of frames between the "calling" code and the one that calls this function. Args: