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

Move gatherer and repr/eq hiding logic into core - various logic bugfixes #14

Merged
merged 18 commits into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a8a6e4f
Group gatherers
DavidCEllis May 28, 2024
6ba5059
Add core init/repr/compare/kw_only attributes to Field, do not duplic…
DavidCEllis May 28, 2024
fc914c1
Move repr and eq logic from prefab into core, remove code duplication.
DavidCEllis May 28, 2024
6ce58b3
Move KW_ONLY sentinel into __init__ core - add kw_only logic to annot…
DavidCEllis May 28, 2024
4c6fd78
Simplify prefab gathering logic.
DavidCEllis May 28, 2024
fad7dda
slot_gatherer logic now handled by prefab_gatherer
DavidCEllis May 28, 2024
333b06e
Reworking of slotmaker logic, remove slotmaker base class from Field.
DavidCEllis May 28, 2024
fef89d1
SlotMakerMeta should not remake slots if they already exist.
DavidCEllis May 28, 2024
972fc20
Place slots after other modifications.
DavidCEllis May 28, 2024
a8eca5e
Replace direct annotations access with get_annotations.
DavidCEllis May 29, 2024
ec423ba
Move validation logic from Attribute to Field
DavidCEllis May 29, 2024
280ea09
Lower case '<generated class' to match '<class' on regular reprs.
DavidCEllis May 29, 2024
846b0cb
Test for the field flags in slotclasses.
DavidCEllis May 29, 2024
c21e820
Test slotter on class with unannotated fields.
DavidCEllis May 29, 2024
f414b92
Improve type hint resolving logic
DavidCEllis May 30, 2024
6c1d990
* Reduce repetition in 'Field' construction
DavidCEllis May 30, 2024
9702e3e
rename make_attribute_gatherer to make_field_gatherer
DavidCEllis May 30, 2024
6f57561
auto renaming only caught one instance of make_attribute_gatherer
DavidCEllis May 30, 2024
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
561 changes: 390 additions & 171 deletions src/ducktools/classbuilder/__init__.py

Large diffs are not rendered by default.

129 changes: 91 additions & 38 deletions src/ducktools/classbuilder/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import typing

from collections.abc import Callable
from types import MappingProxyType
from typing_extensions import dataclass_transform

_py_type = type | str # Alias for type hint values
_CopiableMappings = dict[str, typing.Any] | MappingProxyType[str, typing.Any]

__version__: str
INTERNALS_DICT: str
META_GATHERER_NAME: str

def get_fields(cls: type, *, local: bool = False) -> dict[str, Field]: ...

Expand All @@ -14,9 +18,14 @@ def get_flags(cls:type) -> dict[str, bool]: ...
def _get_inst_fields(inst: typing.Any) -> dict[str, typing.Any]: ...

class _NothingType:
...
def __repr__(self) -> str: ...
NOTHING: _NothingType

# noinspection PyPep8Naming
class _KW_ONLY_TYPE:
def __repr__(self) -> str: ...

KW_ONLY: _KW_ONLY_TYPE
# Stub Only
_codegen_type = Callable[[type], tuple[str, dict[str, typing.Any]]]

Expand All @@ -33,6 +42,11 @@ def get_init_generator(
) -> Callable[[type], tuple[str, dict[str, typing.Any]]]: ...

def init_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ...

def get_repr_generator(
recursion_safe: bool = False,
eval_safe: bool = False
) -> Callable[[type], tuple[str, dict[str, typing.Any]]]: ...
def repr_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ...
def eq_generator(cls: type) -> tuple[str, dict[str, typing.Any]]: ...

Expand Down Expand Up @@ -70,6 +84,10 @@ def builder(
) -> Callable[[type[_T]], type[_T]]: ...


class SlotFields(dict):
...


class SlotMakerMeta(type):
def __new__(
cls: type[_T],
Expand All @@ -86,6 +104,10 @@ class Field(metaclass=SlotMakerMeta):
default_factory: _NothingType | typing.Any
type: _NothingType | _py_type
doc: None | str
init: bool
repr: bool
compare: bool
kw_only: bool

__slots__: dict[str, str]
__classbuilder_internals__: dict
Expand All @@ -97,6 +119,10 @@ class Field(metaclass=SlotMakerMeta):
default_factory: _NothingType | typing.Any = NOTHING,
type: _NothingType | _py_type = NOTHING,
doc: None | str = None,
init: bool = True,
repr: bool = True,
compare: bool = True,
kw_only: bool = False,
) -> None: ...

def __init_subclass__(cls, frozen: bool = False): ...
Expand All @@ -107,41 +133,64 @@ class Field(metaclass=SlotMakerMeta):
def from_field(cls, fld: Field, /, **kwargs: typing.Any) -> Field: ...


class GatheredFields:
__slots__ = ("fields", "modifications")
# type[Field] doesn't work due to metaclass
# This is not really precise enough because isinstance is used
_ReturnsField = Callable[..., Field]
_FieldType = typing.TypeVar("_FieldType", bound=Field)

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

__classbuilder_internals__: dict
@typing.overload
def make_slot_gatherer(
field_type: type[_FieldType]
) -> Callable[[type | _CopiableMappings], tuple[dict[str, _FieldType], dict[str, typing.Any]]]: ...

def __init__(
self,
fields: dict[str, Field],
modifications: dict[str, typing.Any]
) -> None: ...
@typing.overload
def make_slot_gatherer(
field_type: _ReturnsField = Field
) -> Callable[[type | _CopiableMappings], tuple[dict[str, Field], dict[str, typing.Any]]]: ...

def __repr__(self) -> str: ...
def __eq__(self, other) -> bool: ...
def __call__(self, cls: type) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...
@typing.overload
def make_annotation_gatherer(
field_type: type[_FieldType],
leave_default_values: bool = True,
) -> Callable[[type | _CopiableMappings], tuple[dict[str, _FieldType], dict[str, typing.Any]]]: ...

@typing.overload
def make_annotation_gatherer(
field_type: _ReturnsField = Field,
leave_default_values: bool = True,
) -> Callable[[type | _CopiableMappings], tuple[dict[str, Field], dict[str, typing.Any]]]: ...

class SlotFields(dict):
...
@typing.overload
def make_field_gatherer(
field_type: type[_FieldType],
leave_default_values: bool = True,
) -> Callable[[type | _CopiableMappings], tuple[dict[str, _FieldType], dict[str, typing.Any]]]: ...

_FieldType = typing.TypeVar("_FieldType", bound=Field)
@typing.overload
def make_field_gatherer(
field_type: _ReturnsField = Field,
leave_default_values: bool = True,
) -> Callable[[type | _CopiableMappings], tuple[dict[str, Field], dict[str, typing.Any]]]: ...

@typing.overload
def make_slot_gatherer(
field_type: type[_FieldType]
) -> Callable[[type], tuple[dict[str, _FieldType], dict[str, typing.Any]]]: ...
def make_unified_gatherer(
field_type: type[_FieldType],
leave_default_values: bool = True,
) -> Callable[[type | _CopiableMappings], tuple[dict[str, _FieldType], dict[str, typing.Any]]]: ...

@typing.overload
def make_slot_gatherer(
field_type: SlotMakerMeta = Field
) -> Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]]: ...
def make_unified_gatherer(
field_type: _ReturnsField = Field,
leave_default_values: bool = True,
) -> Callable[[type | _CopiableMappings], tuple[dict[str, Field], dict[str, typing.Any]]]: ...


def slot_gatherer(cls_or_ns: type | _CopiableMappings) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...
def annotation_gatherer(cls_or_ns: type | _CopiableMappings) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...

def unified_gatherer(cls_or_ns: type | _CopiableMappings) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...

def slot_gatherer(cls: type) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...

def check_argument_order(cls: type) -> None: ...

Expand All @@ -163,20 +212,6 @@ def slotclass(
syntax_check: bool = True
) -> Callable[[type[_T]], type[_T]]: ...

@typing.overload
def make_annotation_gatherer(
field_type: type[_FieldType],
leave_default_values: bool = True,
) -> Callable[[type], tuple[dict[str, _FieldType], dict[str, typing.Any]]]: ...

@typing.overload
def make_annotation_gatherer(
field_type: SlotMakerMeta = Field,
leave_default_values: bool = True,
) -> Callable[[type], tuple[dict[str, Field], dict[str, typing.Any]]]: ...

def annotation_gatherer(cls: type) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...


@dataclass_transform(field_specifiers=(Field,))
class AnnotationClass(metaclass=SlotMakerMeta):
Expand All @@ -185,3 +220,21 @@ class AnnotationClass(metaclass=SlotMakerMeta):
methods: frozenset[MethodMaker] | set[MethodMaker] = default_methods,
**kwargs,
) -> None: ...

class GatheredFields:
__slots__: dict[str, None]

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

__classbuilder_internals__: dict

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: type) -> tuple[dict[str, Field], dict[str, typing.Any]]: ...
91 changes: 80 additions & 11 deletions src/ducktools/classbuilder/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,99 @@
# SOFTWARE.

import sys
import builtins


def eval_hint(hint, obj_globals=None, obj_locals=None):
class _StringGlobs(dict):
"""
Based on the fake globals dictionary used for annotations
from 3.14. This allows us to evaluate containers which
include forward references.

It's just a dictionary that returns the key if the key
is not found.
"""
def __missing__(self, key):
return key

def __repr__(self):
cls_name = self.__class__.__name__
dict_repr = super().__repr__()
return f"{cls_name}({dict_repr})"


def eval_hint(hint, context=None, *, recursion_limit=5):
"""
Attempt to evaluate a string type hint in the given
context. If this fails, return the original string.
context.

If this raises an exception, return the last string.

If the recursion limit is hit or a previous value returns
on evaluation, return the original hint string.

Example::
import builtins
from typing import ClassVar

from ducktools.classbuilder.annotations import eval_hint

foo = "foo"

context = {**vars(builtins), **globals(), **locals()}
eval_hint("foo", context) # returns 'foo'

eval_hint("ClassVar[str]", context) # returns typing.ClassVar[str]
eval_hint("ClassVar[forwardref]", context) # returns typing.ClassVar[ForwardRef('forwardref')]

:param hint: The existing type hint
:param obj_globals: global context
:param obj_locals: local context
:param context: merged context
:param recursion_limit: maximum number of evaluation loops before
returning the original string.
:return: evaluated hint, or string if it could not evaluate
"""
if context is not None:
context = _StringGlobs(context)

original_hint = hint
seen = set()
i = 0
while isinstance(hint, str):
seen.add(hint)

# noinspection PyBroadException
try:
hint = eval(hint, obj_globals, obj_locals)
hint = eval(hint, context)
except Exception:
break

if hint in seen or i >= recursion_limit:
hint = original_hint
break

i += 1

return hint


def get_annotations(ns):
def get_ns_annotations(ns, eval_str=True):
"""
Given an class namespace, attempt to retrieve the
Given a class namespace, attempt to retrieve the
annotations dictionary and evaluate strings.

Note: This only evaluates in the context of module level globals
and values in the class namespace. Non-local variables will not
be evaluated.

:param ns: Class namespace (eg cls.__dict__)
:param eval_str: Attempt to evaluate string annotations (default to True)
:return: dictionary of evaluated annotations
"""
raw_annotations = ns.get("__annotations__", {})

if not eval_str:
return raw_annotations.copy()

try:
obj_modulename = ns["__module__"]
except KeyError:
Expand All @@ -58,16 +122,21 @@ def get_annotations(ns):
obj_module = sys.modules.get(obj_modulename, None)

if obj_module:
obj_globals = obj_module.__dict__.copy()
obj_globals = vars(obj_module)
else:
obj_globals = {}

obj_locals = ns.copy()
# Type parameters should be usable in hints without breaking
# This is for Python 3.12+
type_params = {
repr(param): param
for param in ns.get("__type_params__", ())
}

raw_annotations = ns.get("__annotations__", {})
context = {**vars(builtins), **obj_globals, **type_params, **ns}

return {
k: eval_hint(v, obj_globals, obj_locals)
k: eval_hint(v, context)
for k, v in raw_annotations.items()
}

Expand Down
17 changes: 13 additions & 4 deletions src/ducktools/classbuilder/annotations.pyi
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import typing
import types


_T = typing.TypeVar("_T")
_CopiableMappings = dict[str, typing.Any] | types.MappingProxyType[str, typing.Any]

class _StringGlobs:
def __missing__(self, key: _T) -> _T: ...


def eval_hint(
hint: type | str,
obj_globals: None | dict[str, typing.Any] = None,
obj_locals: None | dict[str, typing.Any] = None,
context: None | dict[str, typing.Any] = None,
*,
recursion_limit: int = 5
) -> type | str: ...

def get_annotations(ns: _CopiableMappings) -> dict[str, typing.Any]: ...

def get_ns_annotations(
ns: _CopiableMappings,
eval_str: bool = True,
) -> dict[str, typing.Any]: ...

def is_classvar(
hint: object,
Expand Down
Loading