Source code for factory.base

# Copyright: See the LICENSE file.


import collections
import logging
import warnings
from typing import Generic, List, Type, TypeVar

from . import builder, declarations, enums, errors, utils

logger = logging.getLogger('factory.generate')

T = TypeVar('T')

# Factory metaclasses


def get_factory_bases(bases):
    """Retrieve all FactoryMetaClass-derived bases from a list."""
    return [b for b in bases if issubclass(b, BaseFactory)]


def resolve_attribute(name, bases, default=None):
    """Find the first definition of an attribute according to MRO order."""
    for base in bases:
        if hasattr(base, name):
            return getattr(base, name)
    return default


class FactoryMetaClass(type):
    """Factory metaclass for handling ordered declarations."""

    def __call__(cls, **kwargs):
        """Override the default Factory() syntax to call the default strategy.

        Returns an instance of the associated class.
        """

        if cls._meta.strategy == enums.BUILD_STRATEGY:
            return cls.build(**kwargs)
        elif cls._meta.strategy == enums.CREATE_STRATEGY:
            return cls.create(**kwargs)
        elif cls._meta.strategy == enums.STUB_STRATEGY:
            return cls.stub(**kwargs)
        else:
            raise errors.UnknownStrategy('Unknown Meta.strategy: {}'.format(
                cls._meta.strategy))

    def __new__(mcs, class_name, bases, attrs):
        """Record attributes as a pattern for later instance construction.

        This is called when a new Factory subclass is defined; it will collect
        attribute declaration from the class definition.

        Args:
            class_name (str): the name of the class being created
            bases (list of class): the parents of the class being created
            attrs (str => obj dict): the attributes as defined in the class
                definition

        Returns:
            A new class
        """
        parent_factories = get_factory_bases(bases)
        if parent_factories:
            base_factory = parent_factories[0]
        else:
            base_factory = None

        attrs_meta = attrs.pop('Meta', None)
        attrs_params = attrs.pop('Params', None)

        base_meta = resolve_attribute('_meta', bases)
        options_class = resolve_attribute('_options_class', bases, FactoryOptions)

        meta = options_class()
        attrs['_meta'] = meta

        new_class = super().__new__(
            mcs, class_name, bases, attrs)

        meta.contribute_to_class(
            new_class,
            meta=attrs_meta,
            base_meta=base_meta,
            base_factory=base_factory,
            params=attrs_params,
        )

        return new_class

    def __str__(cls):
        if cls._meta.abstract:
            return '<%s (abstract)>' % cls.__name__
        else:
            return f'<{cls.__name__} for {cls._meta.model}>'


class BaseMeta:
    abstract = True
    strategy = enums.CREATE_STRATEGY


class OptionDefault:
    """The default for an option.

    Attributes:
        name: str, the name of the option ('class Meta' attribute)
        value: object, the default value for the option
        inherit: bool, whether to inherit the value from the parent factory's `class Meta`
            when no value is provided
        checker: callable or None, an optional function used to detect invalid option
            values at declaration time
    """
    def __init__(self, name, value, inherit=False, checker=None):
        self.name = name
        self.value = value
        self.inherit = inherit
        self.checker = checker

    def apply(self, meta, base_meta):
        value = self.value
        if self.inherit and base_meta is not None:
            value = getattr(base_meta, self.name, value)
        if meta is not None:
            value = getattr(meta, self.name, value)

        if self.checker is not None:
            self.checker(meta, value)

        return value

    def __str__(self):
        return '%s(%r, %r, inherit=%r)' % (
            self.__class__.__name__,
            self.name, self.value, self.inherit)


class FactoryOptions:
    def __init__(self):
        self.factory = None
        self.base_factory = None
        self.base_declarations = {}
        self.parameters = {}
        self.parameters_dependencies = {}
        self.pre_declarations = builder.DeclarationSet()
        self.post_declarations = builder.DeclarationSet()

        self._counter = None
        self.counter_reference = None

    @property
    def declarations(self):
        base_declarations = dict(self.base_declarations)
        for name, param in utils.sort_ordered_objects(self.parameters.items(), getter=lambda item: item[1]):
            base_declarations.update(param.as_declarations(name, base_declarations))
        return base_declarations

    def _build_default_options(self):
        """"Provide the default value for all allowed fields.

        Custom FactoryOptions classes should override this method
        to update() its return value.
        """

        def is_model(meta, value):
            if isinstance(value, FactoryMetaClass):
                raise TypeError(
                    "%s is already a %s"
                    % (repr(value), Factory.__name__)
                )

        return [
            OptionDefault('model', None, inherit=True, checker=is_model),
            OptionDefault('abstract', False, inherit=False),
            OptionDefault('strategy', enums.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):
        # Exclude private/protected fields from the meta
        if meta is None:
            meta_attrs = {}
        else:
            meta_attrs = {
                k: v
                for (k, v) in vars(meta).items()
                if not k.startswith('_')
            }

        for option in self._build_default_options():
            assert not hasattr(self, option.name), "Can't override field %s." % option.name
            value = option.apply(meta, base_meta)
            meta_attrs.pop(option.name, None)
            setattr(self, option.name, value)

        if meta_attrs:
            # Some attributes in the Meta aren't allowed here
            raise TypeError(
                "'class Meta' for %r got unknown attribute(s) %s"
                % (self.factory, ','.join(sorted(meta_attrs.keys()))))

    def contribute_to_class(self, factory, meta=None, base_meta=None, base_factory=None, params=None):

        self.factory = factory
        self.base_factory = base_factory

        self._fill_from_meta(meta=meta, base_meta=base_meta)

        self.model = self.get_model_class()
        if self.model is None:
            self.abstract = True

        self.counter_reference = self._get_counter_reference()

        # Scan the inheritance chain, starting from the furthest point,
        # excluding the current class, to retrieve all declarations.
        for parent in reversed(self.factory.__mro__[1:]):
            if not hasattr(parent, '_meta'):
                continue
            self.base_declarations.update(parent._meta.base_declarations)
            self.parameters.update(parent._meta.parameters)

        for k, v in vars(self.factory).items():
            if self._is_declaration(k, v):
                self.base_declarations[k] = v

        if params is not None:
            for k, v in utils.sort_ordered_objects(vars(params).items(), getter=lambda item: item[1]):
                if not k.startswith('_'):
                    self.parameters[k] = declarations.SimpleParameter.wrap(v)

        self._check_parameter_dependencies(self.parameters)

        self.pre_declarations, self.post_declarations = builder.parse_declarations(self.declarations)

    def _get_counter_reference(self):
        """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
        else:
            return self

    def _initialize_counter(self):
        """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:
            return

        if self.counter_reference is self:
            self._counter = _Counter(seq=self.factory._setup_next_sequence())
        else:
            self.counter_reference._initialize_counter()
            self._counter = self.counter_reference._counter

    def next_sequence(self):
        """Retrieve a new sequence ID.

        This will call, in order:
        - next_sequence from the base factory, if provided
        - _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()

    def reset_sequence(self, value=None, force=False):
        self._initialize_counter()

        if self.counter_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))

        if value is None:
            value = self.counter_reference.factory._setup_next_sequence()
        self._counter.reset(value)

    def prepare_arguments(self, attributes):
        """Convert an attributes dict to a (args, kwargs) tuple."""
        kwargs = dict(attributes)
        # 1. Extension points
        kwargs = self.factory._adjust_kwargs(**kwargs)

        # 2. Remove hidden objects
        kwargs = {
            k: v for k, v in kwargs.items()
            if k not in self.exclude and k not in self.parameters and v is not declarations.SKIP
        }

        # 3. Rename fields
        for old_name, new_name in self.rename.items():
            if old_name in kwargs:
                kwargs[new_name] = kwargs.pop(old_name)

        # 4. Extract inline args
        args = tuple(
            kwargs.pop(arg_name)
            for arg_name in self.inline_args
        )

        return args, kwargs

    def instantiate(self, step, args, kwargs):
        model = self.get_model_class()

        if step.builder.strategy == enums.BUILD_STRATEGY:
            return self.factory._build(model, *args, **kwargs)
        elif step.builder.strategy == enums.CREATE_STRATEGY:
            return self.factory._create(model, *args, **kwargs)
        else:
            assert step.builder.strategy == enums.STUB_STRATEGY
            return StubObject(**kwargs)

    def use_postgeneration_results(self, step, instance, results):
        self.factory._after_postgeneration(
            instance,
            create=step.builder.strategy == enums.CREATE_STRATEGY,
            results=results,
        )

    def _is_declaration(self, name, value):
        """Determines if a class attribute is a field value declaration.

        Based on the name and value of the class attribute, return ``True`` if
        it looks like a declaration of a default field value, ``False`` if it
        is private (name starts with '_') or a classmethod or staticmethod.

        """
        if isinstance(value, (classmethod, staticmethod)):
            return False
        elif enums.get_builder_phase(value):
            # All objects with a defined 'builder phase' are declarations.
            return True
        return not name.startswith("_")

    def _check_parameter_dependencies(self, parameters):
        """Find out in what order parameters should be called."""
        # Warning: parameters only provide reverse dependencies; we reverse them into standard dependencies.
        # deep_revdeps: set of fields a field depend indirectly upon
        deep_revdeps = collections.defaultdict(set)
        # Actual, direct dependencies
        deps = collections.defaultdict(set)

        for name, parameter in parameters.items():
            if isinstance(parameter, declarations.Parameter):
                field_revdeps = parameter.get_revdeps(parameters)
                if not field_revdeps:
                    continue
                deep_revdeps[name] = set.union(*(deep_revdeps[dep] for dep in field_revdeps))
                deep_revdeps[name] |= set(field_revdeps)
                for dep in field_revdeps:
                    deps[dep].add(name)

        # Check for cyclical dependencies
        cyclic = [name for name, field_deps in deep_revdeps.items() if name in field_deps]
        if cyclic:
            raise errors.CyclicDefinitionError(
                "Cyclic definition detected on %r; Params around %s"
                % (self.factory, ', '.join(cyclic)))
        return deps

    def get_model_class(self):
        """Extension point for loading model classes.

        This can be overridden in framework-specific subclasses to hook into
        existing model repositories, for instance.
        """
        return self.model

    def __str__(self):
        return "<%s for %s>" % (self.__class__.__name__, self.factory.__name__)

    def __repr__(self):
        return str(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(Generic[T]):
    """Factory base support for sequences, attributes and stubs."""

    # Backwards compatibility
    UnknownStrategy = errors.UnknownStrategy
    UnsupportedStrategy = errors.UnsupportedStrategy

    def __new__(cls, *args, **kwargs):
        """Would be called if trying to instantiate the class."""
        raise errors.FactoryError('You cannot instantiate BaseFactory')

    _meta = FactoryOptions()

    # ID to use for the next 'declarations.Sequence' attribute.
    _counter = None

    @classmethod
    def reset_sequence(cls, value=None, force=False):
        """Reset the sequence counter.

        Args:
            value (int or None): the new 'next' sequence value; if None,
                recompute the next value from _setup_next_sequence().
            force (bool): whether to force-reset parent sequence counters
                in a factory inheritance chain.
        """
        cls._meta.reset_sequence(value, force=force)

    @classmethod
    def _setup_next_sequence(cls):
        """Set up an initial sequence value for Sequence attributes.

        Returns:
            int: the first available ID to use for instances of this factory.
        """
        return 0

    @classmethod
    def _adjust_kwargs(cls, **kwargs):
        """Extension point for custom kwargs adjustment."""
        return kwargs

    @classmethod
    def _generate(cls, strategy, params):
        """generate the object.

        Args:
            params (dict): attributes to use for generating the object
            strategy: the strategy to use
        """
        if cls._meta.abstract:
            raise errors.FactoryError(
                "Cannot generate instances of abstract factory %(f)s; "
                "Ensure %(f)s.Meta.model is set and %(f)s.Meta.abstract "
                "is either not set or False." % dict(f=cls.__name__))

        step = builder.StepBuilder(cls._meta, params, strategy)
        return step.build()

    @classmethod
    def _after_postgeneration(cls, instance, create, results=None):
        """Hook called after post-generation declarations have been handled.

        Args:
            instance (object): the generated object
            create (bool): whether the strategy was 'build' or 'create'
            results (dict or None): result of post-generation declarations
        """
        pass

    @classmethod
    def _build(cls, model_class, *args, **kwargs):
        """Actually build an instance of the model_class.

        Customization point, will be called once the full set of args and kwargs
        has been computed.

        Args:
            model_class (type): the class for which an instance should be
                built
            args (tuple): arguments to use when building the class
            kwargs (dict): keyword arguments to use when building the class
        """
        return model_class(*args, **kwargs)

    @classmethod
    def _create(cls, model_class, *args, **kwargs):
        """Actually create an instance of the model_class.

        Customization point, will be called once the full set of args and kwargs
        has been computed.

        Args:
            model_class (type): the class for which an instance should be
                created
            args (tuple): arguments to use when creating the class
            kwargs (dict): keyword arguments to use when creating the class
        """
        return model_class(*args, **kwargs)

    @classmethod
    def build(cls, **kwargs) -> T:
        """Build an instance of the associated class, with overridden attrs."""
        return cls._generate(enums.BUILD_STRATEGY, kwargs)

    @classmethod
    def build_batch(cls, size: int, **kwargs) -> List[T]:
        """Build a batch of instances of the given class, with overridden attrs.

        Args:
            size (int): the number of instances to build

        Returns:
            object list: the built instances
        """
        return [cls.build(**kwargs) for _ in range(size)]

    @classmethod
    def create(cls, **kwargs) -> T:
        """Create an instance of the associated class, with overridden attrs."""
        return cls._generate(enums.CREATE_STRATEGY, kwargs)

    @classmethod
    def create_batch(cls, size: int, **kwargs) -> List[T]:
        """Create a batch of instances of the given class, with overridden attrs.

        Args:
            size (int): the number of instances to create

        Returns:
            object list: the created instances
        """
        return [cls.create(**kwargs) for _ in range(size)]

    @classmethod
    def stub(cls, **kwargs):
        """Retrieve a stub of the associated class, with overridden attrs.

        This will return an object whose attributes are those defined in this
        factory's declarations or in the extra kwargs.
        """
        return cls._generate(enums.STUB_STRATEGY, kwargs)

    @classmethod
    def stub_batch(cls, size, **kwargs):
        """Stub a batch of instances of the given class, with overridden attrs.

        Args:
            size (int): the number of instances to stub

        Returns:
            object list: the stubbed instances
        """
        return [cls.stub(**kwargs) for _ in range(size)]

    @classmethod
    def generate(cls, strategy, **kwargs):
        """Generate a new instance.

        The instance will be created with the given strategy (one of
        BUILD_STRATEGY, CREATE_STRATEGY, STUB_STRATEGY).

        Args:
            strategy (str): the strategy to use for generating the instance.

        Returns:
            object: the generated instance
        """
        assert strategy in (enums.STUB_STRATEGY, enums.BUILD_STRATEGY, enums.CREATE_STRATEGY)
        action = getattr(cls, strategy)
        return action(**kwargs)

    @classmethod
    def generate_batch(cls, strategy, size, **kwargs):
        """Generate a batch of instances.

        The instances will be created with the given strategy (one of
        BUILD_STRATEGY, CREATE_STRATEGY, STUB_STRATEGY).

        Args:
            strategy (str): the strategy to use for generating the instance.
            size (int): the number of instances to generate

        Returns:
            object list: the generated instances
        """
        assert strategy in (enums.STUB_STRATEGY, enums.BUILD_STRATEGY, enums.CREATE_STRATEGY)
        batch_action = getattr(cls, '%s_batch' % strategy)
        return batch_action(size, **kwargs)

    @classmethod
    def simple_generate(cls, create, **kwargs):
        """Generate a new instance.

        The instance will be either 'built' or 'created'.

        Args:
            create (bool): whether to 'build' or 'create' the instance.

        Returns:
            object: the generated instance
        """
        strategy = enums.CREATE_STRATEGY if create else enums.BUILD_STRATEGY
        return cls.generate(strategy, **kwargs)

    @classmethod
    def simple_generate_batch(cls, create, size, **kwargs):
        """Generate a batch of instances.

        These instances will be either 'built' or 'created'.

        Args:
            size (int): the number of instances to generate
            create (bool): whether to 'build' or 'create' the instances.

        Returns:
            object list: the generated instances
        """
        strategy = enums.CREATE_STRATEGY if create else enums.BUILD_STRATEGY
        return cls.generate_batch(strategy, size, **kwargs)


[docs] class Factory(BaseFactory[T], metaclass=FactoryMetaClass): """Factory base with build and create support. This class has the ability to support multiple ORMs by using custom creation functions. """ # Backwards compatibility AssociatedClassError: Type[Exception] class Meta(BaseMeta): pass
# Add the association after metaclass execution. # Otherwise, AssociatedClassError would be detected as a declaration. Factory.AssociatedClassError = errors.AssociatedClassError class StubObject: """A generic container.""" def __init__(self, **kwargs): for field, value in kwargs.items(): setattr(self, field, value)
[docs] class StubFactory(Factory): class Meta: strategy = enums.STUB_STRATEGY model = StubObject @classmethod def build(cls, **kwargs): return cls.stub(**kwargs) @classmethod def create(cls, **kwargs): raise errors.UnsupportedStrategy()
class BaseDictFactory(Factory): """Factory for dictionary-like classes.""" class Meta: abstract = True @classmethod def _build(cls, model_class, *args, **kwargs): if args: raise ValueError( "DictFactory %r does not support Meta.inline_args." % cls) return model_class(**kwargs) @classmethod def _create(cls, model_class, *args, **kwargs): return cls._build(model_class, *args, **kwargs) class DictFactory(BaseDictFactory): class Meta: model = dict class BaseListFactory(Factory): """Factory for list-like classes.""" class Meta: abstract = True @classmethod def _build(cls, model_class, *args, **kwargs): if args: raise ValueError( "ListFactory %r does not support Meta.inline_args." % cls) # kwargs are constructed from a list, their insertion order matches the list # order, no additional sorting is required. values = kwargs.values() return model_class(values) @classmethod def _create(cls, model_class, *args, **kwargs): return cls._build(model_class, *args, **kwargs) class ListFactory(BaseListFactory): class Meta: model = list
[docs] def use_strategy(new_strategy): """Force the use of a different strategy. This is an alternative to setting default_strategy in the class definition. """ warnings.warn( "use_strategy() is deprecated and will be removed in the future.", DeprecationWarning, stacklevel=2, ) def wrapped_class(klass): klass._meta.strategy = new_strategy return klass return wrapped_class