Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/add unique feature arg in faker factory #820

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ ChangeLog

- Add support for Django 3.1
- Add support for Python 3.9
- Add support for `unique` Faker feature

*Removed:*

Expand Down
18 changes: 18 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,24 @@ Faker
date_end=datetime.date(2020, 5, 31),
)

Since Faker 4.9.0 version (see `Faker documentation <https://faker.readthedocs.io/en/master/fakerclass.html#unique-values>`_),
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.
arthurHamon2 marked this conversation as resolved.
Show resolved Hide resolved
)

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.

Expand Down
73 changes: 33 additions & 40 deletions factory/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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."""
Expand Down Expand Up @@ -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."""

Expand All @@ -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):
Expand Down
22 changes: 16 additions & 6 deletions factory/faker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand All @@ -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]
Expand Down
64 changes: 64 additions & 0 deletions factory/sequences.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading