diff options
author | Thomas Goirand <thomas@goirand.fr> | 2014-05-03 22:57:46 +0800 |
---|---|---|
committer | Thomas Goirand <thomas@goirand.fr> | 2014-05-03 22:57:46 +0800 |
commit | 1073f2ae50fb0999712b6744b082f7424e4490c3 (patch) | |
tree | 26943f3545ce1fc1ae54e0398fbbd78df641f54d | |
parent | c9f01d77941527b62ca67b1064bd3fb849b6a064 (diff) | |
parent | 90db123ada9739a19f3b408b50e006700923f651 (diff) | |
download | factory-boy-1073f2ae50fb0999712b6744b082f7424e4490c3.tar factory-boy-1073f2ae50fb0999712b6744b082f7424e4490c3.tar.gz |
Merge tag '2.3.1' into debian/unstable
Release of factory_boy 2.3.1
50 files changed, 4325 insertions, 708 deletions
@@ -1,11 +1,15 @@ -*.pyc +# Temporary files .*.swp +*.pyc +*.pyo + +# Build-related files +docs/_build/ .coverage -MANIFEST +.tox +*.egg-info +*.egg build/ dist/ htmlcov/ -docs/_build -docs/_static -docs/_templates -.tox +MANIFEST diff --git a/.travis.yml b/.travis.yml index d0735d8..2bfb978 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,9 @@ script: - python setup.py test install: - - "if [[ $TRAVIS_PYTHON_VERSION = 2.6 ]]; then pip install unittest2 --use-mirrors; fi" + - if [[ $TRAVIS_PYTHON_VERSION = 2.6 ]]; then pip install unittest2 --use-mirrors; fi + - pip install Django sqlalchemy --use-mirrors + - if ! python --version 2>&1 | grep -q -i pypy ; then pip install Pillow --use-mirrors; fi notifications: email: false diff --git a/MANIFEST.in b/MANIFEST.in index 3912fee..3dfc1be 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,6 @@ -include README +include README.rst include docs/Makefile recursive-include docs *.py *.rst include docs/_static/.keep_dir prune docs/_build -recursive-include tests *.py +recursive-include tests *.py *.data @@ -2,6 +2,8 @@ PACKAGE=factory TESTS_DIR=tests DOC_DIR=docs +# Use current python binary instead of system default. +COVERAGE = python $(shell which coverage) all: default @@ -11,6 +13,9 @@ default: clean: find . -type f -name '*.pyc' -delete + find . -type f -path '*/__pycache__/*' -delete + find . -type d -empty -delete + @rm -rf tmp_test/ test: @@ -20,10 +25,10 @@ pylint: pylint --rcfile=.pylintrc --report=no $(PACKAGE)/ coverage: - coverage erase - coverage run "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" --branch setup.py test - coverage report "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" - coverage html "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" + $(COVERAGE) erase + $(COVERAGE) run "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" --branch setup.py test + $(COVERAGE) report "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" + $(COVERAGE) html "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" doc: $(MAKE) -C $(DOC_DIR) html @@ -1,233 +0,0 @@ -factory_boy -=========== - -.. image:: https://secure.travis-ci.org/rbarrois/factory_boy.png?branch=master - :target: http://travis-ci.org/rbarrois/factory_boy/ - -factory_boy is a fixtures replacement based on thoughtbot's `factory_girl <http://github.com/thoughtbot/factory_girl>`_. - -Its features include: - -- Straightforward syntax -- Support for multiple build strategies (saved/unsaved instances, attribute dicts, stubbed objects) -- Powerful helpers for common cases (sequences, sub-factories, reverse dependencies, circular factories, ...) -- Multiple factories per class support, including inheritance -- Support for various ORMs (currently Django, Mogo) - - -Links ------ - -* Documentation: http://factoryboy.readthedocs.org/ -* Official repository: https://github.com/rbarrois/factory_boy -* Package: https://pypi.python.org/pypi/factory_boy/ - -factory_boy supports Python 2.6, 2.7, 3.2 and 3.3, as well as PyPy; it requires only the standard Python library. - - -Download --------- - -PyPI: https://pypi.python.org/pypi/factory_boy/ - -.. code-block:: sh - - $ pip install factory_boy - -Source: https://github.com/rbarrois/factory_boy/ - -.. code-block:: sh - - $ git clone git://github.com/rbarrois/factory_boy/ - $ python setup.py install - - -Usage ------ - - -.. note:: This section provides a quick summary of factory_boy features. - A more detailed listing is available in the full documentation. - - -Defining factories -"""""""""""""""""" - -Factories declare a set of attributes used to instantiate an object. The class of the object must be defined in the FACTORY_FOR attribute: - -.. code-block:: python - - import factory - from . import models - - class UserFactory(factory.Factory): - FACTORY_FOR = models.User - - first_name = 'John' - last_name = 'Doe' - admin = False - - # Another, different, factory for the same object - class AdminFactory(factory.Factory): - FACTORY_FOR = models.User - - first_name = 'Admin' - last_name = 'User' - admin = True - - -Using factories -""""""""""""""" - -factory_boy supports several different build strategies: build, create, attributes and stub: - -.. code-block:: python - - # Returns a User instance that's not saved - user = UserFactory.build() - - # Returns a saved User instance - user = UserFactory.create() - - # Returns a dict of attributes that can be used to build a User instance - attributes = UserFactory.attributes() - - -You can use the Factory class as a shortcut for the default build strategy: - -.. code-block:: python - - # Same as UserFactory.create() - user = UserFactory() - - -No matter which strategy is used, it's possible to override the defined attributes by passing keyword arguments: - -.. code-block:: pycon - - # Build a User instance and override first_name - >>> user = UserFactory.build(first_name='Joe') - >>> user.first_name - "Joe" - - -Lazy Attributes -""""""""""""""" - -Most factory attributes can be added using static values that are evaluated when the factory is defined, -but some attributes (such as fields whose value is computed from other elements) -will need values assigned each time an instance is generated. - -These "lazy" attributes can be added as follows: - -.. code-block:: python - - class UserFactory(factory.Factory): - FACTORY_FOR = models.User - first_name = 'Joe' - last_name = 'Blow' - email = factory.LazyAttribute(lambda a: '{0}.{1}@example.com'.format(a.first_name, a.last_name).lower()) - -.. code-block:: pycon - - >>> UserFactory().email - "joe.blow@example.com" - - -Sequences -""""""""" - -Unique values in a specific format (for example, e-mail addresses) can be generated using sequences. Sequences are defined by using ``Sequence`` or the decorator ``sequence``: - -.. code-block:: python - - class UserFactory(factory.Factory): - FACTORY_FOR = models.User - email = factory.Sequence(lambda n: 'person{0}@example.com'.format(n)) - - >>> UserFactory().email - 'person0@example.com' - >>> UserFactory().email - 'person1@example.com' - - -Associations -"""""""""""" - -Some objects have a complex field, that should itself be defined from a dedicated factories. -This is handled by the ``SubFactory`` helper: - -.. code-block:: python - - class PostFactory(factory.Factory): - FACTORY_FOR = models.Post - author = factory.SubFactory(UserFactory) - - -The associated object's strategy will be used: - - -.. code-block:: python - - # Builds and saves a User and a Post - >>> post = PostFactory() - >>> post.id is None # Post has been 'saved' - False - >>> post.author.id is None # post.author has been saved - False - - # Builds but does not save a User, and then builds but does not save a Post - >>> post = PostFactory.build() - >>> post.id is None - True - >>> post.author.id is None - True - - -Contributing ------------- - -factory_boy is distributed under the MIT License. - -Issues should be opened through `GitHub Issues <http://github.com/rbarrois/factory_boy/issues/>`_; whenever possible, a pull request should be included. - -All pull request should pass the test suite, which can be launched simply with: - -.. code-block:: sh - - $ python setup.py test - - -.. note:: - - Running test requires the unittest2 (standard in Python 2.7+) and mock libraries. - - -In order to test coverage, please use: - -.. code-block:: sh - - $ pip install coverage - $ coverage erase; coverage run --branch setup.py test; coverage report - - -Contents, indices and tables ----------------------------- - -.. toctree:: - :maxdepth: 2 - - introduction - reference - orms - recipes - fuzzy - examples - internals - changelog - ideas - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/README.rst b/README.rst index 100b938..0371b28 120000..100644 --- a/README.rst +++ b/README.rst @@ -1 +1,277 @@ -README
\ No newline at end of file +factory_boy +=========== + +.. image:: https://secure.travis-ci.org/rbarrois/factory_boy.png?branch=master + :target: http://travis-ci.org/rbarrois/factory_boy/ + +factory_boy is a fixtures replacement based on thoughtbot's `factory_girl <http://github.com/thoughtbot/factory_girl>`_. + +Its features include: + +- Straightforward syntax +- Support for multiple build strategies (saved/unsaved instances, attribute dicts, stubbed objects) +- Powerful helpers for common cases (sequences, sub-factories, reverse dependencies, circular factories, ...) +- Multiple factories per class support, including inheritance +- Support for various ORMs (currently Django, Mogo, SQLAlchemy) + + +Links +----- + +* Documentation: http://factoryboy.readthedocs.org/ +* Official repository: https://github.com/rbarrois/factory_boy +* Package: https://pypi.python.org/pypi/factory_boy/ + +factory_boy supports Python 2.6, 2.7, 3.2 and 3.3, as well as PyPy; it requires only the standard Python library. + + +Download +-------- + +PyPI: https://pypi.python.org/pypi/factory_boy/ + +.. code-block:: sh + + $ pip install factory_boy + +Source: https://github.com/rbarrois/factory_boy/ + +.. code-block:: sh + + $ git clone git://github.com/rbarrois/factory_boy/ + $ python setup.py install + + +Usage +----- + + +.. note:: This section provides a quick summary of factory_boy features. + A more detailed listing is available in the full documentation. + + +Defining factories +"""""""""""""""""" + +Factories declare a set of attributes used to instantiate an object. The class of the object must be defined in the FACTORY_FOR attribute: + +.. code-block:: python + + import factory + from . import models + + class UserFactory(factory.Factory): + FACTORY_FOR = models.User + + first_name = 'John' + last_name = 'Doe' + admin = False + + # Another, different, factory for the same object + class AdminFactory(factory.Factory): + FACTORY_FOR = models.User + + first_name = 'Admin' + last_name = 'User' + admin = True + + +Using factories +""""""""""""""" + +factory_boy supports several different build strategies: build, create, attributes and stub: + +.. code-block:: python + + # Returns a User instance that's not saved + user = UserFactory.build() + + # Returns a saved User instance + user = UserFactory.create() + + # Returns a dict of attributes that can be used to build a User instance + attributes = UserFactory.attributes() + + +You can use the Factory class as a shortcut for the default build strategy: + +.. code-block:: python + + # Same as UserFactory.create() + user = UserFactory() + + +No matter which strategy is used, it's possible to override the defined attributes by passing keyword arguments: + +.. code-block:: pycon + + # Build a User instance and override first_name + >>> user = UserFactory.build(first_name='Joe') + >>> user.first_name + "Joe" + + +Lazy Attributes +""""""""""""""" + +Most factory attributes can be added using static values that are evaluated when the factory is defined, +but some attributes (such as fields whose value is computed from other elements) +will need values assigned each time an instance is generated. + +These "lazy" attributes can be added as follows: + +.. code-block:: python + + class UserFactory(factory.Factory): + FACTORY_FOR = models.User + first_name = 'Joe' + last_name = 'Blow' + email = factory.LazyAttribute(lambda a: '{0}.{1}@example.com'.format(a.first_name, a.last_name).lower()) + +.. code-block:: pycon + + >>> UserFactory().email + "joe.blow@example.com" + + +Sequences +""""""""" + +Unique values in a specific format (for example, e-mail addresses) can be generated using sequences. Sequences are defined by using ``Sequence`` or the decorator ``sequence``: + +.. code-block:: python + + class UserFactory(factory.Factory): + FACTORY_FOR = models.User + email = factory.Sequence(lambda n: 'person{0}@example.com'.format(n)) + + >>> UserFactory().email + 'person0@example.com' + >>> UserFactory().email + 'person1@example.com' + + +Associations +"""""""""""" + +Some objects have a complex field, that should itself be defined from a dedicated factories. +This is handled by the ``SubFactory`` helper: + +.. code-block:: python + + class PostFactory(factory.Factory): + FACTORY_FOR = models.Post + author = factory.SubFactory(UserFactory) + + +The associated object's strategy will be used: + + +.. code-block:: python + + # Builds and saves a User and a Post + >>> post = PostFactory() + >>> post.id is None # Post has been 'saved' + False + >>> post.author.id is None # post.author has been saved + False + + # Builds but does not save a User, and then builds but does not save a Post + >>> post = PostFactory.build() + >>> post.id is None + True + >>> post.author.id is None + True + + +Debugging factory_boy +""""""""""""""""""""" + +Debugging factory_boy can be rather complex due to the long chains of calls. +Detailed logging is available through the ``factory`` logger. + +A helper, :meth:`factory.debug()`, is available to ease debugging: + +.. code-block:: python + + with factory.debug(): + obj = TestModel2Factory() + + + import logging + logger = logging.getLogger('factory') + logger.addHandler(logging.StreamHandler()) + logger.setLevel(logging.DEBUG) + +This will yield messages similar to those (artificial indentation): + +.. code-block:: ini + + BaseFactory: Preparing tests.test_using.TestModel2Factory(extra={}) + LazyStub: Computing values for tests.test_using.TestModel2Factory(two=<OrderedDeclarationWrapper for <factory.declarations.SubFactory object at 0x1e15610>>) + SubFactory: Instantiating tests.test_using.TestModelFactory(__containers=(<LazyStub for tests.test_using.TestModel2Factory>,), one=4), create=True + BaseFactory: Preparing tests.test_using.TestModelFactory(extra={'__containers': (<LazyStub for tests.test_using.TestModel2Factory>,), 'one': 4}) + LazyStub: Computing values for tests.test_using.TestModelFactory(one=4) + LazyStub: Computed values, got tests.test_using.TestModelFactory(one=4) + BaseFactory: Generating tests.test_using.TestModelFactory(one=4) + LazyStub: Computed values, got tests.test_using.TestModel2Factory(two=<tests.test_using.TestModel object at 0x1e15410>) + BaseFactory: Generating tests.test_using.TestModel2Factory(two=<tests.test_using.TestModel object at 0x1e15410>) + + +ORM Support +""""""""""" + +factory_boy has specific support for a few ORMs, through specific :class:`~factory.Factory` subclasses: + +* Django, with :class:`~factory.django.DjangoModelFactory` +* Mogo, with :class:`~factory.mogo.MogoFactory` +* MongoEngine, with :class:`~factory.mongoengine.MongoEngineFactory` +* SQLAlchemy, with :class:`~factory.alchemy.SQLAlchemyModelFactory` + +Contributing +------------ + +factory_boy is distributed under the MIT License. + +Issues should be opened through `GitHub Issues <http://github.com/rbarrois/factory_boy/issues/>`_; whenever possible, a pull request should be included. + +All pull request should pass the test suite, which can be launched simply with: + +.. code-block:: sh + + $ python setup.py test + + +.. note:: + + Running test requires the unittest2 (standard in Python 2.7+) and mock libraries. + + +In order to test coverage, please use: + +.. code-block:: sh + + $ pip install coverage + $ coverage erase; coverage run --branch setup.py test; coverage report + + +Contents, indices and tables +---------------------------- + +.. toctree:: + :maxdepth: 2 + + introduction + reference + orms + recipes + fuzzy + examples + internals + changelog + ideas + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000..e828644 --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,5 @@ +coverage +Django +Pillow +sqlalchemy +mongoengine
\ No newline at end of file diff --git a/docs/changelog.rst b/docs/changelog.rst index 173c40f..4917578 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,24 +1,139 @@ ChangeLog ========= + +.. _v2.3.1: + +2.3.1 (2014-01-22) +------------------ + +*Bugfix:* + + - Fix badly written assert containing state-changing code, spotted by `chsigi <https://github.com/chsigi>`_ (:issue:`126`) + - Don't crash when handling objects whose __repr__ is non-pure-ascii bytes on Py2, + discovered by `mbertheau <https://github.com/mbertheau>`_ (:issue:`123`) and `strycore <https://github.com/strycore>`_ (:issue:`127`) + +.. _v2.3.0: + +2.3.0 (2013-12-25) +------------------ + +*New:* + + - Add :class:`~factory.fuzzy.FuzzyText`, thanks to `jdufresne <https://github.com/jdufresne>`_ (:issue:`97`) + - Add :class:`~factory.fuzzy.FuzzyDecimal`, thanks to `thedrow <https://github.com/thedrow>`_ (:issue:`94`) + - Add support for :class:`~mongoengine.EmbeddedDocument`, thanks to `imiric <https://github.com/imiric>`_ (:issue:`100`) + +.. _v2.2.1: + +2.2.1 (2013-09-24) +------------------ + +*Bugfix:* + + - Fixed sequence counter for :class:`~factory.django.DjangoModelFactory` when a factory + inherits from another factory relating to an abstract model. + +.. _v2.2.0: + +2.2.0 (2013-09-24) +------------------ + +*Bugfix:* + + - Removed duplicated :class:`~factory.alchemy.SQLAlchemyModelFactory` lurking in :mod:`factory` + (:issue:`83`) + - Properly handle sequences within object inheritance chains. + If FactoryA inherits from FactoryB, and their associated classes share the same link, + sequence counters will be shared (:issue:`93`) + - Properly handle nested :class:`~factory.SubFactory` overrides + +*New:* + + - The :class:`~factory.django.DjangoModelFactory` now supports the ``FACTORY_FOR = 'myapp.MyModel'`` + syntax, making it easier to shove all factories in a single module (:issue:`66`). + - Add :meth:`factory.debug()` helper for easier backtrace analysis + - Adding factory support for mongoengine with :class:`~factory.mongoengine.MongoEngineFactory`. + +.. _v2.1.2: + +2.1.2 (2013-08-14) +------------------ + +*New:* + + - The :class:`~factory.Factory.ABSTRACT_FACTORY` keyword is now optional, and automatically set + to ``True`` if neither the :class:`~factory.Factory` subclass nor its parent declare the + :class:`~factory.Factory.FACTORY_FOR` attribute (:issue:`74`) + + +.. _v2.1.1: + +2.1.1 (2013-07-02) +------------------ + +*Bugfix:* + + - Properly retrieve the ``color`` keyword argument passed to :class:`~factory.django.ImageField` + +.. _v2.1.0: + +2.1.0 (2013-06-26) +------------------ + +*New:* + + - Add :class:`~factory.fuzzy.FuzzyDate` thanks to `saulshanabrook <https://github.com/saulshanabrook>`_ + - Add :class:`~factory.fuzzy.FuzzyDateTime` and :class:`~factory.fuzzy.FuzzyNaiveDateTime`. + - Add a :attr:`~factory.containers.LazyStub.factory_parent` attribute to the + :class:`~factory.containers.LazyStub` passed to :class:`~factory.LazyAttribute`, in order to access + fields defined in wrapping factories. + - Move :class:`~factory.django.DjangoModelFactory` and :class:`~factory.mogo.MogoFactory` + to their own modules (:mod:`factory.django` and :mod:`factory.mogo`) + - Add the :meth:`~factory.Factory.reset_sequence` classmethod to :class:`~factory.Factory` + to ease resetting the sequence counter for a given factory. + - Add debug messages to ``factory`` logger. + - Add a :meth:`~factory.Iterator.reset` method to :class:`~factory.Iterator` (:issue:`63`) + - Add support for the SQLAlchemy ORM through :class:`~factory.alchemy.SQLAlchemyModelFactory` + (:issue:`64`, thanks to `Romain Commandé <https://github.com/rcommande>`_) + - Add :class:`factory.django.FileField` and :class:`factory.django.ImageField` hooks for + related Django model fields (:issue:`52`) + +*Bugfix* + + - Properly handle non-integer pks in :class:`~factory.django.DjangoModelFactory` (:issue:`57`). + - Disable :class:`~factory.RelatedFactory` generation when a specific value was + passed (:issue:`62`, thanks to `Gabe Koscky <https://github.com/dhekke>`_) + +*Deprecation:* + + - Rename :class:`~factory.RelatedFactory`'s ``name`` argument to ``factory_related_name`` (See :issue:`58`) + + +.. _v2.0.2: + 2.0.2 (2013-04-16) ------------------ *New:* - - When :attr:`~factory.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE` is + - When :attr:`~factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE` is empty, use ``Model.objects.create()`` instead of ``Model.objects.get_or_create``. +.. _v2.0.1: + 2.0.1 (2013-04-16) ------------------ *New:* - Don't push ``defaults`` to ``get_or_create`` when - :attr:`~factory.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE` is not set. + :attr:`~factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE` is not set. +.. _v2.0.0: + 2.0.0 (2013-04-15) ------------------ @@ -29,11 +144,11 @@ ChangeLog - The default :attr:`~factory.Sequence.type` for :class:`~factory.Sequence` is now :obj:`int` - Fields listed in :attr:`~factory.Factory.FACTORY_HIDDEN_ARGS` won't be passed to the associated class' constructor - - Add support for ``get_or_create`` in :class:`~factory.DjangoModelFactory`, - through :attr:`~factory.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE`. + - Add support for ``get_or_create`` in :class:`~factory.django.DjangoModelFactory`, + through :attr:`~factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE`. - Add support for :mod:`~factory.fuzzy` attribute definitions. - The :class:`Sequence` counter can be overridden when calling a generating function - - Add :class:`~factory.Dict` and :class:`~factory.List` declarations (Closes #18). + - Add :class:`~factory.Dict` and :class:`~factory.List` declarations (Closes :issue:`18`). *Removed:* @@ -46,6 +161,8 @@ ChangeLog - Remove :meth:`~factory.Factory.set_building_function` / :meth:`~factory.Factory.set_creation_function` +.. _v1.3.0: + 1.3.0 (2013-03-11) ------------------ @@ -60,7 +177,7 @@ New - **Global:** - Rewrite the whole documentation - - Provide a dedicated :class:`~factory.MogoFactory` subclass of :class:`~factory.Factory` + - Provide a dedicated :class:`~factory.mogo.MogoFactory` subclass of :class:`~factory.Factory` - **The Factory class:** - Better creation/building customization hooks at :meth:`factory.Factory._build` and :meth:`factory.Factory.create` @@ -77,7 +194,7 @@ New its :attr:`~factory.Iterator.cycle` argument to ``False`` - Allow overriding default arguments in a :class:`~factory.PostGenerationMethodCall` when generating an instance of the factory - - An object created by a :class:`~factory.DjangoModelFactory` will be saved + - An object created by a :class:`~factory.django.DjangoModelFactory` will be saved again after :class:`~factory.PostGeneration` hooks execution @@ -112,7 +229,7 @@ In order to upgrade client code, apply the following rules: :class:`~factory.Factory`, instead of relying on automagic associated class discovery - When using factory_boy for Django models, have each factory inherit from - :class:`~factory.DjangoModelFactory` + :class:`~factory.django.DjangoModelFactory` - Replace ``factory.CircularSubFactory('some.module', 'Symbol')`` with ``factory.SubFactory('some.module.Symbol')`` - Replace ``factory.InfiniteIterator(iterable)`` with ``factory.Iterator(iterable)`` @@ -124,6 +241,8 @@ In order to upgrade client code, apply the following rules: +.. _v1.2.0: + 1.2.0 (2012-09-08) ------------------ @@ -131,6 +250,9 @@ In order to upgrade client code, apply the following rules: - Add :class:`~factory.CircularSubFactory` to solve circular dependencies between factories + +.. _v1.1.5: + 1.1.5 (2012-07-09) ------------------ @@ -138,6 +260,9 @@ In order to upgrade client code, apply the following rules: - Fix :class:`~factory.PostGenerationDeclaration` and derived classes. + +.. _v1.1.4: + 1.1.4 (2012-06-19) ------------------ @@ -149,6 +274,9 @@ In order to upgrade client code, apply the following rules: - Introduce :class:`~factory.PostGeneration` and :class:`~factory.RelatedFactory` + +.. _v1.1.3: + 1.1.3 (2012-03-09) ------------------ @@ -156,6 +284,9 @@ In order to upgrade client code, apply the following rules: - Fix packaging rules + +.. _v1.1.2: + 1.1.2 (2012-02-25) ------------------ @@ -165,6 +296,9 @@ In order to upgrade client code, apply the following rules: - Provide :func:`~factory.Factory.generate` and :func:`~factory.Factory.simple_generate`, that allow specifying the instantiation strategy directly. Also provides :func:`~factory.Factory.generate_batch` and :func:`~factory.Factory.simple_generate_batch`. + +.. _v1.1.1: + 1.1.1 (2012-02-24) ------------------ @@ -172,6 +306,9 @@ In order to upgrade client code, apply the following rules: - Add :func:`~factory.Factory.build_batch`, :func:`~factory.Factory.create_batch` and :func:`~factory.Factory.stub_batch`, to instantiate factories in batch + +.. _v1.1.0: + 1.1.0 (2012-02-24) ------------------ @@ -190,6 +327,9 @@ In order to upgrade client code, apply the following rules: - Auto-discovery of :attr:`~factory.Factory.FACTORY_FOR` based on class name is now deprecated + +.. _v1.0.4: + 1.0.4 (2011-12-21) ------------------ @@ -201,7 +341,7 @@ In order to upgrade client code, apply the following rules: - Introduce :data:`~factory.MOGO_BUILD` build function - Add support for inheriting from multiple :class:`~factory.Factory` - Base :class:`~factory.Factory` classes can now be declared :attr:`abstract <factory.Factory.ABSTRACT_FACTORY>`. - - Provide :class:`~factory.DjangoModelFactory`, whose :class:`~factory.Sequence` counter starts at the next free database id + - Provide :class:`~factory.django.DjangoModelFactory`, whose :class:`~factory.Sequence` counter starts at the next free database id - Introduce :class:`~factory.SelfAttribute`, a shortcut for ``factory.LazyAttribute(lambda o: o.foo.bar.baz``. *Bugfix:* @@ -210,6 +350,9 @@ In order to upgrade client code, apply the following rules: - Share sequence counter between parent and subclasses - Fix :class:`~factory.SubFactory` / :class:`~factory.Sequence` interferences + +.. _v1.0.2: + 1.0.2 (2011-05-16) ------------------ @@ -217,6 +360,9 @@ In order to upgrade client code, apply the following rules: - Introduce :class:`~factory.SubFactory` + +.. _v1.0.1: + 1.0.1 (2011-05-13) ------------------ @@ -229,6 +375,9 @@ In order to upgrade client code, apply the following rules: - Fix concurrency between :class:`~factory.LazyAttribute` and :class:`~factory.Sequence` + +.. _v1.0.0: + 1.0.0 (2010-08-22) ------------------ diff --git a/docs/conf.py b/docs/conf.py index 0ccaf29..4f76d45 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,16 @@ sys.path.insert(0, os.path.dirname(os.path.abspath('.'))) # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.extlinks', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', +] + +extlinks = { + 'issue': ('https://github.com/rbarrois/factory_boy/issues/%s', 'issue #'), +} # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -237,4 +246,8 @@ intersphinx_mapping = { 'http://docs.djangoproject.com/en/dev/', 'http://docs.djangoproject.com/en/dev/_objects/', ), + 'sqlalchemy': ( + 'http://docs.sqlalchemy.org/en/rel_0_8/', + 'http://docs.sqlalchemy.org/en/rel_0_8/objects.inv', + ), } diff --git a/docs/fuzzy.rst b/docs/fuzzy.rst index f1f4085..b94dfa5 100644 --- a/docs/fuzzy.rst +++ b/docs/fuzzy.rst @@ -24,6 +24,35 @@ FuzzyAttribute The callable that generates random values +FuzzyText +--------- + + +.. class:: FuzzyText(length=12, chars=string.ascii_letters, prefix='') + + The :class:`FuzzyText` fuzzer yields random strings beginning with + the given :attr:`prefix`, followed by :attr:`length` charactes chosen + from the :attr:`chars` character set, + and ending with the given :attr:`suffix`. + + .. attribute:: length + + int, the length of the random part + + .. attribute:: prefix + + text, an optional prefix to prepend to the random part + + .. attribute:: suffix + + text, an optional suffix to append to the random part + + .. attribute:: chars + + char iterable, the chars to choose from; defaults to the list of ascii + letters and numbers. + + FuzzyChoice ----------- @@ -69,6 +98,197 @@ FuzzyInteger int, the inclusive higher bound of generated integers +FuzzyDecimal +------------ + +.. class:: FuzzyDecimal(low[, high]) + + The :class:`FuzzyDecimal` fuzzer generates random integers within a given + inclusive range. + + The :attr:`low` bound may be omitted, in which case it defaults to 0: + + .. code-block:: pycon + + >>> FuzzyDecimal(0.5, 42.7) + >>> fi.low, fi.high + 0.5, 42.7 + + >>> fi = FuzzyDecimal(42.7) + >>> fi.low, fi.high + 0.0, 42.7 + + >>> fi = FuzzyDecimal(0.5, 42.7, 3) + >>> fi.low, fi.high, fi.precision + 0.5, 42.7, 3 + + .. attribute:: low + + decimal, the inclusive lower bound of generated decimals + + .. attribute:: high + + decimal, the inclusive higher bound of generated decimals + + .. attribute:: precision + int, the number of digits to generate after the dot. The default is 2 digits. + + +FuzzyDate +--------- + +.. class:: FuzzyDate(start_date[, end_date]) + + The :class:`FuzzyDate` fuzzer generates random dates within a given + inclusive range. + + The :attr:`end_date` bound may be omitted, in which case it defaults to the current date: + + .. code-block:: pycon + + >>> fd = FuzzyDate(datetime.date(2008, 1, 1)) + >>> fd.start_date, fd.end_date + datetime.date(2008, 1, 1), datetime.date(2013, 4, 16) + + .. attribute:: start_date + + :class:`datetime.date`, the inclusive lower bound of generated dates + + .. attribute:: end_date + + :class:`datetime.date`, the inclusive higher bound of generated dates + + +FuzzyDateTime +------------- + +.. class:: FuzzyDateTime(start_dt[, end_dt], tz=UTC, force_year=None, force_month=None, force_day=None, force_hour=None, force_minute=None, force_second=None, force_microsecond=None) + + The :class:`FuzzyDateTime` fuzzer generates random timezone-aware datetime within a given + inclusive range. + + The :attr:`end_dt` bound may be omitted, in which case it defaults to ``datetime.datetime.now()`` + localized into the UTC timezone. + + .. code-block:: pycon + + >>> fdt = FuzzyDateTime(datetime.datetime(2008, 1, 1, tzinfo=UTC)) + >>> fdt.start_dt, fdt.end_dt + datetime.datetime(2008, 1, 1, tzinfo=UTC), datetime.datetime(2013, 4, 21, 19, 13, 32, 458487, tzinfo=UTC) + + + The ``force_XXX`` keyword arguments force the related value of generated datetimes: + + .. code-block:: pycon + + >>> fdt = FuzzyDateTime(datetime.datetime(2008, 1, 1, tzinfo=UTC), datetime.datetime(2009, 1, 1, tzinfo=UTC), + ... force_day=3, force_second=42) + >>> fdt.evaluate(2, None, False) # Actual code used by ``SomeFactory.build()`` + datetime.datetime(2008, 5, 3, 12, 13, 42, 124848, tzinfo=UTC) + + + .. attribute:: start_dt + + :class:`datetime.datetime`, the inclusive lower bound of generated datetimes + + .. attribute:: end_dt + + :class:`datetime.datetime`, the inclusive upper bound of generated datetimes + + + .. attribute:: force_year + + int or None; if set, forces the :attr:`~datetime.datetime.year` of generated datetime. + + .. attribute:: force_month + + int or None; if set, forces the :attr:`~datetime.datetime.month` of generated datetime. + + .. attribute:: force_day + + int or None; if set, forces the :attr:`~datetime.datetime.day` of generated datetime. + + .. attribute:: force_hour + + int or None; if set, forces the :attr:`~datetime.datetime.hour` of generated datetime. + + .. attribute:: force_minute + + int or None; if set, forces the :attr:`~datetime.datetime.minute` of generated datetime. + + .. attribute:: force_second + + int or None; if set, forces the :attr:`~datetime.datetime.second` of generated datetime. + + .. attribute:: force_microsecond + + int or None; if set, forces the :attr:`~datetime.datetime.microsecond` of generated datetime. + + +FuzzyNaiveDateTime +------------------ + +.. class:: FuzzyNaiveDateTime(start_dt[, end_dt], force_year=None, force_month=None, force_day=None, force_hour=None, force_minute=None, force_second=None, force_microsecond=None) + + The :class:`FuzzyNaiveDateTime` fuzzer generates random naive datetime within a given + inclusive range. + + The :attr:`end_dt` bound may be omitted, in which case it defaults to ``datetime.datetime.now()``: + + .. code-block:: pycon + + >>> fdt = FuzzyNaiveDateTime(datetime.datetime(2008, 1, 1)) + >>> fdt.start_dt, fdt.end_dt + datetime.datetime(2008, 1, 1), datetime.datetime(2013, 4, 21, 19, 13, 32, 458487) + + + The ``force_XXX`` keyword arguments force the related value of generated datetimes: + + .. code-block:: pycon + + >>> fdt = FuzzyNaiveDateTime(datetime.datetime(2008, 1, 1), datetime.datetime(2009, 1, 1), + ... force_day=3, force_second=42) + >>> fdt.evaluate(2, None, False) # Actual code used by ``SomeFactory.build()`` + datetime.datetime(2008, 5, 3, 12, 13, 42, 124848) + + + .. attribute:: start_dt + + :class:`datetime.datetime`, the inclusive lower bound of generated datetimes + + .. attribute:: end_dt + + :class:`datetime.datetime`, the inclusive upper bound of generated datetimes + + + .. attribute:: force_year + + int or None; if set, forces the :attr:`~datetime.datetime.year` of generated datetime. + + .. attribute:: force_month + + int or None; if set, forces the :attr:`~datetime.datetime.month` of generated datetime. + + .. attribute:: force_day + + int or None; if set, forces the :attr:`~datetime.datetime.day` of generated datetime. + + .. attribute:: force_hour + + int or None; if set, forces the :attr:`~datetime.datetime.hour` of generated datetime. + + .. attribute:: force_minute + + int or None; if set, forces the :attr:`~datetime.datetime.minute` of generated datetime. + + .. attribute:: force_second + + int or None; if set, forces the :attr:`~datetime.datetime.second` of generated datetime. + + .. attribute:: force_microsecond + + int or None; if set, forces the :attr:`~datetime.datetime.microsecond` of generated datetime. + Custom fuzzy fields ------------------- diff --git a/docs/introduction.rst b/docs/introduction.rst index 8bbb10c..86e2046 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -99,7 +99,7 @@ This is achieved with the :class:`~factory.Sequence` declaration: >>> UserFactory() <User: user2> -.. note:: For more complex situations, you may also use the :meth:`~factory.@sequence` decorator: +.. note:: For more complex situations, you may also use the :meth:`~factory.@sequence` decorator (note that ``self`` is not added as first parameter): .. code-block:: python @@ -107,7 +107,7 @@ This is achieved with the :class:`~factory.Sequence` declaration: FACTORY_FOR = models.User @factory.sequence - def username(self, n): + def username(n): return 'user%d' % n @@ -140,7 +140,19 @@ taking the object being built and returning the value for the field: <User: user3 (doe@example.com)> -.. note:: As for :class:`~factory.Sequence`, a :meth:`~factory.@lazy_attribute` decorator is available. +.. note:: As for :class:`~factory.Sequence`, a :meth:`~factory.@lazy_attribute` decorator is available: + + +.. code-block:: python + + class UserFactory(factory.Factory): + FACTORY_FOR = models.User + + username = factory.Sequence(lambda n: 'user%d' % n) + + @factory.lazy_attribute + def email(self): + return '%s@example.com' % self.username Inheritance @@ -227,7 +239,7 @@ All factories support two built-in strategies: as for a Django model. Starting from 2.0, :meth:`factory.Factory.create` simply calls ``AssociatedClass(**kwargs)``. - You should use :class:`~factory.DjangoModelFactory` for Django models. + You should use :class:`~factory.django.DjangoModelFactory` for Django models. When a :class:`~factory.Factory` includes related fields (:class:`~factory.SubFactory`, :class:`~factory.RelatedFactory`), @@ -253,6 +265,6 @@ Calling a :class:`~factory.Factory` subclass will provide an object through the <MyClass: X (saved)> -The default strategy can ba changed by setting the class-level :attr:`~factory.Factory.FACTROY_STRATEGY` attribute. +The default strategy can be changed by setting the class-level :attr:`~factory.Factory.FACTORY_STRATEGY` attribute. diff --git a/docs/orms.rst b/docs/orms.rst index 8e5b6f6..e50e706 100644 --- a/docs/orms.rst +++ b/docs/orms.rst @@ -11,6 +11,8 @@ adding dedicated features. Django ------ +.. currentmodule:: factory.django + The first versions of factory_boy were designed specifically for Django, but the library has now evolved to be framework-independant. @@ -24,14 +26,16 @@ All factories for a Django :class:`~django.db.models.Model` should use the :class:`DjangoModelFactory` base class. -.. class:: DjangoModelFactory(Factory) +.. class:: DjangoModelFactory(factory.Factory) Dedicated class for Django :class:`~django.db.models.Model` factories. This class provides the following features: - * :func:`~Factory.create()` uses :meth:`Model.objects.create() <django.db.models.query.QuerySet.create>` - * :func:`~Factory._setup_next_sequence()` selects the next unused primary key value + * The :attr:`~factory.Factory.FACTORY_FOR` attribute also supports the ``'app.Model'`` + syntax + * :func:`~factory.Factory.create()` uses :meth:`Model.objects.create() <django.db.models.query.QuerySet.create>` + * :func:`~factory.Factory._setup_next_sequence()` selects the next unused primary key value * When using :class:`~factory.RelatedFactory` or :class:`~factory.PostGeneration` attributes, the base object will be :meth:`saved <django.db.models.Model.save>` once all post-generation hooks have run. @@ -44,8 +48,8 @@ All factories for a Django :class:`~django.db.models.Model` should use the .. code-block:: python - class UserFactory(factory.DjangoModelFactory): - FACTORY_FOR = models.User + class UserFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = 'myapp.User' # Equivalent to ``FACTORY_FOR = myapp.models.User`` FACTORY_DJANGO_GET_OR_CREATE = ('username',) username = 'john' @@ -68,3 +72,201 @@ All factories for a Django :class:`~django.db.models.Model` should use the <User: jack> >>> User.objects.all() [<User: john>, <User: jack>] + + +.. note:: If a :class:`DjangoModelFactory` relates to an :obj:`~django.db.models.Options.abstract` + model, be sure to declare the :class:`DjangoModelFactory` as abstract: + + .. code-block:: python + + class MyAbstractModelFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = models.MyAbstractModel + ABSTRACT_FACTORY = True + + class MyConcreteModelFactory(MyAbstractModelFactory): + FACTORY_FOR = models.MyConcreteModel + + Otherwise, factory_boy will try to get the 'next PK' counter from the abstract model. + + +.. class:: FileField + + Custom declarations for :class:`django.db.models.FileField` + + .. method:: __init__(self, from_path='', from_file='', data=b'', filename='example.dat') + + :param str from_path: Use data from the file located at ``from_path``, + and keep its filename + :param file from_file: Use the contents of the provided file object; use its filename + if available + :param bytes data: Use the provided bytes as file contents + :param str filename: The filename for the FileField + +.. note:: If the value ``None`` was passed for the :class:`FileField` field, this will + disable field generation: + +.. code-block:: python + + class MyFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = models.MyModel + + the_file = factory.django.FileField(filename='the_file.dat') + +.. code-block:: pycon + + >>> MyFactory(the_file__data=b'uhuh').the_file.read() + b'uhuh' + >>> MyFactory(the_file=None).the_file + None + + +.. class:: ImageField + + Custom declarations for :class:`django.db.models.ImageField` + + .. method:: __init__(self, from_path='', from_file='', filename='example.jpg', width=100, height=100, color='green', format='JPEG') + + :param str from_path: Use data from the file located at ``from_path``, + and keep its filename + :param file from_file: Use the contents of the provided file object; use its filename + if available + :param str filename: The filename for the ImageField + :param int width: The width of the generated image (default: ``100``) + :param int height: The height of the generated image (default: ``100``) + :param str color: The color of the generated image (default: ``'green'``) + :param str format: The image format (as supported by PIL) (default: ``'JPEG'``) + +.. note:: If the value ``None`` was passed for the :class:`FileField` field, this will + disable field generation: + +.. note:: Just as Django's :class:`django.db.models.ImageField` requires the + Python Imaging Library, this :class:`ImageField` requires it too. + +.. code-block:: python + + class MyFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = models.MyModel + + the_image = factory.django.ImageField(color='blue') + +.. code-block:: pycon + + >>> MyFactory(the_image__width=42).the_image.width + 42 + >>> MyFactory(the_image=None).the_image + None + + +Mogo +---- + +.. currentmodule:: factory.mogo + +factory_boy supports `Mogo`_-style models, through the :class:`MogoFactory` class. + +`Mogo`_ is a wrapper around the ``pymongo`` library for MongoDB. + +.. _Mogo: https://github.com/joshmarshall/mogo + +.. class:: MogoFactory(factory.Factory) + + Dedicated class for `Mogo`_ models. + + This class provides the following features: + + * :func:`~factory.Factory.build()` calls a model's ``new()`` method + * :func:`~factory.Factory.create()` builds an instance through ``new()`` then + saves it. + + +MongoEngine +----------- + +.. currentmodule:: factory.mongoengine + +factory_boy supports `MongoEngine`_-style models, through the :class:`MongoEngineFactory` class. + +`mongoengine`_ is a wrapper around the ``pymongo`` library for MongoDB. + +.. _mongoengine: http://mongoengine.org/ + +.. class:: MongoEngineFactory(factory.Factory) + + Dedicated class for `MongoEngine`_ models. + + This class provides the following features: + + * :func:`~factory.Factory.build()` calls a model's ``__init__`` method + * :func:`~factory.Factory.create()` builds an instance through ``__init__`` then + saves it. + + .. note:: If the :attr:`associated class <factory.Factory.FACTORY_FOR>` is a :class:`mongoengine.EmbeddedDocument`, + the :meth:`~MongoEngineFactory.create` function won't "save" it, since this wouldn't make sense. + + This feature makes it possible to use :class:`~factory.SubFactory` to create embedded document. + + +SQLAlchemy +---------- + +.. currentmodule:: factory.alchemy + + +Factoy_boy also supports `SQLAlchemy`_ models through the :class:`SQLAlchemyModelFactory` class. + +To work, this class needs an `SQLAlchemy`_ session object affected to "FACTORY_SESSION" class attribute. + +.. _SQLAlchemy: http://www.sqlalchemy.org/ + +.. class:: SQLAlchemyModelFactory(factory.Factory) + + Dedicated class for `SQLAlchemy`_ models. + + This class provides the following features: + + * :func:`~factory.Factory.create()` uses :meth:`sqlalchemy.orm.session.Session.add` + * :func:`~factory.Factory._setup_next_sequence()` selects the next unused primary key value + + .. attribute:: FACTORY_SESSION + + Fields whose SQLAlchemy session object are passed will be used to communicate with the database + +A (very) simple exemple: + +.. code-block:: python + + from sqlalchemy import Column, Integer, Unicode, create_engine + from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import scoped_session, sessionmaker + + session = scoped_session(sessionmaker()) + engine = create_engine('sqlite://') + session.configure(bind=engine) + Base = declarative_base() + + + class User(Base): + """ A SQLAlchemy simple model class who represents a user """ + __tablename__ = 'UserTable' + + id = Column(Integer(), primary_key=True) + name = Column(Unicode(20)) + + Base.metadata.create_all(engine) + + + class UserFactory(SQLAlchemyModelFactory): + FACTORY_FOR = User + FACTORY_SESSION = session # the SQLAlchemy session object + + id = factory.Sequence(lambda n: n) + name = factory.Sequence(lambda n: u'User %d' % n) + +.. code-block:: pycon + + >>> session.query(User).all() + [] + >>> UserFactory() + <User: User 1> + >>> session.query(User).all() + [<User: User 1>] diff --git a/docs/recipes.rst b/docs/recipes.rst index e226732..c1f3700 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -25,7 +25,7 @@ use the :class:`~factory.SubFactory` declaration: import factory from . import models - class UserFactory(factory.DjangoModelFactory): + class UserFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.User first_name = factory.Sequence(lambda n: "Agent %03d" % n) @@ -52,7 +52,7 @@ use a :class:`~factory.RelatedFactory` declaration: # factories.py - class UserFactory(factory.DjangoModelFactory): + class UserFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.User log = factory.RelatedFactory(UserLogFactory, 'user', action=models.UserLog.ACTION_CREATE) @@ -62,6 +62,65 @@ When a :class:`UserFactory` is instantiated, factory_boy will call ``UserLogFactory(user=that_user, action=...)`` just before returning the created ``User``. +Example: Django's Profile +""""""""""""""""""""""""" + +Django (<1.5) provided a mechanism to attach a ``Profile`` to a ``User`` instance, +using a :class:`~django.db.models.ForeignKey` from the ``Profile`` to the ``User``. + +A typical way to create those profiles was to hook a post-save signal to the ``User`` model. + +factory_boy allows to define attributes of such profiles dynamically when creating a ``User``: + +.. code-block:: python + + class ProfileFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = my_models.Profile + + title = 'Dr' + # We pass in profile=None to prevent UserFactory from creating another profile + # (this disables the RelatedFactory) + user = factory.SubFactory('app.factories.UserFactory', profile=None) + + class UserFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = auth_models.User + + username = factory.Sequence(lambda n: "user_%d" % n) + + # We pass in 'user' to link the generated Profile to our just-generated User + # This will call ProfileFactory(user=our_new_user), thus skipping the SubFactory. + profile = factory.RelatedFactory(ProfileFactory, 'user') + + @classmethod + def _generate(cls, create, attrs): + """Override the default _generate() to disable the post-save signal.""" + + # Note: If the signal was defined with a dispatch_uid, include that in both calls. + post_save.disconnect(handler_create_user_profile, auth_models.User) + user = super(UserFactory, cls)._generate(create, attrs) + post_save.connect(handler_create_user_profile, auth_models.User) + return user + +.. OHAI_VIM:* + + +.. code-block:: pycon + + >>> u = UserFactory(profile__title=u"Lord") + >>> u.get_profile().title + u"Lord" + +Such behaviour can be extended to other situations where a signal interferes with +factory_boy related factories. + +.. note:: When any :class:`~factory.RelatedFactory` or :class:`~factory.post_generation` + attribute is defined on the :class:`~factory.django.DjangoModelFactory` subclass, + a second ``save()`` is performed *after* the call to ``_create()``. + + Code working with signals should thus override the :meth:`~factory.Factory._generate` + method. + + Simple ManyToMany ----------------- @@ -85,12 +144,12 @@ hook: # factories.py - class GroupFactory(factory.DjangoModelFactory): + class GroupFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.Group name = factory.Sequence(lambda n: "Group #%s" % n) - class UserFactory(factory.DjangoModelFactory): + class UserFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.User name = "John Doe" @@ -140,17 +199,17 @@ If more links are needed, simply add more :class:`RelatedFactory` declarations: # factories.py - class UserFactory(factory.DjangoModelFactory): + class UserFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.User name = "John Doe" - class GroupFactory(factory.DjangoModelFactory): + class GroupFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.Group name = "Admins" - class GroupLevelFactory(factory.DjangoModelFactory): + class GroupLevelFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.GroupLevel user = factory.SubFactory(UserFactory) @@ -213,20 +272,20 @@ Here, we want: .. code-block:: python # factories.py - class CountryFactory(factory.DjangoModelFactory): + class CountryFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.Country name = factory.Iterator(["France", "Italy", "Spain"]) lang = factory.Iterator(['fr', 'it', 'es']) - class UserFactory(factory.DjangoModelFactory): + class UserFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.User name = "John" lang = factory.SelfAttribute('country.lang') country = factory.SubFactory(CountryFactory) - class CompanyFactory(factory.DjangoModelFactory): + class CompanyFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.Company name = "ACME, Inc." diff --git a/docs/reference.rst b/docs/reference.rst index 81aa645..53584a0 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -19,15 +19,18 @@ The :class:`Factory` class .. attribute:: FACTORY_FOR - This required attribute describes the class of objects to generate. - It may only be absent if the factory has been marked abstract through - :attr:`ABSTRACT_FACTORY`. + This optional attribute describes the class of objects to generate. + + If unset, it will be inherited from parent :class:`Factory` subclasses. .. attribute:: ABSTRACT_FACTORY This attribute indicates that the :class:`Factory` subclass should not be used to generate objects, but instead provides some extra defaults. + It will be automatically set to ``True`` if neither the :class:`Factory` + subclass nor its parents define the :attr:`~Factory.FACTORY_FOR` attribute. + .. attribute:: FACTORY_ARG_PARAMETERS Some factories require non-keyword arguments to their :meth:`~object.__init__`. @@ -211,7 +214,7 @@ The :class:`Factory` class .. code-block:: python class BaseBackendFactory(factory.Factory): - ABSTRACT_FACTORY = True + ABSTRACT_FACTORY = True # Optional def _create(cls, target_class, *args, **kwargs): obj = target_class(*args, **kwargs) @@ -234,6 +237,48 @@ The :class:`Factory` class values, for instance. + **Advanced functions:** + + + .. classmethod:: reset_sequence(cls, value=None, force=False) + + :arg int value: The value to reset the sequence to + :arg bool force: Whether to force-reset the sequence + + Allows to reset the sequence counter for a :class:`~factory.Factory`. + The new value can be passed in as the ``value`` argument: + + .. code-block:: pycon + + >>> SomeFactory.reset_sequence(4) + >>> SomeFactory._next_sequence + 4 + + Since subclasses of a non-:attr:`abstract <factory.Factory.ABSTRACT_FACTORY>` + :class:`~factory.Factory` share the same sequence counter, special care needs + to be taken when resetting the counter of such a subclass. + + By default, :meth:`reset_sequence` will raise a :exc:`ValueError` when + called on a subclassed :class:`~factory.Factory` subclass. This can be + avoided by passing in the ``force=True`` flag: + + .. code-block:: pycon + + >>> InheritedFactory.reset_sequence() + Traceback (most recent call last): + File "factory_boy/tests/test_base.py", line 179, in test_reset_sequence_subclass_parent + SubTestObjectFactory.reset_sequence() + File "factory_boy/factory/base.py", line 250, in reset_sequence + "Cannot reset the sequence of a factory subclass. " + ValueError: Cannot reset the sequence of a factory subclass. Please call reset_sequence() on the root factory, or call reset_sequence(forward=True). + + >>> InheritedFactory.reset_sequence(force=True) + >>> + + This is equivalent to calling :meth:`reset_sequence` on the base + factory in the chain. + + .. _strategies: Strategies @@ -318,6 +363,38 @@ factory_boy supports two main strategies for generating instances, plus stubs. with a default strategy set to :data:`STUB_STRATEGY`. +.. function:: debug(logger='factory', stream=None) + + :param str logger: The name of the logger to enable debug for + :param file stream: The stream to send debug output to, defaults to :obj:`sys.stderr` + + Context manager to help debugging factory_boy behavior. + It will temporarily put the target logger (e.g ``'factory'``) in debug mode, + sending all output to :obj`~sys.stderr`; + upon leaving the context, the logging levels are reset. + + A typical use case is to understand what happens during a single factory call: + + .. code-block:: python + + with factory.debug(): + obj = TestModel2Factory() + + This will yield messages similar to those (artificial indentation): + + .. code-block:: ini + + BaseFactory: Preparing tests.test_using.TestModel2Factory(extra={}) + LazyStub: Computing values for tests.test_using.TestModel2Factory(two=<OrderedDeclarationWrapper for <factory.declarations.SubFactory object at 0x1e15610>>) + SubFactory: Instantiating tests.test_using.TestModelFactory(__containers=(<LazyStub for tests.test_using.TestModel2Factory>,), one=4), create=True + BaseFactory: Preparing tests.test_using.TestModelFactory(extra={'__containers': (<LazyStub for tests.test_using.TestModel2Factory>,), 'one': 4}) + LazyStub: Computing values for tests.test_using.TestModelFactory(one=4) + LazyStub: Computed values, got tests.test_using.TestModelFactory(one=4) + BaseFactory: Generating tests.test_using.TestModelFactory(one=4) + LazyStub: Computed values, got tests.test_using.TestModel2Factory(two=<tests.test_using.TestModel object at 0x1e15410>) + BaseFactory: Generating tests.test_using.TestModel2Factory(two=<tests.test_using.TestModel object at 0x1e15410>) + + .. _declarations: Declarations @@ -353,6 +430,11 @@ accept the object being built as sole argument, and return a value. 'leo@example.com' +The object passed to :class:`LazyAttribute` is not an instance of the target class, +but instead a :class:`~containers.LazyStub`: a temporary container that computes +the value of all declared fields. + + Decorator ~~~~~~~~~ @@ -586,7 +668,7 @@ handles more complex cases: SubFactory """""""""" -.. class:: SubFactory(sub_factory, **kwargs) +.. class:: SubFactory(factory, **kwargs) .. OHAI_VIM** @@ -601,6 +683,20 @@ The :class:`SubFactory` attribute should be called with: factory +.. note:: + + When passing an actual :class:`~factory.Factory` for the + :attr:`~factory.SubFactory.factory` argument, make sure to pass + the class and not instance (i.e no ``()`` after the class): + + .. code-block:: python + + class FooFactory(factory.Factory): + FACTORY_FOR = Foo + + bar = factory.SubFactory(BarFactory) # Not BarFactory() + + Definition ~~~~~~~~~~ @@ -786,6 +882,19 @@ Obviously, this "follow parents" hability also handles overriding some attribute 'cn' +This feature is also available to :class:`LazyAttribute` and :class:`LazyAttributeSequence`, +through the :attr:`~containers.LazyStub.factory_parent` attribute of the passed-in object: + +.. code-block:: python + + class CompanyFactory(factory.Factory): + FACTORY_FOR = Company + country = factory.SubFactory(CountryFactory) + owner = factory.SubFactory(UserFactory, + language=factory.LazyAttribute(lambda user: user.factory_parent.country.language), + ) + + Iterator """""""" @@ -810,6 +919,14 @@ Iterator .. versionadded:: 1.3.0 + .. method:: reset() + + Reset the internal iterator used by the attribute, so that the next value + will be the first value generated by the iterator. + + May be called several times. + + Each call to the factory will receive the next value from the iterable: .. code-block:: python @@ -879,6 +996,24 @@ use the :func:`iterator` decorator: yield line +Resetting +~~~~~~~~~ + +In order to start back at the first value in an :class:`Iterator`, +simply call the :meth:`~Iterator.reset` method of that attribute +(accessing it from the bare :class:`~Factory` subclass): + +.. code-block:: pycon + + >>> UserFactory().lang + 'en' + >>> UserFactory().lang + 'fr' + >>> UserFactory.lang.reset() + >>> UserFactory().lang + 'en' + + Dict and List """"""""""""" @@ -1013,7 +1148,7 @@ as keyword arguments; ``{'post_x': 2}`` will be passed to ``SomeFactory.FACTORY_ RelatedFactory """""""""""""" -.. class:: RelatedFactory(factory, name='', **kwargs) +.. class:: RelatedFactory(factory, factory_related_name='', **kwargs) .. OHAI_VIM** @@ -1033,13 +1168,27 @@ RelatedFactory .. attribute:: name The generated object (where the :class:`RelatedFactory` attribute will - set) may be passed to the related factory if the :attr:`name` parameter + set) may be passed to the related factory if the :attr:`factory_related_name` parameter is set. It will be passed as a keyword argument, using the :attr:`name` value as keyword: +.. note:: + + When passing an actual :class:`~factory.Factory` for the + :attr:`~factory.RelatedFactory.factory` argument, make sure to pass + the class and not instance (i.e no ``()`` after the class): + + .. code-block:: python + + class FooFactory(factory.Factory): + FACTORY_FOR = Foo + + bar = factory.RelatedFactory(BarFactory) # Not BarFactory() + + .. code-block:: python class CityFactory(factory.Factory): @@ -1069,6 +1218,22 @@ Extra kwargs may be passed to the related factory, through the usual ``ATTR__SUB >>> City.objects.get(capital_of=england) <City: London> +If a value if passed for the :class:`RelatedFactory` attribute, this disables +:class:`RelatedFactory` generation: + +.. code-block:: pycon + + >>> france = CountryFactory() + >>> paris = City.objects.get() + >>> paris + <City: Paris> + >>> reunion = CountryFactory(capital_city=paris) + >>> City.objects.count() # No new capital_city generated + 1 + >>> guyane = CountryFactory(capital_city=paris, capital_city__name='Kourou') + >>> City.objects.count() # No new capital_city generated, ``name`` ignored. + 1 + PostGeneration """""""""""""" @@ -1224,7 +1389,7 @@ factory during instantiation. .. code-block:: python - class UserFactory(factory.DjangoModelFactory): + class UserFactory(factory.django.DjangoModelFactory): FACTORY_FOR = User username = 'user' @@ -1315,12 +1480,12 @@ Lightweight factory declaration UserFactory = make_factory(models.User, login='john', email=factory.LazyAttribute(lambda u: '%s@example.com' % u.login), - FACTORY_CLASS=factory.DjangoModelFactory, + FACTORY_CLASS=factory.django.DjangoModelFactory, ) # This is equivalent to: - class UserFactory(factory.DjangoModelFactory): + class UserFactory(factory.django.DjangoModelFactory): FACTORY_FOR = models.User login = 'john' diff --git a/factory/__init__.py b/factory/__init__.py index e1138fa..aa550e8 100644 --- a/factory/__init__.py +++ b/factory/__init__.py @@ -20,18 +20,17 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -__version__ = '2.0.2' +__version__ = '2.3.1' __author__ = 'Raphaël Barrois <raphael.barrois+fboy@polytechnique.org>' + from .base import ( Factory, BaseDictFactory, DictFactory, BaseListFactory, ListFactory, - MogoFactory, StubFactory, - DjangoModelFactory, BUILD_STRATEGY, CREATE_STRATEGY, @@ -39,6 +38,10 @@ from .base import ( use_strategy, ) +# Backward compatibility; this should be removed soon. +from .mogo import MogoFactory +from .django import DjangoModelFactory + from .declarations import ( LazyAttribute, Iterator, @@ -55,6 +58,8 @@ from .declarations import ( ) from .helpers import ( + debug, + build, create, stub, diff --git a/factory/alchemy.py b/factory/alchemy.py new file mode 100644 index 0000000..cec15c9 --- /dev/null +++ b/factory/alchemy.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013 Romain Commandé +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from __future__ import unicode_literals +from sqlalchemy.sql.functions import max + +from . import base + + +class SQLAlchemyModelFactory(base.Factory): + """Factory for SQLAlchemy models. """ + + ABSTRACT_FACTORY = True + FACTORY_SESSION = None + + @classmethod + def _setup_next_sequence(cls, *args, **kwargs): + """Compute the next available PK, based on the 'pk' database field.""" + session = cls.FACTORY_SESSION + model = cls.FACTORY_FOR + pk = getattr(model, model.__mapper__.primary_key[0].name) + max_pk = session.query(max(pk)).one()[0] + if isinstance(max_pk, int): + return max_pk + 1 if max_pk else 1 + else: + return 1 + + @classmethod + def _create(cls, target_class, *args, **kwargs): + """Create an instance of the model, and save it to the database.""" + session = cls.FACTORY_SESSION + obj = target_class(*args, **kwargs) + session.add(obj) + return obj diff --git a/factory/base.py b/factory/base.py index 2ff2944..3c6571c 100644 --- a/factory/base.py +++ b/factory/base.py @@ -20,7 +20,12 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +import logging + from . import containers +from . import utils + +logger = logging.getLogger('factory.generate') # Strategies BUILD_STRATEGY = 'build' @@ -35,6 +40,7 @@ FACTORY_CLASS_DECLARATION = 'FACTORY_FOR' CLASS_ATTRIBUTE_DECLARATIONS = '_declarations' CLASS_ATTRIBUTE_POSTGEN_DECLARATIONS = '_postgen_declarations' CLASS_ATTRIBUTE_ASSOCIATED_CLASS = '_associated_class' +CLASS_ATTRIBUTE_IS_ABSTRACT = '_abstract_factory' class FactoryError(Exception): @@ -96,10 +102,6 @@ class FactoryMetaClass(type): Returns: class: the class to associate with this factory - - Raises: - AssociatedClassError: If we were unable to associate this factory - to a class. """ if FACTORY_CLASS_DECLARATION in attrs: return attrs[FACTORY_CLASS_DECLARATION] @@ -109,10 +111,8 @@ class FactoryMetaClass(type): if inherited is not None: return inherited - raise AssociatedClassError( - "Could not determine the class associated with %s. " - "Use the FACTORY_FOR attribute to specify an associated class." % - class_name) + # Nothing found, return None. + return None @classmethod def _extract_declarations(mcs, bases, attributes): @@ -150,7 +150,7 @@ class FactoryMetaClass(type): return attributes - def __new__(mcs, class_name, bases, attrs, extra_attrs=None): + def __new__(mcs, class_name, bases, attrs): """Record attributes as a pattern for later instance construction. This is called when a new Factory subclass is defined; it will collect @@ -161,9 +161,6 @@ class FactoryMetaClass(type): bases (list of class): the parents of the class being created attrs (str => obj dict): the attributes as defined in the class definition - extra_attrs (str => obj dict): extra attributes that should not be - included in the factory defaults, even if public. This - argument is only provided by extensions of this metaclass. Returns: A new class @@ -173,44 +170,77 @@ class FactoryMetaClass(type): return super(FactoryMetaClass, mcs).__new__( mcs, class_name, bases, attrs) - is_abstract = attrs.pop('ABSTRACT_FACTORY', False) extra_attrs = {} - if not is_abstract: + is_abstract = attrs.pop('ABSTRACT_FACTORY', False) + + base = parent_factories[0] + inherited_associated_class = base._get_target_class() + associated_class = mcs._discover_associated_class(class_name, attrs, + inherited_associated_class) - base = parent_factories[0] + # Invoke 'lazy-loading' hooks. + associated_class = base._load_target_class(associated_class) - inherited_associated_class = getattr(base, - CLASS_ATTRIBUTE_ASSOCIATED_CLASS, None) - associated_class = mcs._discover_associated_class(class_name, attrs, - inherited_associated_class) + if associated_class is None: + is_abstract = True + else: # If inheriting the factory from a parent, keep a link to it. # This allows to use the sequence counters from the parents. - if associated_class == inherited_associated_class: + if (inherited_associated_class is not None + and issubclass(associated_class, inherited_associated_class)): attrs['_base_factory'] = base # The CLASS_ATTRIBUTE_ASSOCIATED_CLASS must *not* be taken into # account when parsing the declared attributes of the new class. - extra_attrs = {CLASS_ATTRIBUTE_ASSOCIATED_CLASS: associated_class} + extra_attrs[CLASS_ATTRIBUTE_ASSOCIATED_CLASS] = associated_class + + extra_attrs[CLASS_ATTRIBUTE_IS_ABSTRACT] = is_abstract # Extract pre- and post-generation declarations attributes = mcs._extract_declarations(parent_factories, attrs) - - # Add extra args if provided. - if extra_attrs: - attributes.update(extra_attrs) + attributes.update(extra_attrs) return super(FactoryMetaClass, mcs).__new__( mcs, class_name, bases, attributes) def __str__(cls): - return '<%s for %s>' % (cls.__name__, - getattr(cls, CLASS_ATTRIBUTE_ASSOCIATED_CLASS).__name__) + if cls._abstract_factory: + return '<%s (abstract)>' + else: + return '<%s for %s>' % (cls.__name__, + getattr(cls, CLASS_ATTRIBUTE_ASSOCIATED_CLASS).__name__) # Factory base classes + +class _Counter(object): + """Simple, naive counter. + + Attributes: + for_class (obj): the class this counter related to + seq (int): the next value + """ + + def __init__(self, seq, for_class): + self.seq = seq + self.for_class = for_class + + def next(self): + value = self.seq + self.seq += 1 + return value + + def reset(self, next_value=0): + self.seq = next_value + + def __repr__(self): + return '<_Counter for %s.%s, next=%d>' % ( + self.for_class.__module__, self.for_class.__name__, self.seq) + + class BaseFactory(object): """Factory base support for sequences, attributes and stubs.""" @@ -223,16 +253,19 @@ class BaseFactory(object): raise FactoryError('You cannot instantiate BaseFactory') # ID to use for the next 'declarations.Sequence' attribute. - _next_sequence = None + _counter = None # Base factory, if this class was inherited from another factory. This is - # used for sharing the _next_sequence counter among factories for the same + # used for sharing the sequence _counter among factories for the same # class. _base_factory = None # Holds the target class, once resolved. _associated_class = None + # Whether this factory is considered "abstract", thus uncallable. + _abstract_factory = False + # List of arguments that should be passed as *args instead of **kwargs FACTORY_ARG_PARAMETERS = () @@ -240,6 +273,31 @@ class BaseFactory(object): FACTORY_HIDDEN_ARGS = () @classmethod + def reset_sequence(cls, value=None, force=False): + """Reset the sequence counter. + + Args: + value (int or None): the new 'next' sequence value; if None, + recompute the next value from _setup_next_sequence(). + force (bool): whether to force-reset parent sequence counters + in a factory inheritance chain. + """ + if cls._base_factory: + if force: + cls._base_factory.reset_sequence(value=value) + else: + raise ValueError( + "Cannot reset the sequence of a factory subclass. " + "Please call reset_sequence() on the root factory, " + "or call reset_sequence(force=True)." + ) + else: + cls._setup_counter() + if value is None: + value = cls._setup_next_sequence() + cls._counter.reset(value) + + @classmethod def _setup_next_sequence(cls): """Set up an initial sequence value for Sequence attributes. @@ -249,6 +307,19 @@ class BaseFactory(object): return 0 @classmethod + def _setup_counter(cls): + """Ensures cls._counter is set for this class. + + Due to the way inheritance works in Python, we need to ensure that the + ``_counter`` attribute has been initialized for *this* Factory subclass, + not one of its parents. + """ + if cls._counter is None or cls._counter.for_class != cls: + first_seq = cls._setup_next_sequence() + cls._counter = _Counter(for_class=cls, seq=first_seq) + logger.debug("%r: Setting up next sequence (%d)", cls, first_seq) + + @classmethod def _generate_next_sequence(cls): """Retrieve a new sequence ID. @@ -259,17 +330,15 @@ class BaseFactory(object): """ # Rely upon our parents - if cls._base_factory: + if cls._base_factory and not cls._base_factory._abstract_factory: + logger.debug("%r: reusing sequence from %r", cls, cls._base_factory) return cls._base_factory._generate_next_sequence() - # Make sure _next_sequence is initialized - if cls._next_sequence is None: - cls._next_sequence = cls._setup_next_sequence() + # Make sure _counter is initialized + cls._setup_counter() # Pick current value, then increase class counter for the next call. - next_sequence = cls._next_sequence - cls._next_sequence += 1 - return next_sequence + return cls._counter.next() @classmethod def attributes(cls, create=False, extra=None): @@ -285,7 +354,13 @@ class BaseFactory(object): force_sequence = None if extra: force_sequence = extra.pop('__sequence', None) - return containers.AttributeBuilder(cls, extra).build( + log_ctx = '%s.%s' % (cls.__module__, cls.__name__) + logger.debug('BaseFactory: Preparing %s.%s(extra=%r)', + cls.__module__, + cls.__name__, + extra, + ) + return containers.AttributeBuilder(cls, extra, log_ctx=log_ctx).build( create=create, force_sequence=force_sequence, ) @@ -306,6 +381,21 @@ class BaseFactory(object): return kwargs @classmethod + def _load_target_class(cls, class_definition): + """Extension point for loading target classes. + + This can be overridden in framework-specific subclasses to hook into + existing model repositories, for instance. + """ + return class_definition + + @classmethod + def _get_target_class(cls): + """Retrieve the actual, associated target class.""" + definition = getattr(cls, CLASS_ATTRIBUTE_ASSOCIATED_CLASS, None) + return cls._load_target_class(definition) + + @classmethod def _prepare(cls, create, **kwargs): """Prepare an object for this factory. @@ -313,7 +403,7 @@ class BaseFactory(object): create: bool, whether to create or to build the object **kwargs: arguments to pass to the creation function """ - target_class = getattr(cls, CLASS_ATTRIBUTE_ASSOCIATED_CLASS) + target_class = cls._get_target_class() kwargs = cls._adjust_kwargs(**kwargs) # Remove 'hidden' arguments. @@ -323,6 +413,11 @@ class BaseFactory(object): # Extract *args from **kwargs args = tuple(kwargs.pop(key) for key in cls.FACTORY_ARG_PARAMETERS) + logger.debug('BaseFactory: Generating %s.%s(%s)', + cls.__module__, + cls.__name__, + utils.log_pprint(args, kwargs), + ) if create: return cls._create(target_class, *args, **kwargs) else: @@ -336,6 +431,12 @@ class BaseFactory(object): create (bool): whether to 'build' or 'create' the object attrs (dict): attributes to use for generating the object """ + if cls._abstract_factory: + raise FactoryError( + "Cannot generate instances of abstract factory %(f)s; " + "Ensure %(f)s.FACTORY_FOR is set and %(f)s.ABSTRACT_FACTORY " + "is either not set or False." % dict(f=cls)) + # Extract declarations used for post-generation postgen_declarations = getattr(cls, CLASS_ATTRIBUTE_POSTGEN_DECLARATIONS) @@ -349,9 +450,8 @@ class BaseFactory(object): # Handle post-generation attributes results = {} for name, decl in sorted(postgen_declarations.items()): - extracted, extracted_kwargs = postgen_attributes[name] - results[name] = decl.call(obj, create, extracted, - **extracted_kwargs) + extraction_context = postgen_attributes[name] + results[name] = decl.call(obj, create, extraction_context) cls._after_postgeneration(obj, create, results) @@ -533,7 +633,7 @@ Factory = FactoryMetaClass('Factory', (BaseFactory,), { This class has the ability to support multiple ORMs by using custom creation functions. """, - }) +}) # Backwards compatibility @@ -554,83 +654,6 @@ class StubFactory(Factory): raise UnsupportedStrategy() -class DjangoModelFactory(Factory): - """Factory for Django models. - - This makes sure that the 'sequence' field of created objects is a new id. - - Possible improvement: define a new 'attribute' type, AutoField, which would - handle those for non-numerical primary keys. - """ - - ABSTRACT_FACTORY = True - FACTORY_DJANGO_GET_OR_CREATE = () - - @classmethod - def _get_manager(cls, target_class): - try: - return target_class._default_manager # pylint: disable=W0212 - except AttributeError: - return target_class.objects - - @classmethod - def _setup_next_sequence(cls): - """Compute the next available PK, based on the 'pk' database field.""" - - model = cls._associated_class # pylint: disable=E1101 - manager = cls._get_manager(model) - - try: - return 1 + manager.values_list('pk', flat=True - ).order_by('-pk')[0] - except IndexError: - return 1 - - @classmethod - def _get_or_create(cls, target_class, *args, **kwargs): - """Create an instance of the model through objects.get_or_create.""" - manager = cls._get_manager(target_class) - - assert 'defaults' not in cls.FACTORY_DJANGO_GET_OR_CREATE, ( - "'defaults' is a reserved keyword for get_or_create " - "(in %s.FACTORY_DJANGO_GET_OR_CREATE=%r)" - % (cls, cls.FACTORY_DJANGO_GET_OR_CREATE)) - - key_fields = {} - for field in cls.FACTORY_DJANGO_GET_OR_CREATE: - key_fields[field] = kwargs.pop(field) - key_fields['defaults'] = kwargs - - obj, _created = manager.get_or_create(*args, **key_fields) - return obj - - @classmethod - def _create(cls, target_class, *args, **kwargs): - """Create an instance of the model, and save it to the database.""" - manager = cls._get_manager(target_class) - - if cls.FACTORY_DJANGO_GET_OR_CREATE: - return cls._get_or_create(target_class, *args, **kwargs) - - return manager.create(*args, **kwargs) - - @classmethod - def _after_postgeneration(cls, obj, create, results=None): - """Save again the instance if creating and at least one hook ran.""" - if create and results: - # Some post-generation hooks ran, and may have modified us. - obj.save() - - -class MogoFactory(Factory): - """Factory for mogo objects.""" - ABSTRACT_FACTORY = True - - @classmethod - def _build(cls, target_class, *args, **kwargs): - return target_class.new(*args, **kwargs) - - class BaseDictFactory(Factory): """Factory for dictionary-like classes.""" ABSTRACT_FACTORY = True diff --git a/factory/compat.py b/factory/compat.py index 84f31b7..7747b1a 100644 --- a/factory/compat.py +++ b/factory/compat.py @@ -23,13 +23,56 @@ """Compatibility tools""" +import datetime +import decimal import sys PY2 = (sys.version_info[0] == 2) -if PY2: +if PY2: # pragma: no cover def is_string(obj): return isinstance(obj, (str, unicode)) -else: + + from StringIO import StringIO as BytesIO + +else: # pragma: no cover def is_string(obj): return isinstance(obj, str) + + from io import BytesIO + + +if sys.version_info[:2] == (2, 6): # pragma: no cover + def float_to_decimal(fl): + return decimal.Decimal(str(fl)) +else: # pragma: no cover + def float_to_decimal(fl): + return decimal.Decimal(fl) + + +try: # pragma: no cover + # Python >= 3.2 + UTC = datetime.timezone.utc +except AttributeError: # pragma: no cover + try: + # Fallback to pytz + from pytz import UTC + except ImportError: + + # Ok, let's write our own. + class _UTC(datetime.tzinfo): + """The UTC tzinfo.""" + + def utcoffset(self, dt): + return datetime.timedelta(0) + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return datetime.timedelta(0) + + def localize(self, dt): + dt.astimezone(self) + + UTC = _UTC() diff --git a/factory/containers.py b/factory/containers.py index ee2ad82..4537e44 100644 --- a/factory/containers.py +++ b/factory/containers.py @@ -20,6 +20,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +import logging + +logger = logging.getLogger(__name__) from . import declarations from . import utils @@ -49,16 +52,18 @@ class LazyStub(object): __initialized = False - def __init__(self, attrs, containers=(), target_class=object): + def __init__(self, attrs, containers=(), target_class=object, log_ctx=None): self.__attrs = attrs self.__values = {} self.__pending = [] self.__containers = containers self.__target_class = target_class + self.__log_ctx = log_ctx or '%s.%s' % (target_class.__module__, target_class.__name__) + self.factory_parent = containers[0] if containers else None self.__initialized = True def __repr__(self): - return '<LazyStub for %s>' % self.__target_class.__name__ + return '<LazyStub for %s.%s>' % (self.__target_class.__module__, self.__target_class.__name__) def __str__(self): return '<LazyStub for %s with %s>' % ( @@ -71,8 +76,14 @@ class LazyStub(object): dict: map of attribute name => computed value """ res = {} + logger.debug("LazyStub: Computing values for %s(%s)", + self.__log_ctx, utils.log_pprint(kwargs=self.__attrs), + ) for attr in self.__attrs: res[attr] = getattr(self, attr) + logger.debug("LazyStub: Computed values, got %s(%s)", + self.__log_ctx, utils.log_pprint(kwargs=res), + ) return res def __getattr__(self, name): @@ -92,7 +103,8 @@ class LazyStub(object): if isinstance(val, LazyValue): self.__pending.append(name) val = val.evaluate(self, self.__containers) - assert name == self.__pending.pop() + last = self.__pending.pop() + assert name == last self.__values[name] = val return val else: @@ -218,8 +230,8 @@ class AttributeBuilder(object): overridden default values for the related SubFactory. """ - def __init__(self, factory, extra=None, *args, **kwargs): - super(AttributeBuilder, self).__init__(*args, **kwargs) + def __init__(self, factory, extra=None, log_ctx=None, **kwargs): + super(AttributeBuilder, self).__init__(**kwargs) if not extra: extra = {} @@ -227,13 +239,15 @@ class AttributeBuilder(object): self.factory = factory self._containers = extra.pop('__containers', ()) self._attrs = factory.declarations(extra) + self._log_ctx = log_ctx + initial_declarations = factory.declarations({}) attrs_with_subfields = [ - k for k, v in self._attrs.items() + k for k, v in initial_declarations.items() if self.has_subfields(v)] self._subfields = utils.multi_extract_dict( - attrs_with_subfields, self._attrs) + attrs_with_subfields, self._attrs) def has_subfields(self, value): return isinstance(value, declarations.ParameteredAttribute) @@ -258,14 +272,14 @@ class AttributeBuilder(object): for k, v in self._attrs.items(): if isinstance(v, declarations.OrderedDeclaration): v = OrderedDeclarationWrapper(v, - sequence=sequence, - create=create, - extra=self._subfields.get(k, {}), + sequence=sequence, + create=create, + extra=self._subfields.get(k, {}), ) wrapped_attrs[k] = v stub = LazyStub(wrapped_attrs, containers=self._containers, - target_class=self.factory) + target_class=self.factory, log_ctx=self._log_ctx) return stub.__fill__() diff --git a/factory/declarations.py b/factory/declarations.py index 974b4ac..037a679 100644 --- a/factory/declarations.py +++ b/factory/declarations.py @@ -22,11 +22,16 @@ import itertools +import warnings +import logging from . import compat from . import utils +logger = logging.getLogger('factory.generate') + + class OrderedDeclaration(object): """A factory declaration. @@ -66,6 +71,7 @@ class LazyAttribute(OrderedDeclaration): self.function = function def evaluate(self, sequence, obj, create, extra=None, containers=()): + logger.debug("LazyAttribute: Evaluating %r on %r", self.function, obj) return self.function(obj) @@ -130,6 +136,8 @@ class SelfAttribute(OrderedDeclaration): target = containers[self.depth - 2] else: target = obj + + logger.debug("SelfAttribute: Picking attribute %r on %r", self.attribute_name, target) return deepgetattr(target, self.attribute_name, self.default) def __repr__(self): @@ -155,16 +163,20 @@ class Iterator(OrderedDeclaration): self.getter = getter if cycle: - self.iterator = itertools.cycle(iterator) - else: - self.iterator = iter(iterator) + iterator = itertools.cycle(iterator) + self.iterator = utils.ResetableIterator(iterator) def evaluate(self, sequence, obj, create, extra=None, containers=()): - value = next(self.iterator) + logger.debug("Iterator: Fetching next value from %r", self.iterator) + value = next(iter(self.iterator)) if self.getter is None: return value return self.getter(value) + def reset(self): + """Reset the internal iterator.""" + self.iterator.reset() + class Sequence(OrderedDeclaration): """Specific OrderedDeclaration to use for 'sequenced' fields. @@ -183,6 +195,7 @@ class Sequence(OrderedDeclaration): self.type = type def evaluate(self, sequence, obj, create, extra=None, containers=()): + logger.debug("Sequence: Computing next value of %r for seq=%d", self.function, sequence) return self.function(self.type(sequence)) @@ -196,6 +209,8 @@ class LazyAttributeSequence(Sequence): of counter for the 'function' attribute. """ def evaluate(self, sequence, obj, create, extra=None, containers=()): + logger.debug("LazyAttributeSequence: Computing next value of %r for seq=%d, obj=%r", + self.function, sequence, obj) return self.function(obj, self.type(sequence)) @@ -300,6 +315,40 @@ class ParameteredAttribute(OrderedDeclaration): raise NotImplementedError() +class _FactoryWrapper(object): + """Handle a 'factory' arg. + + Such args can be either a Factory subclass, or a fully qualified import + path for that subclass (e.g 'myapp.factories.MyFactory'). + """ + def __init__(self, factory_or_path): + self.factory = None + self.module = self.name = '' + if isinstance(factory_or_path, type): + self.factory = factory_or_path + else: + if not (compat.is_string(factory_or_path) and '.' in factory_or_path): + raise ValueError( + "A factory= argument must receive either a class " + "or the fully qualified path to a Factory subclass; got " + "%r instead." % factory_or_path) + self.module, self.name = factory_or_path.rsplit('.', 1) + + def get(self): + if self.factory is None: + self.factory = utils.import_object( + self.module, + self.name, + ) + return self.factory + + def __repr__(self): + if self.factory is None: + return '<_FactoryImport: %s.%s>' % (self.module, self.name) + else: + return '<_FactoryImport: %s>' % self.factory.__class__ + + class SubFactory(ParameteredAttribute): """Base class for attributes based upon a sub-factory. @@ -313,26 +362,11 @@ class SubFactory(ParameteredAttribute): def __init__(self, factory, **kwargs): super(SubFactory, self).__init__(**kwargs) - if isinstance(factory, type): - self.factory = factory - self.factory_module = self.factory_name = '' - else: - # Must be a string - if not (compat.is_string(factory) and '.' in factory): - raise ValueError( - "The argument of a SubFactory must be either a class " - "or the fully qualified path to a Factory class; got " - "%r instead." % factory) - self.factory = None - self.factory_module, self.factory_name = factory.rsplit('.', 1) + self.factory_wrapper = _FactoryWrapper(factory) def get_factory(self): """Retrieve the wrapped factory.Factory subclass.""" - if self.factory is None: - # Must be a module path - self.factory = utils.import_object( - self.factory_module, self.factory_name) - return self.factory + return self.factory_wrapper.get() def generate(self, sequence, obj, create, params): """Evaluate the current definition and fill its attributes. @@ -344,6 +378,11 @@ class SubFactory(ParameteredAttribute): override the wrapped factory's defaults """ subfactory = self.get_factory() + logger.debug("SubFactory: Instantiating %s.%s(%s), create=%r", + subfactory.__module__, subfactory.__name__, + utils.log_pprint(kwargs=params), + create, + ) return subfactory.simple_generate(create, **params) @@ -355,6 +394,7 @@ class Dict(SubFactory): def generate(self, sequence, obj, create, params): dict_factory = self.get_factory() + logger.debug("Dict: Building dict(%s)", utils.log_pprint(kwargs=params)) return dict_factory.simple_generate(create, __sequence=sequence, **params) @@ -369,11 +409,30 @@ class List(SubFactory): def generate(self, sequence, obj, create, params): list_factory = self.get_factory() + logger.debug('List: Building list(%s)', + utils.log_pprint(args=[v for _i, v in sorted(params.items())]), + ) return list_factory.simple_generate(create, __sequence=sequence, **params) +class ExtractionContext(object): + """Private class holding all required context from extraction to postgen.""" + def __init__(self, value=None, did_extract=False, extra=None, for_field=''): + self.value = value + self.did_extract = did_extract + self.extra = extra or {} + self.for_field = for_field + + def __repr__(self): + return 'ExtractionContext(%r, %r, %r)' % ( + self.value, + self.did_extract, + self.extra, + ) + + class PostGenerationDeclaration(object): """Declarations to be called once the target object has been generated.""" @@ -390,20 +449,24 @@ class PostGenerationDeclaration(object): (object, dict): a tuple containing the attribute at 'name' (if provided) and a dict of extracted attributes """ - extracted = attrs.pop(name, None) + try: + extracted = attrs.pop(name) + did_extract = True + except KeyError: + extracted = None + did_extract = False + kwargs = utils.extract_dict(name, attrs) - return extracted, kwargs + return ExtractionContext(extracted, did_extract, kwargs, name) - def call(self, obj, create, extracted=None, **kwargs): # pragma: no cover + def call(self, obj, create, extraction_context): # pragma: no cover """Call this hook; no return value is expected. Args: obj (object): the newly generated object create (bool): whether the object was 'built' or 'created' - extracted (object): the value given for <name> in the - object definition, or None if not provided. - kwargs (dict): declarations extracted from the object - definition for this hook + extraction_context: An ExtractionContext containing values + extracted from the containing factory's declaration """ raise NotImplementedError() @@ -414,8 +477,17 @@ class PostGeneration(PostGenerationDeclaration): super(PostGeneration, self).__init__() self.function = function - def call(self, obj, create, extracted=None, **kwargs): - return self.function(obj, create, extracted, **kwargs) + def call(self, obj, create, extraction_context): + logger.debug('PostGeneration: Calling %s.%s(%s)', + self.function.__module__, + self.function.__name__, + utils.log_pprint( + (obj, create, extraction_context.value), + extraction_context.extra, + ), + ) + return self.function(obj, create, + extraction_context.value, **extraction_context.extra) class RelatedFactory(PostGenerationDeclaration): @@ -428,40 +500,48 @@ class RelatedFactory(PostGenerationDeclaration): calling the related factory """ - def __init__(self, factory, name='', **defaults): + def __init__(self, factory, factory_related_name='', **defaults): super(RelatedFactory, self).__init__() - self.name = name + if factory_related_name == '' and defaults.get('name') is not None: + warnings.warn( + "Usage of RelatedFactory(SomeFactory, name='foo') is deprecated" + " and will be removed in the future. Please use the" + " RelatedFactory(SomeFactory, 'foo') or" + " RelatedFactory(SomeFactory, factory_related_name='foo')" + " syntax instead", PendingDeprecationWarning, 2) + factory_related_name = defaults.pop('name') + + self.name = factory_related_name self.defaults = defaults - - if isinstance(factory, type): - self.factory = factory - self.factory_module = self.factory_name = '' - else: - # Must be a string - if not (compat.is_string(factory) and '.' in factory): - raise ValueError( - "The argument of a SubFactory must be either a class " - "or the fully qualified path to a Factory class; got " - "%r instead." % factory) - self.factory = None - self.factory_module, self.factory_name = factory.rsplit('.', 1) + self.factory_wrapper = _FactoryWrapper(factory) def get_factory(self): """Retrieve the wrapped factory.Factory subclass.""" - if self.factory is None: - # Must be a module path - self.factory = utils.import_object( - self.factory_module, self.factory_name) - return self.factory + return self.factory_wrapper.get() + + def call(self, obj, create, extraction_context): + factory = self.get_factory() + + if extraction_context.did_extract: + # The user passed in a custom value + logger.debug('RelatedFactory: Using provided %r instead of ' + 'generating %s.%s.', + extraction_context.value, + factory.__module__, factory.__name__, + ) + return extraction_context.value - def call(self, obj, create, extracted=None, **kwargs): passed_kwargs = dict(self.defaults) - passed_kwargs.update(kwargs) + passed_kwargs.update(extraction_context.extra) if self.name: passed_kwargs[self.name] = obj - factory = self.get_factory() - factory.simple_generate(create, **passed_kwargs) + logger.debug('RelatedFactory: Generating %s.%s(%s)', + factory.__module__, + factory.__name__, + utils.log_pprint((create,), passed_kwargs), + ) + return factory.simple_generate(create, **passed_kwargs) class PostGenerationMethodCall(PostGenerationDeclaration): @@ -483,17 +563,22 @@ class PostGenerationMethodCall(PostGenerationDeclaration): self.method_args = args self.method_kwargs = kwargs - def call(self, obj, create, extracted=None, **kwargs): - if extracted is None: + def call(self, obj, create, extraction_context): + if not extraction_context.did_extract: passed_args = self.method_args elif len(self.method_args) <= 1: # Max one argument expected - passed_args = (extracted,) + passed_args = (extraction_context.value,) else: - passed_args = tuple(extracted) + passed_args = tuple(extraction_context.value) passed_kwargs = dict(self.method_kwargs) - passed_kwargs.update(kwargs) + passed_kwargs.update(extraction_context.extra) method = getattr(obj, self.method_name) - method(*passed_args, **passed_kwargs) + logger.debug('PostGenerationMethodCall: Calling %r.%s(%s)', + obj, + self.method_name, + utils.log_pprint(passed_args, passed_kwargs), + ) + return method(*passed_args, **passed_kwargs) diff --git a/factory/django.py b/factory/django.py new file mode 100644 index 0000000..fee8e52 --- /dev/null +++ b/factory/django.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Mark Sandstrom +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +from __future__ import absolute_import +from __future__ import unicode_literals + +import os + +"""factory_boy extensions for use with the Django framework.""" + +try: + from django.core import files as django_files +except ImportError as e: # pragma: no cover + django_files = None + import_failure = e + + +from . import base +from . import declarations +from .compat import BytesIO, is_string + + +def require_django(): + """Simple helper to ensure Django is available.""" + if django_files is None: # pragma: no cover + raise import_failure + + +class DjangoModelFactory(base.Factory): + """Factory for Django models. + + This makes sure that the 'sequence' field of created objects is a new id. + + Possible improvement: define a new 'attribute' type, AutoField, which would + handle those for non-numerical primary keys. + """ + + ABSTRACT_FACTORY = True # Optional, but explicit. + FACTORY_DJANGO_GET_OR_CREATE = () + + @classmethod + def _load_target_class(cls, definition): + + if is_string(definition) and '.' in definition: + app, model = definition.split('.', 1) + from django.db.models import loading as django_loading + return django_loading.get_model(app, model) + + return definition + + @classmethod + def _get_manager(cls, target_class): + try: + return target_class._default_manager # pylint: disable=W0212 + except AttributeError: + return target_class.objects + + @classmethod + def _setup_next_sequence(cls): + """Compute the next available PK, based on the 'pk' database field.""" + + model = cls._get_target_class() # pylint: disable=E1101 + manager = cls._get_manager(model) + + try: + return 1 + manager.values_list('pk', flat=True + ).order_by('-pk')[0] + except (IndexError, TypeError): + # IndexError: No instance exist yet + # TypeError: pk isn't an integer type + return 1 + + @classmethod + def _get_or_create(cls, target_class, *args, **kwargs): + """Create an instance of the model through objects.get_or_create.""" + manager = cls._get_manager(target_class) + + assert 'defaults' not in cls.FACTORY_DJANGO_GET_OR_CREATE, ( + "'defaults' is a reserved keyword for get_or_create " + "(in %s.FACTORY_DJANGO_GET_OR_CREATE=%r)" + % (cls, cls.FACTORY_DJANGO_GET_OR_CREATE)) + + key_fields = {} + for field in cls.FACTORY_DJANGO_GET_OR_CREATE: + key_fields[field] = kwargs.pop(field) + key_fields['defaults'] = kwargs + + obj, _created = manager.get_or_create(*args, **key_fields) + return obj + + @classmethod + def _create(cls, target_class, *args, **kwargs): + """Create an instance of the model, and save it to the database.""" + manager = cls._get_manager(target_class) + + if cls.FACTORY_DJANGO_GET_OR_CREATE: + return cls._get_or_create(target_class, *args, **kwargs) + + return manager.create(*args, **kwargs) + + @classmethod + def _after_postgeneration(cls, obj, create, results=None): + """Save again the instance if creating and at least one hook ran.""" + if create and results: + # Some post-generation hooks ran, and may have modified us. + obj.save() + + +class FileField(declarations.PostGenerationDeclaration): + """Helper to fill in django.db.models.FileField from a Factory.""" + + DEFAULT_FILENAME = 'example.dat' + + def __init__(self, **defaults): + require_django() + self.defaults = defaults + super(FileField, self).__init__() + + def _make_data(self, params): + """Create data for the field.""" + return params.get('data', b'') + + def _make_content(self, extraction_context): + path = '' + params = dict(self.defaults) + params.update(extraction_context.extra) + + if params.get('from_path') and params.get('from_file'): + raise ValueError( + "At most one argument from 'from_file' and 'from_path' should " + "be non-empty when calling factory.django.FileField." + ) + + if extraction_context.did_extract: + # Should be a django.core.files.File + content = extraction_context.value + path = content.name + + elif params.get('from_path'): + path = params['from_path'] + f = open(path, 'rb') + content = django_files.File(f, name=path) + + elif params.get('from_file'): + f = params['from_file'] + content = django_files.File(f) + path = content.name + + else: + data = self._make_data(params) + content = django_files.base.ContentFile(data) + + if path: + default_filename = os.path.basename(path) + else: + default_filename = self.DEFAULT_FILENAME + + filename = params.get('filename', default_filename) + return filename, content + + def call(self, obj, create, extraction_context): + """Fill in the field.""" + if extraction_context.did_extract and extraction_context.value is None: + # User passed an empty value, don't fill + return + + filename, content = self._make_content(extraction_context) + field_file = getattr(obj, extraction_context.for_field) + try: + field_file.save(filename, content, save=create) + finally: + content.file.close() + return field_file + + +class ImageField(FileField): + DEFAULT_FILENAME = 'example.jpg' + + def _make_data(self, params): + # ImageField (both django's and factory_boy's) require PIL. + # Try to import it along one of its known installation paths. + try: + from PIL import Image + except ImportError: + import Image + + width = params.get('width', 100) + height = params.get('height', width) + color = params.get('color', 'blue') + image_format = params.get('format', 'JPEG') + + thumb = Image.new('RGB', (width, height), color) + thumb_io = BytesIO() + thumb.save(thumb_io, format=image_format) + return thumb_io.getvalue() + diff --git a/factory/fuzzy.py b/factory/fuzzy.py index 186b4a7..34949c5 100644 --- a/factory/fuzzy.py +++ b/factory/fuzzy.py @@ -23,9 +23,14 @@ """Additional declarations for "fuzzy" attribute definitions.""" +from __future__ import unicode_literals +import decimal import random +import string +import datetime +from . import compat from . import declarations @@ -35,7 +40,7 @@ class BaseFuzzyAttribute(declarations.OrderedDeclaration): Custom fuzzers should override the `fuzz()` method. """ - def fuzz(self): + def fuzz(self): # pragma: no cover raise NotImplementedError() def evaluate(self, sequence, obj, create, extra=None, containers=()): @@ -58,6 +63,36 @@ class FuzzyAttribute(BaseFuzzyAttribute): return self.fuzzer() +class FuzzyText(BaseFuzzyAttribute): + """Random string with a given prefix. + + Generates a random string of the given length from chosen chars. + If a prefix or a suffix are supplied, they will be prepended / appended + to the generated string. + + Args: + prefix (text): An optional prefix to prepend to the random string + length (int): the length of the random part + suffix (text): An optional suffix to append to the random string + chars (str list): the chars to choose from + + Useful for generating unique attributes where the exact value is + not important. + """ + + def __init__(self, prefix='', length=12, suffix='', + chars=string.ascii_letters, **kwargs): + super(FuzzyText, self).__init__(**kwargs) + self.prefix = prefix + self.suffix = suffix + self.length = length + self.chars = tuple(chars) # Unroll iterators + + def fuzz(self): + chars = [random.choice(self.chars) for _i in range(self.length)] + return self.prefix + ''.join(chars) + self.suffix + + class FuzzyChoice(BaseFuzzyAttribute): """Handles fuzzy choice of an attribute.""" @@ -84,3 +119,143 @@ class FuzzyInteger(BaseFuzzyAttribute): def fuzz(self): return random.randint(self.low, self.high) + + +class FuzzyDecimal(BaseFuzzyAttribute): + """Random decimal within a given range.""" + + def __init__(self, low, high=None, precision=2, **kwargs): + if high is None: + high = low + low = 0.0 + + self.low = low + self.high = high + self.precision = precision + + super(FuzzyDecimal, self).__init__(**kwargs) + + def fuzz(self): + base = compat.float_to_decimal(random.uniform(self.low, self.high)) + return base.quantize(decimal.Decimal(10) ** -self.precision) + + +class FuzzyDate(BaseFuzzyAttribute): + """Random date within a given date range.""" + + def __init__(self, start_date, end_date=None, **kwargs): + super(FuzzyDate, self).__init__(**kwargs) + if end_date is None: + end_date = datetime.date.today() + + if start_date > end_date: + raise ValueError( + "FuzzyDate boundaries should have start <= end; got %r > %r." + % (start_date, end_date)) + + self.start_date = start_date.toordinal() + self.end_date = end_date.toordinal() + + def fuzz(self): + return datetime.date.fromordinal(random.randint(self.start_date, self.end_date)) + + +class BaseFuzzyDateTime(BaseFuzzyAttribute): + """Base class for fuzzy datetime-related attributes. + + Provides fuzz() computation, forcing year/month/day/hour/... + """ + + def _check_bounds(self, start_dt, end_dt): + if start_dt > end_dt: + raise ValueError( + """%s boundaries should have start <= end, got %r > %r""" % ( + self.__class__.__name__, start_dt, end_dt)) + + def __init__(self, start_dt, end_dt=None, + force_year=None, force_month=None, force_day=None, + force_hour=None, force_minute=None, force_second=None, + force_microsecond=None, **kwargs): + super(BaseFuzzyDateTime, self).__init__(**kwargs) + + if end_dt is None: + end_dt = self._now() + + self._check_bounds(start_dt, end_dt) + + self.start_dt = start_dt + self.end_dt = end_dt + self.force_year = force_year + self.force_month = force_month + self.force_day = force_day + self.force_hour = force_hour + self.force_minute = force_minute + self.force_second = force_second + self.force_microsecond = force_microsecond + + def fuzz(self): + delta = self.end_dt - self.start_dt + microseconds = delta.microseconds + 1000000 * (delta.seconds + (delta.days * 86400)) + + offset = random.randint(0, microseconds) + result = self.start_dt + datetime.timedelta(microseconds=offset) + + if self.force_year is not None: + result = result.replace(year=self.force_year) + if self.force_month is not None: + result = result.replace(month=self.force_month) + if self.force_day is not None: + result = result.replace(day=self.force_day) + if self.force_hour is not None: + result = result.replace(hour=self.force_hour) + if self.force_minute is not None: + result = result.replace(minute=self.force_minute) + if self.force_second is not None: + result = result.replace(second=self.force_second) + if self.force_microsecond is not None: + result = result.replace(microsecond=self.force_microsecond) + + return result + + +class FuzzyNaiveDateTime(BaseFuzzyDateTime): + """Random naive datetime within a given range. + + If no upper bound is given, will default to datetime.datetime.utcnow(). + """ + + def _now(self): + return datetime.datetime.now() + + def _check_bounds(self, start_dt, end_dt): + if start_dt.tzinfo is not None: + raise ValueError( + "FuzzyNaiveDateTime only handles naive datetimes, got start=%r" + % start_dt) + if end_dt.tzinfo is not None: + raise ValueError( + "FuzzyNaiveDateTime only handles naive datetimes, got end=%r" + % end_dt) + super(FuzzyNaiveDateTime, self)._check_bounds(start_dt, end_dt) + + +class FuzzyDateTime(BaseFuzzyDateTime): + """Random timezone-aware datetime within a given range. + + If no upper bound is given, will default to datetime.datetime.now() + If no timezone is given, will default to utc. + """ + + def _now(self): + return datetime.datetime.now(tz=compat.UTC) + + def _check_bounds(self, start_dt, end_dt): + if start_dt.tzinfo is None: + raise ValueError( + "FuzzyDateTime only handles aware datetimes, got start=%r" + % start_dt) + if end_dt.tzinfo is None: + raise ValueError( + "FuzzyDateTime only handles aware datetimes, got end=%r" + % end_dt) + super(FuzzyDateTime, self)._check_bounds(start_dt, end_dt) diff --git a/factory/helpers.py b/factory/helpers.py index 8f0d161..37b41bf 100644 --- a/factory/helpers.py +++ b/factory/helpers.py @@ -23,11 +23,29 @@ """Simple wrappers around Factory class definition.""" +import contextlib +import logging from . import base from . import declarations +@contextlib.contextmanager +def debug(logger='factory', stream=None): + logger_obj = logging.getLogger(logger) + old_level = logger_obj.level + + handler = logging.StreamHandler(stream) + handler.setLevel(logging.DEBUG) + logger_obj.addHandler(handler) + logger_obj.setLevel(logging.DEBUG) + + yield + + logger_obj.setLevel(old_level) + logger_obj.removeHandler(handler) + + def make_factory(klass, **kwargs): """Create a new, simple factory for the given class.""" factory_name = '%sFactory' % klass.__name__ diff --git a/factory/mogo.py b/factory/mogo.py new file mode 100644 index 0000000..48d9677 --- /dev/null +++ b/factory/mogo.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Mark Sandstrom +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +from __future__ import unicode_literals + + +"""factory_boy extensions for use with the mogo library (pymongo wrapper).""" + + +from . import base + + +class MogoFactory(base.Factory): + """Factory for mogo objects.""" + ABSTRACT_FACTORY = True + + @classmethod + def _build(cls, target_class, *args, **kwargs): + return target_class.new(*args, **kwargs) + + @classmethod + def _create(cls, target_class, *args, **kwargs): + instance = target_class.new(*args, **kwargs) + instance.save() + return instance diff --git a/factory/mongoengine.py b/factory/mongoengine.py new file mode 100644 index 0000000..462f5f2 --- /dev/null +++ b/factory/mongoengine.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Mark Sandstrom +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +from __future__ import unicode_literals + + +"""factory_boy extensions for use with the mongoengine library (pymongo wrapper).""" + + +from . import base + + +class MongoEngineFactory(base.Factory): + """Factory for mongoengine objects.""" + ABSTRACT_FACTORY = True + + @classmethod + def _build(cls, target_class, *args, **kwargs): + return target_class(*args, **kwargs) + + @classmethod + def _create(cls, target_class, *args, **kwargs): + instance = target_class(*args, **kwargs) + if instance._is_document: + instance.save() + return instance diff --git a/factory/utils.py b/factory/utils.py index fb8cfef..276977a 100644 --- a/factory/utils.py +++ b/factory/utils.py @@ -20,6 +20,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +from __future__ import unicode_literals + +import collections #: String for splitting an attribute name into a #: (subfactory_name, subfactory_field) tuple. @@ -94,3 +97,47 @@ def import_object(module_name, attribute_name): module = __import__(module_name, {}, {}, [attribute_name], 0) return getattr(module, attribute_name) + +def _safe_repr(obj): + try: + obj_repr = repr(obj) + except UnicodeError: + return '<bad_repr object at %s>' % id(obj) + + try: # Convert to "text type" (= unicode) + return '%s' % obj_repr + except UnicodeError: # non-ascii bytes repr on Py2 + return obj_repr.decode('utf-8') + + +def log_pprint(args=(), kwargs=None): + kwargs = kwargs or {} + return ', '.join( + [_safe_repr(arg) for arg in args] + + [ + '%s=%s' % (key, _safe_repr(value)) + for key, value in kwargs.items() + ] + ) + + +class ResetableIterator(object): + """An iterator wrapper that can be 'reset()' to its start.""" + def __init__(self, iterator, **kwargs): + super(ResetableIterator, self).__init__(**kwargs) + self.iterator = iter(iterator) + self.past_elements = collections.deque() + self.next_elements = collections.deque() + + def __iter__(self): + while True: + if self.next_elements: + yield self.next_elements.popleft() + else: + value = next(self.iterator) + self.past_elements.append(value) + yield value + + def reset(self): + self.next_elements.clear() + self.next_elements.extend(self.past_elements) @@ -1,73 +1,40 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import codecs import os import re import sys -from distutils.core import setup -from distutils import cmd -root = os.path.abspath(os.path.dirname(__file__)) +from setuptools import setup -def get_version(*module_dir_components): - version_re = re.compile(r"^__version__ = ['\"](.*)['\"]$") - module_root = os.path.join(root, *module_dir_components) - module_init = os.path.join(module_root, '__init__.py') - with open(module_init, 'r') as f: +root_dir = os.path.abspath(os.path.dirname(__file__)) + + +def get_version(package_name): + version_re = re.compile(r"^__version__ = [\"']([\w_.-]+)[\"']$") + package_components = package_name.split('.') + init_path = os.path.join(root_dir, *(package_components + ['__init__.py'])) + with codecs.open(init_path, 'r', 'utf-8') as f: for line in f: match = version_re.match(line[:-1]) if match: return match.groups()[0] return '0.1.0' -VERSION = get_version('factory') - - -class test(cmd.Command): - """Run the tests for this package.""" - command_name = 'test' - description = 'run the tests associated with the package' - - user_options = [ - ('test-suite=', None, "A test suite to run (defaults to 'tests')"), - ] - - def initialize_options(self): - self.test_runner = None - self.test_suite = None - def finalize_options(self): - self.ensure_string('test_suite', 'tests') +if sys.version_info[0:2] < (2, 7): # pragma: no cover + test_loader = 'unittest2:TestLoader' +else: + test_loader = 'unittest:TestLoader' - def run(self): - """Run the test suite.""" - try: - import unittest2 as unittest - except ImportError: - import unittest - if self.verbose: - verbosity=1 - else: - verbosity=0 - - loader = unittest.TestLoader() - suite = unittest.TestSuite() - - if self.test_suite == 'tests': - for test_module in loader.discover('.'): - suite.addTest(test_module) - else: - suite.addTest(loader.loadTestsFromName(self.test_suite)) - - result = unittest.TextTestRunner(verbosity=verbosity).run(suite) - if not result.wasSuccessful(): - sys.exit(1) +PACKAGE = 'factory' setup( name='factory_boy', - version=VERSION, + version=get_version(PACKAGE), description="A verstile test fixtures replacement based on thoughtbot's factory_girl for Ruby.", author='Mark Sandstrom', author_email='mark@deliciouslynerdy.com', @@ -77,22 +44,29 @@ setup( keywords=['factory_boy', 'factory', 'fixtures'], packages=['factory'], license='MIT', + setup_requires=[ + 'setuptools>=0.8', + ], + tests_require=[ + 'mock', + ], classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Framework :: Django', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Topic :: Software Development :: Testing', - 'Topic :: Software Development :: Libraries :: Python Modules' + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Framework :: Django", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.2", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Testing", + "Topic :: Software Development :: Libraries :: Python Modules" ], - cmdclass={'test': test}, + test_suite='tests', + test_loader=test_loader, ) diff --git a/tests/__init__.py b/tests/__init__.py index 3c620d6..5b6fc55 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,6 +4,10 @@ from .test_base import * from .test_containers import * from .test_declarations import * +from .test_django import * from .test_fuzzy import * +from .test_helpers import * from .test_using import * from .test_utils import * +from .test_alchemy import * +from .test_mongoengine import * diff --git a/tests/alchemyapp/__init__.py b/tests/alchemyapp/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/alchemyapp/__init__.py diff --git a/tests/alchemyapp/models.py b/tests/alchemyapp/models.py new file mode 100644 index 0000000..e0193d4 --- /dev/null +++ b/tests/alchemyapp/models.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013 Romain Commandé +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +"""Helpers for testing SQLAlchemy apps.""" + +from sqlalchemy import Column, Integer, Unicode, create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import scoped_session, sessionmaker + +session = scoped_session(sessionmaker()) +engine = create_engine('sqlite://') +session.configure(bind=engine) +Base = declarative_base() + + +class StandardModel(Base): + __tablename__ = 'StandardModelTable' + + id = Column(Integer(), primary_key=True) + foo = Column(Unicode(20)) + + +class NonIntegerPk(Base): + __tablename__ = 'NonIntegerPk' + + id = Column(Unicode(20), primary_key=True) + +Base.metadata.create_all(engine) diff --git a/tests/alter_time.py b/tests/alter_time.py new file mode 100644 index 0000000..db0a611 --- /dev/null +++ b/tests/alter_time.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# This code is in the public domain +# Author: Raphaël Barrois + + +from __future__ import print_function + +import datetime +import mock + + +real_datetime_class = datetime.datetime + +def mock_datetime_now(target, datetime_module): + """Override ``datetime.datetime.now()`` with a custom target value. + + This creates a new datetime.datetime class, and alters its now()/utcnow() + methods. + + Returns: + A mock.patch context, can be used as a decorator or in a with. + """ + + # See http://bugs.python.org/msg68532 + # And http://docs.python.org/reference/datamodel.html#customizing-instance-and-subclass-checks + class DatetimeSubclassMeta(type): + """We need to customize the __instancecheck__ method for isinstance(). + + This must be performed at a metaclass level. + """ + + @classmethod + def __instancecheck__(mcs, obj): + return isinstance(obj, real_datetime_class) + + class BaseMockedDatetime(real_datetime_class): + @classmethod + def now(cls, tz=None): + return target.replace(tzinfo=tz) + + @classmethod + def utcnow(cls): + return target + + # Python2 & Python3-compatible metaclass + MockedDatetime = DatetimeSubclassMeta('datetime', (BaseMockedDatetime,), {}) + + return mock.patch.object(datetime_module, 'datetime', MockedDatetime) + +real_date_class = datetime.date + +def mock_date_today(target, datetime_module): + """Override ``datetime.date.today()`` with a custom target value. + + This creates a new datetime.date class, and alters its today() method. + + Returns: + A mock.patch context, can be used as a decorator or in a with. + """ + + # See http://bugs.python.org/msg68532 + # And http://docs.python.org/reference/datamodel.html#customizing-instance-and-subclass-checks + class DateSubclassMeta(type): + """We need to customize the __instancecheck__ method for isinstance(). + + This must be performed at a metaclass level. + """ + + @classmethod + def __instancecheck__(mcs, obj): + return isinstance(obj, real_date_class) + + class BaseMockedDate(real_date_class): + @classmethod + def today(cls): + return target + + # Python2 & Python3-compatible metaclass + MockedDate = DateSubclassMeta('date', (BaseMockedDate,), {}) + + return mock.patch.object(datetime_module, 'date', MockedDate) + + +def main(): # pragma: no cover + """Run a couple of tests""" + target_dt = real_datetime_class(2009, 1, 1) + target_date = real_date_class(2009, 1, 1) + + print("Entering mock") + with mock_datetime_now(target_dt, datetime): + print("- now ->", datetime.datetime.now()) + print("- isinstance(now, dt) ->", isinstance(datetime.datetime.now(), datetime.datetime)) + print("- isinstance(target, dt) ->", isinstance(target_dt, datetime.datetime)) + + with mock_date_today(target_date, datetime): + print("- today ->", datetime.date.today()) + print("- isinstance(now, date) ->", isinstance(datetime.date.today(), datetime.date)) + print("- isinstance(target, date) ->", isinstance(target_date, datetime.date)) + + + print("Outside mock") + print("- now ->", datetime.datetime.now()) + print("- isinstance(now, dt) ->", isinstance(datetime.datetime.now(), datetime.datetime)) + print("- isinstance(target, dt) ->", isinstance(target_dt, datetime.datetime)) + + print("- today ->", datetime.date.today()) + print("- isinstance(now, date) ->", isinstance(datetime.date.today(), datetime.date)) + print("- isinstance(target, date) ->", isinstance(target_date, datetime.date)) + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/tests/compat.py b/tests/compat.py index 6a1eb80..ff96f13 100644 --- a/tests/compat.py +++ b/tests/compat.py @@ -25,13 +25,18 @@ import sys is_python2 = (sys.version_info[0] == 2) -try: +if sys.version_info[0:2] < (2, 7): # pragma: no cover import unittest2 as unittest -except ImportError: +else: # pragma: no cover import unittest -if sys.version_info[0:2] < (3, 3): +if sys.version_info[0] == 2: # pragma: no cover + import StringIO as io +else: # pragma: no cover + import io + +if sys.version_info[0:2] < (3, 3): # pragma: no cover import mock -else: +else: # pragma: no cover from unittest import mock diff --git a/tests/cyclic/foo.py b/tests/cyclic/foo.py index 7f00f12..e584ed1 100644 --- a/tests/cyclic/foo.py +++ b/tests/cyclic/foo.py @@ -23,7 +23,7 @@ import factory -from cyclic import bar +from . import bar as bar_mod class Foo(object): def __init__(self, bar, x): @@ -35,4 +35,4 @@ class FooFactory(factory.Factory): FACTORY_FOR = Foo x = 42 - bar = factory.SubFactory(bar.BarFactory) + bar = factory.SubFactory(bar_mod.BarFactory) diff --git a/tests/djapp/__init__.py b/tests/djapp/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/djapp/__init__.py diff --git a/tests/djapp/models.py b/tests/djapp/models.py new file mode 100644 index 0000000..e98279d --- /dev/null +++ b/tests/djapp/models.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +"""Helpers for testing django apps.""" + +import os.path + +try: + from PIL import Image +except ImportError: + try: + import Image + except ImportError: + Image = None + +from django.conf import settings +from django.db import models + +class StandardModel(models.Model): + foo = models.CharField(max_length=20) + + +class NonIntegerPk(models.Model): + foo = models.CharField(max_length=20, primary_key=True) + bar = models.CharField(max_length=20, blank=True) + + +class AbstractBase(models.Model): + foo = models.CharField(max_length=20) + + class Meta: + abstract = True + + +class ConcreteSon(AbstractBase): + pass + + +class StandardSon(StandardModel): + pass + + +WITHFILE_UPLOAD_TO = 'django' +WITHFILE_UPLOAD_DIR = os.path.join(settings.MEDIA_ROOT, WITHFILE_UPLOAD_TO) + +class WithFile(models.Model): + afile = models.FileField(upload_to=WITHFILE_UPLOAD_TO) + + +if Image is not None: # PIL is available + + class WithImage(models.Model): + animage = models.ImageField(upload_to=WITHFILE_UPLOAD_TO) + +else: + class WithImage(models.Model): + pass diff --git a/tests/djapp/settings.py b/tests/djapp/settings.py new file mode 100644 index 0000000..c1b79b0 --- /dev/null +++ b/tests/djapp/settings.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +"""Settings for factory_boy/Django tests.""" + +import os + +FACTORY_ROOT = os.path.join( + os.path.abspath(os.path.dirname(__file__)), # /path/to/fboy/tests/djapp/ + os.pardir, # /path/to/fboy/tests/ + os.pardir, # /path/to/fboy +) + +MEDIA_ROOT = os.path.join(FACTORY_ROOT, 'tmp_test') + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + }, +} + + +INSTALLED_APPS = [ + 'tests.djapp' +] + + +SECRET_KEY = 'testing.' diff --git a/tests/test_alchemy.py b/tests/test_alchemy.py new file mode 100644 index 0000000..4255417 --- /dev/null +++ b/tests/test_alchemy.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013 Romain Command& +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Tests for factory_boy/SQLAlchemy interactions.""" + +import factory +from .compat import unittest + + +try: + import sqlalchemy +except ImportError: + sqlalchemy = None + +if sqlalchemy: + from factory.alchemy import SQLAlchemyModelFactory + from .alchemyapp import models +else: + + class Fake(object): + FACTORY_SESSION = None + + models = Fake() + models.StandardModel = Fake() + models.NonIntegerPk = Fake() + models.session = Fake() + SQLAlchemyModelFactory = Fake + + +class StandardFactory(SQLAlchemyModelFactory): + FACTORY_FOR = models.StandardModel + FACTORY_SESSION = models.session + + id = factory.Sequence(lambda n: n) + foo = factory.Sequence(lambda n: 'foo%d' % n) + + +class NonIntegerPkFactory(SQLAlchemyModelFactory): + FACTORY_FOR = models.NonIntegerPk + FACTORY_SESSION = models.session + + id = factory.Sequence(lambda n: 'foo%d' % n) + + +@unittest.skipIf(sqlalchemy is None, "SQLalchemy not installed.") +class SQLAlchemyPkSequenceTestCase(unittest.TestCase): + + def setUp(self): + super(SQLAlchemyPkSequenceTestCase, self).setUp() + StandardFactory.reset_sequence(1) + NonIntegerPkFactory.FACTORY_SESSION.rollback() + + def test_pk_first(self): + std = StandardFactory.build() + self.assertEqual('foo1', std.foo) + + def test_pk_many(self): + std1 = StandardFactory.build() + std2 = StandardFactory.build() + self.assertEqual('foo1', std1.foo) + self.assertEqual('foo2', std2.foo) + + def test_pk_creation(self): + std1 = StandardFactory.create() + self.assertEqual('foo1', std1.foo) + self.assertEqual(1, std1.id) + + StandardFactory.reset_sequence() + std2 = StandardFactory.create() + self.assertEqual('foo2', std2.foo) + self.assertEqual(2, std2.id) + + def test_pk_force_value(self): + std1 = StandardFactory.create(id=10) + self.assertEqual('foo1', std1.foo) # sequence was set before pk + self.assertEqual(10, std1.id) + + StandardFactory.reset_sequence() + std2 = StandardFactory.create() + self.assertEqual('foo11', std2.foo) + self.assertEqual(11, std2.id) + + +@unittest.skipIf(sqlalchemy is None, "SQLalchemy not installed.") +class SQLAlchemyNonIntegerPkTestCase(unittest.TestCase): + def setUp(self): + super(SQLAlchemyNonIntegerPkTestCase, self).setUp() + NonIntegerPkFactory.reset_sequence() + NonIntegerPkFactory.FACTORY_SESSION.rollback() + + def test_first(self): + nonint = NonIntegerPkFactory.build() + self.assertEqual('foo1', nonint.id) + + def test_many(self): + nonint1 = NonIntegerPkFactory.build() + nonint2 = NonIntegerPkFactory.build() + + self.assertEqual('foo1', nonint1.id) + self.assertEqual('foo2', nonint2.id) + + def test_creation(self): + nonint1 = NonIntegerPkFactory.create() + self.assertEqual('foo1', nonint1.id) + + NonIntegerPkFactory.reset_sequence() + nonint2 = NonIntegerPkFactory.build() + self.assertEqual('foo1', nonint2.id) + + def test_force_pk(self): + nonint1 = NonIntegerPkFactory.create(id='foo10') + self.assertEqual('foo10', nonint1.id) + + NonIntegerPkFactory.reset_sequence() + nonint2 = NonIntegerPkFactory.create() + self.assertEqual('foo1', nonint2.id) diff --git a/tests/test_base.py b/tests/test_base.py index 73e59fa..8cea6fc 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -34,6 +34,7 @@ class TestObject(object): self.three = three self.four = four + class FakeDjangoModel(object): @classmethod def create(cls, **kwargs): @@ -46,6 +47,7 @@ class FakeDjangoModel(object): setattr(self, name, value) self.id = None + class FakeModelFactory(base.Factory): ABSTRACT_FACTORY = True @@ -71,6 +73,20 @@ class AbstractFactoryTestCase(unittest.TestCase): # Passed + def test_factory_for_and_abstract_factory_optional(self): + """Ensure that ABSTRACT_FACTORY is optional.""" + class TestObjectFactory(base.Factory): + pass + + # passed + + def test_abstract_factory_cannot_be_called(self): + class TestObjectFactory(base.Factory): + pass + + self.assertRaises(base.FactoryError, TestObjectFactory.build) + self.assertRaises(base.FactoryError, TestObjectFactory.create) + class FactoryTestCase(unittest.TestCase): def test_factory_for(self): @@ -116,6 +132,84 @@ class FactoryTestCase(unittest.TestCase): self.assertEqual(4, len(ones)) +class FactorySequenceTestCase(unittest.TestCase): + def setUp(self): + super(FactorySequenceTestCase, self).setUp() + + class TestObjectFactory(base.Factory): + FACTORY_FOR = TestObject + one = declarations.Sequence(lambda n: n) + + self.TestObjectFactory = TestObjectFactory + + def test_reset_sequence(self): + o1 = self.TestObjectFactory() + self.assertEqual(0, o1.one) + + o2 = self.TestObjectFactory() + self.assertEqual(1, o2.one) + + self.TestObjectFactory.reset_sequence() + o3 = self.TestObjectFactory() + self.assertEqual(0, o3.one) + + def test_reset_sequence_with_value(self): + o1 = self.TestObjectFactory() + self.assertEqual(0, o1.one) + + o2 = self.TestObjectFactory() + self.assertEqual(1, o2.one) + + self.TestObjectFactory.reset_sequence(42) + o3 = self.TestObjectFactory() + self.assertEqual(42, o3.one) + + def test_reset_sequence_subclass_fails(self): + """Tests that the sequence of a 'slave' factory cannot be reseted.""" + class SubTestObjectFactory(self.TestObjectFactory): + pass + + self.assertRaises(ValueError, SubTestObjectFactory.reset_sequence) + + def test_reset_sequence_subclass_force(self): + """Tests that reset_sequence(force=True) works.""" + class SubTestObjectFactory(self.TestObjectFactory): + pass + + o1 = SubTestObjectFactory() + self.assertEqual(0, o1.one) + + o2 = SubTestObjectFactory() + self.assertEqual(1, o2.one) + + SubTestObjectFactory.reset_sequence(force=True) + o3 = SubTestObjectFactory() + self.assertEqual(0, o3.one) + + # The master sequence counter has been reset + o4 = self.TestObjectFactory() + self.assertEqual(1, o4.one) + + def test_reset_sequence_subclass_parent(self): + """Tests that the sequence of a 'slave' factory cannot be reseted.""" + class SubTestObjectFactory(self.TestObjectFactory): + pass + + o1 = SubTestObjectFactory() + self.assertEqual(0, o1.one) + + o2 = SubTestObjectFactory() + self.assertEqual(1, o2.one) + + self.TestObjectFactory.reset_sequence() + o3 = SubTestObjectFactory() + self.assertEqual(0, o3.one) + + o4 = self.TestObjectFactory() + self.assertEqual(1, o4.one) + + + class FactoryDefaultStrategyTestCase(unittest.TestCase): def setUp(self): self.default_strategy = base.Factory.FACTORY_STRATEGY @@ -238,12 +332,10 @@ class FactoryCreationTestCase(unittest.TestCase): # Errors def test_no_associated_class(self): - try: - class Test(base.Factory): - pass - self.fail() - except base.Factory.AssociatedClassError as e: - self.assertTrue('autodiscovery' not in str(e)) + class Test(base.Factory): + pass + + self.assertTrue(Test._abstract_factory) class PostGenerationParsingTestCase(unittest.TestCase): @@ -268,5 +360,5 @@ class PostGenerationParsingTestCase(unittest.TestCase): -if __name__ == '__main__': +if __name__ == '__main__': # pragma: no cover unittest.main() diff --git a/tests/test_containers.py b/tests/test_containers.py index 70ed885..8b78dc7 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -68,6 +68,16 @@ class LazyStubTestCase(unittest.TestCase): containers=()) self.assertEqual(2, stub.one) + def test_access_parent(self): + """Test simple access to a stub' parent.""" + o1 = containers.LazyStub({'rank': 1}) + o2 = containers.LazyStub({'rank': 2}, (o1,)) + stub = containers.LazyStub({'rank': 3}, (o2, o1)) + + self.assertEqual(3, stub.rank) + self.assertEqual(2, stub.factory_parent.rank) + self.assertEqual(1, stub.factory_parent.factory_parent.rank) + def test_cyclic_definition(self): class LazyAttr(containers.LazyValue): def __init__(self, attrname): @@ -349,5 +359,5 @@ class AttributeBuilderTestCase(unittest.TestCase): pass -if __name__ == '__main__': +if __name__ == '__main__': # pragma: no cover unittest.main() diff --git a/tests/test_declarations.py b/tests/test_declarations.py index 4c08dfa..86bc8b5 100644 --- a/tests/test_declarations.py +++ b/tests/test_declarations.py @@ -107,6 +107,27 @@ class IteratorTestCase(unittest.TestCase): self.assertEqual(2, it.evaluate(1, None, False)) self.assertRaises(StopIteration, it.evaluate, 2, None, False) + def test_reset_cycle(self): + it = declarations.Iterator([1, 2]) + self.assertEqual(1, it.evaluate(0, None, False)) + self.assertEqual(2, it.evaluate(1, None, False)) + self.assertEqual(1, it.evaluate(2, None, False)) + self.assertEqual(2, it.evaluate(3, None, False)) + self.assertEqual(1, it.evaluate(4, None, False)) + it.reset() + self.assertEqual(1, it.evaluate(5, None, False)) + self.assertEqual(2, it.evaluate(6, None, False)) + + def test_reset_no_cycling(self): + it = declarations.Iterator([1, 2], cycle=False) + self.assertEqual(1, it.evaluate(0, None, False)) + self.assertEqual(2, it.evaluate(1, None, False)) + self.assertRaises(StopIteration, it.evaluate, 2, None, False) + it.reset() + self.assertEqual(1, it.evaluate(0, None, False)) + self.assertEqual(2, it.evaluate(1, None, False)) + self.assertRaises(StopIteration, it.evaluate, 2, None, False) + def test_getter(self): it = declarations.Iterator([(1, 2), (1, 3)], getter=lambda p: p[1]) self.assertEqual(2, it.evaluate(0, None, False)) @@ -119,9 +140,11 @@ class PostGenerationDeclarationTestCase(unittest.TestCase): def test_extract_no_prefix(self): decl = declarations.PostGenerationDeclaration() - extracted, kwargs = decl.extract('foo', {'foo': 13, 'foo__bar': 42}) - self.assertEqual(extracted, 13) - self.assertEqual(kwargs, {'bar': 42}) + context = decl.extract('foo', + {'foo': 13, 'foo__bar': 42}) + self.assertTrue(context.did_extract) + self.assertEqual(context.value, 13) + self.assertEqual(context.extra, {'bar': 42}) def test_decorator_simple(self): call_params = [] @@ -130,45 +153,53 @@ class PostGenerationDeclarationTestCase(unittest.TestCase): call_params.append(args) call_params.append(kwargs) - extracted, kwargs = foo.extract('foo', + context = foo.extract('foo', {'foo': 13, 'foo__bar': 42, 'blah': 42, 'blah__baz': 1}) - self.assertEqual(13, extracted) - self.assertEqual({'bar': 42}, kwargs) + self.assertTrue(context.did_extract) + self.assertEqual(13, context.value) + self.assertEqual({'bar': 42}, context.extra) # No value returned. - foo.call(None, False, extracted, **kwargs) + foo.call(None, False, context) self.assertEqual(2, len(call_params)) self.assertEqual((None, False, 13), call_params[0]) self.assertEqual({'bar': 42}, call_params[1]) -class SubFactoryTestCase(unittest.TestCase): +class FactoryWrapperTestCase(unittest.TestCase): + def test_invalid_path(self): + self.assertRaises(ValueError, declarations._FactoryWrapper, 'UnqualifiedSymbol') + self.assertRaises(ValueError, declarations._FactoryWrapper, 42) + + def test_class(self): + w = declarations._FactoryWrapper(datetime.date) + self.assertEqual(datetime.date, w.get()) - def test_arg(self): - self.assertRaises(ValueError, declarations.SubFactory, 'UnqualifiedSymbol') + def test_path(self): + w = declarations._FactoryWrapper('datetime.date') + self.assertEqual(datetime.date, w.get()) def test_lazyness(self): - f = declarations.SubFactory('factory.declarations.Sequence', x=3) + f = declarations._FactoryWrapper('factory.declarations.Sequence') self.assertEqual(None, f.factory) - self.assertEqual({'x': 3}, f.defaults) - - factory_class = f.get_factory() + factory_class = f.get() self.assertEqual(declarations.Sequence, factory_class) def test_cache(self): + """Ensure that _FactoryWrapper tries to import only once.""" orig_date = datetime.date - f = declarations.SubFactory('datetime.date') - self.assertEqual(None, f.factory) + w = declarations._FactoryWrapper('datetime.date') + self.assertEqual(None, w.factory) - factory_class = f.get_factory() + factory_class = w.get() self.assertEqual(orig_date, factory_class) try: # Modify original value datetime.date = None # Repeat import - factory_class = f.get_factory() + factory_class = w.get() self.assertEqual(orig_date, factory_class) finally: @@ -178,106 +209,92 @@ class SubFactoryTestCase(unittest.TestCase): class RelatedFactoryTestCase(unittest.TestCase): - def test_arg(self): - self.assertRaises(ValueError, declarations.RelatedFactory, 'UnqualifiedSymbol') - - def test_lazyness(self): - f = declarations.RelatedFactory('factory.declarations.Sequence', x=3) - self.assertEqual(None, f.factory) - - self.assertEqual({'x': 3}, f.defaults) + def test_deprecate_name(self): + with warnings.catch_warnings(record=True) as w: - factory_class = f.get_factory() - self.assertEqual(declarations.Sequence, factory_class) + warnings.simplefilter('always') + f = declarations.RelatedFactory('datetime.date', name='blah') - def test_cache(self): - """Ensure that RelatedFactory tries to import only once.""" - orig_date = datetime.date - f = declarations.RelatedFactory('datetime.date') - self.assertEqual(None, f.factory) - - factory_class = f.get_factory() - self.assertEqual(orig_date, factory_class) - - try: - # Modify original value - datetime.date = None - # Repeat import - factory_class = f.get_factory() - self.assertEqual(orig_date, factory_class) - - finally: - # IMPORTANT: restore attribute. - datetime.date = orig_date + self.assertEqual('blah', f.name) + self.assertEqual(1, len(w)) + self.assertIn('RelatedFactory', str(w[0].message)) + self.assertIn('factory_related_name', str(w[0].message)) class PostGenerationMethodCallTestCase(unittest.TestCase): def setUp(self): self.obj = mock.MagicMock() + def ctx(self, value=None, force_value=False, extra=None): + return declarations.ExtractionContext( + value, + bool(value) or force_value, + extra, + ) + def test_simplest_setup_and_call(self): decl = declarations.PostGenerationMethodCall('method') - decl.call(self.obj, False) + decl.call(self.obj, False, self.ctx()) self.obj.method.assert_called_once_with() def test_call_with_method_args(self): decl = declarations.PostGenerationMethodCall( 'method', 'data') - decl.call(self.obj, False) + decl.call(self.obj, False, self.ctx()) self.obj.method.assert_called_once_with('data') def test_call_with_passed_extracted_string(self): decl = declarations.PostGenerationMethodCall( 'method') - decl.call(self.obj, False, 'data') + decl.call(self.obj, False, self.ctx('data')) self.obj.method.assert_called_once_with('data') def test_call_with_passed_extracted_int(self): decl = declarations.PostGenerationMethodCall('method') - decl.call(self.obj, False, 1) + decl.call(self.obj, False, self.ctx(1)) self.obj.method.assert_called_once_with(1) def test_call_with_passed_extracted_iterable(self): decl = declarations.PostGenerationMethodCall('method') - decl.call(self.obj, False, (1, 2, 3)) + decl.call(self.obj, False, self.ctx((1, 2, 3))) self.obj.method.assert_called_once_with((1, 2, 3)) def test_call_with_method_kwargs(self): decl = declarations.PostGenerationMethodCall( 'method', data='data') - decl.call(self.obj, False) + decl.call(self.obj, False, self.ctx()) self.obj.method.assert_called_once_with(data='data') def test_call_with_passed_kwargs(self): decl = declarations.PostGenerationMethodCall('method') - decl.call(self.obj, False, data='other') + decl.call(self.obj, False, self.ctx(extra={'data': 'other'})) self.obj.method.assert_called_once_with(data='other') def test_multi_call_with_multi_method_args(self): decl = declarations.PostGenerationMethodCall( 'method', 'arg1', 'arg2') - decl.call(self.obj, False) + decl.call(self.obj, False, self.ctx()) self.obj.method.assert_called_once_with('arg1', 'arg2') def test_multi_call_with_passed_multiple_args(self): decl = declarations.PostGenerationMethodCall( 'method', 'arg1', 'arg2') - decl.call(self.obj, False, ('param1', 'param2', 'param3')) + decl.call(self.obj, False, self.ctx(('param1', 'param2', 'param3'))) self.obj.method.assert_called_once_with('param1', 'param2', 'param3') def test_multi_call_with_passed_tuple(self): decl = declarations.PostGenerationMethodCall( 'method', 'arg1', 'arg2') - decl.call(self.obj, False, (('param1', 'param2'),)) + decl.call(self.obj, False, self.ctx((('param1', 'param2'),))) self.obj.method.assert_called_once_with(('param1', 'param2')) def test_multi_call_with_kwargs(self): decl = declarations.PostGenerationMethodCall( 'method', 'arg1', 'arg2') - decl.call(self.obj, False, x=2) + decl.call(self.obj, False, self.ctx(extra={'x': 2})) self.obj.method.assert_called_once_with('arg1', 'arg2', x=2) -if __name__ == '__main__': +if __name__ == '__main__': # pragma: no cover unittest.main() diff --git a/tests/test_django.py b/tests/test_django.py new file mode 100644 index 0000000..e4bbc2b --- /dev/null +++ b/tests/test_django.py @@ -0,0 +1,515 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +"""Tests for factory_boy/Django interactions.""" + +import os + +import factory +import factory.django + + +try: + import django +except ImportError: # pragma: no cover + django = None + +try: + from PIL import Image +except ImportError: # pragma: no cover + # Try PIL alternate name + try: + import Image + except ImportError: + # OK, not installed + Image = None + + +from .compat import is_python2, unittest +from . import testdata +from . import tools + + +if django is not None: + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.djapp.settings') + + from django import test as django_test + from django.conf import settings + from django.db import models as django_models + from django.test import simple as django_test_simple + from django.test import utils as django_test_utils + from .djapp import models +else: # pragma: no cover + django_test = unittest + + class Fake(object): + pass + + models = Fake() + models.StandardModel = Fake + models.StandardSon = None + models.AbstractBase = Fake + models.ConcreteSon = Fake + models.NonIntegerPk = Fake + models.WithFile = Fake + models.WithImage = Fake + + +test_state = {} + + +def setUpModule(): + if django is None: # pragma: no cover + raise unittest.SkipTest("Django not installed") + django_test_utils.setup_test_environment() + runner = django_test_simple.DjangoTestSuiteRunner() + runner_state = runner.setup_databases() + test_state.update({ + 'runner': runner, + 'runner_state': runner_state, + }) + + +def tearDownModule(): + if django is None: # pragma: no cover + return + runner = test_state['runner'] + runner_state = test_state['runner_state'] + runner.teardown_databases(runner_state) + django_test_utils.teardown_test_environment() + + +class StandardFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = models.StandardModel + + foo = factory.Sequence(lambda n: "foo%d" % n) + + +class StandardFactoryWithPKField(factory.django.DjangoModelFactory): + FACTORY_FOR = models.StandardModel + FACTORY_DJANGO_GET_OR_CREATE = ('pk',) + + foo = factory.Sequence(lambda n: "foo%d" % n) + pk = None + + +class NonIntegerPkFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = models.NonIntegerPk + + foo = factory.Sequence(lambda n: "foo%d" % n) + bar = '' + + +class AbstractBaseFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = models.AbstractBase + ABSTRACT_FACTORY = True + + foo = factory.Sequence(lambda n: "foo%d" % n) + + +class ConcreteSonFactory(AbstractBaseFactory): + FACTORY_FOR = models.ConcreteSon + + +class WithFileFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = models.WithFile + + if django is not None: + afile = factory.django.FileField() + + +class WithImageFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = models.WithImage + + if django is not None: + animage = factory.django.ImageField() + + +@unittest.skipIf(django is None, "Django not installed.") +class DjangoPkSequenceTestCase(django_test.TestCase): + def setUp(self): + super(DjangoPkSequenceTestCase, self).setUp() + StandardFactory.reset_sequence() + + def test_pk_first(self): + std = StandardFactory.build() + self.assertEqual('foo1', std.foo) + + def test_pk_many(self): + std1 = StandardFactory.build() + std2 = StandardFactory.build() + self.assertEqual('foo1', std1.foo) + self.assertEqual('foo2', std2.foo) + + def test_pk_creation(self): + std1 = StandardFactory.create() + self.assertEqual('foo1', std1.foo) + self.assertEqual(1, std1.pk) + + StandardFactory.reset_sequence() + std2 = StandardFactory.create() + self.assertEqual('foo2', std2.foo) + self.assertEqual(2, std2.pk) + + def test_pk_force_value(self): + std1 = StandardFactory.create(pk=10) + self.assertEqual('foo1', std1.foo) # sequence was set before pk + self.assertEqual(10, std1.pk) + + StandardFactory.reset_sequence() + std2 = StandardFactory.create() + self.assertEqual('foo11', std2.foo) + self.assertEqual(11, std2.pk) + + +@unittest.skipIf(django is None, "Django not installed.") +class DjangoPkForceTestCase(django_test.TestCase): + def setUp(self): + super(DjangoPkForceTestCase, self).setUp() + StandardFactoryWithPKField.reset_sequence() + + def test_no_pk(self): + std = StandardFactoryWithPKField() + self.assertIsNotNone(std.pk) + self.assertEqual('foo1', std.foo) + + def test_force_pk(self): + std = StandardFactoryWithPKField(pk=42) + self.assertIsNotNone(std.pk) + self.assertEqual('foo1', std.foo) + + def test_reuse_pk(self): + std1 = StandardFactoryWithPKField(foo='bar') + self.assertIsNotNone(std1.pk) + + std2 = StandardFactoryWithPKField(pk=std1.pk, foo='blah') + self.assertEqual(std1.pk, std2.pk) + self.assertEqual('bar', std2.foo) + + +@unittest.skipIf(django is None, "Django not installed.") +class DjangoModelLoadingTestCase(django_test.TestCase): + """Tests FACTORY_FOR = 'app.Model' pattern.""" + + def test_loading(self): + class ExampleFactory(factory.DjangoModelFactory): + FACTORY_FOR = 'djapp.StandardModel' + + self.assertEqual(models.StandardModel, ExampleFactory._get_target_class()) + + def test_building(self): + class ExampleFactory(factory.DjangoModelFactory): + FACTORY_FOR = 'djapp.StandardModel' + + e = ExampleFactory.build() + self.assertEqual(models.StandardModel, e.__class__) + + def test_inherited_loading(self): + """Proper loading of a model within 'child' factories. + + See https://github.com/rbarrois/factory_boy/issues/109. + """ + class ExampleFactory(factory.DjangoModelFactory): + FACTORY_FOR = 'djapp.StandardModel' + + class Example2Factory(ExampleFactory): + pass + + e = Example2Factory.build() + self.assertEqual(models.StandardModel, e.__class__) + + def test_inherited_loading_and_sequence(self): + """Proper loading of a model within 'child' factories. + + See https://github.com/rbarrois/factory_boy/issues/109. + """ + class ExampleFactory(factory.DjangoModelFactory): + FACTORY_FOR = 'djapp.StandardModel' + + foo = factory.Sequence(lambda n: n) + + class Example2Factory(ExampleFactory): + FACTORY_FOR = 'djapp.StandardSon' + + self.assertEqual(models.StandardSon, Example2Factory._get_target_class()) + + e1 = ExampleFactory.build() + e2 = Example2Factory.build() + e3 = ExampleFactory.build() + self.assertEqual(models.StandardModel, e1.__class__) + self.assertEqual(models.StandardSon, e2.__class__) + self.assertEqual(models.StandardModel, e3.__class__) + self.assertEqual(1, e1.foo) + self.assertEqual(2, e2.foo) + self.assertEqual(3, e3.foo) + + +@unittest.skipIf(django is None, "Django not installed.") +class DjangoNonIntegerPkTestCase(django_test.TestCase): + def setUp(self): + super(DjangoNonIntegerPkTestCase, self).setUp() + NonIntegerPkFactory.reset_sequence() + + def test_first(self): + nonint = NonIntegerPkFactory.build() + self.assertEqual('foo1', nonint.foo) + + def test_many(self): + nonint1 = NonIntegerPkFactory.build() + nonint2 = NonIntegerPkFactory.build() + + self.assertEqual('foo1', nonint1.foo) + self.assertEqual('foo2', nonint2.foo) + + def test_creation(self): + nonint1 = NonIntegerPkFactory.create() + self.assertEqual('foo1', nonint1.foo) + self.assertEqual('foo1', nonint1.pk) + + NonIntegerPkFactory.reset_sequence() + nonint2 = NonIntegerPkFactory.build() + self.assertEqual('foo1', nonint2.foo) + + def test_force_pk(self): + nonint1 = NonIntegerPkFactory.create(pk='foo10') + self.assertEqual('foo10', nonint1.foo) + self.assertEqual('foo10', nonint1.pk) + + NonIntegerPkFactory.reset_sequence() + nonint2 = NonIntegerPkFactory.create() + self.assertEqual('foo1', nonint2.foo) + self.assertEqual('foo1', nonint2.pk) + + +@unittest.skipIf(django is None, "Django not installed.") +class DjangoAbstractBaseSequenceTestCase(django_test.TestCase): + def test_auto_sequence(self): + with factory.debug(): + obj = ConcreteSonFactory() + self.assertEqual(1, obj.pk) + + +@unittest.skipIf(django is None, "Django not installed.") +class DjangoFileFieldTestCase(unittest.TestCase): + + def tearDown(self): + super(DjangoFileFieldTestCase, self).tearDown() + for path in os.listdir(models.WITHFILE_UPLOAD_DIR): + # Remove temporary files written during tests. + os.unlink(os.path.join(models.WITHFILE_UPLOAD_DIR, path)) + + def test_default_build(self): + o = WithFileFactory.build() + self.assertIsNone(o.pk) + self.assertEqual(b'', o.afile.read()) + self.assertEqual('django/example.dat', o.afile.name) + + def test_default_create(self): + o = WithFileFactory.create() + self.assertIsNotNone(o.pk) + self.assertEqual(b'', o.afile.read()) + self.assertEqual('django/example.dat', o.afile.name) + + def test_with_content(self): + o = WithFileFactory.build(afile__data='foo') + self.assertIsNone(o.pk) + self.assertEqual(b'foo', o.afile.read()) + self.assertEqual('django/example.dat', o.afile.name) + + def test_with_file(self): + with open(testdata.TESTFILE_PATH, 'rb') as f: + o = WithFileFactory.build(afile__from_file=f) + self.assertIsNone(o.pk) + self.assertEqual(b'example_data\n', o.afile.read()) + self.assertEqual('django/example.data', o.afile.name) + + def test_with_path(self): + o = WithFileFactory.build(afile__from_path=testdata.TESTFILE_PATH) + self.assertIsNone(o.pk) + self.assertEqual(b'example_data\n', o.afile.read()) + self.assertEqual('django/example.data', o.afile.name) + + def test_with_file_empty_path(self): + with open(testdata.TESTFILE_PATH, 'rb') as f: + o = WithFileFactory.build( + afile__from_file=f, + afile__from_path='' + ) + self.assertIsNone(o.pk) + self.assertEqual(b'example_data\n', o.afile.read()) + self.assertEqual('django/example.data', o.afile.name) + + def test_with_path_empty_file(self): + o = WithFileFactory.build( + afile__from_path=testdata.TESTFILE_PATH, + afile__from_file=None, + ) + self.assertIsNone(o.pk) + self.assertEqual(b'example_data\n', o.afile.read()) + self.assertEqual('django/example.data', o.afile.name) + + def test_error_both_file_and_path(self): + self.assertRaises(ValueError, WithFileFactory.build, + afile__from_file='fakefile', + afile__from_path=testdata.TESTFILE_PATH, + ) + + def test_override_filename_with_path(self): + o = WithFileFactory.build( + afile__from_path=testdata.TESTFILE_PATH, + afile__filename='example.foo', + ) + self.assertIsNone(o.pk) + self.assertEqual(b'example_data\n', o.afile.read()) + self.assertEqual('django/example.foo', o.afile.name) + + def test_existing_file(self): + o1 = WithFileFactory.build(afile__from_path=testdata.TESTFILE_PATH) + + o2 = WithFileFactory.build(afile=o1.afile) + self.assertIsNone(o2.pk) + self.assertEqual(b'example_data\n', o2.afile.read()) + self.assertEqual('django/example_1.data', o2.afile.name) + + def test_no_file(self): + o = WithFileFactory.build(afile=None) + self.assertIsNone(o.pk) + self.assertFalse(o.afile) + + +@unittest.skipIf(django is None, "Django not installed.") +@unittest.skipIf(Image is None, "PIL not installed.") +class DjangoImageFieldTestCase(unittest.TestCase): + + def tearDown(self): + super(DjangoImageFieldTestCase, self).tearDown() + for path in os.listdir(models.WITHFILE_UPLOAD_DIR): + # Remove temporary files written during tests. + os.unlink(os.path.join(models.WITHFILE_UPLOAD_DIR, path)) + + def test_default_build(self): + o = WithImageFactory.build() + self.assertIsNone(o.pk) + self.assertEqual(100, o.animage.width) + self.assertEqual(100, o.animage.height) + self.assertEqual('django/example.jpg', o.animage.name) + + def test_default_create(self): + o = WithImageFactory.create() + self.assertIsNotNone(o.pk) + self.assertEqual(100, o.animage.width) + self.assertEqual(100, o.animage.height) + self.assertEqual('django/example.jpg', o.animage.name) + + def test_with_content(self): + o = WithImageFactory.build(animage__width=13, animage__color='red') + self.assertIsNone(o.pk) + self.assertEqual(13, o.animage.width) + self.assertEqual(13, o.animage.height) + self.assertEqual('django/example.jpg', o.animage.name) + + i = Image.open(os.path.join(settings.MEDIA_ROOT, o.animage.name)) + colors = i.getcolors() + # 169 pixels with rgb(254, 0, 0) + self.assertEqual([(169, (254, 0, 0))], colors) + self.assertEqual('JPEG', i.format) + + def test_gif(self): + o = WithImageFactory.build(animage__width=13, animage__color='blue', animage__format='GIF') + self.assertIsNone(o.pk) + self.assertEqual(13, o.animage.width) + self.assertEqual(13, o.animage.height) + self.assertEqual('django/example.jpg', o.animage.name) + + i = Image.open(os.path.join(settings.MEDIA_ROOT, o.animage.name)) + colors = i.getcolors() + # 169 pixels with color 190 from the GIF palette + self.assertEqual([(169, 190)], colors) + self.assertEqual('GIF', i.format) + + def test_with_file(self): + with open(testdata.TESTIMAGE_PATH, 'rb') as f: + o = WithImageFactory.build(animage__from_file=f) + self.assertIsNone(o.pk) + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(o.animage.read())) + self.assertEqual('django/example.jpeg', o.animage.name) + + def test_with_path(self): + o = WithImageFactory.build(animage__from_path=testdata.TESTIMAGE_PATH) + self.assertIsNone(o.pk) + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(o.animage.read())) + self.assertEqual('django/example.jpeg', o.animage.name) + + def test_with_file_empty_path(self): + with open(testdata.TESTIMAGE_PATH, 'rb') as f: + o = WithImageFactory.build( + animage__from_file=f, + animage__from_path='' + ) + self.assertIsNone(o.pk) + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(o.animage.read())) + self.assertEqual('django/example.jpeg', o.animage.name) + + def test_with_path_empty_file(self): + o = WithImageFactory.build( + animage__from_path=testdata.TESTIMAGE_PATH, + animage__from_file=None, + ) + self.assertIsNone(o.pk) + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(o.animage.read())) + self.assertEqual('django/example.jpeg', o.animage.name) + + def test_error_both_file_and_path(self): + self.assertRaises(ValueError, WithImageFactory.build, + animage__from_file='fakefile', + animage__from_path=testdata.TESTIMAGE_PATH, + ) + + def test_override_filename_with_path(self): + o = WithImageFactory.build( + animage__from_path=testdata.TESTIMAGE_PATH, + animage__filename='example.foo', + ) + self.assertIsNone(o.pk) + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(o.animage.read())) + self.assertEqual('django/example.foo', o.animage.name) + + def test_existing_file(self): + o1 = WithImageFactory.build(animage__from_path=testdata.TESTIMAGE_PATH) + + o2 = WithImageFactory.build(animage=o1.animage) + self.assertIsNone(o2.pk) + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(o2.animage.read())) + self.assertEqual('django/example_1.jpeg', o2.animage.name) + + def test_no_file(self): + o = WithImageFactory.build(animage=None) + self.assertIsNone(o.pk) + self.assertFalse(o.animage) + + +if __name__ == '__main__': # pragma: no cover + unittest.main() diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py index 70a2095..d6f33bb 100644 --- a/tests/test_fuzzy.py +++ b/tests/test_fuzzy.py @@ -21,9 +21,14 @@ # THE SOFTWARE. +import datetime +import decimal + +from factory import compat from factory import fuzzy from .compat import mock, unittest +from . import utils class FuzzyAttributeTestCase(unittest.TestCase): @@ -59,7 +64,7 @@ class FuzzyChoiceTestCase(unittest.TestCase): def options(): for i in range(3): yield i - + d = fuzzy.FuzzyChoice(options()) res = d.evaluate(2, None, False) @@ -102,3 +107,390 @@ class FuzzyIntegerTestCase(unittest.TestCase): res = fuzz.evaluate(2, None, False) self.assertEqual(8, res) + + +class FuzzyDecimalTestCase(unittest.TestCase): + def test_definition(self): + """Tests all ways of defining a FuzzyDecimal.""" + fuzz = fuzzy.FuzzyDecimal(2.0, 3.0) + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertTrue(decimal.Decimal('2.0') <= res <= decimal.Decimal('3.0'), + "value %d is not between 2.0 and 3.0" % res) + + fuzz = fuzzy.FuzzyDecimal(4.0) + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertTrue(decimal.Decimal('0.0') <= res <= decimal.Decimal('4.0'), + "value %d is not between 0.0 and 4.0" % res) + + fuzz = fuzzy.FuzzyDecimal(1.0, 4.0, precision=5) + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertTrue(decimal.Decimal('0.54') <= res <= decimal.Decimal('4.0'), + "value %d is not between 0.54 and 4.0" % res) + self.assertTrue(res.as_tuple().exponent, -5) + + def test_biased(self): + fake_uniform = lambda low, high: low + high + + fuzz = fuzzy.FuzzyDecimal(2.0, 8.0) + + with mock.patch('random.uniform', fake_uniform): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(decimal.Decimal('10.0'), res) + + def test_biased_high_only(self): + fake_uniform = lambda low, high: low + high + + fuzz = fuzzy.FuzzyDecimal(8.0) + + with mock.patch('random.uniform', fake_uniform): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(decimal.Decimal('8.0'), res) + + def test_precision(self): + fake_uniform = lambda low, high: low + high + 0.001 + + fuzz = fuzzy.FuzzyDecimal(8.0, precision=3) + + with mock.patch('random.uniform', fake_uniform): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(decimal.Decimal('8.001').quantize(decimal.Decimal(10) ** -3), res) + + +class FuzzyDateTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Setup useful constants + cls.jan1 = datetime.date(2013, 1, 1) + cls.jan3 = datetime.date(2013, 1, 3) + cls.jan31 = datetime.date(2013, 1, 31) + + def test_accurate_definition(self): + """Tests all ways of defining a FuzzyDate.""" + fuzz = fuzzy.FuzzyDate(self.jan1, self.jan31) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan31) + + def test_partial_definition(self): + """Test defining a FuzzyDate without passing an end date.""" + with utils.mocked_date_today(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyDate(self.jan1) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan3) + + def test_invalid_definition(self): + self.assertRaises(ValueError, fuzzy.FuzzyDate, + self.jan31, self.jan1) + + def test_invalid_partial_definition(self): + with utils.mocked_date_today(self.jan1, fuzzy): + self.assertRaises(ValueError, fuzzy.FuzzyDate, + self.jan31) + + def test_biased(self): + """Tests a FuzzyDate with a biased random.randint.""" + + fake_randint = lambda low, high: (low + high) // 2 + fuzz = fuzzy.FuzzyDate(self.jan1, self.jan31) + + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.date(2013, 1, 16), res) + + def test_biased_partial(self): + """Tests a FuzzyDate with a biased random and implicit upper bound.""" + with utils.mocked_date_today(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyDate(self.jan1) + + fake_randint = lambda low, high: (low + high) // 2 + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.date(2013, 1, 2), res) + + +class FuzzyNaiveDateTimeTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Setup useful constants + cls.jan1 = datetime.datetime(2013, 1, 1) + cls.jan3 = datetime.datetime(2013, 1, 3) + cls.jan31 = datetime.datetime(2013, 1, 31) + + def test_accurate_definition(self): + """Tests explicit definition of a FuzzyNaiveDateTime.""" + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan31) + + def test_partial_definition(self): + """Test defining a FuzzyNaiveDateTime without passing an end date.""" + with utils.mocked_datetime_now(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan3) + + def test_aware_start(self): + """Tests that a timezone-aware start datetime is rejected.""" + self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, + self.jan1.replace(tzinfo=compat.UTC), self.jan31) + + def test_aware_end(self): + """Tests that a timezone-aware end datetime is rejected.""" + self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, + self.jan1, self.jan31.replace(tzinfo=compat.UTC)) + + def test_force_year(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_year=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.year) + + def test_force_month(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_month=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.month) + + def test_force_day(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_day=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.day) + + def test_force_hour(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_hour=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.hour) + + def test_force_minute(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_minute=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.minute) + + def test_force_second(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_second=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.second) + + def test_force_microsecond(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_microsecond=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.microsecond) + + def test_invalid_definition(self): + self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, + self.jan31, self.jan1) + + def test_invalid_partial_definition(self): + with utils.mocked_datetime_now(self.jan1, fuzzy): + self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, + self.jan31) + + def test_biased(self): + """Tests a FuzzyDate with a biased random.randint.""" + + fake_randint = lambda low, high: (low + high) // 2 + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31) + + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.datetime(2013, 1, 16), res) + + def test_biased_partial(self): + """Tests a FuzzyDate with a biased random and implicit upper bound.""" + with utils.mocked_datetime_now(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1) + + fake_randint = lambda low, high: (low + high) // 2 + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.datetime(2013, 1, 2), res) + + +class FuzzyDateTimeTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Setup useful constants + cls.jan1 = datetime.datetime(2013, 1, 1, tzinfo=compat.UTC) + cls.jan3 = datetime.datetime(2013, 1, 3, tzinfo=compat.UTC) + cls.jan31 = datetime.datetime(2013, 1, 31, tzinfo=compat.UTC) + + def test_accurate_definition(self): + """Tests explicit definition of a FuzzyDateTime.""" + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan31) + + def test_partial_definition(self): + """Test defining a FuzzyDateTime without passing an end date.""" + with utils.mocked_datetime_now(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyDateTime(self.jan1) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan3) + + def test_invalid_definition(self): + self.assertRaises(ValueError, fuzzy.FuzzyDateTime, + self.jan31, self.jan1) + + def test_invalid_partial_definition(self): + with utils.mocked_datetime_now(self.jan1, fuzzy): + self.assertRaises(ValueError, fuzzy.FuzzyDateTime, + self.jan31) + + def test_naive_start(self): + """Tests that a timezone-naive start datetime is rejected.""" + self.assertRaises(ValueError, fuzzy.FuzzyDateTime, + self.jan1.replace(tzinfo=None), self.jan31) + + def test_naive_end(self): + """Tests that a timezone-naive end datetime is rejected.""" + self.assertRaises(ValueError, fuzzy.FuzzyDateTime, + self.jan1, self.jan31.replace(tzinfo=None)) + + def test_force_year(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_year=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.year) + + def test_force_month(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_month=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.month) + + def test_force_day(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_day=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.day) + + def test_force_hour(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_hour=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.hour) + + def test_force_minute(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_minute=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.minute) + + def test_force_second(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_second=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.second) + + def test_force_microsecond(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_microsecond=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.microsecond) + + def test_biased(self): + """Tests a FuzzyDate with a biased random.randint.""" + + fake_randint = lambda low, high: (low + high) // 2 + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31) + + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.datetime(2013, 1, 16, tzinfo=compat.UTC), res) + + def test_biased_partial(self): + """Tests a FuzzyDate with a biased random and implicit upper bound.""" + with utils.mocked_datetime_now(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyDateTime(self.jan1) + + fake_randint = lambda low, high: (low + high) // 2 + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.datetime(2013, 1, 2, tzinfo=compat.UTC), res) + + +class FuzzyTextTestCase(unittest.TestCase): + + def test_unbiased(self): + chars = ['a', 'b', 'c'] + fuzz = fuzzy.FuzzyText(prefix='pre', suffix='post', chars=chars, length=12) + res = fuzz.evaluate(2, None, False) + + self.assertEqual('pre', res[:3]) + self.assertEqual('post', res[-4:]) + self.assertEqual(3 + 12 + 4, len(res)) + + for char in res[3:-4]: + self.assertIn(char, chars) + + def test_mock(self): + fake_choice = lambda chars: chars[0] + + chars = ['a', 'b', 'c'] + fuzz = fuzzy.FuzzyText(prefix='pre', suffix='post', chars=chars, length=4) + with mock.patch('random.choice', fake_choice): + res = fuzz.evaluate(2, None, False) + + self.assertEqual('preaaaapost', res) + + def test_generator(self): + def options(): + yield 'a' + yield 'b' + yield 'c' + + fuzz = fuzzy.FuzzyText(chars=options(), length=12) + res = fuzz.evaluate(2, None, False) + + self.assertEqual(12, len(res)) + + for char in res: + self.assertIn(char, ['a', 'b', 'c']) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..f5a66e5 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Mark Sandstrom +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import logging + +from factory import helpers + +from .compat import io, unittest + + +class DebugTest(unittest.TestCase): + """Tests for the 'factory.debug()' helper.""" + + def test_default_logger(self): + stream1 = io.StringIO() + stream2 = io.StringIO() + + l = logging.getLogger('factory.test') + h = logging.StreamHandler(stream1) + h.setLevel(logging.INFO) + l.addHandler(h) + + # Non-debug: no text gets out + l.debug("Test") + self.assertEqual('', stream1.getvalue()) + + with helpers.debug(stream=stream2): + # Debug: text goes to new stream only + l.debug("Test2") + + self.assertEqual('', stream1.getvalue()) + self.assertEqual("Test2\n", stream2.getvalue()) + + def test_alternate_logger(self): + stream1 = io.StringIO() + stream2 = io.StringIO() + + l1 = logging.getLogger('factory.test') + l2 = logging.getLogger('factory.foo') + h = logging.StreamHandler(stream1) + h.setLevel(logging.DEBUG) + l2.addHandler(h) + + # Non-debug: no text gets out + l1.debug("Test") + self.assertEqual('', stream1.getvalue()) + l2.debug("Test") + self.assertEqual('', stream1.getvalue()) + + with helpers.debug('factory.test', stream=stream2): + # Debug: text goes to new stream only + l1.debug("Test2") + l2.debug("Test3") + + self.assertEqual("", stream1.getvalue()) + self.assertEqual("Test2\n", stream2.getvalue()) + diff --git a/tests/test_mongoengine.py b/tests/test_mongoengine.py new file mode 100644 index 0000000..803607a --- /dev/null +++ b/tests/test_mongoengine.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2013 Romain Command& +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Tests for factory_boy/MongoEngine interactions.""" + +import factory +import os +from .compat import unittest + + +try: + import mongoengine +except ImportError: + mongoengine = None + +if mongoengine: + from factory.mongoengine import MongoEngineFactory + + class Address(mongoengine.EmbeddedDocument): + street = mongoengine.StringField() + + class Person(mongoengine.Document): + name = mongoengine.StringField() + address = mongoengine.EmbeddedDocumentField(Address) + + class AddressFactory(MongoEngineFactory): + FACTORY_FOR = Address + + street = factory.Sequence(lambda n: 'street%d' % n) + + class PersonFactory(MongoEngineFactory): + FACTORY_FOR = Person + + name = factory.Sequence(lambda n: 'name%d' % n) + address = factory.SubFactory(AddressFactory) + + +@unittest.skipIf(mongoengine is None, "mongoengine not installed.") +class MongoEngineTestCase(unittest.TestCase): + + db_name = os.environ.get('MONGO_DATABASE', 'factory_boy_test') + db_host = os.environ.get('MONGO_HOST', 'localhost') + db_port = int(os.environ.get('MONGO_PORT', '27017')) + + @classmethod + def setUpClass(cls): + cls.db = mongoengine.connect(cls.db_name, host=cls.db_host, port=cls.db_port) + + @classmethod + def tearDownClass(cls): + cls.db.drop_database(cls.db_name) + + def setUp(self): + mongoengine.connect('factory_boy_test') + + def test_build(self): + std = PersonFactory.build() + self.assertEqual('name0', std.name) + self.assertEqual('street0', std.address.street) + self.assertIsNone(std.id) + + def test_creation(self): + std1 = PersonFactory.create() + self.assertEqual('name1', std1.name) + self.assertEqual('street1', std1.address.street) + self.assertIsNotNone(std1.id) diff --git a/tests/test_using.py b/tests/test_using.py index 821fad3..3979cd0 100644 --- a/tests/test_using.py +++ b/tests/test_using.py @@ -125,7 +125,7 @@ class SimpleBuildTestCase(unittest.TestCase): self.assertEqual(obj.foo, 'bar') def test_create_custom_base(self): - obj = factory.create(FakeModel, foo='bar', FACTORY_CLASS=factory.DjangoModelFactory) + obj = factory.create(FakeModel, foo='bar', FACTORY_CLASS=factory.django.DjangoModelFactory) self.assertEqual(obj.id, 2) self.assertEqual(obj.foo, 'bar') @@ -141,7 +141,7 @@ class SimpleBuildTestCase(unittest.TestCase): def test_create_batch_custom_base(self): objs = factory.create_batch(FakeModel, 4, foo='bar', - FACTORY_CLASS=factory.DjangoModelFactory) + FACTORY_CLASS=factory.django.DjangoModelFactory) self.assertEqual(4, len(objs)) self.assertEqual(4, len(set(objs))) @@ -177,7 +177,7 @@ class SimpleBuildTestCase(unittest.TestCase): def test_generate_create_custom_base(self): obj = factory.generate(FakeModel, factory.CREATE_STRATEGY, foo='bar', - FACTORY_CLASS=factory.DjangoModelFactory) + FACTORY_CLASS=factory.django.DjangoModelFactory) self.assertEqual(obj.id, 2) self.assertEqual(obj.foo, 'bar') @@ -208,7 +208,7 @@ class SimpleBuildTestCase(unittest.TestCase): def test_generate_batch_create_custom_base(self): objs = factory.generate_batch(FakeModel, factory.CREATE_STRATEGY, 20, foo='bar', - FACTORY_CLASS=factory.DjangoModelFactory) + FACTORY_CLASS=factory.django.DjangoModelFactory) self.assertEqual(20, len(objs)) self.assertEqual(20, len(set(objs))) @@ -238,7 +238,7 @@ class SimpleBuildTestCase(unittest.TestCase): self.assertEqual(obj.foo, 'bar') def test_simple_generate_create_custom_base(self): - obj = factory.simple_generate(FakeModel, True, foo='bar', FACTORY_CLASS=factory.DjangoModelFactory) + obj = factory.simple_generate(FakeModel, True, foo='bar', FACTORY_CLASS=factory.django.DjangoModelFactory) self.assertEqual(obj.id, 2) self.assertEqual(obj.foo, 'bar') @@ -264,7 +264,7 @@ class SimpleBuildTestCase(unittest.TestCase): def test_simple_generate_batch_create_custom_base(self): objs = factory.simple_generate_batch(FakeModel, True, 20, foo='bar', - FACTORY_CLASS=factory.DjangoModelFactory) + FACTORY_CLASS=factory.django.DjangoModelFactory) self.assertEqual(20, len(objs)) self.assertEqual(20, len(set(objs))) @@ -299,7 +299,7 @@ class UsingFactoryTestCase(unittest.TestCase): test_object = TestObjectFactory.build() self.assertEqual(test_object.one, 'one') - def test_inheritance(self): + def test_inheriting_target_class(self): @factory.use_strategy(factory.BUILD_STRATEGY) class TestObjectFactory(factory.Factory, TestObject): FACTORY_FOR = TestObject @@ -730,6 +730,78 @@ class UsingFactoryTestCase(unittest.TestCase): test_object_alt = TestObjectFactory.build() self.assertEqual(None, test_object_alt.three) + def test_inheritance_and_sequences(self): + """Sequence counters should be kept within an inheritance chain.""" + class TestObjectFactory(factory.Factory): + FACTORY_FOR = TestObject + + one = factory.Sequence(lambda n: n) + + class TestObjectFactory2(TestObjectFactory): + FACTORY_FOR = TestObject + + to1a = TestObjectFactory() + self.assertEqual(0, to1a.one) + to2a = TestObjectFactory2() + self.assertEqual(1, to2a.one) + to1b = TestObjectFactory() + self.assertEqual(2, to1b.one) + to2b = TestObjectFactory2() + self.assertEqual(3, to2b.one) + + def test_inheritance_sequence_inheriting_objects(self): + """Sequence counters are kept with inheritance, incl. misc objects.""" + class TestObject2(TestObject): + pass + + class TestObjectFactory(factory.Factory): + FACTORY_FOR = TestObject + + one = factory.Sequence(lambda n: n) + + class TestObjectFactory2(TestObjectFactory): + FACTORY_FOR = TestObject2 + + to1a = TestObjectFactory() + self.assertEqual(0, to1a.one) + to2a = TestObjectFactory2() + self.assertEqual(1, to2a.one) + to1b = TestObjectFactory() + self.assertEqual(2, to1b.one) + to2b = TestObjectFactory2() + self.assertEqual(3, to2b.one) + + def test_inheritance_sequence_unrelated_objects(self): + """Sequence counters are kept with inheritance, unrelated objects. + + See issue https://github.com/rbarrois/factory_boy/issues/93 + + Problem: sequence counter is somewhat shared between factories + until the "slave" factory has been called. + """ + + class TestObject2(object): + def __init__(self, one): + self.one = one + + class TestObjectFactory(factory.Factory): + FACTORY_FOR = TestObject + + one = factory.Sequence(lambda n: n) + + class TestObjectFactory2(TestObjectFactory): + FACTORY_FOR = TestObject2 + + to1a = TestObjectFactory() + self.assertEqual(0, to1a.one) + to2a = TestObjectFactory2() + self.assertEqual(0, to2a.one) + to1b = TestObjectFactory() + self.assertEqual(1, to1b.one) + to2b = TestObjectFactory2() + self.assertEqual(1, to2b.one) + + def test_inheritance_with_inherited_class(self): class TestObjectFactory(factory.Factory): FACTORY_FOR = TestObject @@ -946,9 +1018,9 @@ class SubFactoryTestCase(unittest.TestCase): class TestModel2Factory(FakeModelFactory): FACTORY_FOR = TestModel2 two = factory.SubFactory(TestModelFactory, - one=factory.Sequence(lambda n: 'x%dx' % n), - two=factory.LazyAttribute( - lambda o: '%s%s' % (o.one, o.one))) + one=factory.Sequence(lambda n: 'x%dx' % n), + two=factory.LazyAttribute(lambda o: '%s%s' % (o.one, o.one)), + ) test_model = TestModel2Factory(one=42) self.assertEqual('x0x', test_model.two.one) @@ -1056,6 +1128,32 @@ class SubFactoryTestCase(unittest.TestCase): self.assertEqual(outer.wrap.wrapped.two.four, 4) self.assertEqual(outer.wrap.friend, 5) + def test_nested_subfactory_with_override(self): + """Tests replacing a SubFactory field with an actual value.""" + + # The test class + class TestObject(object): + def __init__(self, two='one', wrapped=None): + self.two = two + self.wrapped = wrapped + + # Innermost factory + class TestObjectFactory(factory.Factory): + FACTORY_FOR = TestObject + two = 'two' + + # Intermediary factory + class WrappingTestObjectFactory(factory.Factory): + FACTORY_FOR = TestObject + + wrapped = factory.SubFactory(TestObjectFactory) + wrapped__two = 'three' + + obj = TestObject(two='four') + outer = WrappingTestObjectFactory(wrapped=obj) + self.assertEqual(obj, outer.wrapped) + self.assertEqual('four', outer.wrapped.two) + def test_sub_factory_and_inheritance(self): """Test inheriting from a factory with subfactories, overriding.""" class TestObject(object): @@ -1253,7 +1351,7 @@ class IteratorTestCase(unittest.TestCase): @factory.iterator def one(): - for i in range(10, 50): + for i in range(10, 50): # pragma: no cover yield i objs = TestObjectFactory.build_batch(20) @@ -1298,7 +1396,7 @@ class BetterFakeModel(object): class DjangoModelFactoryTestCase(unittest.TestCase): def test_simple(self): - class FakeModelFactory(factory.DjangoModelFactory): + class FakeModelFactory(factory.django.DjangoModelFactory): FACTORY_FOR = FakeModel obj = FakeModelFactory(one=1) @@ -1312,7 +1410,7 @@ class DjangoModelFactoryTestCase(unittest.TestCase): class MyFakeModel(BetterFakeModel): objects = BetterFakeModelManager({'x': 1}, prev) - class MyFakeModelFactory(factory.DjangoModelFactory): + class MyFakeModelFactory(factory.django.DjangoModelFactory): FACTORY_FOR = MyFakeModel FACTORY_DJANGO_GET_OR_CREATE = ('x',) x = 1 @@ -1333,7 +1431,7 @@ class DjangoModelFactoryTestCase(unittest.TestCase): class MyFakeModel(BetterFakeModel): objects = BetterFakeModelManager({'x': 1, 'y': 2, 'z': 3}, prev) - class MyFakeModelFactory(factory.DjangoModelFactory): + class MyFakeModelFactory(factory.django.DjangoModelFactory): FACTORY_FOR = MyFakeModel FACTORY_DJANGO_GET_OR_CREATE = ('x', 'y', 'z') x = 1 @@ -1354,7 +1452,7 @@ class DjangoModelFactoryTestCase(unittest.TestCase): class MyFakeModel(BetterFakeModel): objects = BetterFakeModelManager({'x': 1}, prev) - class MyFakeModelFactory(factory.DjangoModelFactory): + class MyFakeModelFactory(factory.django.DjangoModelFactory): FACTORY_FOR = MyFakeModel FACTORY_DJANGO_GET_OR_CREATE = ('x',) x = 1 @@ -1375,7 +1473,7 @@ class DjangoModelFactoryTestCase(unittest.TestCase): class MyFakeModel(BetterFakeModel): objects = BetterFakeModelManager({'x': 1, 'y': 2, 'z': 3}, prev) - class MyFakeModelFactory(factory.DjangoModelFactory): + class MyFakeModelFactory(factory.django.DjangoModelFactory): FACTORY_FOR = MyFakeModel FACTORY_DJANGO_GET_OR_CREATE = ('x', 'y', 'z') x = 1 @@ -1389,6 +1487,72 @@ class DjangoModelFactoryTestCase(unittest.TestCase): self.assertEqual(4, obj.z) self.assertEqual(2, obj.id) + def test_sequence(self): + class TestModelFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = TestModel + + a = factory.Sequence(lambda n: 'foo_%s' % n) + + o1 = TestModelFactory() + o2 = TestModelFactory() + + self.assertEqual('foo_2', o1.a) + self.assertEqual('foo_3', o2.a) + + o3 = TestModelFactory.build() + o4 = TestModelFactory.build() + + self.assertEqual('foo_4', o3.a) + self.assertEqual('foo_5', o4.a) + + def test_no_get_or_create(self): + class TestModelFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = TestModel + + a = factory.Sequence(lambda n: 'foo_%s' % n) + + o = TestModelFactory() + self.assertEqual(None, o._defaults) + self.assertEqual('foo_2', o.a) + self.assertEqual(2, o.id) + + def test_get_or_create(self): + class TestModelFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = TestModel + FACTORY_DJANGO_GET_OR_CREATE = ('a', 'b') + + a = factory.Sequence(lambda n: 'foo_%s' % n) + b = 2 + c = 3 + d = 4 + + o = TestModelFactory() + self.assertEqual({'c': 3, 'd': 4}, o._defaults) + self.assertEqual('foo_2', o.a) + self.assertEqual(2, o.b) + self.assertEqual(3, o.c) + self.assertEqual(4, o.d) + self.assertEqual(2, o.id) + + def test_full_get_or_create(self): + """Test a DjangoModelFactory with all fields in get_or_create.""" + class TestModelFactory(factory.django.DjangoModelFactory): + FACTORY_FOR = TestModel + FACTORY_DJANGO_GET_OR_CREATE = ('a', 'b', 'c', 'd') + + a = factory.Sequence(lambda n: 'foo_%s' % n) + b = 2 + c = 3 + d = 4 + + o = TestModelFactory() + self.assertEqual({}, o._defaults) + self.assertEqual('foo_2', o.a) + self.assertEqual(2, o.b) + self.assertEqual(3, o.c) + self.assertEqual(4, o.d) + self.assertEqual(2, o.id) + class PostGenerationTestCase(unittest.TestCase): def test_post_generation(self): @@ -1580,6 +1744,45 @@ class PostGenerationTestCase(unittest.TestCase): self.assertEqual(4, related.two) +class RelatedFactoryExtractionTestCase(unittest.TestCase): + def setUp(self): + self.relateds = [] + + class TestRelatedObject(object): + def __init__(subself, obj): + self.relateds.append(subself) + subself.obj = obj + obj.related = subself + + class TestRelatedObjectFactory(factory.Factory): + FACTORY_FOR = TestRelatedObject + + class TestObjectFactory(factory.Factory): + FACTORY_FOR = TestObject + one = factory.RelatedFactory(TestRelatedObjectFactory, 'obj') + + self.TestRelatedObject = TestRelatedObject + self.TestRelatedObjectFactory = TestRelatedObjectFactory + self.TestObjectFactory = TestObjectFactory + + def test_no_extraction(self): + o = self.TestObjectFactory() + self.assertEqual(1, len(self.relateds)) + rel = self.relateds[0] + self.assertEqual(o, rel.obj) + self.assertEqual(rel, o.related) + + def test_passed_value(self): + o = self.TestObjectFactory(one=42) + self.assertEqual([], self.relateds) + self.assertFalse(hasattr(o, 'related')) + + def test_passed_none(self): + o = self.TestObjectFactory(one=None) + self.assertEqual([], self.relateds) + self.assertFalse(hasattr(o, 'related')) + + class CircularTestCase(unittest.TestCase): def test_example(self): sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) @@ -1767,73 +1970,5 @@ class ListTestCase(unittest.TestCase): ], o.two) -class DjangoModelFactoryTestCase(unittest.TestCase): - def test_sequence(self): - class TestModelFactory(factory.DjangoModelFactory): - FACTORY_FOR = TestModel - - a = factory.Sequence(lambda n: 'foo_%s' % n) - - o1 = TestModelFactory() - o2 = TestModelFactory() - - self.assertEqual('foo_2', o1.a) - self.assertEqual('foo_3', o2.a) - - o3 = TestModelFactory.build() - o4 = TestModelFactory.build() - - self.assertEqual('foo_4', o3.a) - self.assertEqual('foo_5', o4.a) - - def test_no_get_or_create(self): - class TestModelFactory(factory.DjangoModelFactory): - FACTORY_FOR = TestModel - - a = factory.Sequence(lambda n: 'foo_%s' % n) - - o = TestModelFactory() - self.assertEqual(None, o._defaults) - self.assertEqual('foo_2', o.a) - self.assertEqual(2, o.id) - - def test_get_or_create(self): - class TestModelFactory(factory.DjangoModelFactory): - FACTORY_FOR = TestModel - FACTORY_DJANGO_GET_OR_CREATE = ('a', 'b') - - a = factory.Sequence(lambda n: 'foo_%s' % n) - b = 2 - c = 3 - d = 4 - - o = TestModelFactory() - self.assertEqual({'c': 3, 'd': 4}, o._defaults) - self.assertEqual('foo_2', o.a) - self.assertEqual(2, o.b) - self.assertEqual(3, o.c) - self.assertEqual(4, o.d) - self.assertEqual(2, o.id) - - def test_full_get_or_create(self): - """Test a DjangoModelFactory with all fields in get_or_create.""" - class TestModelFactory(factory.DjangoModelFactory): - FACTORY_FOR = TestModel - FACTORY_DJANGO_GET_OR_CREATE = ('a', 'b', 'c', 'd') - - a = factory.Sequence(lambda n: 'foo_%s' % n) - b = 2 - c = 3 - d = 4 - - o = TestModelFactory() - self.assertEqual({}, o._defaults) - self.assertEqual('foo_2', o.a) - self.assertEqual(2, o.b) - self.assertEqual(3, o.c) - self.assertEqual(4, o.d) - self.assertEqual(2, o.id) - - -if __name__ == '__main__': +if __name__ == '__main__': # pragma: no cover unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py index b353c9d..d321c2a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -20,10 +20,14 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +from __future__ import unicode_literals + + +import itertools from factory import utils -from .compat import unittest +from .compat import is_python2, unittest class ExtractDictTestCase(unittest.TestCase): @@ -230,3 +234,154 @@ class ImportObjectTestCase(unittest.TestCase): def test_invalid_module(self): self.assertRaises(ImportError, utils.import_object, 'this-is-an-invalid-module', '__name__') + + +class LogPPrintTestCase(unittest.TestCase): + def test_nothing(self): + txt = utils.log_pprint() + self.assertEqual('', txt) + + def test_only_args(self): + txt = utils.log_pprint((1, 2, 3)) + self.assertEqual('1, 2, 3', txt) + + def test_only_kwargs(self): + txt = utils.log_pprint(kwargs={'a': 1, 'b': 2}) + self.assertIn(txt, ['a=1, b=2', 'b=2, a=1']) + + def test_bytes_args(self): + txt = utils.log_pprint((b'\xe1\xe2',)) + expected = "b'\\xe1\\xe2'" + if is_python2: + expected = expected.lstrip('b') + self.assertEqual(expected, txt) + + def test_text_args(self): + txt = utils.log_pprint(('ŧêßŧ',)) + expected = "'ŧêßŧ'" + if is_python2: + expected = "u'\\u0167\\xea\\xdf\\u0167'" + self.assertEqual(expected, txt) + + def test_bytes_kwargs(self): + txt = utils.log_pprint(kwargs={'x': b'\xe1\xe2', 'y': b'\xe2\xe1'}) + expected1 = "x=b'\\xe1\\xe2', y=b'\\xe2\\xe1'" + expected2 = "y=b'\\xe2\\xe1', x=b'\\xe1\\xe2'" + if is_python2: + expected1 = expected1.replace('b', '') + expected2 = expected2.replace('b', '') + self.assertIn(txt, (expected1, expected2)) + + def test_text_kwargs(self): + txt = utils.log_pprint(kwargs={'x': 'ŧêßŧ', 'y': 'ŧßêŧ'}) + expected1 = "x='ŧêßŧ', y='ŧßêŧ'" + expected2 = "y='ŧßêŧ', x='ŧêßŧ'" + if is_python2: + expected1 = "x=u'\\u0167\\xea\\xdf\\u0167', y=u'\\u0167\\xdf\\xea\\u0167'" + expected2 = "y=u'\\u0167\\xdf\\xea\\u0167', x=u'\\u0167\\xea\\xdf\\u0167'" + self.assertIn(txt, (expected1, expected2)) + + +class ResetableIteratorTestCase(unittest.TestCase): + def test_no_reset(self): + i = utils.ResetableIterator([1, 2, 3]) + self.assertEqual([1, 2, 3], list(i)) + + def test_no_reset_new_iterator(self): + i = utils.ResetableIterator([1, 2, 3]) + iterator = iter(i) + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + + iterator2 = iter(i) + self.assertEqual(3, next(iterator2)) + + def test_infinite(self): + i = utils.ResetableIterator(itertools.cycle([1, 2, 3])) + iterator = iter(i) + values = [next(iterator) for _i in range(10)] + self.assertEqual([1, 2, 3, 1, 2, 3, 1, 2, 3, 1], values) + + def test_reset_simple(self): + i = utils.ResetableIterator([1, 2, 3]) + iterator = iter(i) + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + + i.reset() + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + + def test_reset_at_begin(self): + i = utils.ResetableIterator([1, 2, 3]) + iterator = iter(i) + i.reset() + i.reset() + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + + def test_reset_at_end(self): + i = utils.ResetableIterator([1, 2, 3]) + iterator = iter(i) + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + + i.reset() + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + + def test_reset_after_end(self): + i = utils.ResetableIterator([1, 2, 3]) + iterator = iter(i) + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + self.assertRaises(StopIteration, next, iterator) + + i.reset() + # Previous iter() has stopped + iterator = iter(i) + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + + def test_reset_twice(self): + i = utils.ResetableIterator([1, 2, 3, 4, 5]) + iterator = iter(i) + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + + i.reset() + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + self.assertEqual(4, next(iterator)) + + i.reset() + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + self.assertEqual(4, next(iterator)) + + def test_reset_shorter(self): + i = utils.ResetableIterator([1, 2, 3, 4, 5]) + iterator = iter(i) + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + self.assertEqual(4, next(iterator)) + + i.reset() + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + + i.reset() + self.assertEqual(1, next(iterator)) + self.assertEqual(2, next(iterator)) + self.assertEqual(3, next(iterator)) + self.assertEqual(4, next(iterator)) + diff --git a/tests/testdata/__init__.py b/tests/testdata/__init__.py new file mode 100644 index 0000000..9956610 --- /dev/null +++ b/tests/testdata/__init__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +import os.path + +TESTDATA_ROOT = os.path.abspath(os.path.dirname(__file__)) +TESTFILE_PATH = os.path.join(TESTDATA_ROOT, 'example.data') +TESTIMAGE_PATH = os.path.join(TESTDATA_ROOT, 'example.jpeg') diff --git a/tests/testdata/example.data b/tests/testdata/example.data new file mode 100644 index 0000000..02ff8ec --- /dev/null +++ b/tests/testdata/example.data @@ -0,0 +1 @@ +example_data diff --git a/tests/testdata/example.jpeg b/tests/testdata/example.jpeg Binary files differnew file mode 100644 index 0000000..28beea9 --- /dev/null +++ b/tests/testdata/example.jpeg diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..215fc83 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Mark Sandstrom +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import datetime + +from .compat import mock +from . import alter_time + + +class MultiModulePatcher(object): + """An abstract context processor for patching multiple modules.""" + + def __init__(self, *target_modules, **kwargs): + super(MultiModulePatcher, self).__init__(**kwargs) + self.patchers = [self._build_patcher(mod) for mod in target_modules] + + def _build_patcher(self, target_module): # pragma: no cover + """Build a mock patcher for the target module.""" + raise NotImplementedError() + + def __enter__(self): + for patcher in self.patchers: + mocked_symbol = patcher.start() + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + for patcher in self.patchers: + patcher.stop() + + +class mocked_date_today(MultiModulePatcher): + """A context processor changing the value of date.today().""" + + def __init__(self, target_date, *target_modules, **kwargs): + self.target_date = target_date + super(mocked_date_today, self).__init__(*target_modules, **kwargs) + + def _build_patcher(self, target_module): + module_datetime = getattr(target_module, 'datetime') + return alter_time.mock_date_today(self.target_date, module_datetime) + + +class mocked_datetime_now(MultiModulePatcher): + def __init__(self, target_dt, *target_modules, **kwargs): + self.target_dt = target_dt + super(mocked_datetime_now, self).__init__(*target_modules, **kwargs) + + def _build_patcher(self, target_module): + module_datetime = getattr(target_module, 'datetime') + return alter_time.mock_datetime_now(self.target_dt, module_datetime) |