diff options
58 files changed, 1809 insertions, 477 deletions
@@ -5,6 +5,7 @@ # Build-related files docs/_build/ +auto_dev_requirements*.txt .coverage .tox *.egg-info diff --git a/.travis.yml b/.travis.yml index 2bfb978..ff805b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,16 @@ language: python python: - - "2.6" - "2.7" - - "3.2" - - "3.3" + - "3.4" + - "3.5" - "pypy" script: - - python setup.py test + - SKIP_MONGOENGINE=1 python setup.py test install: - - 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 + - make install-deps notifications: email: false @@ -1,5 +1,5 @@ Copyright (c) 2010 Mark Sandstrom -Copyright (c) 2011-2013 Raphaël Barrois +Copyright (c) 2011-2015 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 @@ -1,30 +1,60 @@ PACKAGE=factory TESTS_DIR=tests DOC_DIR=docs +EXAMPLES_DIR=examples # Use current python binary instead of system default. COVERAGE = python $(shell which coverage) +# Dependencies +DJANGO ?= 1.9 +NEXT_DJANGO = $(shell python -c "v='$(DJANGO)'; parts=v.split('.'); parts[-1]=str(int(parts[-1])+1); print('.'.join(parts))") + +ALCHEMY ?= 1.0 +NEXT_ALCHEMY = $(shell python -c "v='$(ALCHEMY)'; parts=v.split('.'); parts[-1]=str(int(parts[-1])+1); print('.'.join(parts))") + +MONGOENGINE ?= 0.10 +NEXT_MONGOENGINE = $(shell python -c "v='$(MONGOENGINE)'; parts=v.split('.'); parts[-1]=str(int(parts[-1])+1); print('.'.join(parts))") + +REQ_FILE = auto_dev_requirements_django$(DJANGO)_alchemy$(ALCHEMY)_mongoengine$(MONGOENGINE).txt +EXAMPLES_REQ_FILES = $(shell find $(EXAMPLES_DIR) -name requirements.txt) + all: default default: +install-deps: $(REQ_FILE) + pip install --upgrade pip setuptools + pip install --upgrade -r $< + pip freeze + +$(REQ_FILE): dev_requirements.txt requirements.txt $(EXAMPLES_REQ_FILES) + grep --no-filename "^[^#-]" $^ | egrep -v "^(Django|SQLAlchemy|mongoengine)" > $@ + echo "Django>=$(DJANGO),<$(NEXT_DJANGO)" >> $@ + echo "SQLAlchemy>=$(ALCHEMY),<$(NEXT_ALCHEMY)" >> $@ + echo "mongoengine>=$(MONGOENGINE),<$(NEXT_MONGOENGINE)" >> $@ + + clean: find . -type f -name '*.pyc' -delete find . -type f -path '*/__pycache__/*' -delete find . -type d -empty -delete + @rm -f auto_dev_requirements_* @rm -rf tmp_test/ -test: +test: install-deps example-test python -W default setup.py test +example-test: + $(MAKE) -C $(EXAMPLES_DIR) test + pylint: pylint --rcfile=.pylintrc --report=no $(PACKAGE)/ -coverage: +coverage: install-deps $(COVERAGE) erase $(COVERAGE) run "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" --branch setup.py test $(COVERAGE) report "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" @@ -34,4 +64,4 @@ doc: $(MAKE) -C $(DOC_DIR) html -.PHONY: all default clean coverage doc pylint test +.PHONY: all default clean coverage doc install-deps pylint test @@ -4,6 +4,22 @@ factory_boy .. image:: https://secure.travis-ci.org/rbarrois/factory_boy.png?branch=master :target: http://travis-ci.org/rbarrois/factory_boy/ +.. image:: https://img.shields.io/pypi/v/factory_boy.svg + :target: http://factoryboy.readthedocs.org/en/latest/changelog.html + :alt: Latest Version + +.. image:: https://img.shields.io/pypi/pyversions/factory_boy.svg + :target: https://pypi.python.org/pypi/factory_boy/ + :alt: Supported Python versions + +.. image:: https://img.shields.io/pypi/wheel/factory_boy.svg + :target: https://pypi.python.org/pypi/factory_boy/ + :alt: Wheel status + +.. image:: https://img.shields.io/pypi/l/factory_boy.svg + :target: https://pypi.python.org/pypi/factory_boy/ + :alt: License + factory_boy is a fixtures replacement based on thoughtbot's `factory_girl <http://github.com/thoughtbot/factory_girl>`_. As a fixtures replacement tool, it aims to replace static, hard to maintain fixtures @@ -52,7 +68,7 @@ Its main features include: - Straightforward declarative syntax - Chaining factory calls while retaining the global context -- Support for multiple build strategies (saved/unsaved instances, attribute dicts, stubbed objects) +- Support for multiple build strategies (saved/unsaved instances, stubbed objects) - Multiple factories per class support, including inheritance @@ -62,8 +78,9 @@ Links * Documentation: http://factoryboy.readthedocs.org/ * Repository: https://github.com/rbarrois/factory_boy * Package: https://pypi.python.org/pypi/factory_boy/ +* Mailing-list: `factoryboy@googlegroups.com <mailto:factoryboy@googlegroups.com>`_ | https://groups.google.com/forum/#!forum/factoryboy -factory_boy supports Python 2.6, 2.7, 3.2 and 3.3, as well as PyPy; it requires only the standard Python library. +factory_boy supports Python 2.6, 2.7, 3.2 to 3.5, as well as PyPy; it requires only the standard Python library. Download @@ -123,7 +140,7 @@ The class of the object must be defined in the ``model`` field of a ``class Meta Using factories """"""""""""""" -factory_boy supports several different build strategies: build, create, attributes and stub: +factory_boy supports several different build strategies: build, create, and stub: .. code-block:: python @@ -133,8 +150,8 @@ factory_boy supports several different build strategies: build, create, attribut # 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() + # Returns a stub object (just a bunch of attributes) + obj = UserFactory.stub() You can use the Factory class as a shortcut for the default build strategy: @@ -159,12 +176,38 @@ It is also possible to create a bunch of objects in a single call: .. code-block:: pycon - >>> users = UserFactory.build(10, first_name="Joe") + >>> users = UserFactory.build_batch(10, first_name="Joe") >>> len(users) 10 >>> [user.first_name for user in users] ["Joe", "Joe", "Joe", "Joe", "Joe", "Joe", "Joe", "Joe", "Joe", "Joe"] + +Realistic, random values +"""""""""""""""""""""""" + +Demos look better with random yet realistic values; and those realistic values can also help discover bugs. +For this, factory_boy relies on the excellent `fake-factory <https://pypi.python.org/pypi/fake-factory>`_ library: + +.. code-block:: python + + class RandomUserFactory(factory.Factory): + class Meta: + model = models.User + + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name') + +.. code-block:: pycon + + >>> UserFactory() + <User: Lucy Murray> + + +.. note:: Use of fully randomized data in tests is quickly a problem for reproducing broken builds. + To that purpose, factory_boy provides helpers to handle the random seeds it uses. + + Lazy Attributes """"""""""""""" @@ -250,7 +293,7 @@ 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: +A helper, `factory.debug()`, is available to ease debugging: .. code-block:: python @@ -281,12 +324,12 @@ This will yield messages similar to those (artificial indentation): ORM Support """"""""""" -factory_boy has specific support for a few ORMs, through specific :class:`~factory.Factory` subclasses: +factory_boy has specific support for a few ORMs, through specific ``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` +* Django, with ``factory.django.DjangoModelFactory`` +* Mogo, with ``factory.mogo.MogoFactory`` +* MongoEngine, with ``factory.mongoengine.MongoEngineFactory`` +* SQLAlchemy, with ``factory.alchemy.SQLAlchemyModelFactory`` Contributing ------------ @@ -294,25 +337,41 @@ 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. +Questions and suggestions are welcome on the `mailing-list <mailto:factoryboy@googlegroups.com>`_. All pull request should pass the test suite, which can be launched simply with: .. code-block:: sh - $ python setup.py test + $ make test + -.. note:: +In order to test coverage, please use: + +.. code-block:: sh - Running test requires the unittest2 (standard in Python 2.7+) and mock libraries. + $ make coverage -In order to test coverage, please use: +To test with a specific framework version, you may use: + +.. code-block:: sh + + $ make DJANGO=1.9 test + +Valid options are: + +* ``DJANGO`` for ``Django`` +* ``MONGOENGINE`` for ``mongoengine`` +* ``ALCHEMY`` for ``SQLAlchemy`` + + +To avoid running ``mongoengine`` tests (e.g no mongo server installed), run: .. code-block:: sh - $ pip install coverage - $ coverage erase; coverage run --branch setup.py test; coverage report + $ make SKIP_MONGOENGINE=1 test Contents, indices and tables diff --git a/dev_requirements.txt b/dev_requirements.txt index bdc23d0..c78aa9d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,6 +1,13 @@ +-r requirements.txt +-r examples/requirements.txt + coverage Django Pillow -sqlalchemy +SQLAlchemy mongoengine mock +wheel + +Sphinx +sphinx_rtd_theme diff --git a/docs/changelog.rst b/docs/changelog.rst index 7d77f7f..fa542f4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,107 @@ ChangeLog ========= +.. _v2.6.0: + +2.6.0 (2015-10-20) +------------------ + +*New:* + + - Add :attr:`factory.FactoryOptions.rename` to help handle conflicting names (:issue:`206`) + - Add support for random-yet-realistic values through `fake-factory <https://pypi.python.org/pypi/fake-factory>`_, + through the :class:`factory.Faker` class. + - :class:`factory.Iterator` no longer begins iteration of its argument at import time, + thus allowing to pass in a lazy iterator such as a Django queryset + (i.e ``factory.Iterator(models.MyThingy.objects.all())``). + - Simplify imports for ORM layers, now available through a simple ``factory`` import, + at ``factory.alchemy.SQLAlchemyModelFactory`` / ``factory.django.DjangoModelFactory`` / ``factory.mongoengine.MongoEngineFactory``. + +*Bugfix:* + + - :issue:`201`: Properly handle custom Django managers when dealing with abstract Django models. + - :issue:`212`: Fix :meth:`factory.django.mute_signals` to handle Django's signal caching + - :issue:`228`: Don't load :func:`django.apps.apps.get_model()` until required + - :issue:`219`: Stop using :meth:`mogo.model.Model.new()`, deprecated 4 years ago. + +.. _v2.5.2: + +2.5.2 (2015-04-21) +------------------ + +*Bugfix:* + + - Add support for Django 1.7/1.8 + - Add support for mongoengine>=0.9.0 / pymongo>=2.1 + +.. _v2.5.1: + +2.5.1 (2015-03-27) +------------------ + +*Bugfix:* + + - Respect custom managers in :class:`~factory.django.DjangoModelFactory` (see :issue:`192`) + - Allow passing declarations (e.g :class:`~factory.Sequence`) as parameters to :class:`~factory.django.FileField` + and :class:`~factory.django.ImageField`. + +.. _v2.5.0: + +2.5.0 (2015-03-26) +------------------ + +*New:* + + - Add support for getting/setting :mod:`factory.fuzzy`'s random state (see :issue:`175`, :issue:`185`). + - Support lazy evaluation of iterables in :class:`factory.fuzzy.FuzzyChoice` (see :issue:`184`). + - Support non-default databases at the factory level (see :issue:`171`) + - Make :class:`factory.django.FileField` and :class:`factory.django.ImageField` non-post_generation, i.e normal fields also available in ``save()`` (see :issue:`141`). + +*Bugfix:* + + - Avoid issues when using :meth:`factory.django.mute_signals` on a base factory class (see :issue:`183`). + - Fix limitations of :class:`factory.StubFactory`, that can now use :class:`factory.SubFactory` and co (see :issue:`131`). + + +*Deprecation:* + + - Remove deprecated features from :ref:`v2.4.0` + - Remove the auto-magical sequence setup (based on the latest primary key value in the database) for Django and SQLAlchemy; + this relates to issues :issue:`170`, :issue:`153`, :issue:`111`, :issue:`103`, :issue:`92`, :issue:`78`. See https://github.com/rbarrois/factory_boy/commit/13d310f for technical details. + +.. warning:: Version 2.5.0 removes the 'auto-magical sequence setup' bug-and-feature. + This could trigger some bugs when tests expected a non-zero sequence reference. + +Upgrading +""""""""" + +.. warning:: Version 2.5.0 removes features that were marked as deprecated in :ref:`v2.4.0 <v2.4.0>`. + +All ``FACTORY_*``-style attributes are now declared in a ``class Meta:`` section: + +.. code-block:: python + + # Old-style, deprecated + class MyFactory(factory.Factory): + FACTORY_FOR = models.MyModel + FACTORY_HIDDEN_ARGS = ['a', 'b', 'c'] + + # New-style + class MyFactory(factory.Factory): + class Meta: + model = models.MyModel + exclude = ['a', 'b', 'c'] + +A simple shell command to upgrade the code would be: + +.. code-block:: sh + + # sed -i: inplace update + # grep -l: only file names, not matching lines + sed -i 's/FACTORY_FOR =/class Meta:\n model =/' $(grep -l FACTORY_FOR $(find . -name '*.py')) + +This takes care of all ``FACTORY_FOR`` occurences; the files containing other attributes to rename can be found with ``grep -R FACTORY .`` + .. _v2.4.1: @@ -19,7 +120,7 @@ ChangeLog *New:* - Add support for :attr:`factory.fuzzy.FuzzyInteger.step`, thanks to `ilya-pirogov <https://github.com/ilya-pirogov>`_ (:issue:`120`) - - Add :meth:`~factory.django.mute_signals` decorator to temporarily disable some signals, thanks to `ilya-pirogov <https://github.com>`_ (:issue:`122`) + - Add :meth:`~factory.django.mute_signals` decorator to temporarily disable some signals, thanks to `ilya-pirogov <https://github.com/ilya-pirogov>`_ (:issue:`122`) - Add :class:`~factory.fuzzy.FuzzyFloat` (:issue:`124`) - Declare target model and other non-declaration fields in a ``class Meta`` section. @@ -31,8 +132,6 @@ ChangeLog For :class:`factory.Factory`: * Rename :attr:`~factory.Factory.FACTORY_FOR` to :attr:`~factory.FactoryOptions.model` - - * Rename :attr:`~factory.Factory.FACTORY_FOR` to :attr:`~factory.FactoryOptions.model` * Rename :attr:`~factory.Factory.ABSTRACT_FACTORY` to :attr:`~factory.FactoryOptions.abstract` * Rename :attr:`~factory.Factory.FACTORY_STRATEGY` to :attr:`~factory.FactoryOptions.strategy` * Rename :attr:`~factory.Factory.FACTORY_ARG_PARAMETERS` to :attr:`~factory.FactoryOptions.inline_args` diff --git a/docs/conf.py b/docs/conf.py index 4f76d45..d5b86f4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,7 +51,7 @@ master_doc = 'index' # General information about the project. project = u'Factory Boy' -copyright = u'2011-2013, Raphaël Barrois, Mark Sandstrom' +copyright = u'2011-2015, Raphaël Barrois, Mark Sandstrom' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -114,7 +114,7 @@ pygments_style = 'sphinx' # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -247,7 +247,7 @@ intersphinx_mapping = { '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', + 'http://docs.sqlalchemy.org/en/rel_0_9/', + 'http://docs.sqlalchemy.org/en/rel_0_9/objects.inv', ), } diff --git a/docs/examples.rst b/docs/examples.rst index ee521e3..e7f6057 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -114,9 +114,9 @@ We can now use our factories, for tests: def test_get_profile_stats(self): profiles = [] - profiles.extend(factories.ProfileFactory.batch_create(4)) - profiles.extend(factories.FemaleProfileFactory.batch_create(2)) - profiles.extend(factories.ProfileFactory.batch_create(2, planet="Tatooine")) + profiles.extend(factories.ProfileFactory.create_batch(4)) + profiles.extend(factories.FemaleProfileFactory.create_batch(2)) + profiles.extend(factories.ProfileFactory.create_batch(2, planet="Tatooine")) stats = business_logic.profile_stats(profiles) self.assertEqual({'Earth': 6, 'Mars': 2}, stats.planets) @@ -130,7 +130,7 @@ Or for fixtures: from . import factories def make_objects(): - factories.ProfileFactory.batch_create(size=50) + factories.ProfileFactory.create_batch(size=50) # Let's create a few, known objects. factories.ProfileFactory( diff --git a/docs/fuzzy.rst b/docs/fuzzy.rst index 1480419..6b06608 100644 --- a/docs/fuzzy.rst +++ b/docs/fuzzy.rst @@ -8,6 +8,8 @@ Some tests may be interested in testing with fuzzy, random values. This is handled by the :mod:`factory.fuzzy` module, which provides a few random declarations. +.. note:: Use ``import factory.fuzzy`` to load this module. + FuzzyAttribute -------------- @@ -62,8 +64,11 @@ FuzzyChoice The :class:`FuzzyChoice` fuzzer yields random choices from the given iterable. - .. note:: The passed in :attr:`choices` will be converted into a list at - declaration time. + .. note:: The passed in :attr:`choices` will be converted into a list upon + first use, not at declaration time. + + This allows passing in, for instance, a Django queryset that will + only hit the database during the database, not at import time. .. attribute:: choices @@ -338,3 +343,33 @@ They should inherit from the :class:`BaseFuzzyAttribute` class, and override its The method responsible for generating random values. *Must* be overridden in subclasses. + + +Managing randomness +------------------- + +Using :mod:`random` in factories allows to "fuzz" a program efficiently. +However, it's sometimes required to *reproduce* a failing test. + +:mod:`factory.fuzzy` uses a separate instance of :class:`random.Random`, +and provides a few helpers for this: + +.. method:: get_random_state() + + Call :meth:`get_random_state` to retrieve the random generator's current + state. + +.. method:: set_random_state(state) + + Use :meth:`set_random_state` to set a custom state into the random generator + (fetched from :meth:`get_random_state` in a previous run, for instance) + +.. method:: reseed_random(seed) + + The :meth:`reseed_random` function allows to load a chosen seed into the random generator. + + +Custom :class:`BaseFuzzyAttribute` subclasses **SHOULD** +use :obj:`factory.fuzzy._random` as a randomness source; this ensures that +data they generate can be regenerated using the simple state from +:meth:`get_random_state`. diff --git a/docs/ideas.rst b/docs/ideas.rst index f3c9e62..6e3962d 100644 --- a/docs/ideas.rst +++ b/docs/ideas.rst @@ -6,4 +6,4 @@ This is a list of future features that may be incorporated into factory_boy: * When a :class:`Factory` is built or created, pass the calling context throughout the calling chain instead of custom solutions everywhere * Define a proper set of rules for the support of third-party ORMs - +* Properly evaluate nested declarations (e.g ``factory.fuzzy.FuzzyDate(start_date=factory.SelfAttribute('since'))``) diff --git a/docs/orms.rst b/docs/orms.rst index 2aa27b2..af20917 100644 --- a/docs/orms.rst +++ b/docs/orms.rst @@ -15,7 +15,7 @@ Django The first versions of factory_boy were designed specifically for Django, -but the library has now evolved to be framework-independant. +but the library has now evolved to be framework-independent. Most features should thus feel quite familiar to Django users. @@ -35,20 +35,29 @@ All factories for a Django :class:`~django.db.models.Model` should use the * The :attr:`~factory.FactoryOptions.model` 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. - .. attribute:: FACTORY_DJANGO_GET_OR_CREATE - .. deprecated:: 2.4.0 - See :attr:`DjangoOptions.django_get_or_create`. +.. note:: With Django versions 1.8.0 to 1.8.3, it was no longer possible to call ``.build()`` + on a factory if this factory used a :class:`~factory.SubFactory` pointing + to another model: Django refused to set a :class:`~djang.db.models.ForeignKey` + to an unsaved :class:`~django.db.models.Model` instance. + + See https://code.djangoproject.com/ticket/10811 and https://code.djangoproject.com/ticket/25160 for details. .. class:: DjangoOptions(factory.base.FactoryOptions) - The ``class Meta`` on a :class:`~DjangoModelFactory` supports an extra parameter: + The ``class Meta`` on a :class:`~DjangoModelFactory` supports extra parameters: + + .. attribute:: database + + .. versionadded:: 2.5.0 + + All queries to the related model will be routed to the given database. + It defaults to ``'default'``. .. attribute:: django_get_or_create @@ -117,7 +126,7 @@ Extra fields :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 + if available, unless ``filename`` is also provided. :param bytes data: Use the provided bytes as file contents :param str filename: The filename for the FileField @@ -264,6 +273,34 @@ factory_boy supports `MongoEngine`_-style models, through the :class:`MongoEngin This feature makes it possible to use :class:`~factory.SubFactory` to create embedded document. +A minimalist example: + +.. code-block:: python + + import mongoengine + + class Address(mongoengine.EmbeddedDocument): + street = mongoengine.StringField() + + class Person(mongoengine.Document): + name = mongoengine.StringField() + address = mongoengine.EmbeddedDocumentField(Address) + + import factory + + class AddressFactory(factory.mongoengine.MongoEngineFactory): + class Meta: + model = Address + + street = factory.Sequence(lambda n: 'street%d' % n) + + class PersonFactory(factory.mongoengine.MongoEngineFactory): + class Meta: + model = Person + + name = factory.Sequence(lambda n: 'name%d' % n) + address = factory.SubFactory(AddressFactory) + SQLAlchemy ---------- @@ -284,12 +321,7 @@ To work, this class needs an `SQLAlchemy`_ session object affected to the :attr: 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 - - .. deprecated:: 2.4.0 - See :attr:`~SQLAlchemyOptions.sqlalchemy_session`. .. class:: SQLAlchemyOptions(factory.base.FactoryOptions) @@ -301,7 +333,11 @@ To work, this class needs an `SQLAlchemy`_ session object affected to the :attr: SQLAlchemy session to use to communicate with the database when creating an object through this :class:`SQLAlchemyModelFactory`. -A (very) simple exemple: + .. attribute:: force_flush + + Force a session flush() at the end of :func:`~factory.alchemy.SQLAlchemyModelFactory._create()`. + +A (very) simple example: .. code-block:: python @@ -309,9 +345,8 @@ A (very) simple exemple: 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) + session = scoped_session(sessionmaker(bind=engine)) Base = declarative_base() @@ -324,8 +359,9 @@ A (very) simple exemple: Base.metadata.create_all(engine) + import factory - class UserFactory(SQLAlchemyModelFactory): + class UserFactory(factory.alchemy.SQLAlchemyModelFactory): class Meta: model = User sqlalchemy_session = session # the SQLAlchemy session object @@ -341,3 +377,112 @@ A (very) simple exemple: <User: User 1> >>> session.query(User).all() [<User: User 1>] + + +Managing sessions +""""""""""""""""" + +Since `SQLAlchemy`_ is a general purpose library, +there is no "global" session management system. + +The most common pattern when working with unit tests and ``factory_boy`` +is to use `SQLAlchemy`_'s :class:`sqlalchemy.orm.scoping.scoped_session`: + +* The test runner configures some project-wide :class:`~sqlalchemy.orm.scoping.scoped_session` +* Each :class:`~SQLAlchemyModelFactory` subclass uses this + :class:`~sqlalchemy.orm.scoping.scoped_session` as its :attr:`~SQLAlchemyOptions.sqlalchemy_session` +* The :meth:`~unittest.TestCase.tearDown` method of tests calls + :meth:`Session.remove <sqlalchemy.orm.scoping.scoped_session.remove>` + to reset the session. + +.. note:: See the excellent :ref:`SQLAlchemy guide on scoped_session <sqlalchemy:unitofwork_contextual>` + for details of :class:`~sqlalchemy.orm.scoping.scoped_session`'s usage. + + The basic idea is that declarative parts of the code (including factories) + need a simple way to access the "current session", + but that session will only be created and configured at a later point. + + The :class:`~sqlalchemy.orm.scoping.scoped_session` handles this, + by virtue of only creating the session when a query is sent to the database. + + +Here is an example layout: + +- A global (test-only?) file holds the :class:`~sqlalchemy.orm.scoping.scoped_session`: + +.. code-block:: python + + # myprojet/test/common.py + + from sqlalchemy import orm + Session = orm.scoped_session(orm.sessionmaker()) + + +- All factory access it: + +.. code-block:: python + + # myproject/factories.py + + import factory + import factory.alchemy + + from . import models + from .test import common + + class UserFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = models.User + + # Use the not-so-global scoped_session + # Warning: DO NOT USE common.Session()! + sqlalchemy_session = common.Session + + name = factory.Sequence(lambda n: "User %d" % n) + + +- The test runner configures the :class:`~sqlalchemy.orm.scoping.scoped_session` when it starts: + +.. code-block:: python + + # myproject/test/runtests.py + + import sqlalchemy + + from . import common + + def runtests(): + engine = sqlalchemy.create_engine('sqlite://') + + # It's a scoped_session, and now is the time to configure it. + common.Session.configure(bind=engine) + + run_the_tests + + +- :class:`test cases <unittest.TestCase>` use this ``scoped_session``, + and clear it after each test (for isolation): + +.. code-block:: python + + # myproject/test/test_stuff.py + + import unittest + + from . import common + + class MyTest(unittest.TestCase): + + def setUp(self): + # Prepare a new, clean session + self.session = common.Session() + + def test_something(self): + u = factories.UserFactory() + self.assertEqual([u], self.session.query(User).all()) + + def tearDown(self): + # Rollback the session => no changes to the database + self.session.rollback() + # Remove it, so that the next test gets a new Session() + common.Session.remove() diff --git a/docs/recipes.rst b/docs/recipes.rst index 72dacef..df86bac 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -33,6 +33,29 @@ use the :class:`~factory.SubFactory` declaration: group = factory.SubFactory(GroupFactory) +Choosing from a populated table +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the target of the :class:`~django.db.models.ForeignKey` should be +chosen from a pre-populated table +(e.g :class:`django.contrib.contenttypes.models.ContentType`), +simply use a :class:`factory.Iterator` on the chosen queryset: + +.. code-block:: python + + import factory, factory.django + from . import models + + class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.User + + language = factory.Iterator(models.Language.objects.all()) + +Here, ``models.Language.objects.all()`` won't be evaluated until the +first call to ``UserFactory``; thus avoiding DB queries at import time. + + Reverse dependencies (reverse ForeignKey) ----------------------------------------- @@ -125,8 +148,8 @@ factory_boy related factories. method. -Simple ManyToMany ------------------ +Simple Many-to-many relationship +-------------------------------- Building the adequate link between two models depends heavily on the use case; factory_boy doesn't provide a "all in one tools" as for :class:`~factory.SubFactory` @@ -144,7 +167,7 @@ hook: class User(models.Model): name = models.CharField() - groups = models.ManyToMany(Group) + groups = models.ManyToManyField(Group) # factories.py @@ -181,8 +204,8 @@ the ``groups`` declaration will add passed in groups to the set of groups for th user. -ManyToMany with a 'through' ---------------------------- +Many-to-many relation with a 'through' +-------------------------------------- If only one link is required, this can be simply performed with a :class:`RelatedFactory`. @@ -196,7 +219,7 @@ If more links are needed, simply add more :class:`RelatedFactory` declarations: class Group(models.Model): name = models.CharField() - members = models.ManyToMany(User, through='GroupLevel') + members = models.ManyToManyField(User, through='GroupLevel') class GroupLevel(models.Model): user = models.ForeignKey(User) @@ -327,3 +350,97 @@ default :meth:`Model.objects.create() <django.db.models.query.QuerySet.create>` manager = cls._get_manager(model_class) # The default would use ``manager.create(*args, **kwargs)`` return manager.create_user(*args, **kwargs) + + +Forcing the sequence counter +---------------------------- + +A common pattern with factory_boy is to use a :class:`factory.Sequence` declaration +to provide varying values to attributes declared as unique. + +However, it is sometimes useful to force a given value to the counter, for instance +to ensure that tests are properly reproductible. + +factory_boy provides a few hooks for this: + + +Forcing the value on a per-call basis + In order to force the counter for a specific :class:`~factory.Factory` instantiation, + just pass the value in the ``__sequence=42`` parameter: + + .. code-block:: python + + class AccountFactory(factory.Factory): + class Meta: + model = Account + uid = factory.Sequence(lambda n: n) + name = "Test" + + .. code-block:: pycon + + >>> obj1 = AccountFactory(name="John Doe", __sequence=10) + >>> obj1.uid # Taken from the __sequence counter + 10 + >>> obj2 = AccountFactory(name="Jane Doe") + >>> obj2.uid # The base sequence counter hasn't changed + 1 + + +Resetting the counter globally + If all calls for a factory must start from a deterministic number, + use :meth:`factory.Factory.reset_sequence`; this will reset the counter + to its initial value (as defined by :meth:`factory.Factory._setup_next_sequence`). + + .. code-block:: pycon + + >>> AccountFactory().uid + 1 + >>> AccountFactory().uid + 2 + >>> AccountFactory.reset_sequence() + >>> AccountFactory().uid # Reset to the initial value + 1 + >>> AccountFactory().uid + 2 + + It is also possible to reset the counter to a specific value: + + .. code-block:: pycon + + >>> AccountFactory.reset_sequence(10) + >>> AccountFactory().uid + 10 + >>> AccountFactory().uid + 11 + + This recipe is most useful in a :class:`~unittest.TestCase`'s + :meth:`~unittest.TestCase.setUp` method. + + +Forcing the initial value for all projects + The sequence counter of a :class:`~factory.Factory` can also be set + automatically upon the first call through the + :meth:`~factory.Factory._setup_next_sequence` method; this helps when the + objects's attributes mustn't conflict with pre-existing data. + + A typical example is to ensure that running a Python script twice will create + non-conflicting objects, by setting up the counter to "max used value plus one": + + .. code-block:: python + + class AccountFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Account + + @classmethod + def _setup_next_sequence(cls): + try: + return models.Accounts.objects.latest('uid').uid + 1 + except models.Account.DoesNotExist: + return 1 + + .. code-block:: pycon + + >>> Account.objects.create(uid=42, name="Blah") + >>> AccountFactory.create() # Sets up the account number based on the latest uid + <Account uid=43, name=Test> diff --git a/docs/reference.rst b/docs/reference.rst index b0dda50..b5ccd16 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -42,7 +42,7 @@ The :class:`Factory` class It will be automatically set to ``True`` if neither the :class:`Factory` subclass nor its parents define the :attr:`~FactoryOptions.model` attribute. - .. warning:: This flag is reset to ``False`` When a :class:`Factory` subclasses + .. warning:: This flag is reset to ``False`` when a :class:`Factory` subclasses another one if a :attr:`~FactoryOptions.model` is set. .. versionadded:: 2.4.0 @@ -106,43 +106,36 @@ The :class:`Factory` class .. versionadded:: 2.4.0 - .. attribute:: strategy - - Use this attribute to change the strategy used by a :class:`Factory`. - The default is :data:`BUILD_STRATEGY`. - + .. attribute:: rename + Sometimes, a model expect a field with a name already used by one + of :class:`Factory`'s methods. -.. class:: Factory - - .. note:: In previous versions, the fields of :class:`class Meta <factory.FactoryOptions>` were - defined as class attributes on :class:`Factory`. This is now deprecated and will be removed - in 2.5.0. + In this case, the :attr:`rename` attributes allows to define renaming + rules: the keys of the :attr:`rename` dict are those used in the + :class:`Factory` declarations, and their values the new name: - .. attribute:: FACTORY_FOR + .. code-block:: python - .. deprecated:: 2.4.0 - See :attr:`FactoryOptions.model`. + class ImageFactory(factory.Factory): + # The model expects "attributes" + form_attributes = ['thumbnail', 'black-and-white'] - .. attribute:: ABSTRACT_FACTORY + class Meta: + model = Image + rename = {'form_attributes': 'attributes'} - .. deprecated:: 2.4.0 - See :attr:`FactoryOptions.abstract`. + .. versionadded: 2.6.0 - .. attribute:: FACTORY_ARG_PARAMETERS - .. deprecated:: 2.4.0 - See :attr:`FactoryOptions.inline_args`. + .. attribute:: strategy - .. attribute:: FACTORY_HIDDEN_ARGS + Use this attribute to change the strategy used by a :class:`Factory`. + The default is :data:`CREATE_STRATEGY`. - .. deprecated:: 2.4.0 - See :attr:`FactoryOptions.exclude`. - .. attribute:: FACTORY_STRATEGY - .. deprecated:: 2.4.0 - See :attr:`FactoryOptions.strategy`. +.. class:: Factory **Class-level attributes:** @@ -258,7 +251,6 @@ The :class:`Factory` class .. OHAI_VIM** - .. classmethod:: _setup_next_sequence(cls) This method will compute the first value to use for the sequence counter @@ -398,7 +390,7 @@ factory_boy supports two main strategies for generating instances, plus stubs. when using the ``create`` strategy. That policy will be used if the - :attr:`associated class <FactoryOptions.model` has an ``objects`` + :attr:`associated class <FactoryOptions.model>` has an ``objects`` attribute *and* the :meth:`~Factory._create` classmethod of the :class:`Factory` wasn't overridden. @@ -482,6 +474,83 @@ factory_boy supports two main strategies for generating instances, plus stubs. Declarations ------------ + +Faker +""""" + +.. class:: Faker(provider, locale=None, **kwargs) + + .. OHAIVIM** + + In order to easily define realistic-looking factories, + use the :class:`Faker` attribute declaration. + + This is a wrapper around `fake-factory <https://pypi.python.org/pypi/fake-factory>`_; + its argument is the name of a ``fake-factory`` provider: + + .. code-block:: python + + class UserFactory(factory.Factory): + class Meta: + model = User + + name = factory.Faker('name') + + .. code-block:: pycon + + >>> user = UserFactory() + >>> user.name + 'Lucy Cechtelar' + + + .. attribute:: locale + + If a custom locale is required for one specific field, + use the ``locale`` parameter: + + .. code-block:: python + + class UserFactory(factory.Factory): + class Meta: + model = User + + name = factory.Faker('name', locale='fr_FR') + + .. code-block:: pycon + + >>> user = UserFactory() + >>> user.name + 'Jean Valjean' + + + .. classmethod:: override_default_locale(cls, locale) + + If the locale needs to be overridden for a whole test, + use :meth:`~factory.Faker.override_default_locale`: + + .. code-block:: pycon + + >>> with factory.Faker.override_default_locale('de_DE'): + ... UserFactory() + <User: Johannes Brahms> + + .. classmethod:: add_provider(cls, locale=None) + + Some projects may need to fake fields beyond those provided by ``fake-factory``; + in such cases, use :meth:`factory.Faker.add_provider` to declare additional providers + for those fields: + + .. code-block:: python + + factory.Faker.add_provider(SmileyProvider) + + class FaceFactory(factory.Factory): + class Meta: + model = Face + + smiley = factory.Faker('smiley') + + LazyAttribute """"""""""""" @@ -565,7 +634,7 @@ This declaration takes a single argument, a function accepting a single paramete .. note:: An extra kwarg argument, ``type``, may be provided. - This feature is deprecated in 1.3.0 and will be removed in 2.0.0. + This feature was deprecated in 1.3.0 and will be removed in 2.0.0. .. code-block:: python @@ -1221,7 +1290,7 @@ For instance, a :class:`PostGeneration` hook is declared as ``post``: model = SomeObject @post_generation - def post(self, create, extracted, **kwargs): + def post(obj, create, extracted, **kwargs): obj.set_origin(create) .. OHAI_VIM** @@ -1343,6 +1412,23 @@ If a value if passed for the :class:`RelatedFactory` attribute, this disables 1 +.. note:: The target of the :class:`RelatedFactory` is evaluated *after* the initial factory has been instantiated. + This means that calls to :class:`factory.SelfAttribute` cannot go higher than this :class:`RelatedFactory`: + + .. code-block:: python + + class CountryFactory(factory.Factory): + class Meta: + model = Country + + lang = 'fr' + capital_city = factory.RelatedFactory(CityFactory, 'capital_of', + # factory.SelfAttribute('..lang') will crash, since the context of + # ``CountryFactory`` has already been evaluated. + main_lang=factory.SelfAttribute('capital_of.lang'), + ) + + PostGeneration """""""""""""" diff --git a/examples/Makefile b/examples/Makefile new file mode 100644 index 0000000..6064a9b --- /dev/null +++ b/examples/Makefile @@ -0,0 +1,9 @@ +EXAMPLES = flask_alchemy + +TEST_TARGETS = $(addprefix runtest-,$(EXAMPLES)) + +test: $(TEST_TARGETS) + + +$(TEST_TARGETS): runtest-%: + cd $* && PYTHONPATH=../.. python -m unittest diff --git a/examples/flask_alchemy/demoapp.py b/examples/flask_alchemy/demoapp.py new file mode 100644 index 0000000..4ab42b0 --- /dev/null +++ b/examples/flask_alchemy/demoapp.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2015 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 flask import Flask +from flask.ext.sqlalchemy import SQLAlchemy + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db' +db = SQLAlchemy(app) + + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True) + email = db.Column(db.String(120), unique=True) + + def __init__(self, username, email): + self.username = username + self.email = email + + def __repr__(self): + return '<User %r>' % self.username + + +class UserLog(db.Model): + id = db.Column(db.Integer, primary_key=True) + message = db.Column(db.String(1000)) + + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + user = db.relationship('User', backref=db.backref('logs', lazy='dynamic')) + + def __init__(self, message, user): + self.message = message + self.user = user + + def __repr__(self): + return '<Log for %r: %s>' % (self.user, self.message) diff --git a/examples/flask_alchemy/demoapp_factories.py b/examples/flask_alchemy/demoapp_factories.py new file mode 100644 index 0000000..f32f8c3 --- /dev/null +++ b/examples/flask_alchemy/demoapp_factories.py @@ -0,0 +1,26 @@ +import factory +import factory.fuzzy + +import demoapp + + +class BaseFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + abstract = True + sqlalchemy_session = demoapp.db.session + + +class UserFactory(BaseFactory): + class Meta: + model = demoapp.User + + username = factory.fuzzy.FuzzyText() + email = factory.fuzzy.FuzzyText() + + +class UserLogFactory(BaseFactory): + class Meta: + model = demoapp.UserLog + + message = factory.fuzzy.FuzzyText() + user = factory.SubFactory(UserFactory) diff --git a/examples/flask_alchemy/requirements.txt b/examples/flask_alchemy/requirements.txt new file mode 100644 index 0000000..fb675a9 --- /dev/null +++ b/examples/flask_alchemy/requirements.txt @@ -0,0 +1,2 @@ +Flask +Flask-SQLAlchemy diff --git a/examples/flask_alchemy/test_demoapp.py b/examples/flask_alchemy/test_demoapp.py new file mode 100644 index 0000000..b485a92 --- /dev/null +++ b/examples/flask_alchemy/test_demoapp.py @@ -0,0 +1,35 @@ +import os +import unittest +import tempfile + +import demoapp +import demoapp_factories + +class DemoAppTestCase(unittest.TestCase): + + def setUp(self): + demoapp.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' + demoapp.app.config['TESTING'] = True + self.app = demoapp.app.test_client() + self.db = demoapp.db + self.db.create_all() + + def tearDown(self): + self.db.drop_all() + + def test_user_factory(self): + user = demoapp_factories.UserFactory() + self.db.session.commit() + self.assertIsNotNone(user.id) + self.assertEqual(1, len(demoapp.User.query.all())) + + def test_userlog_factory(self): + userlog = demoapp_factories.UserLogFactory() + self.db.session.commit() + self.assertIsNotNone(userlog.id) + self.assertIsNotNone(userlog.user.id) + self.assertEqual(1, len(demoapp.User.query.all())) + self.assertEqual(1, len(demoapp.UserLog.query.all())) + +if __name__ == '__main__': + unittest.main() diff --git a/examples/requirements.txt b/examples/requirements.txt new file mode 100644 index 0000000..5e11ca5 --- /dev/null +++ b/examples/requirements.txt @@ -0,0 +1 @@ +-r flask_alchemy/requirements.txt diff --git a/factory/__init__.py b/factory/__init__.py index 8fc8ef8..c8bc396 100644 --- a/factory/__init__.py +++ b/factory/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -__version__ = '2.4.1' +__version__ = '2.6.1' __author__ = 'Raphaël Barrois <raphael.barrois+fboy@polytechnique.org>' @@ -40,9 +40,7 @@ from .base import ( use_strategy, ) -# Backward compatibility; this should be removed soon. -from .mogo import MogoFactory -from .django import DjangoModelFactory +from .faker import Faker from .declarations import ( LazyAttribute, @@ -83,3 +81,12 @@ from .helpers import ( post_generation, ) +# Backward compatibility; this should be removed soon. +from . import alchemy +from . import django +from . import mogo +from . import mongoengine + +MogoFactory = mogo.MogoFactory +DjangoModelFactory = django.DjangoModelFactory + diff --git a/factory/alchemy.py b/factory/alchemy.py index 3c91411..a9aab23 100644 --- a/factory/alchemy.py +++ b/factory/alchemy.py @@ -19,7 +19,6 @@ # 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 @@ -28,6 +27,7 @@ class SQLAlchemyOptions(base.FactoryOptions): def _build_default_options(self): return super(SQLAlchemyOptions, self)._build_default_options() + [ base.OptionDefault('sqlalchemy_session', None, inherit=True), + base.OptionDefault('force_flush', False, inherit=True), ] @@ -38,27 +38,12 @@ class SQLAlchemyModelFactory(base.Factory): class Meta: abstract = True - _OLDSTYLE_ATTRIBUTES = base.Factory._OLDSTYLE_ATTRIBUTES.copy() - _OLDSTYLE_ATTRIBUTES.update({ - 'FACTORY_SESSION': 'sqlalchemy_session', - }) - - @classmethod - def _setup_next_sequence(cls, *args, **kwargs): - """Compute the next available PK, based on the 'pk' database field.""" - session = cls._meta.sqlalchemy_session - model = cls._meta.model - 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, model_class, *args, **kwargs): """Create an instance of the model, and save it to the database.""" session = cls._meta.sqlalchemy_session obj = model_class(*args, **kwargs) session.add(obj) + if cls._meta.force_flush: + session.flush() return obj diff --git a/factory/base.py b/factory/base.py index 9e07899..0f2af59 100644 --- a/factory/base.py +++ b/factory/base.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -21,7 +21,6 @@ # THE SOFTWARE. import logging -import warnings from . import containers from . import declarations @@ -109,23 +108,6 @@ class FactoryMetaClass(type): attrs_meta = attrs.pop('Meta', None) - oldstyle_attrs = {} - converted_attrs = {} - for old_name, new_name in base_factory._OLDSTYLE_ATTRIBUTES.items(): - if old_name in attrs: - oldstyle_attrs[old_name] = new_name - converted_attrs[new_name] = attrs.pop(old_name) - if oldstyle_attrs: - warnings.warn( - "Declaring any of %s at class-level is deprecated" - " and will be removed in the future. Please set them" - " as %s attributes of a 'class Meta' attribute." % ( - ', '.join(oldstyle_attrs.keys()), - ', '.join(oldstyle_attrs.values()), - ), - PendingDeprecationWarning, 2) - attrs_meta = type('Meta', (object,), converted_attrs) - base_meta = resolve_attribute('_meta', bases) options_class = resolve_attribute('_options_class', bases, FactoryOptions) @@ -194,6 +176,7 @@ class FactoryOptions(object): OptionDefault('strategy', CREATE_STRATEGY, inherit=True), OptionDefault('inline_args', (), inherit=True), OptionDefault('exclude', (), inherit=True), + OptionDefault('rename', {}, inherit=True), ] def _fill_from_meta(self, meta, base_meta): @@ -322,14 +305,6 @@ class BaseFactory(object): _meta = FactoryOptions() - _OLDSTYLE_ATTRIBUTES = { - 'FACTORY_FOR': 'model', - 'ABSTRACT_FACTORY': 'abstract', - 'FACTORY_STRATEGY': 'strategy', - 'FACTORY_ARG_PARAMETERS': 'inline_args', - 'FACTORY_HIDDEN_ARGS': 'exclude', - } - # ID to use for the next 'declarations.Sequence' attribute. _counter = None @@ -435,10 +410,16 @@ class BaseFactory(object): retrieved DeclarationDict. """ decls = cls._meta.declarations.copy() - decls.update(extra_defs) + decls.update(extra_defs or {}) return decls @classmethod + def _rename_fields(cls, **kwargs): + for old_name, new_name in cls._meta.rename.items(): + kwargs[new_name] = kwargs.pop(old_name) + return kwargs + + @classmethod def _adjust_kwargs(cls, **kwargs): """Extension point for custom kwargs adjustment.""" return kwargs @@ -467,6 +448,7 @@ class BaseFactory(object): **kwargs: arguments to pass to the creation function """ model_class = cls._get_model_class() + kwargs = cls._rename_fields(**kwargs) kwargs = cls._adjust_kwargs(**kwargs) # Remove 'hidden' arguments. @@ -709,7 +691,7 @@ class StubFactory(Factory): @classmethod def build(cls, **kwargs): - raise UnsupportedStrategy() + return cls.stub(**kwargs) @classmethod def create(cls, **kwargs): diff --git a/factory/compat.py b/factory/compat.py index 7747b1a..737d91a 100644 --- a/factory/compat.py +++ b/factory/compat.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -42,14 +42,6 @@ else: # pragma: no cover 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 diff --git a/factory/containers.py b/factory/containers.py index 5116320..0ae354b 100644 --- a/factory/containers.py +++ b/factory/containers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/factory/declarations.py b/factory/declarations.py index 5e7e734..f0dbfe5 100644 --- a/factory/declarations.py +++ b/factory/declarations.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -22,7 +22,6 @@ import itertools -import warnings import logging from . import compat @@ -161,12 +160,19 @@ class Iterator(OrderedDeclaration): def __init__(self, iterator, cycle=True, getter=None): super(Iterator, self).__init__() self.getter = getter + self.iterator = None if cycle: - iterator = itertools.cycle(iterator) - self.iterator = utils.ResetableIterator(iterator) + self.iterator_builder = lambda: utils.ResetableIterator(itertools.cycle(iterator)) + else: + self.iterator_builder = lambda: utils.ResetableIterator(iterator) def evaluate(self, sequence, obj, create, extra=None, containers=()): + # Begin unrolling as late as possible. + # This helps with ResetableIterator(MyModel.objects.all()) + if self.iterator is None: + self.iterator = self.iterator_builder() + logger.debug("Iterator: Fetching next value from %r", self.iterator) value = next(iter(self.iterator)) if self.getter is None: @@ -195,7 +201,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) + logger.debug("Sequence: Computing next value of %r for seq=%s", self.function, sequence) return self.function(self.type(sequence)) @@ -209,7 +215,7 @@ 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", + logger.debug("LazyAttributeSequence: Computing next value of %r for seq=%s, obj=%r", self.function, sequence, obj) return self.function(obj, self.type(sequence)) @@ -502,14 +508,6 @@ class RelatedFactory(PostGenerationDeclaration): def __init__(self, factory, factory_related_name='', **defaults): super(RelatedFactory, self).__init__() - 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 diff --git a/factory/django.py b/factory/django.py index 2b6c463..b3c508c 100644 --- a/factory/django.py +++ b/factory/django.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -32,8 +32,10 @@ import functools """factory_boy extensions for use with the Django framework.""" try: + import django from django.core import files as django_files except ImportError as e: # pragma: no cover + django = None django_files = None import_failure = e @@ -45,6 +47,8 @@ from .compat import BytesIO, is_string logger = logging.getLogger('factory.generate') +DEFAULT_DB_ALIAS = 'default' # Same as django.db.DEFAULT_DB_ALIAS + def require_django(): """Simple helper to ensure Django is available.""" @@ -52,10 +56,41 @@ def require_django(): raise import_failure +_LAZY_LOADS = {} + +def get_model(app, model): + """Wrapper around django's get_model.""" + if 'get_model' not in _LAZY_LOADS: + _lazy_load_get_model() + + _get_model = _LAZY_LOADS['get_model'] + return _get_model(app, model) + + +def _lazy_load_get_model(): + """Lazy loading of get_model. + + get_model loads django.conf.settings, which may fail if + the settings haven't been configured yet. + """ + if django is None: + def get_model(app, model): + raise import_failure + + elif django.VERSION[:2] < (1, 7): + from django.db.models.loading import get_model + + else: + from django import apps as django_apps + get_model = django_apps.apps.get_model + _LAZY_LOADS['get_model'] = get_model + + class DjangoOptions(base.FactoryOptions): def _build_default_options(self): return super(DjangoOptions, self)._build_default_options() + [ base.OptionDefault('django_get_or_create', (), inherit=True), + base.OptionDefault('database', DEFAULT_DB_ALIAS, inherit=True), ] def _get_counter_reference(self): @@ -84,18 +119,12 @@ class DjangoModelFactory(base.Factory): class Meta: abstract = True # Optional, but explicit. - _OLDSTYLE_ATTRIBUTES = base.Factory._OLDSTYLE_ATTRIBUTES.copy() - _OLDSTYLE_ATTRIBUTES.update({ - 'FACTORY_DJANGO_GET_OR_CREATE': 'django_get_or_create', - }) - @classmethod def _load_model_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 get_model(app, model) return definition @@ -104,25 +133,17 @@ class DjangoModelFactory(base.Factory): if model_class is None: raise base.AssociatedClassError("No model set on %s.%s.Meta" % (cls.__module__, cls.__name__)) + try: - return model_class._default_manager # pylint: disable=W0212 + manager = model_class.objects except AttributeError: - return model_class.objects - - @classmethod - def _setup_next_sequence(cls): - """Compute the next available PK, based on the 'pk' database field.""" - - model = cls._get_model_class() # pylint: disable=E1101 - manager = cls._get_manager(model) + # When inheriting from an abstract model with a custom + # manager, the class has no 'objects' field. + manager = model_class._default_manager - 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 + if cls._meta.database != DEFAULT_DB_ALIAS: + manager = manager.using(cls._meta.database) + return manager @classmethod def _get_or_create(cls, model_class, *args, **kwargs): @@ -160,24 +181,22 @@ class DjangoModelFactory(base.Factory): obj.save() -class FileField(declarations.PostGenerationDeclaration): +class FileField(declarations.ParameteredAttribute): """Helper to fill in django.db.models.FileField from a Factory.""" DEFAULT_FILENAME = 'example.dat' + EXTEND_CONTAINERS = True def __init__(self, **defaults): require_django() - self.defaults = defaults - super(FileField, self).__init__() + super(FileField, self).__init__(**defaults) def _make_data(self, params): """Create data for the field.""" return params.get('data', b'') - def _make_content(self, extraction_context): + def _make_content(self, params): path = '' - params = dict(self.defaults) - params.update(extraction_context.extra) if params.get('from_path') and params.get('from_file'): raise ValueError( @@ -185,12 +204,7 @@ class FileField(declarations.PostGenerationDeclaration): "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'): + if params.get('from_path'): path = params['from_path'] f = open(path, 'rb') content = django_files.File(f, name=path) @@ -212,19 +226,13 @@ class FileField(declarations.PostGenerationDeclaration): filename = params.get('filename', default_filename) return filename, content - def call(self, obj, create, extraction_context): + def generate(self, sequence, obj, create, params): """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 + params.setdefault('__sequence', sequence) + params = base.DictFactory.simple_generate(create, **params) + filename, content = self._make_content(params) + return django_files.File(content.file, filename) class ImageField(FileField): @@ -278,6 +286,9 @@ class mute_signals(object): logger.debug('mute_signals: Disabling signal handlers %r', signal.receivers) + # Note that we're using implementation details of + # django.signals, since arguments to signal.connect() + # are lost in signal.receivers self.paused[signal] = signal.receivers signal.receivers = [] @@ -287,8 +298,17 @@ class mute_signals(object): receivers) signal.receivers = receivers + if django.VERSION[:2] >= (1, 6): + with signal.lock: + # Django uses some caching for its signals. + # Since we're bypassing signal.connect and signal.disconnect, + # we have to keep messing with django's internals. + signal.sender_receivers_cache.clear() self.paused = {} + def copy(self): + return mute_signals(*self.signals) + def __call__(self, callable_obj): if isinstance(callable_obj, base.FactoryMetaClass): # Retrieve __func__, the *actual* callable object. @@ -297,7 +317,8 @@ class mute_signals(object): @classmethod @functools.wraps(generate_method) def wrapped_generate(*args, **kwargs): - with self: + # A mute_signals() object is not reentrant; use a copy everytime. + with self.copy(): return generate_method(*args, **kwargs) callable_obj._generate = wrapped_generate @@ -306,7 +327,8 @@ class mute_signals(object): else: @functools.wraps(callable_obj) def wrapper(*args, **kwargs): - with self: + # A mute_signals() object is not reentrant; use a copy everytime. + with self.copy(): return callable_obj(*args, **kwargs) return wrapper diff --git a/factory/faker.py b/factory/faker.py new file mode 100644 index 0000000..5411985 --- /dev/null +++ b/factory/faker.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Mark Sandstrom +# Copyright (c) 2011-2015 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. + + +"""Additional declarations for "faker" attributes. + +Usage: + + class MyFactory(factory.Factory): + class Meta: + model = MyProfile + + first_name = factory.Faker('name') +""" + + +from __future__ import absolute_import +from __future__ import unicode_literals + +import contextlib + +import faker +import faker.config + +from . import declarations + +class Faker(declarations.OrderedDeclaration): + """Wrapper for 'faker' values. + + Args: + provider (str): the name of the Faker field + locale (str): the locale to use for the faker + + All other kwargs will be passed to the underlying provider + (e.g ``factory.Faker('ean', length=10)`` + calls ``faker.Faker.ean(length=10)``) + + Usage: + >>> foo = factory.Faker('name') + """ + def __init__(self, provider, locale=None, **kwargs): + self.provider = provider + self.provider_kwargs = kwargs + self.locale = locale + + def generate(self, extra_kwargs): + kwargs = {} + kwargs.update(self.provider_kwargs) + kwargs.update(extra_kwargs) + faker = self._get_faker(self.locale) + return faker.format(self.provider, **kwargs) + + def evaluate(self, sequence, obj, create, extra=None, containers=()): + return self.generate(extra or {}) + + _FAKER_REGISTRY = {} + _DEFAULT_LOCALE = faker.config.DEFAULT_LOCALE + + @classmethod + @contextlib.contextmanager + def override_default_locale(cls, locale): + old_locale = cls._DEFAULT_LOCALE + cls._DEFAULT_LOCALE = locale + try: + yield + finally: + cls._DEFAULT_LOCALE = old_locale + + @classmethod + def _get_faker(cls, locale=None): + if locale is None: + locale = cls._DEFAULT_LOCALE + + if locale not in cls._FAKER_REGISTRY: + cls._FAKER_REGISTRY[locale] = faker.Faker(locale=locale) + + return cls._FAKER_REGISTRY[locale] + + @classmethod + def add_provider(cls, provider, locale=None): + """Add a new Faker provider for the specified locale""" + cls._get_faker(locale).add_provider(provider) diff --git a/factory/fuzzy.py b/factory/fuzzy.py index 94599b7..a7e834c 100644 --- a/factory/fuzzy.py +++ b/factory/fuzzy.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -34,6 +34,25 @@ from . import compat from . import declarations +_random = random.Random() + + +def get_random_state(): + """Retrieve the state of factory.fuzzy's random generator.""" + return _random.getstate() + + +def set_random_state(state): + """Force-set the state of factory.fuzzy's random generator.""" + return _random.setstate(state) + + +def reseed_random(seed): + """Reseed factory.fuzzy's random generator.""" + r = random.Random(seed) + set_random_state(r.getstate()) + + class BaseFuzzyAttribute(declarations.OrderedDeclaration): """Base class for fuzzy attributes. @@ -81,7 +100,7 @@ class FuzzyText(BaseFuzzyAttribute): """ def __init__(self, prefix='', length=12, suffix='', - chars=string.ascii_letters, **kwargs): + chars=string.ascii_letters, **kwargs): super(FuzzyText, self).__init__(**kwargs) self.prefix = prefix self.suffix = suffix @@ -89,19 +108,27 @@ class FuzzyText(BaseFuzzyAttribute): self.chars = tuple(chars) # Unroll iterators def fuzz(self): - chars = [random.choice(self.chars) for _i in range(self.length)] + 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.""" + """Handles fuzzy choice of an attribute. + + Args: + choices (iterable): An iterable yielding options; will only be unrolled + on the first call. + """ def __init__(self, choices, **kwargs): - self.choices = list(choices) + self.choices = None + self.choices_generator = choices super(FuzzyChoice, self).__init__(**kwargs) def fuzz(self): - return random.choice(self.choices) + if self.choices is None: + self.choices = list(self.choices_generator) + return _random.choice(self.choices) class FuzzyInteger(BaseFuzzyAttribute): @@ -119,7 +146,7 @@ class FuzzyInteger(BaseFuzzyAttribute): super(FuzzyInteger, self).__init__(**kwargs) def fuzz(self): - return random.randrange(self.low, self.high + 1, self.step) + return _random.randrange(self.low, self.high + 1, self.step) class FuzzyDecimal(BaseFuzzyAttribute): @@ -137,7 +164,7 @@ class FuzzyDecimal(BaseFuzzyAttribute): super(FuzzyDecimal, self).__init__(**kwargs) def fuzz(self): - base = compat.float_to_decimal(random.uniform(self.low, self.high)) + base = decimal.Decimal(str(_random.uniform(self.low, self.high))) return base.quantize(decimal.Decimal(10) ** -self.precision) @@ -155,7 +182,7 @@ class FuzzyFloat(BaseFuzzyAttribute): super(FuzzyFloat, self).__init__(**kwargs) def fuzz(self): - return random.uniform(self.low, self.high) + return _random.uniform(self.low, self.high) class FuzzyDate(BaseFuzzyAttribute): @@ -175,7 +202,7 @@ class FuzzyDate(BaseFuzzyAttribute): self.end_date = end_date.toordinal() def fuzz(self): - return datetime.date.fromordinal(random.randint(self.start_date, self.end_date)) + return datetime.date.fromordinal(_random.randint(self.start_date, self.end_date)) class BaseFuzzyDateTime(BaseFuzzyAttribute): @@ -215,7 +242,7 @@ class BaseFuzzyDateTime(BaseFuzzyAttribute): delta = self.end_dt - self.start_dt microseconds = delta.microseconds + 1000000 * (delta.seconds + (delta.days * 86400)) - offset = random.randint(0, microseconds) + offset = _random.randint(0, microseconds) result = self.start_dt + datetime.timedelta(microseconds=offset) if self.force_year is not None: @@ -270,10 +297,10 @@ class FuzzyDateTime(BaseFuzzyDateTime): def _check_bounds(self, start_dt, end_dt): if start_dt.tzinfo is None: raise ValueError( - "FuzzyDateTime only handles aware datetimes, got start=%r" + "FuzzyDateTime requires timezone-aware datetimes, got start=%r" % start_dt) if end_dt.tzinfo is None: raise ValueError( - "FuzzyDateTime only handles aware datetimes, got end=%r" + "FuzzyDateTime requires timezone-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 19431df..60a4d75 100644 --- a/factory/helpers.py +++ b/factory/helpers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -28,7 +28,6 @@ import logging from . import base from . import declarations -from . import django @contextlib.contextmanager diff --git a/factory/mogo.py b/factory/mogo.py index 5541043..aa9f28b 100644 --- a/factory/mogo.py +++ b/factory/mogo.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -37,10 +37,10 @@ class MogoFactory(base.Factory): @classmethod def _build(cls, model_class, *args, **kwargs): - return model_class.new(*args, **kwargs) + return model_class(*args, **kwargs) @classmethod def _create(cls, model_class, *args, **kwargs): - instance = model_class.new(*args, **kwargs) + instance = model_class(*args, **kwargs) instance.save() return instance diff --git a/factory/mongoengine.py b/factory/mongoengine.py index e3ab99c..f50b727 100644 --- a/factory/mongoengine.py +++ b/factory/mongoengine.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/factory/utils.py b/factory/utils.py index 276977a..15dba0a 100644 --- a/factory/utils.py +++ b/factory/utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -101,7 +101,7 @@ def import_object(module_name, attribute_name): def _safe_repr(obj): try: obj_repr = repr(obj) - except UnicodeError: + except Exception: return '<bad_repr object at %s>' % id(obj) try: # Convert to "text type" (= unicode) @@ -110,15 +110,29 @@ def _safe_repr(obj): 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 log_pprint(object): + """Helper for properly printing args / kwargs passed to an object. + + Since it is only used with factory.debug(), the computation is + performed lazily. + """ + __slots__ = ['args', 'kwargs'] + + def __init__(self, args=(), kwargs=None): + self.args = args + self.kwargs = kwargs or {} + + def __repr__(self): + return repr(str(self)) + + def __str__(self): + return ', '.join( + [_safe_repr(arg) for arg in self.args] + + [ + '%s=%s' % (key, _safe_repr(value)) + for key, value in self.kwargs.items() + ] + ) class ResetableIterator(object): diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bd2a4a6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +fake-factory>=0.5.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a9acf1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 @@ -35,7 +35,7 @@ PACKAGE = 'factory' setup( name='factory_boy', version=get_version(PACKAGE), - description="A verstile test fixtures replacement based on thoughtbot's factory_girl for Ruby.", + description="A versatile test fixtures replacement based on thoughtbot's factory_girl for Ruby.", author='Mark Sandstrom', author_email='mark@deliciouslynerdy.com', maintainer='Raphaël Barrois', @@ -44,6 +44,9 @@ setup( keywords=['factory_boy', 'factory', 'fixtures'], packages=['factory'], license='MIT', + install_requires=[ + 'fake-factory>=0.5.0', + ], setup_requires=[ 'setuptools>=0.8', ], @@ -63,9 +66,11 @@ setup( "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Testing", - "Topic :: Software Development :: Libraries :: Python Modules" + "Topic :: Software Development :: Libraries :: Python Modules", ], test_suite='tests', test_loader=test_loader, diff --git a/tests/__init__.py b/tests/__init__.py index 855beea..b2c772d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 Raphaël Barrois + +# factory.django needs a configured Django. +from .test_django import * from .test_base import * from .test_containers import * from .test_declarations import * -from .test_deprecation import * -from .test_django import * +from .test_faker import * from .test_fuzzy import * from .test_helpers import * from .test_using import * diff --git a/tests/compat.py b/tests/compat.py index ff96f13..167c185 100644 --- a/tests/compat.py +++ b/tests/compat.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/tests/cyclic/bar.py b/tests/cyclic/bar.py index a5e6bf1..b4f8e0c 100644 --- a/tests/cyclic/bar.py +++ b/tests/cyclic/bar.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/tests/cyclic/foo.py b/tests/cyclic/foo.py index 18de362..62e58c0 100644 --- a/tests/cyclic/foo.py +++ b/tests/cyclic/foo.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/tests/test_deprecation.py b/tests/cyclic/self_ref.py index a07cbf3..d98b3ab 100644 --- a/tests/test_deprecation.py +++ b/tests/cyclic/self_ref.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -20,30 +19,19 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -"""Tests for deprecated features.""" - -import warnings +"""Helper to test circular factory dependencies.""" import factory -from .compat import mock, unittest -from . import tools - +class TreeElement(object): + def __init__(self, name, parent): + self.parent = parent + self.name = name -class DeprecationTests(unittest.TestCase): - def test_factory_for(self): - class Foo(object): - pass - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') - class FooFactory(factory.Factory): - FACTORY_FOR = Foo +class TreeElementFactory(factory.Factory): + class Meta: + model = TreeElement - self.assertEqual(1, len(w)) - warning = w[0] - # Message is indeed related to the current file - # This is to ensure error messages are readable by end users. - self.assertIn(warning.filename, __file__) - self.assertIn('FACTORY_FOR', str(warning.message)) - self.assertIn('model', str(warning.message)) + name = factory.Sequence(lambda n: "tree%s" % n) + parent = factory.SubFactory('tests.cyclic.self_ref.TreeElementFactory') diff --git a/tests/djapp/models.py b/tests/djapp/models.py index 9b21181..cadefbc 100644 --- a/tests/djapp/models.py +++ b/tests/djapp/models.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -68,6 +68,15 @@ class StandardSon(StandardModel): pass +class PointedModel(models.Model): + foo = models.CharField(max_length=20) + + +class PointingModel(models.Model): + foo = models.CharField(max_length=20) + pointed = models.OneToOneField(PointedModel, related_name='pointer', null=True) + + WITHFILE_UPLOAD_TO = 'django' WITHFILE_UPLOAD_DIR = os.path.join(settings.MEDIA_ROOT, WITHFILE_UPLOAD_TO) @@ -79,6 +88,7 @@ if Image is not None: # PIL is available class WithImage(models.Model): animage = models.ImageField(upload_to=WITHFILE_UPLOAD_TO) + size = models.IntegerField(default=0) else: class WithImage(models.Model): @@ -87,3 +97,27 @@ else: class WithSignals(models.Model): foo = models.CharField(max_length=20) + + +class CustomManager(models.Manager): + + def create(self, arg=None, **kwargs): + return super(CustomManager, self).create(**kwargs) + + +class WithCustomManager(models.Model): + + foo = models.CharField(max_length=20) + + objects = CustomManager() + + +class AbstractWithCustomManager(models.Model): + custom_objects = CustomManager() + + class Meta: + abstract = True + + +class FromAbstractWithCustomManager(AbstractWithCustomManager): + pass diff --git a/tests/djapp/settings.py b/tests/djapp/settings.py index c1b79b0..1ef16d5 100644 --- a/tests/djapp/settings.py +++ b/tests/djapp/settings.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -34,6 +34,9 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', }, + 'replica': { + 'ENGINE': 'django.db.backends.sqlite3', + }, } @@ -41,5 +44,6 @@ INSTALLED_APPS = [ 'tests.djapp' ] +MIDDLEWARE_CLASSES = () SECRET_KEY = 'testing.' diff --git a/tests/test_alchemy.py b/tests/test_alchemy.py index b9222eb..5d8f275 100644 --- a/tests/test_alchemy.py +++ b/tests/test_alchemy.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2013 Romain Command& +# Copyright (c) 2015 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 @@ -23,6 +23,7 @@ import factory from .compat import unittest +import mock try: @@ -55,6 +56,16 @@ class StandardFactory(SQLAlchemyModelFactory): foo = factory.Sequence(lambda n: 'foo%d' % n) +class ForceFlushingStandardFactory(SQLAlchemyModelFactory): + class Meta: + model = models.StandardModel + sqlalchemy_session = mock.MagicMock() + force_flush = True + + id = factory.Sequence(lambda n: n) + foo = factory.Sequence(lambda n: 'foo%d' % n) + + class NonIntegerPkFactory(SQLAlchemyModelFactory): class Meta: model = models.NonIntegerPk @@ -88,18 +99,39 @@ class SQLAlchemyPkSequenceTestCase(unittest.TestCase): StandardFactory.reset_sequence() std2 = StandardFactory.create() - self.assertEqual('foo2', std2.foo) - self.assertEqual(2, std2.id) + self.assertEqual('foo0', std2.foo) + self.assertEqual(0, 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('foo1', std1.foo) # sequence and pk are unrelated self.assertEqual(10, std1.id) StandardFactory.reset_sequence() std2 = StandardFactory.create() - self.assertEqual('foo11', std2.foo) - self.assertEqual(11, std2.id) + self.assertEqual('foo0', std2.foo) # Sequence doesn't care about pk + self.assertEqual(0, std2.id) + + +@unittest.skipIf(sqlalchemy is None, "SQLalchemy not installed.") +class SQLAlchemyForceFlushTestCase(unittest.TestCase): + def setUp(self): + super(SQLAlchemyForceFlushTestCase, self).setUp() + ForceFlushingStandardFactory.reset_sequence(1) + ForceFlushingStandardFactory._meta.sqlalchemy_session.rollback() + ForceFlushingStandardFactory._meta.sqlalchemy_session.reset_mock() + + def test_force_flush_called(self): + self.assertFalse(ForceFlushingStandardFactory._meta.sqlalchemy_session.flush.called) + ForceFlushingStandardFactory.create() + self.assertTrue(ForceFlushingStandardFactory._meta.sqlalchemy_session.flush.called) + + def test_force_flush_not_called(self): + ForceFlushingStandardFactory._meta.force_flush = False + self.assertFalse(ForceFlushingStandardFactory._meta.sqlalchemy_session.flush.called) + ForceFlushingStandardFactory.create() + self.assertFalse(ForceFlushingStandardFactory._meta.sqlalchemy_session.flush.called) + ForceFlushingStandardFactory._meta.force_flush = True @unittest.skipIf(sqlalchemy is None, "SQLalchemy not installed.") @@ -111,22 +143,22 @@ class SQLAlchemyNonIntegerPkTestCase(unittest.TestCase): def test_first(self): nonint = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint.id) + self.assertEqual('foo0', nonint.id) def test_many(self): nonint1 = NonIntegerPkFactory.build() nonint2 = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint1.id) - self.assertEqual('foo2', nonint2.id) + self.assertEqual('foo0', nonint1.id) + self.assertEqual('foo1', nonint2.id) def test_creation(self): nonint1 = NonIntegerPkFactory.create() - self.assertEqual('foo1', nonint1.id) + self.assertEqual('foo0', nonint1.id) NonIntegerPkFactory.reset_sequence() nonint2 = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint2.id) + self.assertEqual('foo0', nonint2.id) def test_force_pk(self): nonint1 = NonIntegerPkFactory.create(id='foo10') @@ -134,4 +166,4 @@ class SQLAlchemyNonIntegerPkTestCase(unittest.TestCase): NonIntegerPkFactory.reset_sequence() nonint2 = NonIntegerPkFactory.create() - self.assertEqual('foo1', nonint2.id) + self.assertEqual('foo0', nonint2.id) diff --git a/tests/test_base.py b/tests/test_base.py index d1df58e..24f64e5 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -403,7 +403,7 @@ class FactoryDefaultStrategyTestCase(unittest.TestCase): self.assertRaises(base.Factory.UnknownStrategy, TestModelFactory) - def test_stub_with_non_stub_strategy(self): + def test_stub_with_create_strategy(self): class TestModelFactory(base.StubFactory): class Meta: model = TestModel @@ -414,8 +414,18 @@ class FactoryDefaultStrategyTestCase(unittest.TestCase): self.assertRaises(base.StubFactory.UnsupportedStrategy, TestModelFactory) + def test_stub_with_build_strategy(self): + class TestModelFactory(base.StubFactory): + class Meta: + model = TestModel + + one = 'one' + TestModelFactory._meta.strategy = base.BUILD_STRATEGY - self.assertRaises(base.StubFactory.UnsupportedStrategy, TestModelFactory) + obj = TestModelFactory() + + # For stubs, build() is an alias of stub(). + self.assertFalse(isinstance(obj, TestModel)) def test_change_strategy(self): @base.use_strategy(base.CREATE_STRATEGY) @@ -454,6 +464,23 @@ class FactoryCreationTestCase(unittest.TestCase): self.assertEqual(TestFactory._meta.strategy, base.STUB_STRATEGY) + def test_stub_and_subfactory(self): + class StubA(base.StubFactory): + class Meta: + model = TestObject + + one = 'blah' + + class StubB(base.StubFactory): + class Meta: + model = TestObject + + stubbed = declarations.SubFactory(StubA, two='two') + + b = StubB() + self.assertEqual('blah', b.stubbed.one) + self.assertEqual('two', b.stubbed.two) + def test_custom_creation(self): class TestModelFactory(FakeModelFactory): class Meta: diff --git a/tests/test_containers.py b/tests/test_containers.py index bd7019e..083b306 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/tests/test_declarations.py b/tests/test_declarations.py index 86bc8b5..2601a38 100644 --- a/tests/test_declarations.py +++ b/tests/test_declarations.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -22,7 +22,6 @@ import datetime import itertools -import warnings from factory import declarations from factory import helpers @@ -207,20 +206,6 @@ class FactoryWrapperTestCase(unittest.TestCase): datetime.date = orig_date -class RelatedFactoryTestCase(unittest.TestCase): - - def test_deprecate_name(self): - with warnings.catch_warnings(record=True) as w: - - warnings.simplefilter('always') - f = declarations.RelatedFactory('datetime.date', name='blah') - - 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() diff --git a/tests/test_django.py b/tests/test_django.py index 41a26cf..103df91 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -21,9 +21,7 @@ """Tests for factory_boy/Django interactions.""" import os - -import factory -import factory.django +from .compat import is_python2, unittest, mock try: @@ -31,6 +29,28 @@ try: except ImportError: # pragma: no cover django = None +# Setup Django as soon as possible +if django is not None: + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.djapp.settings') + + if django.VERSION >= (1, 7, 0): + django.setup() + from django import test as django_test + from django.conf import settings + from django.db import models as django_models + if django.VERSION <= (1, 8, 0): + from django.test.simple import DjangoTestSuiteRunner + else: + from django.test.runner import DiscoverRunner as DjangoTestSuiteRunner + from django.test import utils as django_test_utils + from django.db.models import signals + from .djapp import models + +else: + django_test = unittest + + + try: from PIL import Image except ImportError: # pragma: no cover @@ -42,38 +62,13 @@ except ImportError: # pragma: no cover Image = None -from .compat import is_python2, unittest, mock +import factory +import factory.django + 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 django.db.models import signals - 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 - models.WithSignals = Fake - - test_state = {} @@ -81,7 +76,7 @@ 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 = DjangoTestSuiteRunner() runner_state = runner.setup_databases() test_state.update({ 'runner': runner, @@ -98,72 +93,80 @@ def tearDownModule(): django_test_utils.teardown_test_environment() -class StandardFactory(factory.django.DjangoModelFactory): - class Meta: - model = models.StandardModel +if django is not None: + class StandardFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.StandardModel + + foo = factory.Sequence(lambda n: "foo%d" % n) - foo = factory.Sequence(lambda n: "foo%d" % n) + class StandardFactoryWithPKField(factory.django.DjangoModelFactory): + class Meta: + model = models.StandardModel + django_get_or_create = ('pk',) -class StandardFactoryWithPKField(factory.django.DjangoModelFactory): - class Meta: - model = models.StandardModel - django_get_or_create = ('pk',) + foo = factory.Sequence(lambda n: "foo%d" % n) + pk = None - foo = factory.Sequence(lambda n: "foo%d" % n) - pk = None + class NonIntegerPkFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.NonIntegerPk -class NonIntegerPkFactory(factory.django.DjangoModelFactory): - class Meta: - model = models.NonIntegerPk + foo = factory.Sequence(lambda n: "foo%d" % n) + bar = '' - foo = factory.Sequence(lambda n: "foo%d" % n) - bar = '' + class AbstractBaseFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.AbstractBase + abstract = True -class AbstractBaseFactory(factory.django.DjangoModelFactory): - class Meta: - model = models.AbstractBase - abstract = True + foo = factory.Sequence(lambda n: "foo%d" % n) - foo = factory.Sequence(lambda n: "foo%d" % n) + class ConcreteSonFactory(AbstractBaseFactory): + class Meta: + model = models.ConcreteSon -class ConcreteSonFactory(AbstractBaseFactory): - class Meta: - model = models.ConcreteSon + class AbstractSonFactory(AbstractBaseFactory): + class Meta: + model = models.AbstractSon -class AbstractSonFactory(AbstractBaseFactory): - class Meta: - model = models.AbstractSon + class ConcreteGrandSonFactory(AbstractBaseFactory): + class Meta: + model = models.ConcreteGrandSon -class ConcreteGrandSonFactory(AbstractBaseFactory): - class Meta: - model = models.ConcreteGrandSon + class WithFileFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithFile -class WithFileFactory(factory.django.DjangoModelFactory): - class Meta: - model = models.WithFile + if django is not None: + afile = factory.django.FileField() - if django is not None: - afile = factory.django.FileField() + class WithImageFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithImage -class WithImageFactory(factory.django.DjangoModelFactory): - class Meta: - model = models.WithImage + if django is not None: + animage = factory.django.ImageField() - if django is not None: - animage = factory.django.ImageField() + class WithSignalsFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithSignals -class WithSignalsFactory(factory.django.DjangoModelFactory): - class Meta: - model = models.WithSignals + + class WithCustomManagerFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithCustomManager + + foo = factory.Sequence(lambda n: "foo%d" % n) @unittest.skipIf(django is None, "Django not installed.") @@ -174,6 +177,16 @@ class ModelTests(django_test.TestCase): self.assertRaises(factory.FactoryError, UnsetModelFactory.create) + def test_cross_database(self): + class OtherDBFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.StandardModel + database = 'replica' + + obj = OtherDBFactory() + self.assertFalse(models.StandardModel.objects.exists()) + self.assertEqual(obj, models.StandardModel.objects.using('replica').get()) + @unittest.skipIf(django is None, "Django not installed.") class DjangoPkSequenceTestCase(django_test.TestCase): @@ -183,32 +196,32 @@ class DjangoPkSequenceTestCase(django_test.TestCase): def test_pk_first(self): std = StandardFactory.build() - self.assertEqual('foo1', std.foo) + self.assertEqual('foo0', std.foo) def test_pk_many(self): std1 = StandardFactory.build() std2 = StandardFactory.build() - self.assertEqual('foo1', std1.foo) - self.assertEqual('foo2', std2.foo) + self.assertEqual('foo0', std1.foo) + self.assertEqual('foo1', std2.foo) def test_pk_creation(self): std1 = StandardFactory.create() - self.assertEqual('foo1', std1.foo) + self.assertEqual('foo0', std1.foo) self.assertEqual(1, std1.pk) StandardFactory.reset_sequence() std2 = StandardFactory.create() - self.assertEqual('foo2', std2.foo) + self.assertEqual('foo0', 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('foo0', std1.foo) # sequence is unrelated to pk self.assertEqual(10, std1.pk) StandardFactory.reset_sequence() std2 = StandardFactory.create() - self.assertEqual('foo11', std2.foo) + self.assertEqual('foo0', std2.foo) self.assertEqual(11, std2.pk) @@ -221,12 +234,12 @@ class DjangoPkForceTestCase(django_test.TestCase): def test_no_pk(self): std = StandardFactoryWithPKField() self.assertIsNotNone(std.pk) - self.assertEqual('foo1', std.foo) + self.assertEqual('foo0', std.foo) def test_force_pk(self): std = StandardFactoryWithPKField(pk=42) self.assertIsNotNone(std.pk) - self.assertEqual('foo1', std.foo) + self.assertEqual('foo0', std.foo) def test_reuse_pk(self): std1 = StandardFactoryWithPKField(foo='bar') @@ -295,9 +308,9 @@ class DjangoModelLoadingTestCase(django_test.TestCase): 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) + self.assertEqual(0, e1.foo) + self.assertEqual(1, e2.foo) + self.assertEqual(2, e3.foo) @unittest.skipIf(django is None, "Django not installed.") @@ -308,23 +321,23 @@ class DjangoNonIntegerPkTestCase(django_test.TestCase): def test_first(self): nonint = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint.foo) + self.assertEqual('foo0', nonint.foo) def test_many(self): nonint1 = NonIntegerPkFactory.build() nonint2 = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint1.foo) - self.assertEqual('foo2', nonint2.foo) + self.assertEqual('foo0', nonint1.foo) + self.assertEqual('foo1', nonint2.foo) def test_creation(self): nonint1 = NonIntegerPkFactory.create() - self.assertEqual('foo1', nonint1.foo) - self.assertEqual('foo1', nonint1.pk) + self.assertEqual('foo0', nonint1.foo) + self.assertEqual('foo0', nonint1.pk) NonIntegerPkFactory.reset_sequence() nonint2 = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint2.foo) + self.assertEqual('foo0', nonint2.foo) def test_force_pk(self): nonint1 = NonIntegerPkFactory.create(pk='foo10') @@ -333,8 +346,8 @@ class DjangoNonIntegerPkTestCase(django_test.TestCase): NonIntegerPkFactory.reset_sequence() nonint2 = NonIntegerPkFactory.create() - self.assertEqual('foo1', nonint2.foo) - self.assertEqual('foo1', nonint2.pk) + self.assertEqual('foo0', nonint2.foo) + self.assertEqual('foo0', nonint2.pk) @unittest.skipIf(django is None, "Django not installed.") @@ -351,6 +364,32 @@ class DjangoAbstractBaseSequenceTestCase(django_test.TestCase): @unittest.skipIf(django is None, "Django not installed.") +class DjangoRelatedFieldTestCase(django_test.TestCase): + + @classmethod + def setUpClass(cls): + super(DjangoRelatedFieldTestCase, cls).setUpClass() + class PointedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointedModel + foo = 'ahah' + + class PointerFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointingModel + pointed = factory.SubFactory(PointedFactory, foo='hihi') + foo = 'bar' + + cls.PointedFactory = PointedFactory + cls.PointerFactory = PointerFactory + + def test_direct_related_create(self): + ptr = self.PointerFactory() + self.assertEqual('hihi', ptr.pointed.foo) + self.assertEqual(ptr.pointed, models.PointedModel.objects.get()) + + +@unittest.skipIf(django is None, "Django not installed.") class DjangoFileFieldTestCase(unittest.TestCase): def tearDown(self): @@ -363,6 +402,9 @@ class DjangoFileFieldTestCase(unittest.TestCase): o = WithFileFactory.build() self.assertIsNone(o.pk) self.assertEqual(b'', o.afile.read()) + self.assertEqual('example.dat', o.afile.name) + + o.save() self.assertEqual('django/example.dat', o.afile.name) def test_default_create(self): @@ -374,19 +416,26 @@ class DjangoFileFieldTestCase(unittest.TestCase): def test_with_content(self): o = WithFileFactory.build(afile__data='foo') self.assertIsNone(o.pk) + + # Django only allocates the full path on save() + o.save() 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) + o.save() + 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) + + # Django only allocates the full path on save() + o.save() self.assertEqual(b'example_data\n', o.afile.read()) self.assertEqual('django/example.data', o.afile.name) @@ -396,7 +445,9 @@ class DjangoFileFieldTestCase(unittest.TestCase): afile__from_file=f, afile__from_path='' ) - self.assertIsNone(o.pk) + # Django only allocates the full path on save() + o.save() + self.assertEqual(b'example_data\n', o.afile.read()) self.assertEqual('django/example.data', o.afile.name) @@ -406,6 +457,9 @@ class DjangoFileFieldTestCase(unittest.TestCase): afile__from_file=None, ) self.assertIsNone(o.pk) + + # Django only allocates the full path on save() + o.save() self.assertEqual(b'example_data\n', o.afile.read()) self.assertEqual('django/example.data', o.afile.name) @@ -421,16 +475,24 @@ class DjangoFileFieldTestCase(unittest.TestCase): afile__filename='example.foo', ) self.assertIsNone(o.pk) + + # Django only allocates the full path on save() + o.save() 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) + o1.save() + self.assertEqual('django/example.data', o1.afile.name) - o2 = WithFileFactory.build(afile=o1.afile) + o2 = WithFileFactory.build(afile__from_file=o1.afile) self.assertIsNone(o2.pk) + o2.save() + self.assertEqual(b'example_data\n', o2.afile.read()) - self.assertEqual('django/example_1.data', o2.afile.name) + self.assertNotEqual('django/example.data', o2.afile.name) + self.assertRegexpMatches(o2.afile.name, r'django/example_\w+.data') def test_no_file(self): o = WithFileFactory.build(afile=None) @@ -451,6 +513,8 @@ class DjangoImageFieldTestCase(unittest.TestCase): def test_default_build(self): o = WithImageFactory.build() self.assertIsNone(o.pk) + o.save() + self.assertEqual(100, o.animage.width) self.assertEqual(100, o.animage.height) self.assertEqual('django/example.jpg', o.animage.name) @@ -458,13 +522,28 @@ class DjangoImageFieldTestCase(unittest.TestCase): def test_default_create(self): o = WithImageFactory.create() self.assertIsNotNone(o.pk) + o.save() + self.assertEqual(100, o.animage.width) self.assertEqual(100, o.animage.height) self.assertEqual('django/example.jpg', o.animage.name) + def test_complex_create(self): + o = WithImageFactory.create( + size=10, + animage__filename=factory.Sequence(lambda n: 'img%d.jpg' % n), + __sequence=42, + animage__width=factory.SelfAttribute('..size'), + animage__height=factory.SelfAttribute('width'), + ) + self.assertIsNotNone(o.pk) + self.assertEqual('django/img42.jpg', o.animage.name) + def test_with_content(self): o = WithImageFactory.build(animage__width=13, animage__color='red') self.assertIsNone(o.pk) + o.save() + self.assertEqual(13, o.animage.width) self.assertEqual(13, o.animage.height) self.assertEqual('django/example.jpg', o.animage.name) @@ -478,6 +557,8 @@ class DjangoImageFieldTestCase(unittest.TestCase): def test_gif(self): o = WithImageFactory.build(animage__width=13, animage__color='blue', animage__format='GIF') self.assertIsNone(o.pk) + o.save() + self.assertEqual(13, o.animage.width) self.assertEqual(13, o.animage.height) self.assertEqual('django/example.jpg', o.animage.name) @@ -491,7 +572,8 @@ class DjangoImageFieldTestCase(unittest.TestCase): def test_with_file(self): with open(testdata.TESTIMAGE_PATH, 'rb') as f: o = WithImageFactory.build(animage__from_file=f) - self.assertIsNone(o.pk) + o.save() + # 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) @@ -499,6 +581,8 @@ class DjangoImageFieldTestCase(unittest.TestCase): def test_with_path(self): o = WithImageFactory.build(animage__from_path=testdata.TESTIMAGE_PATH) self.assertIsNone(o.pk) + o.save() + # 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) @@ -509,7 +593,8 @@ class DjangoImageFieldTestCase(unittest.TestCase): animage__from_file=f, animage__from_path='' ) - self.assertIsNone(o.pk) + o.save() + # 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) @@ -520,6 +605,8 @@ class DjangoImageFieldTestCase(unittest.TestCase): animage__from_file=None, ) self.assertIsNone(o.pk) + o.save() + # 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) @@ -536,18 +623,24 @@ class DjangoImageFieldTestCase(unittest.TestCase): animage__filename='example.foo', ) self.assertIsNone(o.pk) + o.save() + # 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) + o1.save() - o2 = WithImageFactory.build(animage=o1.animage) + o2 = WithImageFactory.build(animage__from_file=o1.animage) self.assertIsNone(o2.pk) + o2.save() + # 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) + self.assertNotEqual('django/example.jpeg', o2.animage.name) + self.assertRegexpMatches(o2.animage.name, r'django/example_\w+.jpeg') def test_no_file(self): o = WithImageFactory.build(animage=None) @@ -585,6 +678,19 @@ class PreventSignalsTestCase(unittest.TestCase): self.assertSignalsReactivated() + def test_signal_cache(self): + with factory.django.mute_signals(signals.pre_save, signals.post_save): + signals.post_save.connect(self.handlers.mute_block_receiver) + WithSignalsFactory() + + self.assertTrue(self.handlers.mute_block_receiver.call_count, 1) + self.assertEqual(self.handlers.pre_init.call_count, 1) + self.assertFalse(self.handlers.pre_save.called) + self.assertFalse(self.handlers.post_save.called) + + self.assertSignalsReactivated() + self.assertTrue(self.handlers.mute_block_receiver.call_count, 1) + def test_class_decorator(self): @factory.django.mute_signals(signals.pre_save, signals.post_save) class WithSignalsDecoratedFactory(factory.django.DjangoModelFactory): @@ -599,6 +705,27 @@ class PreventSignalsTestCase(unittest.TestCase): self.assertSignalsReactivated() + def test_class_decorator_with_subfactory(self): + @factory.django.mute_signals(signals.pre_save, signals.post_save) + class WithSignalsDecoratedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithSignals + + @factory.post_generation + def post(obj, create, extracted, **kwargs): + if not extracted: + WithSignalsDecoratedFactory.create(post=42) + + # This will disable the signals (twice), create two objects, + # and reactivate the signals. + WithSignalsDecoratedFactory() + + self.assertEqual(self.handlers.pre_init.call_count, 2) + self.assertFalse(self.handlers.pre_save.called) + self.assertFalse(self.handlers.post_save.called) + + self.assertSignalsReactivated() + def test_class_decorator_build(self): @factory.django.mute_signals(signals.pre_save, signals.post_save) class WithSignalsDecoratedFactory(factory.django.DjangoModelFactory): @@ -641,5 +768,21 @@ class PreventSignalsTestCase(unittest.TestCase): self.assertSignalsReactivated() +@unittest.skipIf(django is None, "Django not installed.") +class DjangoCustomManagerTestCase(unittest.TestCase): + + def test_extra_args(self): + # Our CustomManager will remove the 'arg=' argument. + model = WithCustomManagerFactory(arg='foo') + + def test_with_manager_on_abstract(self): + class ObjFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.FromAbstractWithCustomManager + + # Our CustomManager will remove the 'arg=' argument, + # invalid for the actual model. + ObjFactory.create(arg='invalid') + if __name__ == '__main__': # pragma: no cover unittest.main() diff --git a/tests/test_faker.py b/tests/test_faker.py new file mode 100644 index 0000000..99e54af --- /dev/null +++ b/tests/test_faker.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Mark Sandstrom +# Copyright (c) 2011-2015 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 unittest + +import faker.providers + +import factory + + +class MockFaker(object): + def __init__(self, expected): + self.expected = expected + + def format(self, provider, **kwargs): + return self.expected[provider] + + +class FakerTests(unittest.TestCase): + def setUp(self): + self._real_fakers = factory.Faker._FAKER_REGISTRY + factory.Faker._FAKER_REGISTRY = {} + + def tearDown(self): + factory.Faker._FAKER_REGISTRY = self._real_fakers + + def _setup_mock_faker(self, locale=None, **definitions): + if locale is None: + locale = factory.Faker._DEFAULT_LOCALE + factory.Faker._FAKER_REGISTRY[locale] = MockFaker(definitions) + + def test_simple_biased(self): + self._setup_mock_faker(name="John Doe") + faker_field = factory.Faker('name') + self.assertEqual("John Doe", faker_field.generate({})) + + def test_full_factory(self): + class Profile(object): + def __init__(self, first_name, last_name, email): + self.first_name = first_name + self.last_name = last_name + self.email = email + + class ProfileFactory(factory.Factory): + class Meta: + model = Profile + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name', locale='fr_FR') + email = factory.Faker('email') + + self._setup_mock_faker(first_name="John", last_name="Doe", email="john.doe@example.org") + self._setup_mock_faker(first_name="Jean", last_name="Valjean", email="jvaljean@exemple.fr", locale='fr_FR') + + profile = ProfileFactory() + self.assertEqual("John", profile.first_name) + self.assertEqual("Valjean", profile.last_name) + self.assertEqual('john.doe@example.org', profile.email) + + def test_override_locale(self): + class Profile(object): + def __init__(self, first_name, last_name): + self.first_name = first_name + self.last_name = last_name + + class ProfileFactory(factory.Factory): + class Meta: + model = Profile + + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name', locale='fr_FR') + + self._setup_mock_faker(first_name="John", last_name="Doe") + self._setup_mock_faker(first_name="Jean", last_name="Valjean", locale='fr_FR') + self._setup_mock_faker(first_name="Johannes", last_name="Brahms", locale='de_DE') + + profile = ProfileFactory() + self.assertEqual("John", profile.first_name) + self.assertEqual("Valjean", profile.last_name) + + with factory.Faker.override_default_locale('de_DE'): + profile = ProfileFactory() + self.assertEqual("Johannes", profile.first_name) + self.assertEqual("Valjean", profile.last_name) + + profile = ProfileFactory() + self.assertEqual("John", profile.first_name) + self.assertEqual("Valjean", profile.last_name) + + def test_add_provider(self): + class Face(object): + def __init__(self, smiley, french_smiley): + self.smiley = smiley + self.french_smiley = french_smiley + + class FaceFactory(factory.Factory): + class Meta: + model = Face + + smiley = factory.Faker('smiley') + french_smiley = factory.Faker('smiley', locale='fr_FR') + + class SmileyProvider(faker.providers.BaseProvider): + def smiley(self): + return ':)' + + class FrenchSmileyProvider(faker.providers.BaseProvider): + def smiley(self): + return '(:' + + factory.Faker.add_provider(SmileyProvider) + factory.Faker.add_provider(FrenchSmileyProvider, 'fr_FR') + + face = FaceFactory() + self.assertEqual(":)", face.smiley) + self.assertEqual("(:", face.french_smiley) diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py index 1caeb0a..4c3873a 100644 --- a/tests/test_fuzzy.py +++ b/tests/test_fuzzy.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -55,7 +55,7 @@ class FuzzyChoiceTestCase(unittest.TestCase): d = fuzzy.FuzzyChoice(options) - with mock.patch('random.choice', fake_choice): + with mock.patch('factory.fuzzy._random.choice', fake_choice): res = d.evaluate(2, None, False) self.assertEqual(6, res) @@ -74,6 +74,24 @@ class FuzzyChoiceTestCase(unittest.TestCase): res = d.evaluate(2, None, False) self.assertIn(res, [0, 1, 2]) + def test_lazy_generator(self): + class Gen(object): + def __init__(self, options): + self.options = options + self.unrolled = False + + def __iter__(self): + self.unrolled = True + return iter(self.options) + + opts = Gen([1, 2, 3]) + d = fuzzy.FuzzyChoice(opts) + self.assertFalse(opts.unrolled) + + res = d.evaluate(2, None, False) + self.assertIn(res, [1, 2, 3]) + self.assertTrue(opts.unrolled) + class FuzzyIntegerTestCase(unittest.TestCase): def test_definition(self): @@ -93,7 +111,7 @@ class FuzzyIntegerTestCase(unittest.TestCase): fuzz = fuzzy.FuzzyInteger(2, 8) - with mock.patch('random.randrange', fake_randrange): + with mock.patch('factory.fuzzy._random.randrange', fake_randrange): res = fuzz.evaluate(2, None, False) self.assertEqual((2 + 8 + 1) * 1, res) @@ -103,7 +121,7 @@ class FuzzyIntegerTestCase(unittest.TestCase): fuzz = fuzzy.FuzzyInteger(8) - with mock.patch('random.randrange', fake_randrange): + with mock.patch('factory.fuzzy._random.randrange', fake_randrange): res = fuzz.evaluate(2, None, False) self.assertEqual((0 + 8 + 1) * 1, res) @@ -113,7 +131,7 @@ class FuzzyIntegerTestCase(unittest.TestCase): fuzz = fuzzy.FuzzyInteger(5, 8, 3) - with mock.patch('random.randrange', fake_randrange): + with mock.patch('factory.fuzzy._random.randrange', fake_randrange): res = fuzz.evaluate(2, None, False) self.assertEqual((5 + 8 + 1) * 3, res) @@ -146,7 +164,7 @@ class FuzzyDecimalTestCase(unittest.TestCase): fuzz = fuzzy.FuzzyDecimal(2.0, 8.0) - with mock.patch('random.uniform', fake_uniform): + with mock.patch('factory.fuzzy._random.uniform', fake_uniform): res = fuzz.evaluate(2, None, False) self.assertEqual(decimal.Decimal('10.0'), res) @@ -156,7 +174,7 @@ class FuzzyDecimalTestCase(unittest.TestCase): fuzz = fuzzy.FuzzyDecimal(8.0) - with mock.patch('random.uniform', fake_uniform): + with mock.patch('factory.fuzzy._random.uniform', fake_uniform): res = fuzz.evaluate(2, None, False) self.assertEqual(decimal.Decimal('8.0'), res) @@ -166,11 +184,24 @@ class FuzzyDecimalTestCase(unittest.TestCase): fuzz = fuzzy.FuzzyDecimal(8.0, precision=3) - with mock.patch('random.uniform', fake_uniform): + with mock.patch('factory.fuzzy._random.uniform', fake_uniform): res = fuzz.evaluate(2, None, False) self.assertEqual(decimal.Decimal('8.001').quantize(decimal.Decimal(10) ** -3), res) + @unittest.skipIf(compat.PY2, "decimal.FloatOperation was added in Py3") + def test_no_approximation(self): + """We should not go through floats in our fuzzy calls unless actually needed.""" + fuzz = fuzzy.FuzzyDecimal(0, 10) + + decimal_context = decimal.getcontext() + old_traps = decimal_context.traps[decimal.FloatOperation] + try: + decimal_context.traps[decimal.FloatOperation] = True + fuzz.evaluate(2, None, None) + finally: + decimal_context.traps[decimal.FloatOperation] = old_traps + class FuzzyDateTestCase(unittest.TestCase): @classmethod @@ -214,7 +245,7 @@ class FuzzyDateTestCase(unittest.TestCase): fake_randint = lambda low, high: (low + high) // 2 fuzz = fuzzy.FuzzyDate(self.jan1, self.jan31) - with mock.patch('random.randint', fake_randint): + with mock.patch('factory.fuzzy._random.randint', fake_randint): res = fuzz.evaluate(2, None, False) self.assertEqual(datetime.date(2013, 1, 16), res) @@ -225,7 +256,7 @@ class FuzzyDateTestCase(unittest.TestCase): fuzz = fuzzy.FuzzyDate(self.jan1) fake_randint = lambda low, high: (low + high) // 2 - with mock.patch('random.randint', fake_randint): + with mock.patch('factory.fuzzy._random.randint', fake_randint): res = fuzz.evaluate(2, None, False) self.assertEqual(datetime.date(2013, 1, 2), res) @@ -332,7 +363,7 @@ class FuzzyNaiveDateTimeTestCase(unittest.TestCase): fake_randint = lambda low, high: (low + high) // 2 fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31) - with mock.patch('random.randint', fake_randint): + with mock.patch('factory.fuzzy._random.randint', fake_randint): res = fuzz.evaluate(2, None, False) self.assertEqual(datetime.datetime(2013, 1, 16), res) @@ -343,7 +374,7 @@ class FuzzyNaiveDateTimeTestCase(unittest.TestCase): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1) fake_randint = lambda low, high: (low + high) // 2 - with mock.patch('random.randint', fake_randint): + with mock.patch('factory.fuzzy._random.randint', fake_randint): res = fuzz.evaluate(2, None, False) self.assertEqual(datetime.datetime(2013, 1, 2), res) @@ -450,7 +481,7 @@ class FuzzyDateTimeTestCase(unittest.TestCase): fake_randint = lambda low, high: (low + high) // 2 fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31) - with mock.patch('random.randint', fake_randint): + with mock.patch('factory.fuzzy._random.randint', fake_randint): res = fuzz.evaluate(2, None, False) self.assertEqual(datetime.datetime(2013, 1, 16, tzinfo=compat.UTC), res) @@ -461,7 +492,7 @@ class FuzzyDateTimeTestCase(unittest.TestCase): fuzz = fuzzy.FuzzyDateTime(self.jan1) fake_randint = lambda low, high: (low + high) // 2 - with mock.patch('random.randint', fake_randint): + with mock.patch('factory.fuzzy._random.randint', fake_randint): res = fuzz.evaluate(2, None, False) self.assertEqual(datetime.datetime(2013, 1, 2, tzinfo=compat.UTC), res) @@ -486,7 +517,7 @@ class FuzzyTextTestCase(unittest.TestCase): chars = ['a', 'b', 'c'] fuzz = fuzzy.FuzzyText(prefix='pre', suffix='post', chars=chars, length=4) - with mock.patch('random.choice', fake_choice): + with mock.patch('factory.fuzzy._random.choice', fake_choice): res = fuzz.evaluate(2, None, False) self.assertEqual('preaaaapost', res) @@ -504,3 +535,25 @@ class FuzzyTextTestCase(unittest.TestCase): for char in res: self.assertIn(char, ['a', 'b', 'c']) + + +class FuzzyRandomTestCase(unittest.TestCase): + def test_seeding(self): + fuzz = fuzzy.FuzzyInteger(1, 1000) + + fuzzy.reseed_random(42) + value = fuzz.evaluate(sequence=1, obj=None, create=False) + + fuzzy.reseed_random(42) + value2 = fuzz.evaluate(sequence=1, obj=None, create=False) + self.assertEqual(value, value2) + + def test_reset_state(self): + fuzz = fuzzy.FuzzyInteger(1, 1000) + + state = fuzzy.get_random_state() + value = fuzz.evaluate(sequence=1, obj=None, create=False) + + fuzzy.set_random_state(state) + value2 = fuzz.evaluate(sequence=1, obj=None, create=False) + self.assertEqual(value, value2) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f5a66e5..bee66ca 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/tests/test_mongoengine.py b/tests/test_mongoengine.py index 988c179..148d274 100644 --- a/tests/test_mongoengine.py +++ b/tests/test_mongoengine.py @@ -31,6 +31,9 @@ try: except ImportError: mongoengine = None +if os.environ.get('SKIP_MONGOENGINE') == '1': + mongoengine = None + if mongoengine: from factory.mongoengine import MongoEngineFactory @@ -61,10 +64,20 @@ 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')) + server_timeout_ms = int(os.environ.get('MONGO_TIMEOUT', '300')) @classmethod def setUpClass(cls): - cls.db = mongoengine.connect(cls.db_name, host=cls.db_host, port=cls.db_port) + from pymongo import read_preferences as mongo_rp + cls.db = mongoengine.connect( + db=cls.db_name, + host=cls.db_host, + port=cls.db_port, + # PyMongo>=2.1 requires an explicit read_preference. + read_preference=mongo_rp.ReadPreference.PRIMARY, + # PyMongo>=2.1 has a 20s timeout, use 100ms instead + serverselectiontimeoutms=cls.server_timeout_ms, + ) @classmethod def tearDownClass(cls): diff --git a/tests/test_using.py b/tests/test_using.py index f18df4d..0a893c1 100644 --- a/tests/test_using.py +++ b/tests/test_using.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -69,6 +69,9 @@ class FakeModel(object): def order_by(self, *args, **kwargs): return [1] + def using(self, db): + return self + objects = FakeModelManager() def __init__(self, **kwargs): @@ -1073,6 +1076,21 @@ class KwargAdjustTestCase(unittest.TestCase): self.assertEqual({'x': 1, 'y': 2, 'z': 3, 'foo': 3}, obj.kwargs) self.assertEqual((), obj.args) + def test_rename(self): + class TestObject(object): + def __init__(self, attributes=None): + self.attributes = attributes + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + rename = {'attributes_': 'attributes'} + + attributes_ = 42 + + obj = TestObjectFactory.build() + self.assertEqual(42, obj.attributes) + class SubFactoryTestCase(unittest.TestCase): def test_sub_factory(self): @@ -1475,6 +1493,38 @@ class IteratorTestCase(unittest.TestCase): for i, obj in enumerate(objs): self.assertEqual(i + 10, obj.one) + def test_iterator_late_loading(self): + """Ensure that Iterator doesn't unroll on class creation. + + This allows, for Django objects, to call: + foo = factory.Iterator(models.MyThingy.objects.all()) + """ + class DBRequest(object): + def __init__(self): + self.ready = False + + def __iter__(self): + if not self.ready: + raise ValueError("Not ready!!") + return iter([1, 2, 3]) + + # calling __iter__() should crash + req1 = DBRequest() + with self.assertRaises(ValueError): + iter(req1) + + req2 = DBRequest() + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + one = factory.Iterator(req2) + + req2.ready = True + obj = TestObjectFactory() + self.assertEqual(1, obj.one) + class BetterFakeModelManager(object): def __init__(self, keys, instance): @@ -1490,12 +1540,9 @@ class BetterFakeModelManager(object): instance.id = 2 return instance, True - def values_list(self, *args, **kwargs): + def using(self, db): return self - def order_by(self, *args, **kwargs): - return [1] - class BetterFakeModel(object): @classmethod @@ -1618,14 +1665,14 @@ class DjangoModelFactoryTestCase(unittest.TestCase): o1 = TestModelFactory() o2 = TestModelFactory() - self.assertEqual('foo_2', o1.a) - self.assertEqual('foo_3', o2.a) + self.assertEqual('foo_0', o1.a) + self.assertEqual('foo_1', o2.a) o3 = TestModelFactory.build() o4 = TestModelFactory.build() - self.assertEqual('foo_4', o3.a) - self.assertEqual('foo_5', o4.a) + self.assertEqual('foo_2', o3.a) + self.assertEqual('foo_3', o4.a) def test_no_get_or_create(self): class TestModelFactory(factory.django.DjangoModelFactory): @@ -1636,7 +1683,7 @@ class DjangoModelFactoryTestCase(unittest.TestCase): o = TestModelFactory() self.assertEqual(None, o._defaults) - self.assertEqual('foo_2', o.a) + self.assertEqual('foo_0', o.a) self.assertEqual(2, o.id) def test_get_or_create(self): @@ -1652,7 +1699,7 @@ class DjangoModelFactoryTestCase(unittest.TestCase): o = TestModelFactory() self.assertEqual({'c': 3, 'd': 4}, o._defaults) - self.assertEqual('foo_2', o.a) + self.assertEqual('foo_0', o.a) self.assertEqual(2, o.b) self.assertEqual(3, o.c) self.assertEqual(4, o.d) @@ -1672,7 +1719,7 @@ class DjangoModelFactoryTestCase(unittest.TestCase): o = TestModelFactory() self.assertEqual({}, o._defaults) - self.assertEqual('foo_2', o.a) + self.assertEqual('foo_0', o.a) self.assertEqual(2, o.b) self.assertEqual(3, o.c) self.assertEqual(4, o.d) @@ -1804,7 +1851,7 @@ class PostGenerationTestCase(unittest.TestCase): model = TestObject one = 3 two = 2 - three = factory.RelatedFactory(TestRelatedObjectFactory, name='obj') + three = factory.RelatedFactory(TestRelatedObjectFactory, 'obj') obj = TestObjectFactory.build() # Normal fields @@ -1877,6 +1924,36 @@ class PostGenerationTestCase(unittest.TestCase): self.assertEqual(3, related.one) self.assertEqual(4, related.two) + def test_related_factory_selfattribute(self): + class TestRelatedObject(object): + def __init__(self, obj=None, one=None, two=None): + obj.related = self + self.one = one + self.two = two + self.three = obj + + class TestRelatedObjectFactory(factory.Factory): + class Meta: + model = TestRelatedObject + one = 1 + two = factory.LazyAttribute(lambda o: o.one + 1) + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + one = 3 + two = 2 + three = factory.RelatedFactory(TestRelatedObjectFactory, 'obj', + two=factory.SelfAttribute('obj.two'), + ) + + obj = TestObjectFactory.build(two=4) + self.assertEqual(3, obj.one) + self.assertEqual(4, obj.two) + self.assertEqual(1, obj.related.one) + self.assertEqual(4, obj.related.two) + + class RelatedFactoryExtractionTestCase(unittest.TestCase): def setUp(self): @@ -1938,6 +2015,23 @@ class CircularTestCase(unittest.TestCase): self.assertIsNone(b.foo.bar.foo.bar) +class SelfReferentialTests(unittest.TestCase): + def test_no_parent(self): + from .cyclic import self_ref + + obj = self_ref.TreeElementFactory(parent=None) + self.assertIsNone(obj.parent) + + def test_deep(self): + from .cyclic import self_ref + + obj = self_ref.TreeElementFactory(parent__parent__parent__parent=None) + self.assertIsNotNone(obj.parent) + self.assertIsNotNone(obj.parent.parent) + self.assertIsNotNone(obj.parent.parent.parent) + self.assertIsNone(obj.parent.parent.parent.parent) + + class DictTestCase(unittest.TestCase): def test_empty_dict(self): class TestObjectFactory(factory.Factory): diff --git a/tests/test_utils.py b/tests/test_utils.py index d321c2a..77598e1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 @@ -238,33 +238,33 @@ class ImportObjectTestCase(unittest.TestCase): class LogPPrintTestCase(unittest.TestCase): def test_nothing(self): - txt = utils.log_pprint() + txt = str(utils.log_pprint()) self.assertEqual('', txt) def test_only_args(self): - txt = utils.log_pprint((1, 2, 3)) + txt = str(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}) + txt = str(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',)) + txt = str(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(('ŧêßŧ',)) + txt = str(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'}) + txt = str(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: @@ -273,7 +273,7 @@ class LogPPrintTestCase(unittest.TestCase): self.assertIn(txt, (expected1, expected2)) def test_text_kwargs(self): - txt = utils.log_pprint(kwargs={'x': 'ŧêßŧ', 'y': 'ŧßêŧ'}) + txt = str(utils.log_pprint(kwargs={'x': 'ŧêßŧ', 'y': 'ŧßêŧ'})) expected1 = "x='ŧêßŧ', y='ŧßêŧ'" expected2 = "y='ŧßêŧ', x='ŧêßŧ'" if is_python2: diff --git a/tests/testdata/__init__.py b/tests/testdata/__init__.py index 9956610..b534998 100644 --- a/tests/testdata/__init__.py +++ b/tests/testdata/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/tests/tools.py b/tests/tools.py index 571899b..47f705c 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/tests/utils.py b/tests/utils.py index 215fc83..7a31ed2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois +# Copyright (c) 2011-2015 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 diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 2200f56..0000000 --- a/tox.ini +++ /dev/null @@ -1,17 +0,0 @@ -[tox] -envlist = py26,py27,pypy - -[testenv] -commands= - python -W default setup.py test - -[testenv:py26] - -deps= - mock - unittest2 - -[textenv:py27] - -deps= - mock |