proj/pkgcore/snakeoil:master commit in: tests/klass/, src/snakeoil/chksum/, tests/, /, src/snakeoil/klass/
"Arthur Zamarin" <[email protected]>
| Newsgroups | gmane.linux.gentoo.cvs |
|---|---|
| Message-ID | <1786133858.7f6bb2fd19c88759a04b8ad2f225dbe7ca41427c.arthurzam@gentoo> |
commit: 7f6bb2fd19c88759a04b8ad2f225dbe7ca41427c
Author: Arthur Zamarin <arthurzam <AT> gentoo <DOT> org>
AuthorDate: Fri Aug 7 20:17:38 2026 +0000
Commit: Arthur Zamarin <arthurzam <AT> gentoo <DOT> org>
CommitDate: Fri Aug 7 20:17:38 2026 +0000
URL: https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=7f6bb2fd
klass: deprecate SlotsPicklingMixin, marshal __slots__ natively
SlotsPicklingMixin.__getstate__ built state via get_attrs_of, walking the
class MRO on every serialization with no caching. Python has pickled
slotted classes natively since py3 via object.__getstate__, which is C and
memoizes the slot names on the class through copyreg._slotnames. Dropping
the override is 20x faster for __getstate__ alone, and 4.3x for a full
pickle.dumps of the restriction tuples pkgcheck pushes across its work
queue (19602 items, one per gentoo package: 0.392s -> 0.092s).
The __setstate__ half is not dead: pickle restores slotted state with
setattr, which Simple and Strict block, so an override is needed to
unpickle them at all. Move it there as a single frame object.__setattr__
loop; marshalling is a lower level than runtime manipulation of objects,
and has no business routing through a custom __setattr__.
Inject it only where slotting requires it. Classes carrying a __dict__
never needed it- python restores those by updating the dict directly- and
a __setstate__ costs a python frame per instance, making them 1.6x slower
to unpickle.
copyreg._slotnames also mangles private names, which get_attrs_of did not:
state for a `__foo` slot was silently dropped.
Note the (__dict__, slots) two tuple is now reserved. A subclass whose
__getstate__ returns a two tuple of it's own devising must supply a
matching __setstate__; the generic handler rejects it rather than
misreading it.
chksum.LazilyHashedPath drops its now redundant hooks. Its __getattr__
needed a dunder guard first: it happily accepted __setstate__, and with
.path not yet set during a restore that recursed until the stack blew.
Resolves: https://github.com/pkgcore/snakeoil/issues/117
Signed-off-by: Arthur Zamarin <arthurzam <AT> gentoo.org>
NEWS.rst | 13 +++++
src/snakeoil/chksum/__init__.py | 11 ++--
src/snakeoil/klass/__init__.py | 13 ++++-
src/snakeoil/klass/immutable.py | 60 ++++++++++++++++++++++
tests/klass/test_immutable.py | 111 ++++++++++++++++++++++++++++++++++++++++
tests/klass/test_init.py | 41 +++++++++++----
tests/test_chksum.py | 9 ++++
7 files changed, 239 insertions(+), 19 deletions(-)
diff --git a/NEWS.rst b/NEWS.rst
index 6dfe733..9898052 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -10,6 +10,19 @@ snakeoil 0.11.4 (unreleased)
integer``, caused by ``functools.partial`` becoming a method descriptor
(Arthur Zamarin, https://bugs.gentoo.org/957993)
+- ``snakeoil.klass.immutable.Simple``/``Strict``: slotted subclasses are now
+ picklable and copyable directly; a ``__setstate__`` restoring state past the
+ mutation protections is injected for them. Classes without slotting are left
+ alone, keeping python's C level state restoration (Arthur Zamarin, #117)
+
+- ``snakeoil.klass.immutable.Simple``/``Strict``: fix state of name mangled
+ private slots being silently dropped when pickling (Arthur Zamarin)
+
+- ``snakeoil.klass.SlotsPicklingMixin``: deprecated, removal in 0.12.0. Python
+ pickles ``__slots__`` natively; the mixin only added an uncached MRO walk per
+ serialization. Dropping it makes serializing slotted objects ~4x faster
+ (Arthur Zamarin, #117)
+
snakeoil 0.11.3 (2026-07-30)
----------------------------
diff --git a/src/snakeoil/chksum/__init__.py b/src/snakeoil/chksum/__init__.py
index 0a8e966..0244dc8 100644
--- a/src/snakeoil/chksum/__init__.py
+++ b/src/snakeoil/chksum/__init__.py
@@ -145,8 +145,9 @@ class LazilyHashedPath(Simple):
@Simple.__allow_mutation_wrapper__
def __getattr__(self, attr):
- if not attr.islower():
- # Disallow sHa1.
+ if not attr.islower() or attr.startswith("__"):
+ # Disallow sHa1. Dunders must be rejected outright; protocol probes
+ # like __setstate__ land here before .path is set, else we recurse.
raise AttributeError(attr)
elif attr == "mtime":
val = osutils.stat_mtime_long(self.path)
@@ -163,9 +164,3 @@ class LazilyHashedPath(Simple):
for key in get_handlers():
if hasattr(self, key):
delattr(self, key)
-
- def __getstate__(self):
- return self.__dict__.copy()
-
- def __setstate__(self, data):
- self.__dict__.update(data)
diff --git a/src/snakeoil/klass/__init__.py b/src/snakeoil/klass/__init__.py
index d403493..8d35f2b 100644
--- a/src/snakeoil/klass/__init__.py
+++ b/src/snakeoil/klass/__init__.py
@@ -420,8 +420,19 @@ def cached_hash(
return __hash__
+@deprecated(
+ "python pickles __slots__ natively; inherit snakeoil.klass.immutable.Simple or "
+ "Strict if you need mutation protected classes to unpickle",
+ removal_in=(0, 12, 0),
+ qualname="snakeoil.klass.SlotsPicklingMixin",
+)
class SlotsPicklingMixin:
- """Default pickling support for classes that use __slots__."""
+ """Deprecated. Python pickles __slots__ natively.
+
+ For classes that block mutation, inherit `snakeoil.klass.immutable.Simple`
+ or `snakeoil.klass.immutable.Strict`; they carry the __setstate__ needed to
+ restore state past those protections.
+ """
__slots__ = ()
diff --git a/src/snakeoil/klass/immutable.py b/src/snakeoil/klass/immutable.py
index e1c2f01..8d0c972 100644
--- a/src/snakeoil/klass/immutable.py
+++ b/src/snakeoil/klass/immutable.py
@@ -3,6 +3,7 @@ __all__ = ("Simple", "Strict")
import functools
from contextlib import contextmanager
from contextvars import ContextVar
+from copyreg import _slotnames # pyright: ignore[reportAttributeAccessIssue]
_immutable_allow_mutations = ContextVar(
"immutable_instance_allow_mutation",
@@ -11,6 +12,60 @@ _immutable_allow_mutations = ContextVar(
)
+def _is_state_pair(state) -> bool:
+ """Is this the (__dict__, slots) two tuple `object.__getstate__` produces?"""
+ return (
+ isinstance(state, tuple)
+ and len(state) == 2
+ and (state[0] is None or isinstance(state[0], dict))
+ and isinstance(state[1], dict)
+ )
+
+
+def _restore_state(self, state):
+ """Restore marshalled state, sidestepping the mutation protections.
+
+ Marshalling is a lower level than runtime manipulation of objects, thus this
+ doesn't go through __setattr__. Every shape `object.__getstate__` produces is
+ handled: None, a bare __dict__, or the (__dict__, slots) two tuple.
+
+ Note: a subclass overriding __getstate__ to return anything else must supply
+ it's own __setstate__.
+ """
+ if state is None:
+ return
+ if isinstance(state, dict):
+ # a __dict__ only class, or a flat slot mapping from an older snakeoil.
+ slots = state
+ elif _is_state_pair(state):
+ state, slots = state
+ if state:
+ self.__dict__.update(state)
+ else:
+ raise TypeError(
+ f"{self.__class__.__qualname__} has a custom __getstate__ returning "
+ f"{state!r}; it must supply a matching __setstate__"
+ )
+ if slots:
+ setter = object.__setattr__
+ for k, v in slots.items():
+ setter(self, k, v)
+
+
+_restore_state.__disable_mutation_autowrapping__ = True # pyright: ignore[reportFunctionMemberAccess]
+
+
+def _inject_state_restoration(cls) -> None:
+ """Take over state restoration, but only for classes that need it.
+
+ Python restores __dict__ state directly, but slotted state via setattr- which
+ these classes block. Only intercede for the latter; a __setstate__ costs a
+ python frame per instance that classes w/out slotting shouldn't pay.
+ """
+ if _slotnames(cls) and getattr(cls, "__setstate__", None) is None:
+ cls.__setstate__ = _restore_state
+
+
class Simple:
"""
Make instance immutable, but allow __init__ to mutate.
@@ -80,6 +135,7 @@ class Simple:
# is it wrapped already or was marked to disable wrapping?
if not getattr(method, "__disable_mutation_autowrapping__", False):
setattr(cls, name, cls.__allow_mutation_wrapper__(method))
+ _inject_state_restoration(cls)
return super().__init_subclass__(**kwargs)
def __setattr__(self, name, value):
@@ -107,6 +163,10 @@ class Strict:
__slots__ = ()
+ def __init_subclass__(cls, **kwargs) -> None:
+ _inject_state_restoration(cls)
+ return super().__init_subclass__(**kwargs)
+
def __setattr__(self, attr, _value):
raise AttributeError(self, attr)
diff --git a/tests/klass/test_immutable.py b/tests/klass/test_immutable.py
index 4abba2e..18bdc61 100644
--- a/tests/klass/test_immutable.py
+++ b/tests/klass/test_immutable.py
@@ -1,4 +1,6 @@
import contextvars
+import copy
+import pickle
from functools import partial, wraps
import pytest
@@ -178,3 +180,112 @@ class TestStrict:
def test_basics(self):
self._common()
self._common(slotted=True)
+
+
+class _slotted(immutable.Simple):
+ __slots__ = ("__weakref__", "x", "y")
+
+ def __init__(self, x, y=None):
+ self.x = x
+ if y is not None:
+ self.y = y
+
+
+class _strict_slotted(immutable.Strict):
+ __slots__ = ("x",)
+
+ def __init__(self, x):
+ object.__setattr__(self, "x", x)
+
+
+class _mangled(immutable.Simple):
+ __slots__ = ("__priv",)
+
+ def __init__(self, value):
+ self.__priv = value
+
+ @property
+ def priv(self):
+ return self.__priv
+
+
+class _dicted(immutable.Simple):
+ def __init__(self, **kwargs):
+ for k, v in kwargs.items():
+ object.__setattr__(self, k, v)
+
+
+class _mixed(_dicted):
+ __slots__ = ("x",)
+
+ def __init__(self, x, **kwargs):
+ super().__init__(**kwargs)
+ self.x = x
+
+
+class TestMarshalling(metaclass=inject_context_protection):
+ """Instances must survive pickle/copy despite the mutation protections"""
+
+ def roundtrips(self, obj):
+ yield copy.copy(obj)
+ yield copy.deepcopy(obj)
+ for protocol in (2, pickle.HIGHEST_PROTOCOL):
+ yield pickle.loads(pickle.dumps(obj, protocol))
+
+ def test_slotted(self):
+ for obj in self.roundtrips(_slotted(1, 2)):
+ assert (obj.x, obj.y) == (1, 2)
+
+ def test_strict(self):
+ for obj in self.roundtrips(_strict_slotted(1)):
+ assert obj.x == 1
+
+ def test_unset_slot_stays_unset(self):
+ for obj in self.roundtrips(_slotted(1)):
+ assert obj.x == 1
+ assert not hasattr(obj, "y")
+
+ def test_weakref_is_not_state(self):
+ _, slots = _slotted(1, 2).__getstate__() # pyright: ignore[reportGeneralTypeIssues]
+ assert "__weakref__" not in slots
+
+ def test_mangled_slot(self):
+ for obj in self.roundtrips(_mangled("dar")):
+ assert obj.priv == "dar"
+
+ def test_dicted(self):
+ for obj in self.roundtrips(_dicted(a=1, b=2)):
+ assert obj.a == 1 and obj.b == 2
+
+ def test_dict_and_slots(self):
+ for obj in self.roundtrips(_mixed(1, a=2)):
+ assert (obj.x, obj.a) == (1, 2)
+
+ def test_flat_slot_mapping(self):
+ """state written by the deprecated klass.SlotsPicklingMixin is a flat mapping"""
+ obj = _slotted.__new__(_slotted)
+ obj.__setstate__({"x": 1, "y": 2})
+ assert (obj.x, obj.y) == (1, 2)
+
+ def test_foreign_state_is_rejected(self):
+ """a custom __getstate__ two tuple must not be mistaken for (__dict__, slots)"""
+ obj = _slotted.__new__(_slotted)
+ with pytest.raises(TypeError):
+ obj.__setstate__((1, "not-a-slot-mapping"))
+
+ def test_setstate_is_not_autowrapped(self):
+ for kls in (_slotted, _strict_slotted):
+ assert kls.__setstate__.__disable_mutation_autowrapping__ # pyright: ignore[reportFunctionMemberAccess]
+
+ def test_setstate_only_injected_where_needed(self):
+ """a __setstate__ costs a python frame per instance; only slotting needs it"""
+ assert getattr(_dicted, "__setstate__", None) is None
+ assert _mixed.__setstate__ is not None
+
+ class custom(_slotted):
+ __slots__ = ()
+
+ def __setstate__(self, state):
+ raise AssertionError(state)
+
+ assert custom.__setstate__ is not _slotted.__setstate__
diff --git a/tests/klass/test_init.py b/tests/klass/test_init.py
index 7ccfae8..ae07ee6 100644
--- a/tests/klass/test_init.py
+++ b/tests/klass/test_init.py
@@ -1,5 +1,6 @@
import abc
import inspect
+import pickle
import re
import sys
from functools import partial
@@ -8,6 +9,7 @@ from time import time
import pytest
from snakeoil import klass
+from snakeoil._internals import deprecated
from snakeoil.klass.properties import _internal_jit_attr, _uncached_singleton
if sys.version_info >= (3, 13):
@@ -36,7 +38,7 @@ class Test_GetAttrProxy:
o2 = foo2()
o = foo1(o2)
with pytest.raises(AttributeError):
- getattr(o, "blah")
+ _ = o.blah
assert o.obj == o2
o2.foon = "dar"
assert o.foon == "dar"
@@ -368,7 +370,7 @@ class Test_jit_attr:
base.attr = self.jit_attr_ext_method("f1", "_attr", use_cls_setattr=True)
o = base()
with pytest.raises(TypeError):
- getattr(o, "attr")
+ _ = o.attr
base._setattr_allowed = True
assert o.attr == now
@@ -382,7 +384,7 @@ class Test_jit_attr:
o = base()
# no func...
with pytest.raises(AttributeError):
- getattr(o, "attr")
+ _ = o.attr
base.func = base.f1
assert o.attr == now
assert o._attr2 == now
@@ -414,11 +416,11 @@ class Test_jit_attr:
obj.attr
def test_cached_property(self):
- l = [] # noqa: E741
+ l = []
class foo:
@klass.cached_property
- def blah(self, l=l, i=iter(range(5))): # noqa: E741
+ def blah(self, l=l, i=iter(range(5))):
l.append(None)
return next(i)
@@ -441,11 +443,11 @@ class Test_aliased_attr:
o = cls()
with pytest.raises(AttributeError):
- getattr(o, "attr")
+ _ = o.attr
o.dar = "foon"
with pytest.raises(AttributeError):
- getattr(o, "attr")
+ _ = o.attr
o.dar = o
o.blah = "monkey"
@@ -536,7 +538,7 @@ class TestImmutableInstance:
o = kls()
with pytest.raises(AttributeError):
- setattr(o, "dar", "foon")
+ o.dar = "foon"
with pytest.raises(AttributeError):
delattr(o, "dar")
@@ -554,17 +556,36 @@ class TestImmutableInstance:
o = kls()
with pytest.raises(TypeError):
- setattr(o, "dar", "foon")
+ o.dar = "foon"
with pytest.raises(AttributeError):
delattr(o, "dar")
+with deprecated.suppress_deprecations():
+
+ class _slots_pickling_consumer(klass.SlotsPicklingMixin):
+ __slots__ = ("x",)
+
+
+class TestSlotsPicklingMixin:
+ def test_deprecated_on_subclassing(self):
+ with deprecated_call():
+
+ class kls(klass.SlotsPicklingMixin):
+ __slots__ = ("x",)
+
+ def test_still_functional(self):
+ obj = _slots_pickling_consumer()
+ obj.x = 1
+ assert pickle.loads(pickle.dumps(obj)).x == 1
+
+
class TestAliasMethod:
func = staticmethod(klass.alias_method)
def test_alias_method(self):
class kls:
- __len__ = lambda s: 3 # noqa: E731
+ __len__ = lambda s: 3
lfunc = self.func("__len__")
c = kls()
diff --git a/tests/test_chksum.py b/tests/test_chksum.py
index ea23cbe..edc6dfb 100644
--- a/tests/test_chksum.py
+++ b/tests/test_chksum.py
@@ -1,3 +1,5 @@
+import pickle
+
import pytest
from snakeoil import chksum
@@ -43,3 +45,10 @@ class Test_funcs:
assert chksum.get_handler("x") == 1
assert chksum.get_handler("y") == 2
assert self._inited_count == 1
+
+
+class TestLazilyHashedPath:
+ def test_pickling(self):
+ obj = chksum.LazilyHashedPath("/dev/null", size=0, md5="deadbeef")
+ new = pickle.loads(pickle.dumps(obj))
+ assert (new.path, new.size, new.md5) == ("/dev/null", 0, "deadbeef")