From abfc39e4dfb28a238071391aa0e71e5644c363d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nad=C3=A8ge=20Michel?= Date: Sat, 5 Dec 2020 19:13:22 +0100 Subject: [PATCH 1/7] Documentation, changelog and credits --- CREDITS | 1 + docs/changelog.rst | 1 + docs/introduction.rst | 37 ++++++++++++++++++++++++++++++++ docs/reference.rst | 49 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/CREDITS b/CREDITS index 3c13c247..a80aafa8 100644 --- a/CREDITS +++ b/CREDITS @@ -60,6 +60,7 @@ The project has received contributions from (in alphabetical order): * Martin Bächtold (https://github.com/mbaechtold) * Michael Joseph * Mikhail Korobov +* Nadege Michel (https://github.com/nadege) * Oleg Pidsadnyi * Omer * Pauly Fenwar diff --git a/docs/changelog.rst b/docs/changelog.rst index b98bcfce..83181660 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,7 @@ ChangeLog - Add support for Django 4.2 - Add support for Django 5.0 - Add support for Python 3.12 +- Add support for asynchronous factories *Bugfix:* diff --git a/docs/introduction.rst b/docs/introduction.rst index e9ad3248..82cc5478 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -337,6 +337,7 @@ All factories support two built-in strategies: * ``build`` provides a local object * ``create`` instantiates a local object, and saves it to the database. +* ``create async`` similar to ``create`` but can run asynchronous code. .. note:: For 1.X versions, the ``create`` will actually call ``AssociatedClass.objects.create``, as for a Django model. @@ -370,3 +371,39 @@ Calling a :class:`~factory.Factory` subclass will provide an object through the The default strategy can be changed by setting the ``class Meta`` :attr:`~factory.FactoryOptions.strategy` attribute. + + +Asynchronous Factories +---------------------- + +You need to override the asynchronous method :meth:`factory.Factory._create_model_async` +to define how your objects are created and saved to the database. + +Then, you can then either: + +* use :meth:`factory.Factory.create_async` +* inherit from :class:`factory.AsyncFactory` instead of :class:`~factory.Factory` + to make :attr:`enums.ASYNC_CREATE_STRATEGY` the default strategy and then call the factory. + +.. code-block:: python + + class MyFactory(factory.AsyncFactory): + # ... + + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + await model_class.write_in_db(*args, **kwargs) + +.. code-block:: pycon + + >>> MyFactory.create() + + + >>> MyFactory.create_async() + + + >>> MyFactory.build() + + + >>> MyFactory() # equivalent to MyFactory.create_async() + diff --git a/docs/reference.rst b/docs/reference.rst index 238b56c4..f2399e18 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -217,6 +217,16 @@ Attributes and methods through the 'create' strategy. + .. classmethod:: create_async(cls, **kwargs) + + Provides a new object, using the `create_async` strategy. + + .. classmethod:: create_async_batch(cls, size, **kwargs) + + Provides a list of :obj:`size` instances from the :class:`Factory`, + through the `create_async` strategy. + + .. classmethod:: stub(cls, **kwargs) Provides a new stub @@ -319,6 +329,32 @@ Attributes and methods .. OHAI_VIM* + + .. classmethod:: _create_model_async(cls, model_class, *args, **kwargs) + + .. OHAI_VIM* + + The :meth:`_create_model_async` method is called whenever an instance needs to be + created asynchronously. + It receives the same arguments as :meth:`_build` and :meth:`_create`. + + Subclasses may override this for specific persistence backends: + + .. code-block:: python + + class BaseBackendFactory(factory.Factory): + class Meta: + abstract = True # Optional + + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + obj = model_class(*args, **kwargs) + await obj.async_save() + return obj + + .. OHAI_VIM* + + .. classmethod:: _after_postgeneration(cls, obj, create, results=None) :arg object obj: The object just generated @@ -377,6 +413,11 @@ Attributes and methods factory in the chain. +.. class:: AsyncFactory + +Similar to the :class:`Factory` class but with `create_async` as default strategy. + + .. _parameters: Parameters @@ -588,6 +629,14 @@ factory_boy supports two main strategies for generating instances, plus stubs. :class:`Factory` wasn't overridden. +.. data:: CREATE_ASYNC_STRATEGY + + The `create_async` strategy is similar to the `create` strategy but asynchronous. + + This is the default strategy for subclasses of :class:`AsyncFactory`. + + Default behavior is to call :meth:`~Factory._create`, this can be overridden in :meth:`_create_model_async`. + .. function:: use_strategy(strategy) .. deprecated:: 3.2 From 2607f5c01b50ff38ac40a536861388c7c5ac02a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nad=C3=A8ge=20Michel?= Date: Fri, 30 Oct 2020 11:06:31 +0100 Subject: [PATCH 2/7] Add async Factory support --- factory/__init__.py | 5 ++- factory/base.py | 90 +++++++++++++++++++++++++++++++++++++++++++-- factory/enums.py | 1 + factory/helpers.py | 10 +++++ 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/factory/__init__.py b/factory/__init__.py index bdc3ac0d..8432a0aa 100644 --- a/factory/__init__.py +++ b/factory/__init__.py @@ -1,6 +1,7 @@ # Copyright: See the LICENSE file. from .base import ( + AsyncFactory, BaseDictFactory, BaseListFactory, DictFactory, @@ -28,7 +29,7 @@ Trait, Transformer, ) -from .enums import BUILD_STRATEGY, CREATE_STRATEGY, STUB_STRATEGY +from .enums import ASYNC_CREATE_STRATEGY, BUILD_STRATEGY, CREATE_STRATEGY, STUB_STRATEGY from .errors import FactoryError from .faker import Faker from .helpers import ( @@ -36,6 +37,8 @@ build_batch, container_attribute, create, + create_async, + create_async_batch, create_batch, debug, generate, diff --git a/factory/base.py b/factory/base.py index 36b2359a..ccd31a9f 100644 --- a/factory/base.py +++ b/factory/base.py @@ -1,7 +1,8 @@ # Copyright: See the LICENSE file. - +import asyncio import collections +import inspect import logging import warnings @@ -38,6 +39,8 @@ def __call__(cls, **kwargs): return cls.build(**kwargs) elif cls._meta.strategy == enums.CREATE_STRATEGY: return cls.create(**kwargs) + elif cls._meta.strategy == enums.ASYNC_CREATE_STRATEGY: + return cls.create_async(**kwargs) elif cls._meta.strategy == enums.STUB_STRATEGY: return cls.stub(**kwargs) else: @@ -315,6 +318,8 @@ def instantiate(self, step, args, kwargs): return self.factory._build(model, *args, **kwargs) elif step.builder.strategy == enums.CREATE_STRATEGY: return self.factory._create(model, *args, **kwargs) + elif step.builder.strategy == enums.ASYNC_CREATE_STRATEGY: + return self.factory._create_async(model, *args, **kwargs) else: assert step.builder.strategy == enums.STUB_STRATEGY return StubObject(**kwargs) @@ -505,6 +510,38 @@ def _create(cls, model_class, *args, **kwargs): """ return model_class(*args, **kwargs) + @classmethod + def _create_async(cls, model_class, *args, **kwargs): + """Actually create a Task that will create an instance of the + model_class when awaited. + + Args: + model_class (type): the class for which an instance should be + created + args (tuple): arguments to use when creating the class + kwargs (dict): keyword arguments to use when creating the class + """ + + async def maker_coroutine(): + for key, value in kwargs.items(): + # When using SubFactory, you'll have a Task in the corresponding kwarg + # Await tasks to pass model instances instead, not the Task + if inspect.isawaitable(value): + kwargs[key] = await value + + return await cls._create_model_async(model_class, *args, **kwargs) + + # A Task can be awaited multiple times, unlike a coroutine. + # Useful when a factory and a subfactory must share the same object. + return asyncio.create_task(maker_coroutine()) + + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + """ + By default just run the synchronous create function + """ + return cls._create(model_class, *args, **kwargs) + @classmethod def build(cls, **kwargs): """Build an instance of the associated class, with overridden attrs.""" @@ -539,6 +576,25 @@ def create_batch(cls, size, **kwargs): """ return [cls.create(**kwargs) for _ in range(size)] + @classmethod + def create_async(cls, **kwargs): + """Create a Task that will return an instance of the associated class, + with overridden attrs, when awaited.""" + return cls._generate(enums.ASYNC_CREATE_STRATEGY, kwargs) + + @classmethod + async def create_async_batch(cls, size, **kwargs): + """Create a batch of instances of the given class, with overridden attrs, + using async creation. + + Args: + size (int): the number of instances to create + + Returns: + A list containing the created instances + """ + return [await cls.create_async(**kwargs) for _ in range(size)] + @classmethod def stub(cls, **kwargs): """Retrieve a stub of the associated class, with overridden attrs. @@ -573,7 +629,9 @@ def generate(cls, strategy, **kwargs): Returns: object: the generated instance """ - assert strategy in (enums.STUB_STRATEGY, enums.BUILD_STRATEGY, enums.CREATE_STRATEGY) + assert strategy in ( + enums.STUB_STRATEGY, enums.BUILD_STRATEGY, enums.CREATE_STRATEGY, enums.ASYNC_CREATE_STRATEGY, + ) action = getattr(cls, strategy) return action(**kwargs) @@ -582,7 +640,7 @@ def generate_batch(cls, strategy, size, **kwargs): """Generate a batch of instances. The instances will be created with the given strategy (one of - BUILD_STRATEGY, CREATE_STRATEGY, STUB_STRATEGY). + BUILD_STRATEGY, CREATE_STRATEGY, STUB_STRATEGY, ASYNC_CREATE_STRATEGY). Args: strategy (str): the strategy to use for generating the instance. @@ -591,7 +649,9 @@ def generate_batch(cls, strategy, size, **kwargs): Returns: object list: the generated instances """ - assert strategy in (enums.STUB_STRATEGY, enums.BUILD_STRATEGY, enums.CREATE_STRATEGY) + assert strategy in ( + enums.STUB_STRATEGY, enums.BUILD_STRATEGY, enums.CREATE_STRATEGY, enums.ASYNC_CREATE_STRATEGY + ) batch_action = getattr(cls, '%s_batch' % strategy) return batch_action(size, **kwargs) @@ -642,6 +702,16 @@ class Meta(BaseMeta): Factory.AssociatedClassError = errors.AssociatedClassError +class AsyncFactory(Factory): + """Same as Factory but creation is async by default + + ex: await MyAsyncFactory() + """ + + class Meta(BaseMeta): + strategy = enums.ASYNC_CREATE_STRATEGY + + class StubObject: """A generic container.""" def __init__(self, **kwargs): @@ -663,6 +733,10 @@ def build(cls, **kwargs): def create(cls, **kwargs): raise errors.UnsupportedStrategy() + @classmethod + def create_async(cls, **kwargs): + raise errors.UnsupportedStrategy() + class BaseDictFactory(Factory): """Factory for dictionary-like classes.""" @@ -680,6 +754,10 @@ def _build(cls, model_class, *args, **kwargs): def _create(cls, model_class, *args, **kwargs): return cls._build(model_class, *args, **kwargs) + @classmethod + async def _create_async(cls, model_class, *args, **kwargs): + return cls._build(model_class, *args, **kwargs) + class DictFactory(BaseDictFactory): class Meta: @@ -706,6 +784,10 @@ def _build(cls, model_class, *args, **kwargs): def _create(cls, model_class, *args, **kwargs): return cls._build(model_class, *args, **kwargs) + @classmethod + async def _create_async(cls, model_class, *args, **kwargs): + return cls._build(model_class, *args, **kwargs) + class ListFactory(BaseListFactory): class Meta: diff --git a/factory/enums.py b/factory/enums.py index 02f686e3..d6f8e168 100644 --- a/factory/enums.py +++ b/factory/enums.py @@ -4,6 +4,7 @@ BUILD_STRATEGY = 'build' CREATE_STRATEGY = 'create' STUB_STRATEGY = 'stub' +ASYNC_CREATE_STRATEGY = 'create_async' #: String for splitting an attribute name into a diff --git a/factory/helpers.py b/factory/helpers.py index 496de6e3..9cfdffb2 100644 --- a/factory/helpers.py +++ b/factory/helpers.py @@ -62,6 +62,16 @@ def create_batch(klass, size, **kwargs): return make_factory(klass, **kwargs).create_batch(size) +async def create_async(klass, **kwargs): + """Create a factory for the given class, and create a Task to create an instance.""" + return await make_factory(klass, **kwargs).create_async() + + +async def create_async_batch(klass, size, **kwargs): + """Create a factory for the given class, and create a batch of instances.""" + return await make_factory(klass, **kwargs).create_async_batch(size) + + def stub(klass, **kwargs): """Create a factory for the given class, and stub an instance.""" return make_factory(klass, **kwargs).stub() From e66dce05222eb92636ccf0e27d86deba2a03e4ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nad=C3=A8ge=20Michel?= Date: Fri, 30 Oct 2020 17:07:26 +0100 Subject: [PATCH 3/7] Add test for async factories --- tests/test_base.py | 73 ++++++++++ tests/test_using.py | 328 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 401 insertions(+) diff --git a/tests/test_base.py b/tests/test_base.py index d3b32570..7d94076d 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,5 +1,6 @@ # Copyright: See the LICENSE file. +import asyncio import unittest from factory import base, declarations, enums, errors @@ -26,6 +27,19 @@ def __init__(self, **kwargs): self.id = None +class FakeAsyncModel: + @classmethod + async def create(cls, **kwargs): + instance = cls(**kwargs) + instance.id = 1 + return instance + + def __init__(self, **kwargs): + self.id = None + for name, value in kwargs.items(): + setattr(self, name, value) + + class FakeModelFactory(base.Factory): class Meta: abstract = True @@ -34,11 +48,19 @@ class Meta: def _create(cls, model_class, *args, **kwargs): return model_class.create(**kwargs) + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + return await model_class.create(*args, **kwargs) + class TestModel(FakeDjangoModel): pass +class AsyncTestModel(FakeAsyncModel): + pass + + class SafetyTestCase(unittest.TestCase): def test_base_factory(self): with self.assertRaises(errors.FactoryError): @@ -387,6 +409,45 @@ class Meta: self.assertEqual(test_model.one, 'one') self.assertTrue(test_model.id) + def test_async_create_strategy(self): + class TestModelFactory(base.Factory): + class Meta: + model = AsyncTestModel + strategy = enums.ASYNC_CREATE_STRATEGY + + one = 'one' + + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + return await model_class.create(*args, **kwargs) + + async def test(): + test_model = await TestModelFactory() + self.assertEqual(test_model.one, 'one') + self.assertEqual(test_model.id, 1) + + asyncio.run(test()) + + def test_async_create_strategy_default(self): + # ASYNC_CREATE_STRATEGY is default strategy for AsyncFactory + + class TestModelAsyncFactory(base.AsyncFactory): + class Meta: + model = AsyncTestModel + + one = 'one' + + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + return await model_class.create(*args, **kwargs) + + async def test(): + test_model = await TestModelAsyncFactory() + self.assertEqual(test_model.one, 'one') + self.assertEqual(test_model.id, 1) + + asyncio.run(test()) + def test_stub_strategy(self): class TestModelFactory(base.Factory): class Meta: @@ -421,6 +482,18 @@ class Meta: with self.assertRaises(base.StubFactory.UnsupportedStrategy): TestModelFactory() + def test_stub_with_create_async_strategy(self): + class TestModelFactory(base.StubFactory): + class Meta: + model = TestModel + + one = 'one' + + TestModelFactory._meta.strategy = enums.ASYNC_CREATE_STRATEGY + + with self.assertRaises(base.StubFactory.UnsupportedStrategy): + TestModelFactory() + def test_stub_with_build_strategy(self): class TestModelFactory(base.StubFactory): class Meta: diff --git a/tests/test_using.py b/tests/test_using.py index 5b2200a6..1b145cf1 100644 --- a/tests/test_using.py +++ b/tests/test_using.py @@ -3,6 +3,7 @@ """Tests using factory.""" +import asyncio import collections import datetime import os @@ -95,6 +96,34 @@ def __init__(self, **kwargs): self.id = None +# A unique marker used in tests to assert create function was called. +create_marker = object() + + +class FakeAsyncModel: + + @classmethod + async def create(cls, **kwargs): + instance = cls(**kwargs) + if not instance.id: + instance.id = create_marker + return instance + + def __init__(self, **kwargs): + self.id = None + for name, value in kwargs.items(): + setattr(self, name, value) + + +class FakeSyncModelFactory(factory.Factory): + class Meta: + abstract = True + + @classmethod + def _create(cls, model_class, *args, **kwargs): + return model_class.create(**kwargs) + + class FakeModelFactory(factory.Factory): class Meta: abstract = True @@ -103,11 +132,25 @@ class Meta: def _create(cls, model_class, *args, **kwargs): return model_class.create(**kwargs) + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + return await model_class.create(*args, **kwargs) + + +class FakeAsyncModelFactory(factory.AsyncFactory): + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + return await model_class.create(*args, **kwargs) + class TestModel(FakeModel): pass +class AsyncTestModel(FakeAsyncModel): + pass + + class SimpleBuildTestCase(unittest.TestCase): """Tests the minimalist 'factory.build/create' functions.""" @@ -179,6 +222,59 @@ def test_create_batch_custom_base(self): self.assertEqual(obj.id, 2) self.assertEqual(obj.foo, 'bar') + def test_create_async(self): + + async def test(): + # By default Factory class is used to create the factory + obj = await factory.create_async(FakeAsyncModel, foo='bar') + # FakeAsyncModel.create was not called + self.assertEqual(obj.id, None) + self.assertEqual(obj.foo, 'bar') + + asyncio.run(test()) + + def test_create_async_custom_base(self): + + async def test(): + obj = await factory.create_async(FakeAsyncModel, foo='bar', FACTORY_CLASS=FakeAsyncModelFactory) + self.assertEqual(obj.id, create_marker) + self.assertEqual(obj.foo, 'bar') + + asyncio.run(test()) + + def test_create_async_batch(self): + + async def test(): + objs = await factory.create_async_batch(FakeAsyncModel, 4, foo='bar') + + self.assertEqual(4, len(objs)) + self.assertEqual(4, len(set(objs))) + + for obj in objs: + self.assertEqual(obj.id, None) + self.assertEqual(obj.foo, 'bar') + + asyncio.run(test()) + + def test_create_async_batch_custom_base(self): + + async def test(): + objs = await factory.create_async_batch( + FakeAsyncModel, + 4, + foo='bar', + FACTORY_CLASS=FakeAsyncModelFactory, + ) + + self.assertEqual(4, len(objs)) + self.assertEqual(4, len(set(objs))) + + for obj in objs: + self.assertEqual(obj.id, create_marker) + self.assertEqual(obj.foo, 'bar') + + asyncio.run(test()) + def test_stub(self): obj = factory.stub(TestObject, three=3) self.assertEqual(obj.three, 3) @@ -215,6 +311,29 @@ def test_generate_create_custom_base(self): self.assertEqual(obj.id, 2) self.assertEqual(obj.foo, 'bar') + def test_generate_create_async(self): + + async def test(): + obj = await factory.generate(FakeAsyncModel, factory.ASYNC_CREATE_STRATEGY, foo='bar') + self.assertEqual(obj.id, None) + self.assertEqual(obj.foo, 'bar') + + asyncio.run(test()) + + def test_generate_create_async_custom_base(self): + + async def test(): + obj = await factory.generate( + FakeAsyncModel, + factory.ASYNC_CREATE_STRATEGY, + foo='bar', + FACTORY_CLASS=FakeAsyncModelFactory, + ) + self.assertEqual(obj.id, create_marker) + self.assertEqual(obj.foo, 'bar') + + asyncio.run(test()) + def test_generate_stub(self): obj = factory.generate(FakeModel, factory.STUB_STRATEGY, foo='bar') self.assertFalse(hasattr(obj, 'id')) @@ -257,6 +376,40 @@ def test_generate_batch_create_custom_base(self): self.assertEqual(obj.id, 2) self.assertEqual(obj.foo, 'bar') + def test_generate_batch_create_async(self): + + async def test(): + objs = await factory.generate_batch(FakeAsyncModel, factory.ASYNC_CREATE_STRATEGY, 20, foo='bar') + + self.assertEqual(20, len(objs)) + self.assertEqual(20, len(set(objs))) + + for obj in objs: + self.assertEqual(obj.id, None) + self.assertEqual(obj.foo, 'bar') + + asyncio.run(test()) + + def test_generate_batch_create_async_custom_base(self): + + async def test(): + objs = await factory.generate_batch( + FakeAsyncModel, + factory.ASYNC_CREATE_STRATEGY, + 20, + foo='bar', + FACTORY_CLASS=FakeAsyncModelFactory, + ) + + self.assertEqual(20, len(objs)) + self.assertEqual(20, len(set(objs))) + + for obj in objs: + self.assertEqual(obj.id, create_marker) + self.assertEqual(obj.foo, 'bar') + + asyncio.run(test()) + def test_generate_batch_stub(self): objs = factory.generate_batch(FakeModel, factory.STUB_STRATEGY, 20, foo='bar') @@ -567,6 +720,49 @@ class Meta: test_model = TestModel2Factory() self.assertEqual(4, test_model.two.three) + def test_self_attribute_parent_async(self): + class Book(FakeAsyncModel): + pass + + class Author(FakeAsyncModel): + pass + + class Chapter(FakeAsyncModel): + pass + + class AuthorFactory(FakeAsyncModelFactory): + class Meta: + model = Author + + hometown = "Paris" + + class ChapterFactory(FakeAsyncModelFactory): + class Meta: + model = Chapter + + author = factory.SubFactory(AuthorFactory) + + class BookFactory(FakeAsyncModelFactory): + class Meta: + model = Book + + author = factory.SubFactory(AuthorFactory, hometown="Toronto") + chapter = factory.SubFactory(ChapterFactory, author=factory.SelfAttribute('..author')) + preface = factory.SubFactory(ChapterFactory) + + async def test(): + book = await BookFactory() + self.assertEqual(book.author, book.chapter.author) + self.assertEqual("Toronto", book.author.hometown) + self.assertEqual("Paris", book.preface.author.hometown) + self.assertEqual(create_marker, book.id) + self.assertEqual(create_marker, book.author.id) + self.assertEqual(create_marker, book.chapter.id) + self.assertEqual(create_marker, book.preface.id) + self.assertEqual(create_marker, book.preface.author.id) + + asyncio.run(test()) + def test_sequence_decorator(self): class TestObjectFactory(factory.Factory): class Meta: @@ -641,6 +837,38 @@ class Meta: self.assertEqual(i, obj.two) self.assertTrue(obj.id) + def test_create_async(self): + class TestModelFactory(FakeModelFactory): + class Meta: + model = AsyncTestModel + + one = 'one' + + async def test(): + test_model = await TestModelFactory.create_async() + self.assertEqual(test_model.one, 'one') + self.assertTrue(create_marker, test_model.id) + + asyncio.run(test()) + + def test_create_batch_async(self): + class TestModelFactory(FakeModelFactory): + class Meta: + model = AsyncTestModel + + one = 'one' + + async def test(): + objs = await TestModelFactory.create_async_batch(20, two=factory.Sequence(int)) + self.assertEqual(20, len(objs)) + self.assertEqual(20, len(set(objs))) + + for i, obj in enumerate(objs): + self.assertEqual('one', obj.one) + self.assertEqual(i, obj.two) + self.assertTrue(obj.id) + asyncio.run(test()) + def test_generate_build(self): class TestModelFactory(FakeModelFactory): class Meta: @@ -663,6 +891,20 @@ class Meta: self.assertEqual(test_model.one, 'one') self.assertTrue(test_model.id) + def test_generate_create_async(self): + class TestModelAsyncFactory(FakeAsyncModelFactory): + class Meta: + model = AsyncTestModel + + one = 'one' + + async def test(): + test_model = await TestModelAsyncFactory.generate(factory.ASYNC_CREATE_STRATEGY) + self.assertEqual(test_model.one, 'one') + self.assertEqual(test_model.id, create_marker) + + asyncio.run(test()) + def test_generate_stub(self): class TestModelFactory(FakeModelFactory): class Meta: @@ -708,6 +950,26 @@ class Meta: self.assertEqual('two', obj.two) self.assertTrue(obj.id) + def test_generate_batch_create_async(self): + class TestModelFactory(FakeModelFactory): + class Meta: + model = AsyncTestModel + + one = 'one' + + async def test(): + objs = await TestModelFactory.generate_batch(factory.ASYNC_CREATE_STRATEGY, 20, two='two') + + self.assertEqual(20, len(objs)) + self.assertEqual(20, len(set(objs))) + + for i, obj in enumerate(objs): + self.assertEqual('one', obj.one) + self.assertEqual('two', obj.two) + self.assertTrue(obj.id) + + asyncio.run(test()) + def test_generate_batch_stub(self): class TestModelFactory(FakeModelFactory): class Meta: @@ -1109,6 +1371,39 @@ def _create(cls, model_class, *args, **kwargs): self.assertEqual((1, 2), obj.args) self.assertEqual({'three': 3}, obj.kwargs) + def test_create_async(self): + class TestObject: + def __init__(self, *args, **kwargs): + self.args = None + self.kwargs = None + + @classmethod + async def create(cls, *args, **kwargs): + inst = cls() + inst.args = args + inst.kwargs = kwargs + return inst + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + inline_args = ('one', 'two') + + one = 1 + two = 2 + three = 3 + + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + return await model_class.create(*args, **kwargs) + + async def test(): + obj = await TestObjectFactory.create_async() + self.assertEqual((1, 2), obj.args) + self.assertEqual({'three': 3}, obj.kwargs) + + asyncio.run(test()) + class KwargAdjustTestCase(unittest.TestCase): """Tests for the _adjust_kwargs method.""" @@ -1504,6 +1799,39 @@ class Meta: self.assertEqual(1, test_model.id) self.assertEqual(1, test_model.two.id) + def test_subfactory_async(self): + class FakeAsyncModel2(FakeAsyncModel): + pass + + class TestSyncModelFactory(FakeSyncModelFactory): + + class Meta: + model = TestModel + zero = 9 + + class TestAsyncModelFactory(FakeAsyncModelFactory): + + class Meta: + model = AsyncTestModel + one = 3 + + class TestAsyncModel2Factory(FakeAsyncModelFactory): + + class Meta: + model = FakeAsyncModel2 + two = factory.SubFactory(TestAsyncModelFactory, one=1) + three = factory.SubFactory(TestSyncModelFactory, zero=0) + + async def test(): + test_model = await TestAsyncModel2Factory(two__one=4, three__zero=7) + self.assertEqual(4, test_model.two.one) + self.assertEqual(7, test_model.three.zero) + self.assertEqual(create_marker, test_model.id) + self.assertEqual(create_marker, test_model.two.id) + self.assertEqual(1, test_model.three.id) + + asyncio.run(test()) + def test_sub_factory_with_lazy_fields(self): class TestModel2(FakeModel): pass From bcbbba01894af48c6b866ae69422af76846cdc43 Mon Sep 17 00:00:00 2001 From: Nadege Michel Date: Sat, 22 Jul 2023 09:35:49 +0000 Subject: [PATCH 4/7] Add Async Factory implementation for SqlAlchemy --- factory/alchemy.py | 16 ++++++++++ setup.cfg | 3 +- tests/alchemyapp/models.py | 21 ++++++++++++- tests/test_alchemy.py | 61 +++++++++++++++++++++++++++++++++++++- tox.ini | 3 +- 5 files changed, 100 insertions(+), 4 deletions(-) diff --git a/factory/alchemy.py b/factory/alchemy.py index f934ce5d..37ef6a3d 100644 --- a/factory/alchemy.py +++ b/factory/alchemy.py @@ -129,3 +129,19 @@ def _save(cls, model_class, session, args, kwargs): elif session_persistence == SESSION_PERSISTENCE_COMMIT: session.commit() return obj + + +class SQLAlchemyModelAsyncFactory(SQLAlchemyModelFactory, base.AsyncFactory): + """Async Factory for SQLAlchemy models. """ + + class Meta: + abstract = True + + @classmethod + async def _create_model_async(cls, model_class, *args, **kwargs): + session = cls._meta.sqlalchemy_session + async with session.begin(): + model = model_class(**kwargs) + session.add(model) + + return model diff --git a/setup.cfg b/setup.cfg index 3ba2b7aa..221da09d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,9 +48,10 @@ dev = flake8 isort Pillow - SQLAlchemy + SQLAlchemy[asyncio] sqlalchemy_utils mongoengine + databases[sqlite] wheel>=0.32.0 tox zest.releaser[recommended] diff --git a/tests/alchemyapp/models.py b/tests/alchemyapp/models.py index 20e60aab..6644f56d 100644 --- a/tests/alchemyapp/models.py +++ b/tests/alchemyapp/models.py @@ -4,7 +4,8 @@ """Helpers for testing SQLAlchemy apps.""" import os -from sqlalchemy import Column, Integer, Unicode, create_engine +from sqlalchemy import Boolean, Column, Integer, Unicode, create_engine +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker try: @@ -28,14 +29,24 @@ pg_host = os.environ.get('POSTGRES_HOST', 'localhost') pg_port = os.environ.get('POSTGRES_PORT', '5432') engine_name = f'postgresql+psycopg2://{pg_user}:{pg_password}@{pg_host}:{pg_port}/{pg_database}' + async_engine_name = None else: engine_name = 'sqlite://' + async_engine_name = 'sqlite+aiosqlite://' session = scoped_session(sessionmaker()) engine = create_engine(engine_name) session.configure(bind=engine) Base = declarative_base() +if not async_engine_name: + async_engine = None + async_session = None +else: + async_engine = create_async_engine(async_engine_name) + async_sessionmaker = sessionmaker(async_engine, expire_on_commit=False, class_=AsyncSession) + async_session = async_sessionmaker() + class StandardModel(Base): __tablename__ = 'StandardModelTable' @@ -72,3 +83,11 @@ class SpecialFieldModel(Base): id = Column(Integer(), primary_key=True) session = Column(Unicode(20)) + + +class NoteModel(Base): + __tablename__ = "NoteTable" + + id = Column(Integer(), primary_key=True) + text = Column(Unicode(20)) + completed = Column(Boolean(), default=False) diff --git a/tests/test_alchemy.py b/tests/test_alchemy.py index 19e4f5ee..d4f55a1c 100644 --- a/tests/test_alchemy.py +++ b/tests/test_alchemy.py @@ -2,6 +2,7 @@ """Tests for factory_boy/SQLAlchemy interactions.""" +import asyncio import unittest from unittest import mock @@ -13,7 +14,7 @@ from sqlalchemy_utils import create_database, database_exists, drop_database import factory -from factory.alchemy import SQLAlchemyModelFactory +from factory.alchemy import SQLAlchemyModelAsyncFactory, SQLAlchemyModelFactory from .alchemyapp import models @@ -336,3 +337,61 @@ class Meta: get_or_created_child = SpecialFieldWithGetOrCreateFactory() self.assertEqual(get_or_created_child.session, "") + + +class NoteFactory(SQLAlchemyModelAsyncFactory): + class Meta: + model = models.NoteModel + sqlalchemy_session = models.async_session + + text = factory.Sequence(lambda n: f"Text {n}") + completed = factory.Faker('boolean') + + +if models.async_engine: + + class SQLAlchemyAsyncTestCase(unittest.TestCase): + + def setUp(self): + super().setUp() + + async def asyncSetUp(): + async with models.async_engine.begin() as conn: + await conn.run_sync(models.Base.metadata.drop_all) + await conn.run_sync(models.Base.metadata.create_all) + + NoteFactory.reset_sequence(0) + + asyncio.run(asyncSetUp()) + + def tearDown(self): + + async def asyncTearDown(): + + async with models.async_engine.begin() as conn: + await conn.run_sync(models.Base.metadata.drop_all) + + asyncio.run(asyncTearDown()) + + def test_build(self): + note = NoteFactory.build() + self.assertEqual('Text 0', note.text) + self.assertIn(note.completed, [True, False]) + self.assertIsNone(note.id) + + def test_creation(self): + + async def test(): + + note = await NoteFactory.create_async() + self.assertEqual('Text 0', note.text) + self.assertIn(note.completed, [True, False]) + self.assertIsNotNone(note.id) + + statement = sqlalchemy.select( + sqlalchemy.func.count(models.NoteModel.id) + ).where(models.NoteModel.text == "Text 0") + count = await models.async_session.scalar(statement) + assert count == 1 + + asyncio.run(test()) diff --git a/tox.ini b/tox.ini index 256a88d6..3cbee5b7 100644 --- a/tox.ini +++ b/tox.ini @@ -32,8 +32,9 @@ passenv = POSTGRES_HOST POSTGRES_DATABASE deps = - alchemy: SQLAlchemy + alchemy: SQLAlchemy[asyncio] alchemy: sqlalchemy_utils + alchemy-sqlite: databases[sqlite] mongo: mongoengine django{32,41,42,50,main}: Pillow django32: Django>=3.2,<3.3 From 705b6ff6ca61004ee5220545dbdc70531728abff Mon Sep 17 00:00:00 2001 From: Nadege Michel Date: Sun, 23 Jul 2023 10:31:25 +0000 Subject: [PATCH 5/7] Add support for post generation in async creation --- factory/base.py | 2 +- factory/builder.py | 35 +++++++++++++++++++++--------- factory/declarations.py | 3 ++- tests/test_using.py | 47 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/factory/base.py b/factory/base.py index ccd31a9f..f264b597 100644 --- a/factory/base.py +++ b/factory/base.py @@ -327,7 +327,7 @@ def instantiate(self, step, args, kwargs): def use_postgeneration_results(self, step, instance, results): self.factory._after_postgeneration( instance, - create=step.builder.strategy == enums.CREATE_STRATEGY, + create=step.builder.strategy != enums.BUILD_STRATEGY, results=results, ) diff --git a/factory/builder.py b/factory/builder.py index e76e7556..ea64993f 100644 --- a/factory/builder.py +++ b/factory/builder.py @@ -1,5 +1,6 @@ """Build factory instances.""" +import asyncio import collections from . import enums, errors, utils @@ -277,19 +278,33 @@ def build(self, parent_step=None, force_sequence=None): kwargs=kwargs, ) - postgen_results = {} - for declaration_name in post.sorted(): - declaration = post[declaration_name] - postgen_results[declaration_name] = declaration.declaration.evaluate_post( + def _handle_post_generation(instance): + postgen_results = {} + for declaration_name in post.sorted(): + declaration = post[declaration_name] + postgen_results[declaration_name] = declaration.declaration.evaluate_post( + instance=instance, + step=step, + overrides=declaration.context, + ) + + self.factory_meta.use_postgeneration_results( instance=instance, step=step, - overrides=declaration.context, + results=postgen_results, ) - self.factory_meta.use_postgeneration_results( - instance=instance, - step=step, - results=postgen_results, - ) + + if step.builder.strategy == enums.ASYNC_CREATE_STRATEGY and isinstance(instance, asyncio.Task): + + def post_generation_callback(task): + instance = task.result() + _handle_post_generation(instance) + + instance.add_done_callback(post_generation_callback) + + else: + _handle_post_generation(instance) + return instance def recurse(self, factory_meta, extras): diff --git a/factory/declarations.py b/factory/declarations.py index 70abe35c..846de611 100644 --- a/factory/declarations.py +++ b/factory/declarations.py @@ -679,7 +679,8 @@ def call(self, instance, step, context): context._asdict(), ), ) - create = step.builder.strategy == enums.CREATE_STRATEGY + create = step.builder.strategy != enums.BUILD_STRATEGY + return self.function( instance, create, context.value, **context.extra) diff --git a/tests/test_using.py b/tests/test_using.py index 1b145cf1..9ad3a6cd 100644 --- a/tests/test_using.py +++ b/tests/test_using.py @@ -2576,6 +2576,28 @@ def incr_one(self, _create, _increment): self.assertEqual(3, obj.one) self.assertFalse(hasattr(obj, 'incr_one')) + def test_post_generation_async(self): + class TestAsyncFactory(FakeAsyncModelFactory): + class Meta: + model = AsyncTestModel + + one = 1 + + @factory.post_generation + def incr_one(self, _create, _increment): + self.one += 1 + + async def test(): + obj = await TestAsyncFactory.create_async() + self.assertEqual(2, obj.one) + self.assertFalse(hasattr(obj, 'incr_one')) + + obj = await TestAsyncFactory.create_async(one=2) + self.assertEqual(3, obj.one) + self.assertFalse(hasattr(obj, 'incr_one')) + + asyncio.run(test()) + def test_post_generation_hook(self): class TestObjectFactory(factory.Factory): class Meta: @@ -2598,6 +2620,31 @@ def _after_postgeneration(cls, obj, create, results): self.assertFalse(obj.create) self.assertEqual({'incr_one': 42}, obj.results) + def test_post_generation_hook_async(self): + class TestAsyncFactory(FakeAsyncModelFactory): + class Meta: + model = AsyncTestModel + + one = 1 + + @factory.post_generation + def incr_one(self, _create, _increment): + self.one += 1 + return 42 + + @classmethod + def _after_postgeneration(cls, obj, create, results): + obj.create = create + obj.results = results + + async def test(): + obj = await TestAsyncFactory.create_async() + self.assertEqual(2, obj.one) + self.assertTrue(obj.create) + self.assertEqual({'incr_one': 42}, obj.results) + + asyncio.run(test()) + def test_post_generation_extraction(self): class TestObjectFactory(factory.Factory): class Meta: From 5f2582dabd22a132f2dfc57c41523bb0be4b2552 Mon Sep 17 00:00:00 2001 From: Nadege Michel Date: Sat, 13 Jan 2024 10:05:55 +0000 Subject: [PATCH 6/7] Rebased and adresses simple comments --- docs/introduction.rst | 2 +- docs/reference.rst | 18 +++++++++--------- factory/base.py | 2 +- factory/declarations.py | 2 +- tests/test_alchemy.py | 4 ++-- tests/test_using.py | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/introduction.rst b/docs/introduction.rst index 82cc5478..b46f1b37 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -337,7 +337,7 @@ All factories support two built-in strategies: * ``build`` provides a local object * ``create`` instantiates a local object, and saves it to the database. -* ``create async`` similar to ``create`` but can run asynchronous code. +* ``create_async`` similar to ``create`` but can run asynchronous code. .. note:: For 1.X versions, the ``create`` will actually call ``AssociatedClass.objects.create``, as for a Django model. diff --git a/docs/reference.rst b/docs/reference.rst index f2399e18..52ecdff6 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -203,7 +203,7 @@ Attributes and methods .. classmethod:: build_batch(cls, size, **kwargs) - Provides a list of :obj:`size` instances from the :class:`Factory`, + Provides a list of ``size`` instances from the :class:`Factory`, through the 'build' strategy. @@ -213,18 +213,18 @@ Attributes and methods .. classmethod:: create_batch(cls, size, **kwargs) - Provides a list of :obj:`size` instances from the :class:`Factory`, + Provides a list of ``size`` instances from the :class:`Factory`, through the 'create' strategy. .. classmethod:: create_async(cls, **kwargs) - Provides a new object, using the `create_async` strategy. + Provides a new object, using the ``create_async`` strategy. .. classmethod:: create_async_batch(cls, size, **kwargs) - Provides a list of :obj:`size` instances from the :class:`Factory`, - through the `create_async` strategy. + Asynchronously provides a list of ``size`` instances from the :class:`Factory`, + through the ``create_async`` strategy. .. classmethod:: stub(cls, **kwargs) @@ -415,7 +415,7 @@ Attributes and methods .. class:: AsyncFactory -Similar to the :class:`Factory` class but with `create_async` as default strategy. +Similar to the :class:`Factory` class but with ``create_async`` as default strategy. .. _parameters: @@ -629,13 +629,13 @@ factory_boy supports two main strategies for generating instances, plus stubs. :class:`Factory` wasn't overridden. -.. data:: CREATE_ASYNC_STRATEGY +.. data:: ASYNC_CREATE_STRATEGY - The `create_async` strategy is similar to the `create` strategy but asynchronous. + The ``create_async`` strategy is similar to the ``create`` strategy but asynchronous. This is the default strategy for subclasses of :class:`AsyncFactory`. - Default behavior is to call :meth:`~Factory._create`, this can be overridden in :meth:`_create_model_async`. + Default behavior is to call :meth:`~Factory._create`, this can be overridden in :meth:`Factory._create_model_async`. .. function:: use_strategy(strategy) diff --git a/factory/base.py b/factory/base.py index f264b597..cb32a1b2 100644 --- a/factory/base.py +++ b/factory/base.py @@ -327,7 +327,7 @@ def instantiate(self, step, args, kwargs): def use_postgeneration_results(self, step, instance, results): self.factory._after_postgeneration( instance, - create=step.builder.strategy != enums.BUILD_STRATEGY, + create=step.builder.strategy in (enums.CREATE_STRATEGY, enums.ASYNC_CREATE_STRATEGY), results=results, ) diff --git a/factory/declarations.py b/factory/declarations.py index 846de611..d9a7234e 100644 --- a/factory/declarations.py +++ b/factory/declarations.py @@ -679,7 +679,7 @@ def call(self, instance, step, context): context._asdict(), ), ) - create = step.builder.strategy != enums.BUILD_STRATEGY + create = step.builder.strategy in (enums.CREATE_STRATEGY, enums.ASYNC_CREATE_STRATEGY) return self.function( instance, create, context.value, **context.extra) diff --git a/tests/test_alchemy.py b/tests/test_alchemy.py index d4f55a1c..7bf93f9b 100644 --- a/tests/test_alchemy.py +++ b/tests/test_alchemy.py @@ -390,8 +390,8 @@ async def test(): statement = sqlalchemy.select( sqlalchemy.func.count(models.NoteModel.id) - ).where(models.NoteModel.text == "Text 0") + ) count = await models.async_session.scalar(statement) - assert count == 1 + self.assertEqual(count, 1) asyncio.run(test()) diff --git a/tests/test_using.py b/tests/test_using.py index 9ad3a6cd..a9fb5889 100644 --- a/tests/test_using.py +++ b/tests/test_using.py @@ -847,7 +847,7 @@ class Meta: async def test(): test_model = await TestModelFactory.create_async() self.assertEqual(test_model.one, 'one') - self.assertTrue(create_marker, test_model.id) + self.assertEqual(create_marker, test_model.id) asyncio.run(test()) @@ -866,7 +866,7 @@ async def test(): for i, obj in enumerate(objs): self.assertEqual('one', obj.one) self.assertEqual(i, obj.two) - self.assertTrue(obj.id) + self.assertEqual(obj.id, create_marker) asyncio.run(test()) def test_generate_build(self): @@ -966,7 +966,7 @@ async def test(): for i, obj in enumerate(objs): self.assertEqual('one', obj.one) self.assertEqual('two', obj.two) - self.assertTrue(obj.id) + self.assertEqual(obj.id, create_marker) asyncio.run(test()) From 4ece4ba1d829ad59735174eac827820c751dbaa6 Mon Sep 17 00:00:00 2001 From: Nadege Michel Date: Sat, 13 Jan 2024 10:50:44 +0000 Subject: [PATCH 7/7] Alchemy: Actually save the objects in db --- factory/alchemy.py | 19 ++++++++++++++++--- tests/test_alchemy.py | 1 + 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/factory/alchemy.py b/factory/alchemy.py index 37ef6a3d..dcf99c5e 100644 --- a/factory/alchemy.py +++ b/factory/alchemy.py @@ -140,8 +140,21 @@ class Meta: @classmethod async def _create_model_async(cls, model_class, *args, **kwargs): session = cls._meta.sqlalchemy_session + + if session is None: + raise RuntimeError("No session provided.") + async with session.begin(): - model = model_class(**kwargs) - session.add(model) + return await cls._save(model_class, session, args, kwargs) + + @classmethod + async def _save(cls, model_class, session, args, kwargs): + session_persistence = cls._meta.sqlalchemy_session_persistence - return model + obj = model_class(*args, **kwargs) + session.add(obj) + if session_persistence == SESSION_PERSISTENCE_FLUSH: + await session.flush() + elif session_persistence == SESSION_PERSISTENCE_COMMIT: + await session.commit() + return obj diff --git a/tests/test_alchemy.py b/tests/test_alchemy.py index 7bf93f9b..a0451f22 100644 --- a/tests/test_alchemy.py +++ b/tests/test_alchemy.py @@ -343,6 +343,7 @@ class NoteFactory(SQLAlchemyModelAsyncFactory): class Meta: model = models.NoteModel sqlalchemy_session = models.async_session + sqlalchemy_session_persistence = 'commit' text = factory.Sequence(lambda n: f"Text {n}") completed = factory.Faker('boolean')