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

Auto generation inspector feature #822

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
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
63 changes: 63 additions & 0 deletions factory/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,34 @@ def is_model(meta, value):
)

return [
# The model this factory build
OptionDefault('model', None, inherit=True, checker=is_model),
# Whether this factory is allowed to build objects
OptionDefault('abstract', False, inherit=False),
# The default strategy (BUILD_STRATEGY or CREATE_STRATEGY)
OptionDefault('strategy', enums.CREATE_STRATEGY, inherit=True),
# Declarations that should be passed as *args instead
OptionDefault('inline_args', (), inherit=True),
# Declarations that shouldn't be passed to the object
OptionDefault('exclude', (), inherit=True),
# Declarations that should be used under another name
# to the target model (for name conflict handling)
OptionDefault('rename', {}, inherit=True),

# The introspector class to use; if None (the default),
OptionDefault('introspector_class', None, inherit=True),
# Whether to auto-generate the default set of fields
OptionDefault('default_auto_fields', False, inherit=True),
# Strategy to use in order to get the right declaration of an auto generated field
OptionDefault('mapping_strategy_auto_fields', enums.MAPPING_BY_NAME, inherit=True),
# List of fields to include in auto-generation
OptionDefault('include_auto_fields', (), inherit=False),
# List of fields to exclude from auto-generation
OptionDefault('exclude_auto_fields', (), inherit=False),
# Custom mapping dict that can override or complete the
# default inspector mapping.
OptionDefault('mapping_type_auto_fields', {}, inherit=False),
OptionDefault('mapping_name_auto_fields', {}, inherit=False),
]

def _fill_from_meta(self, meta, base_meta):
Expand Down Expand Up @@ -224,6 +246,21 @@ def contribute_to_class(self, factory, meta=None, base_meta=None, base_factory=N
if self._is_declaration(k, v):
self.base_declarations[k] = v

if not self.abstract and (self.default_auto_fields or self.include_auto_fields):
if self.introspector_class:
self.introspector = self.introspector_class(
factory,
self.mapping_strategy_auto_fields,
mapping_type_auto_fields=self.mapping_type_auto_fields,
mapping_name_auto_fields=self.mapping_name_auto_fields
)
else:
ValueError(
"Missing inspection class. "
"Add it to {}.Meta.introspector_class.".format(self.__class__.__name__)
)
self._autogenerate_declarations()

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('_'):
Expand Down Expand Up @@ -340,6 +377,32 @@ def _is_declaration(self, name, value):
return True
return not name.startswith("_")

def _autogenerate_declarations(self):
field_names = set()
if self.default_auto_fields:
field_names.update(self.introspector.get_default_field_names())

# Apply include_auto_fields/exclude_auto_fields from inheritance chain
factory_parents = [f for f in reversed(self.factory.__mro__[1:]) if hasattr(f, '_meta')]
for parent in factory_parents:
field_names.update(getattr(parent._meta, 'include_auto_fields', []))
field_names.difference_update(getattr(parent._meta, 'exclude_auto_fields', []))

field_names.update(self.include_auto_fields)

# Exclude the fields defined manually in the factory.
exclude_auto_fields = set(self.base_declarations.keys())
exclude_auto_fields.update(self.exclude_auto_fields)
field_names.difference_update(exclude_auto_fields)

auto_declarations = self.introspector.build_declarations(field_names, exclude_auto_fields)
for field_name, auto_declaration in auto_declarations.items():
if field_name not in field_names:
raise ValueError(
'Introspector %s returned a field (%s) that it was not asked for'
% (self.introspector.__class__.__name__, field_name))
self.base_declarations[field_name] = auto_declaration

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.
Expand Down
3 changes: 3 additions & 0 deletions factory/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
CREATE_STRATEGY = 'create'
STUB_STRATEGY = 'stub'

# Auto field mapping strategy
MAPPING_BY_TYPE = 'type'
MAPPING_BY_NAME = 'name' # Note that this mapping strategy check the type validity.

#: String for splitting an attribute name into a
#: (subfactory_name, subfactory_field) tuple.
Expand Down
Empty file.
207 changes: 207 additions & 0 deletions factory/introspector/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import logging
import collections
from abc import ABC, abstractmethod
from difflib import get_close_matches
from typing import Callable, Type, Dict, Union

import factory
from factory import enums
from factory.declarations import BaseDeclaration

logger = logging.getLogger(__name__)

FieldContext = collections.namedtuple(
"FieldContext", ["field", "field_name", "field_type", "model", "factory", "skips"]
)


class DeclarationMapping(ABC):
@property
@abstractmethod
def FIELD_TYPE_MAPPING_AUTO_FIELDS(self) -> Dict[type, Union[Callable, BaseDeclaration]]:
pass

@property
@abstractmethod
def FIELD_NAME_MAPPING_AUTO_FIELDS(self) -> Dict[str, Union[Callable, BaseDeclaration]]:
pass

def __init__(self, custom_field_type_mapping=None, custom_field_name_mapping=None):
self.field_type_mapping = dict(self.FIELD_TYPE_MAPPING_AUTO_FIELDS)
self.field_name_mapping = dict(self.FIELD_NAME_MAPPING_AUTO_FIELDS)
if custom_field_type_mapping:
self.field_type_mapping.update(custom_field_type_mapping)
if custom_field_name_mapping:
self.field_name_mapping.update(custom_field_name_mapping)

def get_by_field_name(self, field_name, field_type):
"""Get the mapped declaration based on the field name.
The field type is here only to check if the declaration returns the same
type as the field is expected to get.
The matching by field name is done with the python difflib library.
This method can be overridden to customize the field name search algorithm.

Args:
field_name (str): the field name to search in the mapping
field_type (type): the type of the field

Returns:
The declaration or a callable returning a declaration found
inside the declared mapping in `FIELD_NAME_MAPPING_AUTO_FIELDS`.
"""
formatter_name = None
# Get the closest match according to the name
# and the type of the formatter value.
closest_matches = get_close_matches(
field_name, self.field_name_mapping.keys(), n=3, cutoff=0.6
)
if closest_matches:
# Check type is matching
for closest_match in closest_matches:
if self.field_name_mapping[closest_match] == field_type:
formatter_name = factory.Faker(closest_match)
break
return formatter_name

def get_by_field_type(self, field_type):
"""Get the mapped declaration based on the field type.

Args:
field_type (type): the type of the field
Returns:

The declaration or a callable returning a declaration found
inside the declared mapping in `FIELD_TYPE_MAPPING_AUTO_FIELDS`.
"""
return self.field_type_mapping.get(field_type)


class BaseIntrospector(ABC):
"""Introspector for models.

Extracts declarations from a model.
"""

DECLARATION_MAPPING_CLASS: Type[DeclarationMapping] = None

def __init__(
self,
factory_class,
mapping_strategy_auto_fields=enums.MAPPING_BY_NAME,
mapping_type_auto_fields=None,
mapping_name_auto_fields=None,
):
self._factory_class = factory_class
self.model = self._factory_class._meta.model
self.declaration_mapping = self.DECLARATION_MAPPING_CLASS(
custom_field_type_mapping=mapping_type_auto_fields,
custom_field_name_mapping=mapping_name_auto_fields,
)
self.mapping_strategy_auto_fields = mapping_strategy_auto_fields

@abstractmethod
def get_default_field_names(self):
"""
Fetch default "auto-declarable" field names from a model.
Override to define what fields are included by default
"""
raise NotImplementedError(
"Introspector %r doesn't know how to extract fields from %s" % (self, model)
)

@abstractmethod
def get_field_by_name(self, field_name):
"""
Get the actual "field descriptor" for a given field name
Actual return value will depend on your underlying lib
May return None if the field does not exist
"""
raise NotImplementedError(
"Introspector %r doesn't know how to fetch field %s from %r"
% (self._factory_class, field_name, self.model)
)

def build_declaration(self, field_context):
"""Build a factory.Declaration from a FieldContext

Note that FieldContext may be None if get_field_by_name() returned None

Returns:
factory.Declaration
"""
field_type = field_context.field_type
field_name = field_context.field_name
declaration_by_name = None
declaration_by_type = self.declaration_mapping.get_by_field_type(field_type)
if self.mapping_strategy_auto_fields == enums.MAPPING_BY_NAME:
declaration_by_name = self.declaration_mapping.get_by_field_name(
field_name, field_type
)
mapped_declaration = (
declaration_by_name if declaration_by_name else declaration_by_type
)

if mapped_declaration is None:
raise NotImplementedError(
"Introspector {} lacks mapping for building field {} (type: {}). "
"Add it to {}.Meta.custom_mapping_auto_fields.".format(
self, field_name, field_type, self._factory_class.__name__
)
)

# A callable can be passed, evaluate it and make sure it returns a declaration.
if isinstance(mapped_declaration, Callable):
mapped_declaration = mapped_declaration(field_context)

if not isinstance(mapped_declaration, BaseDeclaration):
raise ValueError(
"The related mapping of field {} (type: {}) must be a callable or a declaration. "
"Type {} is not supported.".format(
field_name, field_type, type(mapped_declaration)
)
)
return mapped_declaration

def build_declarations(self, fields_names, skip_fields_names):
"""Build declarations for a set of fields.

Args:
fields_names (str iterable): list of fields to build
skip_fields_names (str iterable): list of fields that should *NOT* be built.

Returns:
(str, factory.Declaration) dict: the new declarations.
"""
declarations = {}
for field_name in fields_names:
if field_name in skip_fields_names:
continue

sub_skip_pattern = "%s__" % field_name
sub_skips = [
sk[len(sub_skip_pattern) :]
for sk in skip_fields_names
if sk.startswith(sub_skip_pattern)
]

field = self.get_field_by_name(field_name)

field_ctxt = self.build_field_context(field, field_name, sub_skips)
declaration = self.build_declaration(field_ctxt)
if declaration is not None:
declarations[field_name] = declaration

return declarations

def build_field_context(self, field, field_name, sub_skips):
return FieldContext(
field=field,
field_name=field_name,
field_type=type(field),
model=self.model,
factory=self._factory_class,
skips=sub_skips,
)

def __repr__(self):
return "<%s for %s>" % (self.__class__.__name__, self._factory_class.__name__)
Loading