from collections.abc import Callable
from inspect import getattr_static
from typing import Any, TypeVar, cast
from typing_extensions import dataclass_transform, get_type_hints
from ..cos.objects.containers import PdfDictionary
from .accessors import (
_MISSING_TYPE,
HAS_DEFAULT_FACTORY,
MISSING,
Accessor,
lookup_accessor_by_field,
)
_T = TypeVar("_T")
[docs]
class Field:
[docs]
def __init__(
self,
key: str | None = None,
default_value: Any = MISSING,
default_factory: Callable[[], Any] | _MISSING_TYPE = MISSING,
encoder: Callable[[Any], Any] | None = None,
decoder: Callable[[Any], Any] | None = None,
init: bool | None = None,
repr_: bool | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
# To be filled in later
self.name: str | None = None
self.type_: type | None = None
self._key = key
self.default_value = default_value
self.default_factory = default_factory
self.encoder = encoder
self.decoder = decoder
self.init = init
self.repr_ = repr_
self.metadata = metadata
@property
def key(self) -> str:
if self._key is None:
raise ValueError(f"no key assigned to field {self.name!r}.")
return self._key
@property
def default(self) -> Any:
if (df := self.default_factory) and callable(df):
return df()
return self.default_value
[docs]
def field(
key: str | None = None,
default: Any = MISSING,
default_factory: Callable[[], Any] | _MISSING_TYPE = MISSING,
encoder: Callable[[Any], Any] | None = None,
decoder: Callable[[Any], Any] | None = None,
init: bool | None = None,
repr_: bool | None = None,
metadata: dict[str, Any] | None = None,
) -> Any:
"""Defines a field in a dictmodel.
Arguments:
key (str, optional):
The name of the key that will be accessed by this field. If not specified,
the key will be the title-cased version of the field name.
default (Any, optional):
The default value of the field if it is not specified. If no default is
specified, the field is assumed to be required.
default_factory (Callable[[], Any], optional):
A callable that takes no arguments and produces the default value of the field.
This can be used to specify default mutable values.
encoder (Callable[[Any], Any], optional):
A callable that takes one argument and transforms the value that will be set
for the field in the underlying dictionary.
decoder (Callable[[Any], Any], optional):
A callable that takes one argument and transforms the value returned when
getting the field from the underlying dictionary.
init (bool | None, optional):
Whether this field will appear as part of the class constructor. If not specified,
it defaults to the value of the ``init`` argument in the dictmodel.
repr_ (bool | None, optional):
Whether this field will appear as part of the class representation.
If not specified, it defaults to the value of the ``repr_`` argument
in the dictmodel.
metadata (dict[str, Any], optional):
Additional metadata for this field which may be used by the accessor.
.. note::
default and default_factory are mutually exclusive. If both are specified,
default_factory takes precedence.
The encoder and decoder argument must both be specified if used. These values
are only honored if the field type is not itself already handled by an accessor;
otherwise, it is ignored.
"""
return Field(key, default, default_factory, encoder, decoder, init, repr_, metadata)
T = TypeVar("T")
[docs]
def defaultize(cls: type[T]) -> T:
"""Returns an instance of a dictmodel ``cls`` initialized with default accessor values."""
accessors = getattr(cls, "__accessors__", MISSING)
if accessors is MISSING:
raise TypeError(f"type {cls!r} is not a dictmodel")
mapping: dict[str, Any] = {}
for acc in cast(list[Accessor], accessors):
assert acc.field.name is not None
if not acc.field.init:
continue
if (factory := acc.field.default_factory) is not MISSING and callable(factory):
mapping[acc.field.name] = factory()
elif acc.field.default is not MISSING:
mapping[acc.field.name] = acc.field.default
else:
mapping[acc.field.name] = None
return cls(**mapping)
[docs]
def snake_to_title_case(value: str) -> str:
return "".join(val.title() for val in value.split("_"))
T = TypeVar("T")
[docs]
def build_repr(cls: type[T], repr_accessors: list[Accessor]):
def _repr(self: T) -> str:
attrs: list[str] = []
for acc in repr_accessors:
assert acc.field.name is not None
value = getattr(self, acc.field.name, acc.field.default)
if value == acc.field.default:
continue
attrs.append(f"{acc.field.name}={value!r}")
return f"{cls.__name__}({', '.join(attrs)})"
return _repr
[docs]
def create_accessors(cls, *, parent_init: bool = True, parent_repr: bool = True) -> list[Accessor]:
accessors = []
for attr, type_ in get_type_hints(cls, include_extras=True).items():
default = getattr_static(cls, attr, MISSING)
if isinstance(default, Field):
model_field = default
elif hasattr(default, "field"):
# inherited field from an accessor
model_field = default.field
else:
model_field = Field(default_value=default)
if model_field._key is None:
model_field._key = snake_to_title_case(attr)
model_field.name = attr
model_field.type_ = type_
if model_field.init is None:
model_field.init = parent_init
if model_field.repr_ is None:
model_field.repr_ = parent_repr
accessor, metadata = lookup_accessor_by_field(model_field)
if accessor is None:
raise ValueError(f"no accessor registered for type {type_!r}")
if metadata:
if model_field.metadata is not None:
model_field.metadata |= metadata
else:
model_field.metadata = metadata
accessors.append(accessor(model_field))
return accessors
[docs]
@dataclass_transform(field_specifiers=(field,))
def dictmodel(_cls: type[_T] | None = None, *, init: bool = True, repr_: bool = True):
def wrapper(cls: type[_T]) -> type[_T]:
if not issubclass(cls, PdfDictionary):
raise TypeError("cls must be a subclass of PdfDictionary")
accessors = create_accessors(cls, parent_init=init, parent_repr=repr_)
init_args = ["self"]
default_map: dict[str, Any] = {}
for accessor in accessors:
assert accessor.field.name is not None
setattr(cls, accessor.field.name, accessor)
if not accessor.field.init:
continue
init_arg_string = accessor.field.name
if accessor.field.default_factory is not MISSING:
init_arg_string += " = HAS_DEFAULT_FACTORY"
elif accessor.field.default_value is not MISSING:
default_map[accessor.field.name] = accessor.field.default
init_arg_string += f" = _dflt[{accessor.field.name!r}]"
init_args.append(init_arg_string)
required_subcls_args: list[str] = []
for acc in getattr(cls, "__accessors__", []):
if not acc.field.init:
continue
if acc.field.default is not MISSING:
required_subcls_args.append(f"{acc.field.name}={acc.field.name}")
else:
required_subcls_args.append(acc.field.name)
init_fn_body = [
f"def __init__({', '.join(init_args)}):",
f" super({cls.__name__}, self).__init__({', '.join(required_subcls_args)})",
]
for acc in accessors:
if not acc.field.init or not acc.field.name:
continue
fname = acc.field.name
if (df := acc.field.default_factory) is not MISSING and callable(df):
default_map[fname] = df()
init_fn_body.append(
f" self.{fname} = "
f"{fname} if {fname} is not HAS_DEFAULT_FACTORY else _dflt[{fname!r}]\n"
)
else:
init_fn_body.append(f" self.{fname} = {fname}\n")
repr_fn = build_repr(cls, [acc for acc in accessors if acc.field.repr_])
namespace = {}
if "__post_init__" in cls.__dict__:
init_fn_body.append(" self.__post_init__()")
exec(
"\n".join(init_fn_body),
{cls.__name__: cls, "_dflt": default_map, "HAS_DEFAULT_FACTORY": HAS_DEFAULT_FACTORY},
namespace,
)
if "__init__" not in cls.__dict__:
cls.__init__ = namespace["__init__"]
cls.__init__.__name__ = "__init__"
cls.__init__.__qualname__ = f"{cls.__qualname__}.__init__"
if "__repr__" not in cls.__dict__:
cls.__repr__ = repr_fn
cls.__repr__.__name__ = "__repr__"
cls.__repr__.__qualname__ = f"{cls.__qualname__}.__repr__"
setattr(cls, "__accessors__", accessors)
return cls
return wrapper(_cls) if callable(_cls) else wrapper