Source code for dataclass_builder.factory

"""Create :func:`dataclasses.dataclass` builders for specific dataclasses.

This module uses a factory to build builder classes that build a specific
dataclass.  These builder classes implement the builder pattern and allow
constructing dataclasses over a period of time instead of all at once.


Examples
--------
Using specialized builders allows for better documentation than the
:class:`DataclassBuilder` wrapper and allows for type checking because
annotations are dynamically generated.

.. testcode::

    from dataclasses import dataclass
    from dataclass_builder import (dataclass_builder, build, fields,
                                   REQUIRED, OPTIONAL)

    @dataclass
    class Point:
        x: float
        y: float
        w: float = 1.0

    PointBuilder = dataclass_builder(Point)

Now we can build a point.

.. doctest::

    >>> builder = PointBuilder()
    >>> builder.x = 5.8
    >>> builder.y = 8.1
    >>> builder.w = 2.0
    >>> build(builder)
    Point(x=5.8, y=8.1, w=2.0)

As long as the dataclass the builder was constructed for does not have a
`build` field then a `build` method will be generated as well.

    >>> builder.build()
    Point(x=5.8, y=8.1, w=2.0)

Field values can also be provided in the constructor.

.. doctest::

    >>> builder = PointBuilder(x=5.8, w=100)
    >>> builder.y = 8.1
    >>> builder.build()
    Point(x=5.8, y=8.1, w=100)

.. note::

    Positional arguments are not allowed.

Fields with default values in the dataclass are optional in the builder.

.. doctest::

    >>> builder = PointBuilder()
    >>> builder.x = 5.8
    >>> builder.y = 8.1
    >>> builder.build()
    Point(x=5.8, y=8.1, w=1.0)

Fields that don't have default values in the dataclass are not optional.

.. doctest::

    >>> builder = PointBuilder()
    >>> builder.y = 8.1
    >>> builder.build()
    Traceback (most recent call last):
    ...
    MissingFieldError: field 'x' of dataclass 'Point' is not optional

Fields not defined in the dataclass cannot be set in the builder.

.. doctest::

    >>> builder.z = 3.0
    Traceback (most recent call last):
    ...
    UndefinedFieldError: dataclass 'Point' does not define field 'z'

.. note::

    No exception will be raised for fields beginning with an underscore as they
    are reserved for use by subclasses.

Accessing a field of the builder before it is set gives either the `REQUIRED`
or `OPTIONAL` constant

.. doctest::

    >>> builder = PointBuilder()
    >>> builder.x
    REQUIRED
    >>> builder.w
    OPTIONAL

The `fields` method can be used to retrieve a dictionary of settable fields for
the builder.  This is a mapping of field names to :class:`dataclasses.Field`
objects from which extra data can be retrieved such as the type of the data
stored in the field.

.. doctest::

    >>> list(builder.fields().keys())
    ['x', 'y', 'w']
    >>> [f.type.__name__ for f in builder.fields().values()]
    ['float', 'float', 'float']

A subset of the fields can be also be retrieved, for instance, to only get
required fields:

.. doctest::

    >>> list(builder.fields(optional=False).keys())
    ['x', 'y']

or only the optional fields.

.. doctest::

    >>> list(builder.fields(required=False).keys())
    ['w']

.. note::

    If the underlying dataclass has a field named `fields` this method will
    not be generated and instead the :func:`fields` function should be used
    instead.

"""

from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Dict,
    Mapping,
    MutableMapping,
    Optional,
    Sequence,
    Type,
    cast,
)

from ._common import (
    MISSING,
    OPTIONAL,
    REQUIRED,
    _is_required,
    _optional_fields,
    _required_fields,
    _settable_fields,
)
from .exceptions import MissingFieldError, UndefinedFieldError

if TYPE_CHECKING:
    from dataclasses import Field, is_dataclass
else:
    from dataclasses import is_dataclass

__all__ = ["dataclass_builder"]


# copied (and modified) from dataclasses._create_fn to avoid dependency on
# private functions in dataclasses
def _create_fn(
    name: str,
    args: Sequence[str],
    body: Sequence[str],
    env: Optional[Dict[str, Any]] = None,
    *,
    return_type: Any = MISSING,
) -> Callable[..., Any]:
    locals_: MutableMapping[str, Any] = {}
    return_annotation = ""
    if env is None:
        env = {}
    if return_type is not MISSING:
        env["_return_type"] = return_type
        return_annotation = "->_return_type"
    args = ", ".join(args)
    body = "\n".join(f" {line}" for line in body)
    txt = f"def {name}({args}){return_annotation}:\n{body}"
    # this is how the dataclasses module makes custom methods so it's good
    # enough for this package
    exec(txt, env, locals_)  # pylint: disable=exec-used
    return cast(Callable[..., Any], locals_[name])


def _create_init_method(fields: Mapping[str, "Field[Any]"]) -> Callable[..., None]:
    env: Dict[str, Any] = {
        f"_{name}_type": field.type for name, field in fields.items()
    }
    env["REQUIRED"] = REQUIRED
    env["OPTIONAL"] = OPTIONAL

    def is_required(field: "Field[Any]") -> str:
        return "REQUIRED" if _is_required(field) else "OPTIONAL"

    if fields:
        args = ["self", "*"] + [
            f"{name}: _{name}_type = {is_required(field)}"
            for name, field in fields.items()
        ]
    else:
        args = ["self"]
    body = [f"self.{name}: _{name}_type = {name}" for name in fields]
    body = ["self.__initialized = False"] + body + ["self.__initialized = True"]
    return _create_fn("__init__", args, body, env, return_type=None)


def _create_class_docstring(dataclass: Any) -> str:
    dname = dataclass.__qualname__
    try:
        dname = dataclass.__module__ + "." + dname
    except AttributeError:
        pass
    params = []
    for name in _settable_fields(dataclass).keys():
        params.append(f"    :param {name}: Optionally initialize `{name}` field.\n")
    docstring = rf"""Builder for the :class:`{dname}` dataclass.

    This class allows the :class:`{dname}` dataclass to be constructed with the
    builder pattern.  Once an instance is constructed simply assign to it's
    attributes, which are identical to the :class:`{dname}` dataclass.  When
    done use it's `build` method, or the :func:`build` function if one of the
    fields is `build`, to make an instance of the :class:`{dname}` dataclass
    using the field values set on this builder.

    .. warning::

        Because this class overrides attribute assignment, care must be taken
        when extending to only use private and/or "dunder" attributes and
        methods.

    See :class:`{dname}` for further information on each filed.

{''.join(params)}

    :raises dataclass_builder.exceptions.UndefinedFieldError:
        If you try to assign to a field that is not part of :class:`{dname}`\ 's
        `__init__` method.
    :raises dataclass_builder.exceptions.MissingFieldError:
        If :func:`build` is called on this builder before all non default
        fields of the dataclass are assigned.
        """
    return docstring


[docs]def dataclass_builder( # noqa: C901 dataclass: Type[Any], *, name: Optional[str] = None ) -> Type[Any]: """Create a new builder class specialized to a given dataclass. :param dataclass: The :func:`dataclasses.dataclass` to create the builder for. :param name: Override the name of the builder, by default it will be '<dataclass>Builder' where <dataclass> is replaced by the name of the dataclass. :return object: A new dataclass builder class that is specialized to the given `dataclass`. If the given :func:`dataclasses.dataclass` does not contain the fields `build` or `fields` these will be exposed as public methods with the same signature as the :func:`dataclass_builder.utility.build` and :func:`dataclass_builder.utility.fields` functions respectively. :raises TypeError: If `dataclass` is not a :func:`dataclasses.dataclass`. This is decided via :func:`dataclasses.is_dataclass`. """ if not is_dataclass(dataclass): raise TypeError("must be called with a dataclass type") settable_fields = _settable_fields(dataclass) required_fields = _required_fields(dataclass) optional_fields = _optional_fields(dataclass) # validate identifiers for name_ in _settable_fields(dataclass): # there should not be anyway to trigger this branch if not name_.isidentifier(): # pragma: no cover raise RuntimeError( f"field name '{name_}'' could cause a security issue, refusing" f" to construct builder for '{dataclass.__qualname__}'" ) dname = dataclass.__qualname__ try: dname = dataclass.__module__ + "." + dname except AttributeError: pass def _setattr_method(self: Any, name: str, value: Any) -> None: # self.__initialized is not protected member access, since this is # a class method if ( name.startswith("_") or hasattr(self, name) or not self.__initialized ): # pylint: disable=protected-access object.__setattr__(self, name, value) else: raise UndefinedFieldError( f"dataclass '{dataclass.__qualname__}' does not define " f"field '{name}'", dataclass, name, ) _setattr_method.__doc__ = f"""\ Set a field value, or an object attribute if it is private. .. note:: This will pass through all attributes beginning with an underscore. If this is a valid field of the dataclass it will still be built correctly but UndefinedFieldError will not be thrown for attributes beginning with an underscore. If you need the exception to be thrown then set the field in the constructor. :param name: Name of the dataclass field or private/dunder attribute to set. :param value: Value to assign to the dataclass field or private/dunder attribute. :raises dataclass_builder.exceptions.UndefinedFieldError: If `name` is not initialisable in the :class:`{dname}` dataclass. If `name` is private (begins with an underscore) or is a "dunder" then this exception will not be raised. """ def _repr_method(self: Any) -> str: """Print a representation of the builder. >>> PointBuilder = dataclass_builder(Point) >>> PointBuilder(x=4.0, w=2.0) PointBuilder(x=4.0, w=2.0) :return: String representation that can be used to construct this builder instance. """ args = [] for name in settable_fields: value = getattr(self, name) if value not in (REQUIRED, OPTIONAL): args.append(f"{name}={repr(value)}") return f'{self.__class__.__qualname__}({", ".join(args)})' def _build_method(self: Any) -> Any: # check for missing required fields for name, field in required_fields.items(): if getattr(self, name) is REQUIRED: raise MissingFieldError( f"field '{name}' of dataclass '{dataclass.__qualname__}' " "is not optional", dataclass, field, ) # build dataclass kwargs = { name: getattr(self, name) for name in settable_fields if getattr(self, name) is not OPTIONAL } return dataclass(**kwargs) _build_method.__doc__ = f"""\ Build a :class:`{dname}` dataclass using the fields from this builder. :return: An instance of the :class:`{dname}` dataclass using the fields set on this builder instance. :raises dataclass_builder.exceptions.MissingFieldError: If not all of the required fields have been assigned to this builder instance. """ def _fields_method( _: Any, required: bool = True, optional: bool = True ) -> Mapping[str, "Field[Any]"]: if not required and not optional: return {} if required and not optional: return required_fields if not required and optional: return optional_fields return settable_fields _fields_method.__doc__ = f"""Get a dictionary of the builder's fields. :param required: Set to False to not report required fields. :param optional: Set to False to not report optional fields. :return: A mapping from field names to actual :class:`dataclasses.Field`'s in the same order as in the :class:`{dname}` dataclass. """ # Fix return type of build, it won't help Mypy as it cannot handle # classes created at runtime but typing.get_type_hints will work properly. # # See: https://github.com/python/mypy/wiki/Unsupported-Python-Features _build_method.__annotations__["return"] = dataclass # assemble new builder class methods dict_: Dict[str, Any] = dict() dict_["__init__"] = _create_init_method(settable_fields) dict_["__setattr__"] = _setattr_method dict_["__repr__"] = _repr_method dict_["_build"] = _build_method dict_["_fields"] = _fields_method dict_["__doc__"] = _create_class_docstring(dataclass) if "build" not in settable_fields: dict_["build"] = _build_method if "fields" not in settable_fields: dict_["fields"] = _fields_method if name is None: name = f"{dataclass.__name__}Builder" return type(name, (object,), dict_)