Skip to content
Merged
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
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,10 @@ exclude-newer-package = { reannotate = "1 hour" }

[tool.ruff]
extend-exclude = ["src/ducktools/classbuilder/_cached_methods.py"]

[tool.ruff.lint]
ignore = ["BLE001", "I001", "PLC0414", "RUF023", "S102"]

[tool.ruff.lint.extend-per-file-ignores]
"*.pyi" = ["PYI042"]
"src/ducktools/classbuilder/constants.py" = ["RUF012"] # Non-annotated dict
55 changes: 30 additions & 25 deletions src/ducktools/classbuilder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,15 @@ def builder(
# Assign all of the method generators
internal_methods = add_methods(cls, methods, internals=internals)

if "__eq__" in internal_methods and "__hash__" not in internal_methods:
if (
"__eq__" in internal_methods
and "__hash__" not in internal_methods
and "__hash__" not in cls.__dict__
):
# If an eq method has been defined and a hash method has not
# Then the class is not frozen unless the user has
# defined a hash method
if "__hash__" not in cls.__dict__:
setattr(cls, "__hash__", None)
cls.__hash__ = None

# Add attribute indicating build completed
internals["build_complete"] = True
Expand Down Expand Up @@ -376,21 +379,24 @@ def __new__(
# Dict access is faster if there is a __dict__ available.
cached_properties = {}

if "__dict__" not in slot_values and "__dict__" not in base_attribs:
# Don't import functools
if functools := sys.modules.get("functools"):
# Iterate over a copy as we will mutate the original
for k, v in ns.copy().items():
if isinstance(v, functools.cached_property):
cached_properties[k] = v
del ns[k]
# Add to slots only if it is not already a slot
slot_attrib = base_attribs.get(k, NOTHING)
if (
slot_attrib is NOTHING
or type(slot_attrib) not in existing_slot_types
):
slot_values[k] = None
# Check for cached properties - don't import functools if it's not imported
if (
(functools := sys.modules.get("functools"))
and "__dict__" not in slot_values
and "__dict__" not in base_attribs
):
# Iterate over a copy as we will mutate the original
for k, v in ns.copy().items():
if isinstance(v, functools.cached_property):
cached_properties[k] = v
del ns[k]
# Add to slots only if it is not already a slot
slot_attrib = base_attribs.get(k, NOTHING)
if (
slot_attrib is NOTHING
or type(slot_attrib) not in existing_slot_types
):
slot_values[k] = None

# Place slots *after* everything else to be safe
ns["__slots__"] = slot_values
Expand All @@ -404,9 +410,9 @@ def __new__(
# Now reconstruct cached properties
if cached_properties:
# Now the class and slots have been created, create any new cached properties
for name, prop in cached_properties.items():
for attrname, prop in cached_properties.items():
# This may be inherited, which is fine
slot = getattr(new_cls, name)
slot = getattr(new_cls, attrname)

# May be a replaced cached property already, if so extract the actual slot
if isinstance(slot, _SlottedCachedProperty):
Expand All @@ -415,10 +421,10 @@ def __new__(
slotted_property = _SlottedCachedProperty(
slot=slot,
func=prop.func,
attrname=name,
attrname=attrname,
)

setattr(new_cls, name, slotted_property)
setattr(new_cls, attrname, slotted_property)

else:
if gatherer is not None:
Expand Down Expand Up @@ -569,7 +575,7 @@ def from_field(cls, fld, /, **kwargs):
"""
# type is special cased to get the internal value
inst_fields = {
k: getattr(fld, k) if k != "type" else getattr(fld, "_type")
k: getattr(fld, k) if k != "type" else fld._type
for k in get_fields(type(fld))
}
argument_dict = {**inst_fields, **kwargs}
Expand Down Expand Up @@ -803,8 +809,7 @@ def field_attribute_gatherer(cls_or_ns):

cls_modifications = {}

for name in cls_attributes.keys():
attrib = cls_attributes[name]
for name, attrib in cls_attributes.items():
if leave_default_values:
cls_modifications[name] = attrib.default
else:
Expand Down
108 changes: 44 additions & 64 deletions src/ducktools/classbuilder/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,16 @@ if sys.version_info >= (3, 14):
import reannotate
import annotationlib

_private_type = reannotate.DeferredAnnotation | type | str
_field_type = annotationlib.ForwardRef | type | str
type _private_type = reannotate.DeferredAnnotation | type | str
type _field_type = annotationlib.ForwardRef | type | str
else:
_private_type = _field_type = type | str
type _private_type = type | str
type _field_type = type | str

_CopiableMappings = dict[str, typing.Any] | MappingProxyType[str, typing.Any]
type _CopiableMappings = dict[str, typing.Any] | MappingProxyType[str, typing.Any]

_T = typing.TypeVar("_T")
_FieldType = typing.TypeVar("_FieldType", bound=Field)
_gatherer_argtype = type | _CopiableMappings
_gatherer_returntype = tuple[dict[str, Field], dict[str, typing.Any]]
type _gatherer_argtype = type | _CopiableMappings
type _gatherer_returntype = tuple[dict[str, Field], dict[str, typing.Any]]

__version__: str
__version_tuple__: tuple[str | int, ...]
Expand All @@ -59,7 +58,10 @@ class GetFieldsProtocol(typing.Protocol):
@typing.type_check_only
class NoArgGathererProtocol(typing.Protocol):
def __call__(
self, cls_or_ns: _gatherer_argtype, *, cls_annotations: None | dict[str, typing.Any]
self,
cls_or_ns: _gatherer_argtype,
*,
cls_annotations: None | dict[str, typing.Any]
) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...

@typing.type_check_only
Expand All @@ -69,72 +71,71 @@ class NoArgAnnotationGathererProtocol(typing.Protocol):
) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...

@typing.type_check_only
class GathererProtocol(typing.Protocol, typing.Generic[_FieldType]):
class GathererProtocol[FT: Field](typing.Protocol):
def __call__(
self,
cls_or_ns: _gatherer_argtype,
) -> tuple[dict[str, _FieldType], dict[str, typing.Any]]: ...
) -> tuple[dict[str, FT], dict[str, typing.Any]]: ...

@typing.type_check_only
class AnnotationGathererProtocol(typing.Protocol, typing.Generic[_FieldType]):
class AnnotationGathererProtocol[FT: Field](typing.Protocol):
def __call__(
self,
cls_or_ns: _gatherer_argtype,
*,
cls_annotations: None | dict[str, typing.Any],
) -> tuple[dict[str, _FieldType], dict[str, typing.Any]]: ...
) -> tuple[dict[str, FT], dict[str, typing.Any]]: ...


default_methods: frozenset[MethodMaker]

_TypeT = typing.TypeVar("_TypeT", bound=type)
# _TypeT = typing.TypeVar("_TypeT", bound=type)

# Construction functions
@typing.overload
def builder(
cls: _TypeT,
def builder[TypeT: type](
cls: TypeT,
/,
*,
gatherer: GathererProtocol[Field] | NoArgGathererProtocol,
methods: frozenset[MethodMaker] | set[MethodMaker],
flags: dict[str, bool] | None = None,
field_getter: GetFieldsProtocol = ...,
) -> _TypeT: ...
) -> TypeT: ...
@typing.overload
def builder(
def builder[TypeT: type](
cls: None = None,
/,
*,
gatherer: GathererProtocol[Field] | NoArgGathererProtocol,
methods: frozenset[MethodMaker] | set[MethodMaker],
flags: dict[str, bool] | None = None,
field_getter: GetFieldsProtocol = ...,
) -> Callable[[_TypeT], _TypeT]: ...
) -> Callable[[TypeT], TypeT]: ...

class SlotFields(dict): ...

class SlotMakerMeta(type):
class SlotMakerMeta[TypeT: type](type):
def __new__(
cls: type[_TypeT],
cls: type[TypeT],
name: str,
bases: tuple[type, ...],
ns: dict[str, typing.Any],
slots: bool = ...,
gatherer: GathererProtocol | None = ...,
ignore_annotations: bool | None = ...,
**kwargs: typing.Any,
) -> _TypeT: ...
) -> TypeT: ...

class GatheredFields:
__slots__: tuple[str, ...]
__slots__: tuple[str, ...] = ...

fields: dict[str, Field]
modifications: dict[str, typing.Any]

def __init__(
self, fields: dict[str, Field], modifications: dict[str, typing.Any]
) -> None: ...
def __repr__(self) -> str: ...
def __eq__(self, other) -> bool: ...
def __call__(
self, cls_or_ns: _gatherer_argtype,
Expand All @@ -153,7 +154,7 @@ class Field(metaclass=SlotMakerMeta):
compare: bool
kw_only: bool

__slots__: dict[str, str]
__slots__: typing.ClassVar[dict[str, str]] = ...
__classbuilder_internals__: dict

def __init__(
Expand All @@ -169,7 +170,6 @@ class Field(metaclass=SlotMakerMeta):
kw_only: bool = ...,
) -> None: ...
def __init_subclass__(cls, frozen: bool = ..., ignore_annotations: bool = ...): ...
def __repr__(self) -> str: ...
def __eq__(self, other: Field | object) -> bool: ...
def __replace__(self, **kwargs) -> typing.Self: ...
def validate_field(self) -> None: ...
Expand All @@ -181,47 +181,27 @@ class Field(metaclass=SlotMakerMeta):

# These types only exist because type[Field] doesn't seem to resolve correctly
# Technically they're wrong as `isinstance` gets used
_ReturnsField = Callable[..., Field]

# Gatherers
@typing.overload
def make_slot_gatherer(
field_type: _ReturnsField = ...,
) -> NoArgGathererProtocol: ...
@typing.overload
def make_slot_gatherer(
field_type: type[_FieldType],
) -> GathererProtocol[_FieldType]: ...
@typing.overload
def make_annotation_gatherer(
field_type: _ReturnsField = ...,
leave_default_values: bool = False,
) -> NoArgAnnotationGathererProtocol: ...
@typing.overload
field_type: type[Field] = ...,
) -> GathererProtocol[Field]: ...

def make_annotation_gatherer(
field_type: type[_FieldType],
field_type: type[Field] = ...,
leave_default_values: bool = False,
) -> AnnotationGathererProtocol[_FieldType]: ...
@typing.overload
def make_field_gatherer(
field_type: _ReturnsField = ...,
leave_default_values: bool = False,
) -> NoArgGathererProtocol: ...
@typing.overload
) -> AnnotationGathererProtocol[Field]: ...

def make_field_gatherer(
field_type: type[_FieldType],
field_type: type[Field] = ...,
leave_default_values: bool = False,
) -> GathererProtocol[_FieldType]: ...
@typing.overload
def make_unified_gatherer(
field_type: _ReturnsField = ...,
leave_default_values: bool = ...,
) -> NoArgGathererProtocol: ...
@typing.overload
) -> GathererProtocol[Field]: ...

def make_unified_gatherer(
field_type: type[_FieldType],
field_type: type[Field] = ...,
leave_default_values: bool = ...,
) -> GathererProtocol[_FieldType]: ...
) -> GathererProtocol[Field]: ...

def slot_gatherer(cls_or_ns: type | _CopiableMappings) -> _gatherer_returntype: ...
def annotation_gatherer(
cls_or_ns: type | _CopiableMappings,
Expand All @@ -232,22 +212,22 @@ def unified_gatherer(cls_or_ns: type | _CopiableMappings) -> _gatherer_returntyp
def check_argument_order(cls: type) -> None: ...

# Generic replace function
def replace(obj: _T, /, **changes: typing.Any) -> _T: ...
def replace[T](obj: T, /, **changes: typing.Any) -> T: ...

# Basic slotclass example
@typing.overload
def slotclass(
cls: _TypeT,
def slotclass[TypeT: type](
cls: TypeT,
/,
*,
methods: frozenset[MethodMaker] | set[MethodMaker] = default_methods,
syntax_check: bool = True,
) -> _TypeT: ...
) -> TypeT: ...
@typing.overload
def slotclass(
def slotclass[TypeT: type](
cls: None = None,
/,
*,
methods: frozenset[MethodMaker] | set[MethodMaker] = default_methods,
syntax_check: bool = True,
) -> Callable[[_TypeT], _TypeT]: ...
) -> Callable[[TypeT], TypeT]: ...
2 changes: 1 addition & 1 deletion src/ducktools/classbuilder/annotations.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import sys
import typing
import types

_CopiableMappings = dict[str, typing.Any] | types.MappingProxyType[str, typing.Any]
type _CopiableMappings = dict[str, typing.Any] | types.MappingProxyType[str, typing.Any]

def get_func_annotations(
func: types.FunctionType,
Expand Down
7 changes: 2 additions & 5 deletions src/ducktools/classbuilder/annotations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,9 @@ def is_type(hint, t):

# Strip `Annotated`
if _get_origin(hint) is _Annotated:
hint = hint.__origin__
hint = hint.__origin__ # type: ignore

if hint is t or getattr(hint, "__origin__", None) is t:
return True

return False
return (hint is t or getattr(hint, "__origin__", None) is t)


def replace_generic_with_arg(hint):
Expand Down
3 changes: 1 addition & 2 deletions src/ducktools/classbuilder/constants.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,11 @@ REPLACE_NAME: str
class _NothingType:
custom: str | None
def __new__(cls, custom: str | None = ...) -> typing.Self: ...
def __repr__(self) -> str: ...

NOTHING: _NothingType
FIELD_NOTHING: _NothingType

class _KW_ONLY_META(type):
def __repr__(self) -> str: ...
...

class KW_ONLY(metaclass=_KW_ONLY_META): ...
Loading