Skip to content

Commit e28f6c6

Browse files
committed
feat: allow class subtractions to be pickled/unpickled, make type name friendlier to inflection.titleize(...), ensure a test for class method replacements, pickling, bump to v0.6.1
1 parent 2784646 commit e28f6c6

4 files changed

Lines changed: 129 additions & 30 deletions

File tree

instruct/__init__.py

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
)
4040
from weakref import WeakValueDictionary
4141

42+
import inflection
4243
from jinja2 import Environment, PackageLoader
4344

4445
from .about import __version__
@@ -69,10 +70,15 @@
6970
# Public helpers
7071

7172

72-
def public_class(instance_or_type: Union[Type[T], T], *property_path: str) -> Type[T]:
73+
def public_class(
74+
instance_or_type: Union[Type[T], T], *property_path: str, preserve_subtraction: bool = False
75+
) -> Type[T]:
7376
"""
7477
Given a data class or instance of, give us the public facing
7578
class.
79+
80+
preserve_subtraction indicates that we want the public facing subtracted class as opposed
81+
to its root parent.
7682
"""
7783
if not isinstance(instance_or_type, type):
7884
cls: Type[T] = type(instance_or_type)
@@ -93,6 +99,8 @@ def public_class(instance_or_type: Union[Type[T], T], *property_path: str) -> Ty
9399
next_cls, = cls._nested_atomic_collection_keys[key]
94100
return public_class(next_cls, *property_path[1:])
95101
cls = getattr(cls, "_parent", cls)
102+
if preserve_subtraction and cls._skipped_fields:
103+
return cls
96104
if cls._skipped_fields:
97105
bases = tuple(x for x in cls.__bases__ if ismetasubclass(x, Atomic))
98106
while len(bases) == 1 and bases[0]._skipped_fields:
@@ -123,6 +131,7 @@ def keys(
123131
if instance is not None and not all:
124132
return AtomicKeysView(instance)
125133
if all:
134+
# Known as a KeysView as well
126135
return cls._all_accessible_fields
127136
return KeysView(tuple(cls._slots))
128137
if len(property_path) == 1:
@@ -208,6 +217,28 @@ def aslist(instance: T) -> List[Any]:
208217
# End of public helpers
209218

210219

220+
def _dump_skipped_fields(cls) -> Optional[FrozenMapping[str, Any]]:
221+
assert isinstance(cls, type), f"{cls} is not a class"
222+
skipped = {key: None for key in cls._skipped_fields}
223+
for key in cls._slots:
224+
typedef = cls._slots[key]
225+
if key in cls._nested_atomic_collection_keys:
226+
skipped_on_typedef_merged = {}
227+
for typedef in cls._nested_atomic_collection_keys[key]:
228+
skipped_on_typedef = _dump_skipped_fields(typedef)
229+
if skipped_on_typedef:
230+
skipped_on_typedef_merged.update(skipped_on_typedef)
231+
if skipped_on_typedef_merged:
232+
skipped[key] = skipped_on_typedef_merged
233+
elif ismetasubclass(typedef, Atomic):
234+
skipped_on_typedef = _dump_skipped_fields(typedef)
235+
if skipped_on_typedef:
236+
skipped[key] = skipped_on_typedef
237+
if not skipped:
238+
return None
239+
return FrozenMapping(skipped)
240+
241+
211242
class ItemsView(_ItemsView):
212243
__slots__ = ()
213244
if TYPE_CHECKING:
@@ -796,8 +827,16 @@ def create_union_coerce_function(
796827

797828
def cast_values(value):
798829
if isinstance(value, cast_type_cls):
830+
# The current value is already the parent type, so apply a down coerce
831+
# The function is probably not ready for encountering the parent type
832+
# as normal use would be a no-op.
799833
return complex_type_cast(value)
800-
return custom_cast_function(value)
834+
value = custom_cast_function(value)
835+
# Did the original coerce function make a parent type?
836+
if isinstance(value, cast_type_cls):
837+
# Yes, so let's down down-coerce it to the end type:
838+
return complex_type_cast(value)
839+
return value
801840

802841
cast_values.__union_subtypes__ = (custom_cast_types, custom_cast_function)
803842
if isinstance(custom_cast_types, tuple):
@@ -861,22 +900,20 @@ def apply_skip_keys(
861900
current_definition = e.value
862901

863902
if replace_class_refs:
903+
# Coerce functions may produce the parent type.
904+
# So what we'll do is a two pass function
905+
# - one pass that just downcoerces functions that are the
906+
# final parent type
907+
# - second pass after the original coerce that *will* produce the
908+
# parent type.
864909
parent_type_path, parent_type_coerce_function = transform_typing_to_coerce(
865910
original_definition, dict(replace_class_refs)
866911
)
867-
if current_coerce is not None:
868-
current_coerce_types, existing_coerce_function = current_coerce
869-
new_coerce_function = replace_class_references(
870-
existing_coerce_function, *replace_class_refs
871-
)
872-
else:
873-
new_coerce_function = None
874-
current_coerce_types = None
875912
new_coerce_definition = create_union_coerce_function(
876913
parent_type_path,
877914
parent_type_coerce_function,
878-
current_coerce_types,
879-
new_coerce_function,
915+
current_coerce[0] if current_coerce else None,
916+
current_coerce[1] if current_coerce else None,
880917
)
881918
return ModifiedSkipTypes(
882919
current_definition,
@@ -1040,7 +1077,7 @@ def __sub__(self: Atomic, skip_fields: Union[Mapping[str, Any], Iterable[Any]])
10401077
parent.__name__: (parent, child) for parent, child in mutant_classes
10411078
}
10421079
if debug_mode:
1043-
print(f"Mutants: {mutant_class_parent_names}")
1080+
logger.debug(f"Mutants: {mutant_class_parent_names}")
10441081

10451082
attrs = {"__slots__": ()}
10461083

@@ -1053,7 +1090,7 @@ def __sub__(self: Atomic, skip_fields: Union[Mapping[str, Any], Iterable[Any]])
10531090
*[mutant_class_parent_names[parent_name] for parent_name in parents_to_replace],
10541091
)
10551092
if debug_mode:
1056-
print(f"{function_name} has {parents_to_replace}")
1093+
logger.debug(f"{function_name} has {parents_to_replace}")
10571094
attrs[function_name] = mutated_function_value
10581095

10591096
skip_entire_keys = FrozenMapping(skip_entire_keys)
@@ -1065,9 +1102,11 @@ def __sub__(self: Atomic, skip_fields: Union[Mapping[str, Any], Iterable[Any]])
10651102

10661103
changes = ""
10671104
if skip_entire_keys:
1068-
changes = "Minus{}".format("And".join(skip_entire_keys))
1105+
changes = "Without{}".format("And".join(key.capitalize() for key in skip_entire_keys))
10691106
if redefinitions:
1070-
changes = "{}Modified{}".format(changes, "And".join(sorted(redefinitions)))
1107+
changes = "{}ButModified{}".format(
1108+
changes, "And".join(sorted(key.capitalize() for key in redefinitions))
1109+
)
10711110
if not changes:
10721111
return self
10731112
value = type(f"{cls.__name__}{changes}", (cls,), attrs, skip_fields=skip_entire_keys)
@@ -1486,8 +1525,8 @@ def __new__(
14861525
ns_globals,
14871526
ns_globals,
14881527
)
1489-
support_cls_attrs["__new__"] = ns_globals["__new__"]
1490-
class_cell_fixups.append(("__new__", cast(FunctionType, ns_globals["__new__"])))
1528+
support_cls_attrs["__new__"] = new_new = ns_globals.pop("__new__")
1529+
class_cell_fixups.append(("__new__", cast(FunctionType, new_new)))
14911530
for key in (
14921531
"__iter__",
14931532
"__getstate__",
@@ -1539,8 +1578,9 @@ def __new__(
15391578
extra_slots = tuple(_dedupe(pending_extra_slots))
15401579
support_cls_attrs["__extra_slots__"] = ReadOnly(extra_slots)
15411580
support_cls_attrs["_properties"] = properties = KeysView(properties)
1581+
# create a constant ordered keys view representing the columns and the properties
15421582
support_cls_attrs["_all_accessible_fields"] = ReadOnly(
1543-
combined_columns.keys() | KeysView(properties)
1583+
KeysView(tuple(field for field in chain(combined_columns, properties)))
15441584
)
15451585
support_cls_attrs["_configuration"] = ReadOnly(conf)
15461586

@@ -1805,7 +1845,9 @@ def wrapper(func):
18051845
return wrapper
18061846

18071847

1808-
def load_cls(cls, args, kwargs):
1848+
def load_cls(cls, args, kwargs, skip_fields: Optional[FrozenMapping] = None):
1849+
if skip_fields:
1850+
cls = cls - skip_fields
18091851
return cls(*args, **kwargs)
18101852

18111853

@@ -1896,13 +1938,17 @@ def __len__(self):
18961938
return len(keys(self.__class__))
18971939

18981940
def __contains__(self, item):
1899-
return item in self._all_accessible_fields
1941+
cls = type(self)
1942+
if item in cls._skipped_fields:
1943+
return False
1944+
return item in cls._all_accessible_fields
19001945

19011946
def __reduce__(self):
19021947
# Create an empty class then let __setstate__ in the autogen
19031948
# code to handle passing raw values.
1904-
data_class, support_cls, *_ = self.__class__.__mro__
1905-
return load_cls, (support_cls, (), {}), self.__getstate__()
1949+
cls = public_class(type(self))
1950+
skipped_fields = _dump_skipped_fields(type(self))
1951+
return load_cls, (cls, (), {}, skipped_fields), self.__getstate__()
19061952

19071953
@classmethod
19081954
def _create_invalid_type(cls, field_name, val, val_type, types_required):
@@ -1930,7 +1976,7 @@ def _handle_init_errors(self, errors, errored_keys, unrecognized_keys):
19301976
fields = ", ".join(unrecognized_keys)
19311977
errors.append(self._create_invalid_value(f"Unrecognized fields {fields}"))
19321978
if errors:
1933-
typename = type(self).__name__[1:]
1979+
typename = inflection.titleize(type(self).__name__[1:])
19341980
if len(errors) == 1:
19351981
raise ClassCreationFailed(
19361982
f"Unable to construct {typename}, encountered {len(errors)} "

instruct/about.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.6.0"
1+
__version__ = "0.6.1"

instruct/types.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
)
88
from types import MethodType
99
from weakref import WeakKeyDictionary, WeakValueDictionary
10-
from typing import Any, Dict, Optional, Type, Generic, Callable, Union, Mapping
11-
from .typing import T
10+
from typing import Any, Dict, Optional, Type, Generic, Callable, Mapping
11+
from .typing import T, U
1212

1313

1414
class AttrsDict(UserDict):
@@ -38,7 +38,7 @@ def _caculate_hash(mapping):
3838
FROZEN_MAPPING_SINGLETONS = WeakValueDictionary()
3939

4040

41-
class FrozenMapping(AbstractMapping):
41+
class FrozenMapping(AbstractMapping, Mapping[T, U]):
4242
__slots__ = "__value", "__hashcode", "__weakref__"
4343

4444
__value: Dict[Any, Any]

tests/test_atomic.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import pytest
1616
import instruct
17+
import inflection
1718
from instruct import (
1819
Base,
1920
add_event_listener,
@@ -58,9 +59,9 @@ class LinkedFields(Base):
5859

5960
__coerce__ = {"id": (str, lambda obj: int(obj, 10))}
6061

61-
def __init__(self, **kwargs):
62+
def __init__(self, *args, **kwargs):
6263
self.id = 0
63-
super().__init__(**kwargs)
64+
super().__init__(*args, **kwargs)
6465

6566
@add_event_listener("id")
6667
def _on_id_change(self, old, new):
@@ -138,6 +139,18 @@ def test_pickle():
138139
assert l1 == l2
139140

140141

142+
def test_subtracted_pickle():
143+
cls = LinkedFields - {"id"}
144+
assert instruct.keys(cls) == {"name"}
145+
c = cls("Autumn")
146+
assert cls._skipped_fields == {"id": None}
147+
assert instruct._dump_skipped_fields(cls) == {"id": None}
148+
data = pickle.dumps(c)
149+
c2 = pickle.loads(data)
150+
assert c == c2
151+
assert instruct.public_class(c2, preserve_subtraction=True) is cls
152+
153+
141154
def test_partial_pickle():
142155
l1 = LinkedFields(id=2)
143156
assert l1.name is None
@@ -978,6 +991,10 @@ class Position(Base):
978991

979992
assert FacelessPerson is FacelessPosition._nested_atomic_collection_keys["supervisor"][0]
980993
assert FacelessPerson is FacelessPosition._slots["worker"]
994+
assert instruct._dump_skipped_fields(FacelessPosition) == {
995+
"supervisor": {"name": None, "created_date": None},
996+
"worker": {"name": None, "created_date": None},
997+
}
981998

982999

9831000
def test_include_keys_complex():
@@ -1028,9 +1045,11 @@ class Position(Base):
10281045

10291046
def test_skip_keys_coerce():
10301047
def parse_supervisors(values):
1048+
assert not isinstance(values, Person)
10311049
return tuple(Person(**value) for value in values)
10321050

10331051
def parse_person(value):
1052+
assert not isinstance(value, Person)
10341053
return Person(**value)
10351054

10361055
class Person(Base):
@@ -1065,6 +1084,13 @@ class Position(Base):
10651084
assert fp.supervisor[0].id == 2
10661085
fp.worker = {"created_date": "0", "id": 456, "name": "Sam"}
10671086
assert fp.to_json() == {"id": 1, "supervisor": [{"id": 2}], "worker": {"id": 456}}
1087+
fp.worker = Person(789, "abxdef", "0")
1088+
assert fp.to_json() == {"id": 1, "supervisor": [{"id": 2}], "worker": {"id": 789}}
1089+
assert isinstance(fp.worker, Person)
1090+
assert isinstance(fp.worker, Person & "id")
1091+
# Allow us to check the exact type of the subtraction is the same:
1092+
assert instruct.public_class(fp.worker, preserve_subtraction=True) is (Person & "id")
1093+
assert instruct.public_class(fp.worker, preserve_subtraction=True) is not Person
10681094

10691095

10701096
def test_skip_keys_coerce_classmethod():
@@ -1078,6 +1104,10 @@ def parse(cls, item):
10781104
assert cls is d, "class mismatch!"
10791105
return d(**item)
10801106

1107+
@classmethod
1108+
def check_class(cls, expected_clss):
1109+
return instruct.public_class(cls, preserve_subtraction=True) is expected_clss
1110+
10811111
d = Person
10821112

10831113
class Position(Base):
@@ -1097,6 +1127,12 @@ def some_prop(self):
10971127
return self.task_name
10981128
return None
10991129

1130+
@classmethod
1131+
def convert_to_person(cls, item):
1132+
if not isinstance(item, Person):
1133+
item = Person.from_json(item)
1134+
return item
1135+
11001136
p = Position.from_json({"id": 1, "task_name": "Business Partnerships"})
11011137
p.supervisor = [{"created_date": "0", "id": 2, "name": "John"}]
11021138
p.worker = {"created_date": "0", "id": 456, "name": "Sam"}
@@ -1109,6 +1145,8 @@ def some_prop(self):
11091145
assert fp.supervisor[0].name is None
11101146
assert fp.supervisor[0].id == 2
11111147
fp.worker = {"created_date": "0", "id": 456, "name": "Sam"}
1148+
assert len(fp.worker) == 1
1149+
assert "name" not in fp.worker
11121150
assert isinstance(fp.worker, FacelessPerson)
11131151
assert fp.to_json() == {"id": 1, "supervisor": [{"id": 2}], "worker": {"id": 456}}
11141152
assert (
@@ -1117,6 +1155,13 @@ def some_prop(self):
11171155
)
11181156
assert fp.some_prop is None
11191157
assert d is Person
1158+
assert fp.worker.check_class(FacelessPerson)
1159+
1160+
# This makes use of replace_class_references:
1161+
# We will replace Person in the convert_to_person function with the FacelessPerson
1162+
# class reference.
1163+
bart = FacelessPosition.convert_to_person({"created_date": "0", "id": 912, "name": "Bart"})
1164+
assert isinstance(bart, FacelessPerson)
11201165

11211166
p = Position.from_json({"id": 1, "task_name": "Business Partnerships"})
11221167
p.supervisor = [{"created_date": "0", "id": 2, "name": "John"}]
@@ -1128,6 +1173,12 @@ def some_prop(self):
11281173
"worker": {"created_date": "0", "id": 456, "name": "Sam"},
11291174
}
11301175

1176+
with pytest.raises(instruct.exceptions.ClassCreationFailed) as exc:
1177+
FacelessPosition(farts=1)
1178+
1179+
# ensure the human friendly name is in the class error.
1180+
assert exc.match(inflection.titleize(instruct.public_class(FacelessPosition).__name__))
1181+
11311182

11321183
def test_skip_keys_keys():
11331184
class Person(Base):
@@ -1180,6 +1231,8 @@ class Position(Base):
11801231
class NamelessPerson(Person - {"name"}):
11811232
pass
11821233

1234+
assert not NamelessPerson._skipped_fields
1235+
11831236
me = Person(1, "Autumn", "N/A")
11841237
assert instruct.public_class(me) is Person
11851238
assert instruct.public_class(Person) is Person

0 commit comments

Comments
 (0)