diff --git a/docs/changelog.rst b/docs/changelog.rst index a8588d4c..b38b3301 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,7 @@ ChangeLog - Add support for Django 3.1 - Add support for Python 3.9 + - Add support for `unique` Faker feature *Removed:* diff --git a/docs/reference.rst b/docs/reference.rst index 0c938693..74c94924 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -709,6 +709,24 @@ Faker date_end=datetime.date(2020, 5, 31), ) + Since Faker 4.9.0 version (see `Faker documentation `_), + on every provider, you can specify whether to return an unique value or not: + + .. code-block:: python + + class UserFactory(fatory.Factory): + class Meta: + model = User + + arrival = factory.Faker( + 'date_between_dates', + date_start=datetime.date(2020, 1, 1), + date_end=datetime.date(2020, 5, 31), + unique=True # The generated date is guaranteed to be unique inside the test execution. + ) + + Note that an `UniquenessException` will be thrown if Faker fails to generate an unique value. + As with :class:`~factory.SubFactory`, the parameters can be any valid declaration. This does not apply to the provider name or the locale. diff --git a/factory/base.py b/factory/base.py index c338c9aa..391f3a0e 100644 --- a/factory/base.py +++ b/factory/base.py @@ -4,6 +4,7 @@ import collections import logging +from .sequences import _Counter, _UniqueStore from . import builder, declarations, enums, errors, utils logger = logging.getLogger('factory.generate') @@ -143,8 +144,8 @@ def __init__(self): self.pre_declarations = builder.DeclarationSet() self.post_declarations = builder.DeclarationSet() - self._counter = None - self.counter_reference = None + self._generators = {} + self.generator_references = {} @property def declarations(self): @@ -210,7 +211,8 @@ def contribute_to_class(self, factory, meta=None, base_meta=None, base_factory=N if self.model is None: self.abstract = True - self.counter_reference = self._get_counter_reference() + for generator_type in [_Counter, _UniqueStore]: + self.generator_references[generator_type] = self._get_generator_reference(generator_type) # Scan the inheritance chain, starting from the furthest point, # excluding the current class, to retrieve all declarations. @@ -233,31 +235,37 @@ def contribute_to_class(self, factory, meta=None, base_meta=None, base_factory=N self.pre_declarations, self.post_declarations = builder.parse_declarations(self.declarations) - def _get_counter_reference(self): + def _get_generator_reference(self, generator_class): """Identify which factory should be used for a shared counter.""" if (self.model is not None and self.base_factory is not None and self.base_factory._meta.model is not None and issubclass(self.model, self.base_factory._meta.model)): - return self.base_factory._meta.counter_reference + return self.base_factory._meta.generator_references[generator_class] else: return self - def _initialize_counter(self): + def _initialize_generator(self, generator_class): """Initialize our counter pointer. If we're the top-level factory, instantiate a new counter Otherwise, point to the top-level factory's counter. """ - if self._counter is not None: + if self._generators.get(generator_class) is not None: return - if self.counter_reference is self: - self._counter = _Counter(seq=self.factory._setup_next_sequence()) + if self.generator_references.get(generator_class) is self: + if generator_class == _Counter: + self._generators[generator_class] = generator_class(seq=self.factory._setup_next_sequence()) + elif generator_class == _UniqueStore: + self._generators[generator_class] = generator_class( + # locale=self.factory.meta.locale, + # providers=self.factory.meta.custom_providers + ) else: - self.counter_reference._initialize_counter() - self._counter = self.counter_reference._counter + self.generator_references[generator_class]._initialize_generator(generator_class) + self._generators[generator_class] = self.generator_references[generator_class]._generators[generator_class] def next_sequence(self): """Retrieve a new sequence ID. @@ -267,20 +275,26 @@ def next_sequence(self): - _setup_next_sequence, if this is the 'toplevel' factory and the sequence counter wasn't initialized yet; then increase it. """ - self._initialize_counter() - return self._counter.next() + return self.next(_Counter) def reset_sequence(self, value=None, force=False): - self._initialize_counter() + self.reset(_Counter, value=value, force=force) + + def next(self, generator_class, *args, **kwargs): + self._initialize_generator(generator_class) + return self._generators[generator_class].next(*args, **kwargs) - if self.counter_reference is not self and not force: + def reset(self, generator_class, value=None, force=False): + self._initialize_generator(generator_class) + generator_reference = self.generator_references[generator_class] + if generator_reference is not self and not force: raise ValueError( "Can't reset a sequence on descendant factory %r; reset sequence on %r or use `force=True`." - % (self.factory, self.counter_reference.factory)) + % (self.factory, generator_reference.factory)) if value is None: - value = self.counter_reference.factory._setup_next_sequence() - self._counter.reset(value) + value = generator_reference.factory._setup_next_sequence() + self._generators[generator_class].reset(value) def prepare_arguments(self, attributes): """Convert an attributes dict to a (args, kwargs) tuple.""" @@ -382,28 +396,6 @@ def __repr__(self): # Factory base classes - - -class _Counter: - """Simple, naive counter. - - Attributes: - for_class (obj): the class this counter related to - seq (int): the next value - """ - - def __init__(self, seq): - self.seq = seq - - def next(self): - value = self.seq - self.seq += 1 - return value - - def reset(self, next_value=0): - self.seq = next_value - - class BaseFactory: """Factory base support for sequences, attributes and stubs.""" @@ -419,6 +411,7 @@ def __new__(cls, *args, **kwargs): # ID to use for the next 'declarations.Sequence' attribute. _counter = None + _unique_store = None @classmethod def reset_sequence(cls, value=None, force=False): diff --git a/factory/faker.py b/factory/faker.py index 1c9e28aa..26fc196a 100644 --- a/factory/faker.py +++ b/factory/faker.py @@ -15,9 +15,10 @@ class Meta: import contextlib -import faker -import faker.config +import faker as faker_lib +import faker.config as faker_lib_config +from .sequences import _UniqueStore from . import declarations @@ -38,17 +39,26 @@ class Faker(declarations.ParameteredDeclaration): def __init__(self, provider, **kwargs): locale = kwargs.pop('locale', None) self.provider = provider + self.local_faker_instances = {} super().__init__( locale=locale, **kwargs) + def evaluate(self, instance, step, extra): + unique = extra.pop('unique', False) + if unique: + extra.pop('locale') + generated_value = step.builder.factory_meta.next(_UniqueStore, self.provider, **extra) + else: + generated_value = self.generate(extra) + return generated_value + def generate(self, params): locale = params.pop('locale') - subfaker = self._get_faker(locale) - return subfaker.format(self.provider, **params) + return self._get_faker(locale).format(self.provider, **params) _FAKER_REGISTRY = {} - _DEFAULT_LOCALE = faker.config.DEFAULT_LOCALE + _DEFAULT_LOCALE = faker_lib_config.DEFAULT_LOCALE @classmethod @contextlib.contextmanager @@ -66,7 +76,7 @@ def _get_faker(cls, locale=None): locale = cls._DEFAULT_LOCALE if locale not in cls._FAKER_REGISTRY: - subfaker = faker.Faker(locale=locale) + subfaker = faker_lib.Faker(locale=locale) cls._FAKER_REGISTRY[locale] = subfaker return cls._FAKER_REGISTRY[locale] diff --git a/factory/sequences.py b/factory/sequences.py new file mode 100644 index 00000000..67c25898 --- /dev/null +++ b/factory/sequences.py @@ -0,0 +1,64 @@ +from abc import abstractmethod + +from faker.proxy import Faker + + +class ISequenceGenerator: + + @abstractmethod + def initialize(self, seed): + pass + + @abstractmethod + def next(self, *args, **kwargs): + pass + + @abstractmethod + def reset(self): + pass + + +class _Counter(ISequenceGenerator): + """Simple, naive counter. + + Attributes: + for_class (obj): the class this counter related to + seq (int): the next value + """ + + def __init__(self, seq): + self.seq = seq + + def initialize(self, seed): + pass + + def next(self, *args, **kwargs): + value = self.seq + self.seq += 1 + return value + + def reset(self, next_value=0): + self.seq = next_value + + +class _UniqueStore(ISequenceGenerator): + + def __init__(self, locale=None, custom_providers=None): + self.faker_instance = Faker( + locale=locale, includes=custom_providers + ) + + def initialize(self, seed): + self.faker_instance.seed(seed) + + def next(self, *args, **kwargs): + if len(args) == 0: + raise ValueError("No provider passed in parameters.") + provider = args[0] + faker_provider_func = getattr(self.faker_instance.unique, provider, None) + if faker_provider_func is None: + raise ValueError(f"Faker provider {provider} is not available.") + return faker_provider_func(*args[1:], **kwargs) + + def reset(self): + self.faker_instance.unique.clear() diff --git a/setup.cfg b/setup.cfg index 1005f55b..584e941c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,7 +35,7 @@ classifiers = zip_safe = false packages = factory python_requires = >=3.6 -install_requires = Faker>=0.7.0 +install_requires = Faker>=4.9.0 [options.extras_require] dev = diff --git a/tests/test_faker.py b/tests/test_faker.py index fb9b7218..6bb0adec 100644 --- a/tests/test_faker.py +++ b/tests/test_faker.py @@ -2,14 +2,27 @@ import collections import datetime + import random import unittest +from unittest import mock import faker.providers +from faker.exceptions import UniquenessException import factory +class MockUniqueProxy: + + def __init__(self, expected): + self.expected = expected + self.random = random.Random() + + def format(self, provider, **kwargs): + return "unique {}".format(self.expected[provider]) + + class MockFaker: def __init__(self, expected): self.expected = expected @@ -18,6 +31,10 @@ def __init__(self, expected): def format(self, provider, **kwargs): return self.expected[provider] + @property + def unique(self): + return MockUniqueProxy(self.expected) + class AdvancedMockFaker: def __init__(self, handlers): @@ -168,3 +185,98 @@ def fake_select_date(start_date, end_date): self.assertEqual(may_4th, trip.departure) self.assertEqual(october_19th, trip.transfer) self.assertEqual(may_25th, trip.arrival) + + def test_faker_unique(self): + self._setup_mock_faker(name="John Doe", unique=True) + with mock.patch("factory.faker.faker_lib.Faker") as faker_mock: + faker_mock.return_value = MockFaker(dict(name="John Doe")) + faker_field = factory.Faker('name', unique=True) + self.assertEqual( + "unique John Doe", + faker_field.generate({'locale': None, 'unique': True}) + ) + + +class RealFakerTest(unittest.TestCase): + + def test_faker_not_unique_not_raising_exception(self): + faker_field = factory.Faker('pyint') + # Make sure that without unique we can still create duplicated faker values. + self.assertEqual(1, faker_field.generate({'locale': None, 'min_value': 1, 'max_value': 1})) + self.assertEqual(1, faker_field.generate({'locale': None, 'min_value': 1, 'max_value': 1})) + + def test_faker_unique_raising_exception(self): + faker_field = factory.Faker('pyint', min_value=1, max_value=1, unique=True) + # Make sure creating duplicated values raises an exception on the second call + # (which produces an identical value to the previous one). + self.assertEqual(1, faker_field.generate({'locale': None, 'min_value': 1, 'max_value': 1, 'unique': True})) + self.assertRaises( + UniquenessException, + faker_field.generate, + {'locale': None, 'min_value': 1, 'max_value': 1, 'unique': True} + ) + + def test_faker_shared_faker_instance(self): + class Foo: + def __init__(self, val): + self.val = val + + class Bar: + def __init__(self, val): + self.val = val + + class Factory1(factory.Factory): + val = factory.Faker('pyint', min_value=1, max_value=1, unique=True) + + class Meta: + model = Foo + + class Factory2(factory.Factory): + val = factory.Faker('pyint', min_value=1, max_value=1, unique=True) + + class Meta: + model = Bar + + f1 = Factory1.build() + f2 = Factory2.build() + self.assertEqual(f1.val, 1) + self.assertEqual(f2.val, 1) + + def test_faker_inherited_faker_instance(self): + class Foo: + def __init__(self, val): + self.val = val + + class Bar(Foo): + def __init__(self, val): + super().__init__(val) + + class Factory1(factory.Factory): + val = factory.Faker('pyint', min_value=1, max_value=1, unique=True) + + class Meta: + model = Foo + + class Factory2(Factory1): + + class Meta: + model = Bar + + Factory1.build() + with self.assertRaises(UniquenessException): + Factory2.build() + + def test_faker_clear_unique_store(self): + class Foo: + def __init__(self, val): + self.val = val + + class Factory1(factory.Factory): + val = factory.Faker('pyint', min_value=1, max_value=1, unique=True) + + class Meta: + model = Foo + + Factory1.build() + Factory1.val.clear_unique_store() + Factory1.build()