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")
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.