commit:     47091ae95bee57f95c877b54f2eec6ea4d565c20
Author:     Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Mon Oct 27 06:23:27 2025 +0000
Commit:     Arthur Zamarin <arthurzam <AT> gentoo <DOT> org>
CommitDate: Mon Oct 27 21:38:13 2025 +0000
URL:        
https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=47091ae9

feat: rewrite Immutable* to drop metaclasses

The only reason this was metaclasses- and the inject form- was to work
around py2k.  The metaclasses are no longer necessary, thus use this
form that just is simple inheritance, and does the nasty as needed.

Signed-off-by: Brian Harring <ferringb <AT> gmail.com>

 src/snakeoil/chksum/__init__.py                 |  13 +-
 src/snakeoil/klass/__init__.py                  |   3 +-
 src/snakeoil/klass/immutable.py                 | 114 ++++++++++++++++++
 src/snakeoil/klass/meta.py                      | 153 ------------------------
 src/snakeoil/klass/util.py                      |  29 ++++-
 tests/klass/{test_meta.py => test_immutable.py} |  78 +++++++-----
 tests/klass/test_init.py                        |   2 -
 tests/klass/test_util.py                        |  38 +++++-
 8 files changed, 230 insertions(+), 200 deletions(-)

diff --git a/src/snakeoil/chksum/__init__.py b/src/snakeoil/chksum/__init__.py
index bc6bef2..3016b9f 100644
--- a/src/snakeoil/chksum/__init__.py
+++ b/src/snakeoil/chksum/__init__.py
@@ -6,7 +6,9 @@ import os
 import sys
 from importlib import import_module
 
-from .. import klass, osutils
+from snakeoil.klass.immutable import Simple
+
+from .. import osutils
 from .defaults import chksum_loop_over_file
 
 chksum_types = {}
@@ -130,7 +132,7 @@ def get_chksums(location, *chksums, **kwds):
     )
 
 
-class LazilyHashedPath(metaclass=klass.immutable_instance):
+class LazilyHashedPath(Simple):
     """Given a pathway, compute chksums on demand via attribute access."""
 
     def __init__(self, path, **initial_values):
@@ -139,6 +141,7 @@ class LazilyHashedPath(metaclass=klass.immutable_instance):
         for attr, val in initial_values.items():
             f(self, attr, val)
 
+    @Simple.__allow_mutation_wrapper__
     def __getattr__(self, attr):
         if not attr.islower():
             # Disallow sHa1.
@@ -153,6 +156,7 @@ class LazilyHashedPath(metaclass=klass.immutable_instance):
         object.__setattr__(self, attr, val)
         return val
 
+    @Simple.__allow_mutation_wrapper__
     def clear(self):
         for key in get_handlers():
             if hasattr(self, key):
@@ -161,6 +165,5 @@ class LazilyHashedPath(metaclass=klass.immutable_instance):
     def __getstate__(self):
         return self.__dict__.copy()
 
-    def __setstate__(self, state):
-        for k, v in state.items():
-            object.__setattr__(self, k, v)
+    def __setstate__(self, data):
+        self.__dict__.update(data)

diff --git a/src/snakeoil/klass/__init__.py b/src/snakeoil/klass/__init__.py
index ca9ae9f..7f7db4b 100644
--- a/src/snakeoil/klass/__init__.py
+++ b/src/snakeoil/klass/__init__.py
@@ -46,7 +46,6 @@ from snakeoil.deprecation import deprecated as warn_deprecated
 
 from ..caching import WeakInstMeta
 from .deprecated import ImmutableInstance, immutable_instance, 
inject_immutable_instance
-from .meta import combine_classes
 from .properties import (
     _uncached_singleton,  # noqa: F401 .  This exists purely due to a stupid 
usage of pkgcore.ebuild.profile which is being removed.
     alias,
@@ -60,7 +59,7 @@ from .properties import (
     jit_attr_named,
     jit_attr_none,
 )
-from .util import get_attrs_of, get_slots_of
+from .util import combine_classes, get_attrs_of, get_slots_of
 
 sentinel = object()
 

diff --git a/src/snakeoil/klass/immutable.py b/src/snakeoil/klass/immutable.py
new file mode 100644
index 0000000..986897c
--- /dev/null
+++ b/src/snakeoil/klass/immutable.py
@@ -0,0 +1,114 @@
+__all__ = ("Simple", "Strict")
+
+import functools
+from contextlib import contextmanager
+from contextvars import ContextVar
+
+_immutable_allow_mutations = ContextVar(
+    "immutable_instance_allow_mutation",
+    # no object's pointer will ever be zero, so this is safe.
+    default=0,
+)
+
+
+class Simple:
+    """
+    Make instance immutable, but allow __init__ to mutate.
+
+    Like :class:Strict, these protections can be sidestepped by
+    object.__setatttr__ directly. Additionally for any code decoratored with
+    :meth:allow_mutation, mutation is allowed in that invocation.
+
+    Once in a mutation block, anything that block calls is still allowed to
+    mutate this instance unless it enters another mutation block (another 
instance),
+    and that block tries to mutate the original instance.  TL;dr: you'll know
+    if you hit that edgecase.
+
+    >>> from snakeoil.klass.immutable import Simple
+    >>> class foo(Simple):
+    ...   def __init__(self):
+    ...     self.x = 1 # works
+    ...     self._subinit() # works, we're in a mutation context
+    ...   def _subinit(self):
+    ...     # this only works if invoke in a mutation context.
+    ...     # IE, __init__ for example.
+    ...     self.x = 2
+    >>>
+    >>> try: foo().x =1 # doesn't
+    ... except AttributeError: pass
+    >>>
+    >>> try: foo()._subinit() # same thing, this is disallowed
+    ... except AttributError: pass
+
+    This is async and thread safe.  It is not safe within generator context
+    due to a python limitation.
+    """
+
+    __slots__ = ()
+    __immutable_methods_to_autowrap__ = (
+        "__init__",
+        "__setstate__",
+        # Note, due to the mecahnism relying on id(self), the decorator 
__del__ can't-
+        # even during exception an exception of the mutable block- pin the 
reference
+        # forcing it to stay alive.
+        "__del__",
+    )
+
+    @contextmanager
+    def __allow_mutation__(self):
+        """Allow temporary mutation via context manager"""
+        last = _immutable_allow_mutations.set(id(self))
+        try:
+            yield
+        finally:
+            _immutable_allow_mutations.reset(last)
+
+    @classmethod
+    def __allow_mutation_wrapper__(cls, functor):
+        @functools.wraps(functor)
+        def f(instance, *args, **kwargs):
+            with cls.__allow_mutation__(instance):
+                return functor(instance, *args, **kwargs)
+
+        f.__disable_mutation_autowrapping__ = True  # pyright: 
ignore[reportAttributeAccessIssue] # it's already wrapped.
+        return f
+
+    def __init_subclass__(cls, *args, **kwargs) -> None:
+        """Modify the subclass allowing mutation for default allowed methods"""
+        for name in cls.__immutable_methods_to_autowrap__:
+            if (method := getattr(cls, name, None)) is not None:
+                # 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))
+        return super().__init_subclass__(*args, **kwargs)
+
+    def __setattr__(self, name, value):
+        if id(self) != _immutable_allow_mutations.get():
+            raise AttributeError(self, name, "object is locked against 
mutation")
+        object.__setattr__(self, name, value)
+
+    def __delattr__(self, attr: str):
+        if id(self) != _immutable_allow_mutations.get():
+            raise AttributeError(self, attr, "object is locked against 
mutation")
+        object.__delattr__(self, attr)
+
+
+class Strict:
+    """
+    Make instances effectively immutable.
+
+    This is the 'strict' implementation; __setattr__ and __delattr__
+    will never allow mutation.  Any mutation- during __init__ for example,
+    must be done via object.__setattr__(self, 'attr', value).
+
+    It's strongly advised you look at :class:Simple since that relaxes
+    the rules for things like __init__.
+    """
+
+    __slots__ = ()
+
+    def __setattr__(self, attr, _value):
+        raise AttributeError(self, attr)
+
+    def __delattr__(self, attr):
+        raise AttributeError(self, attr)

diff --git a/src/snakeoil/klass/meta.py b/src/snakeoil/klass/meta.py
deleted file mode 100644
index 899752f..0000000
--- a/src/snakeoil/klass/meta.py
+++ /dev/null
@@ -1,153 +0,0 @@
-__all__ = ("Immutable", "ImmutableStrict")
-
-import functools
-from contextlib import contextmanager
-from contextvars import ContextVar
-
-_immutable_allow_mutations = ContextVar(
-    "immutable_instance_allow_mutation",
-    # no object's pointer will ever be zero, so this is safe.
-    default=0,
-)
-
-
-class Immutable(type):
-    """
-    Make instance immutable, but allow __init__ to mutate.
-
-    Like :class:ImmutableStrict, these protections can be sidestepped by
-    object.__setatttr__ directly. Additionally for any code decoratored with
-    :meth:allow_mutation, mutation is allowed in that invocation.
-
-    Once in a mutation block, anything that block calls is still allowed to
-    mutate this instance unless it enters another mutation block (another 
instance),
-    and that block tries to mutate the original instance.  TL;dr: you'll know
-    if you hit that edgecase.
-
-    >>> from snakeoil.klass.meta import Immutable
-    >>> class foo(metaclass=Immutable):
-    ...   def __init__(self):
-    ...     self.x = 1 # works
-    ...     self._subinit() # works, we're in a mutation context
-    ...   def _subinit(self):
-    ...     # this only works if invoke in a mutation context.
-    ...     # IE, __init__ for example.
-    ...     self.x = 2
-    >>>
-    >>> try: foo().x =1 # doesn't
-    ... except AttributeError: pass
-    >>>
-    >>> try: foo()._subinit() # same thing, this is disallowed
-    ... except AttributError: pass
-
-    This is async and thread safe.  It is not safe within generator context
-    due to a python limitation.
-    """
-
-    default_methods_to_wrap = (
-        "__init__",
-        "__setstate__",
-        # Note, due to the mecahnism relying on id(self), the decorator 
__del__ can't-
-        # even during exception an exception of the mutable block- pin the 
reference
-        # forcing it to stay alive.
-        "__del__",
-    )
-
-    class Mixin:
-        # ensure that if we're in a pure slotted inheritance, we don't break 
it.
-        __slots__ = ()
-
-        @contextmanager
-        def __allow_mutation_block__(self):
-            """Allow temporary mutation via context manager"""
-            last = _immutable_allow_mutations.set(id(self))
-            try:
-                yield
-            finally:
-                _immutable_allow_mutations.reset(last)
-
-        def __setattr__(self, name, value):
-            if id(self) != _immutable_allow_mutations.get():
-                raise AttributeError(self, name, "object is locked against 
mutation")
-            object.__setattr__(self, name, value)
-
-        def __delattr__(self, attr: str):
-            if id(self) != _immutable_allow_mutations.get():
-                raise AttributeError(self, attr, "object is locked against 
mutation")
-            object.__delattr__(self, attr)
-
-    @functools.wraps(type.__new__)
-    def __new__(cls, name, bases, scope) -> type:
-        for method in cls.default_methods_to_wrap:
-            if f := scope.get(method):
-                scope[method] = cls.allow_mutation(f)
-
-        if not any(cls.Mixin in base.mro() for base in bases):
-            bases = (cls.Mixin,) + bases
-
-        return super().__new__(cls, name, bases, scope)
-
-    @classmethod
-    def allow_mutation(cls, functor):
-        """Decorator allowing temporary mutation of an immutable instance"""
-
-        @functools.wraps(functor)
-        def f(self, *args, **kwargs):
-            with cls.Mixin.__allow_mutation_block__(self):
-                return functor(self, *args, **kwargs)
-
-        return f
-
-
-class ImmutableStrict(type):
-    """
-    Make instances effectively immutable.
-
-    This is the 'strict' implementation; __setattr__ and __delattr__
-    will never allow mutation.  Any mutation- during __init__ for example,
-    must be done via object.__setattr__(self, 'attr', value).
-
-    It's strongly advised you look at :class:Simple since that relaxes
-    the rules for things like __init__.
-    """
-
-    class Mixin:
-        __slots__ = ()
-
-        def __setattr__(self, attr, _value):
-            raise AttributeError(self, attr)
-
-        def __delattr__(self, attr):
-            raise AttributeError(self, attr)
-
-    @functools.wraps(type.__new__)
-    def __new__(cls, name, bases, scope):
-        if not any(cls.Mixin in base.mro() for base in bases):
-            bases = (cls.Mixin,) + bases
-        return super().__new__(cls, name, bases, scope)
-
-
[email protected]_cache
-def combine_classes(kls: type, *extra: type) -> type:
-    """Given a set of classes, combine this as if one had wrote the class by 
hand
-
-    This is primarily for composing metaclasses on the fly, like thus:
-
-    Effectively:
-    class foo(metaclass=combine_metaclasses(kls1, kls2, kls3)): pass
-
-    is the same as if you did this:
-    class mkls(kls1, kls2, kls3): pass
-    class foo(metaclass=mkls): pass
-    """
-    klses = [kls]
-    klses.extend(extra)
-
-    if len(klses) == 1:
-        return kls
-
-    class combined(*klses):
-        pass
-
-    combined.__name__ = f"combined_{'_'.join(kls.__qualname__ for kls in 
klses)}"
-    return combined

diff --git a/src/snakeoil/klass/util.py b/src/snakeoil/klass/util.py
index 4701b8a..5fbe410 100644
--- a/src/snakeoil/klass/util.py
+++ b/src/snakeoil/klass/util.py
@@ -1,5 +1,6 @@
-__all__ = ("get_attrs_of", "get_slots_of")
+__all__ = ("get_attrs_of", "get_slots_of", "combine_classes")
 import builtins
+import functools
 from typing import Any, Iterable
 
 _known_builtins = frozenset(
@@ -60,3 +61,29 @@ def get_attrs_of(
                 if (o := getattr(obj, slot, _sentinel)) is not _sentinel:
                     yield slot, o
                     seen.add(slot)
+
+
[email protected]_cache
+def combine_classes(kls: type, *extra: type) -> type:
+    """Given a set of classes, combine this as if one had wrote the class by 
hand
+
+    This is primarily for composing metaclasses on the fly; this:
+
+    class foo(metaclass=combine_metaclasses(kls1, kls2, kls3)): pass
+
+    is the same as if you did this:
+
+    class mkls(kls1, kls2, kls3): pass
+    class foo(metaclass=mkls): pass
+    """
+    klses = [kls]
+    klses.extend(extra)
+
+    if len(klses) == 1:
+        return kls
+
+    class combined(*klses):
+        pass
+
+    combined.__name__ = f"combined_{'_'.join(kls.__qualname__ for kls in 
klses)}"
+    return combined

diff --git a/tests/klass/test_meta.py b/tests/klass/test_immutable.py
similarity index 70%
rename from tests/klass/test_meta.py
rename to tests/klass/test_immutable.py
index 239838d..4abba2e 100644
--- a/tests/klass/test_meta.py
+++ b/tests/klass/test_immutable.py
@@ -3,7 +3,7 @@ from functools import partial, wraps
 
 import pytest
 
-from snakeoil.klass import combine_classes, meta
+from snakeoil.klass import immutable
 
 
 def inject_context_protection(name: str, bases: tuple[type, ...], scope) -> 
type:
@@ -49,7 +49,7 @@ class 
TestInjectContextProtection(metaclass=inject_context_protection):
 
 
 class TestSimpleImmutable(metaclass=inject_context_protection):
-    class _immutable_test_kls(metaclass=meta.Immutable):
+    class _immutable_test_kls(immutable.Simple):
         def __init__(self, recurse=False):
             self.dar = 1
             if recurse:
@@ -58,7 +58,7 @@ class 
TestSimpleImmutable(metaclass=inject_context_protection):
                 pytest.raises(AttributeError, setattr, o, "dar", 4)
                 self.dar = 3
 
-        @meta.Immutable.allow_mutation
+        @immutable.Simple.__allow_mutation_wrapper__
         def set_dar(self, value: int) -> None:
             self.dar = value
 
@@ -69,9 +69,10 @@ class 
TestSimpleImmutable(metaclass=inject_context_protection):
         def setstate(self, data):
             self.x = data
 
-        class foo(metaclass=meta.Immutable):
+        class foo(immutable.Simple):
             __init__ = init
 
+        assert foo.__init__.__disable_mutation_autowrapping__  # pyright: 
ignore[reportFunctionMemberAccess]
         assert foo.__init__ is not init
 
         class foo2(foo):
@@ -80,10 +81,25 @@ class 
TestSimpleImmutable(metaclass=inject_context_protection):
         assert foo.__init__ is foo2.__init__
         assert foo2.__setstate__ is not setstate
 
-        # ensure that we're not daftly injecting extra instances of the 
default class logic.
-        # this is required to ensure we're not overriding things further down 
mro.
-        assert len([x for x in foo.mro() if x == meta.Immutable.Mixin]) == 1
-        assert len([x for x in foo2.mro() if x == meta.Immutable.Mixin]) == 1
+        def self_mutation_managing_init(self):
+            pass
+
+        self_mutation_managing_init.__disable_mutation_autowrapping__ = True  
# pyright: ignore[reportFunctionMemberAccess]
+
+        class foo3(foo2):
+            __init__ = self_mutation_managing_init
+
+        assert foo3.__init__ is self_mutation_managing_init, (
+            "__init__ was marked to not be wrapped, but got wrapped anyways"
+        )
+
+    def test_disallowed_mutation(self):
+        class kls(immutable.Simple):
+            pass
+
+        obj = kls()
+        pytest.raises(AttributeError, setattr, obj, "x", 1)
+        pytest.raises(AttributeError, delattr, obj, "y")
 
     def test_mutation_init(self):
         o = self._immutable_test_kls()
@@ -105,7 +121,7 @@ class 
TestSimpleImmutable(metaclass=inject_context_protection):
         assert o.dar == 1
         o.set_dar(5)
         assert o.dar == 5
-        with o.__allow_mutation_block__():
+        with o.__allow_mutation__():
             o.dar = 6
         assert o.dar == 6
 
@@ -118,7 +134,7 @@ class 
TestSimpleImmutable(metaclass=inject_context_protection):
         var = contextvars.ContextVar("test", default=1)
 
         @push_context
-        def basic():
+        def basic(var=var):
             assert 1 == var.get()
             var.set(2)
 
@@ -126,7 +142,7 @@ class 
TestSimpleImmutable(metaclass=inject_context_protection):
         assert 1 == var.get()
 
         @push_context
-        def generator(val=2):
+        def generator(val=2, var=var):
             assert 1 == var.get()
             var.set(2)
             yield
@@ -140,29 +156,25 @@ class 
TestSimpleImmutable(metaclass=inject_context_protection):
         del var
 
 
-def test_combine_metaclasses():
-    class kls1(type):
-        pass
-
-    class kls2(type):
-        pass
-
-    class kls3(type):
-        pass
-
-    # assert it requires at least one arg
-    pytest.raises(TypeError, combine_classes)
+class TestStrict:
+    def _common(self, slotted=False):
+        class kls(immutable.Strict):
+            if slotted:
+                __slots__ = ("x",)
 
-    assert combine_classes(kls1) is kls1, "unneeded derivative metaclass was 
created"
+            def m(self):
+                self.x = 1
 
-    # assert that it refuses duplicats
-    pytest.raises(TypeError, combine_classes, kls1, kls1)
+        obj = kls()
+        pytest.raises(AttributeError, setattr, obj, "x", 2)
+        pytest.raises(AttributeError, delattr, obj, "x")
+        if slotted:
+            pytest.raises(AttributeError, setattr, obj, "y", 2)
+            pytest.raises(AttributeError, delattr, obj, "y")
 
-    # there is caching, thus also do identity check whilst checking the MRO 
chain
-    kls = combine_classes(kls1, kls2, kls3)
-    assert kls is combine_classes(kls1, kls2, kls3), (
-        "combine_metaclass uses lru_cache to avoid generating duplicate 
classes, however this didn't cache"
-    )
+        kls.__init__ = kls.m
+        pytest.raises(AttributeError, kls)
 
-    combined = combine_classes(kls1, kls2)
-    assert [combined, kls1, kls2, type, object] == list(combined.__mro__)
+    def test_basics(self):
+        self._common()
+        self._common(slotted=True)

diff --git a/tests/klass/test_init.py b/tests/klass/test_init.py
index bd5e6ce..92c2a52 100644
--- a/tests/klass/test_init.py
+++ b/tests/klass/test_init.py
@@ -1,4 +1,3 @@
-import contextvars
 import math
 import re
 from functools import partial
@@ -7,7 +6,6 @@ from time import time
 import pytest
 
 from snakeoil import klass
-from snakeoil.klass import combine_classes, meta
 from snakeoil.klass.properties import _internal_jit_attr, _uncached_singleton
 
 

diff --git a/tests/klass/test_util.py b/tests/klass/test_util.py
index fa13033..6039055 100644
--- a/tests/klass/test_util.py
+++ b/tests/klass/test_util.py
@@ -1,7 +1,9 @@
 import weakref
 from typing import Any
 
-from snakeoil.klass.util import get_attrs_of
+import pytest
+
+from snakeoil.klass.util import combine_classes, get_attrs_of
 
 
 def test_get_attrs_of():
@@ -50,9 +52,9 @@ def test_get_attrs_of():
     # the fun one.  Mixed slotting.
     obj = mk_obj(mk_obj(), slots="x", create=True)
     obj.x = 1
-    assert "x" not in obj.__dict__, (
-        "a slotted variable was tucked into __dict__; this is not how python 
is understood to work for this code.  While real code can do this- it's dumb 
but possible- this test doesn't do that, thus something is off."
-    )
+    assert (
+        "x" not in obj.__dict__
+    ), "a slotted variable was tucked into __dict__; this is not how python is 
understood to work for this code.  While real code can do this- it's dumb but 
possible- this test doesn't do that, thus something is off."
     assert_attrs(obj, {"x": 1})
     obj.y = 2
     assert_attrs(obj, {"x": 1, "y": 2})
@@ -65,3 +67,31 @@ def test_get_attrs_of():
     assert_attrs(obj, {"__weakref__": ref}, weakref=True)
     obj.blah = 1
     assert_attrs(obj, {}, suppressions=["blah"])
+
+
+def test_combine_classes():
+    class kls1(type):
+        pass
+
+    class kls2(type):
+        pass
+
+    class kls3(type):
+        pass
+
+    # assert it requires at least one arg
+    pytest.raises(TypeError, combine_classes)
+
+    assert combine_classes(kls1) is kls1, "unneeded derivative metaclass was 
created"
+
+    # assert that it refuses duplicats
+    pytest.raises(TypeError, combine_classes, kls1, kls1)
+
+    # there is caching, thus also do identity check whilst checking the MRO 
chain
+    kls = combine_classes(kls1, kls2, kls3)
+    assert (
+        kls is combine_classes(kls1, kls2, kls3)
+    ), "combine_metaclass uses lru_cache to avoid generating duplicate 
classes, however this didn't cache"
+
+    combined = combine_classes(kls1, kls2)
+    assert [combined, kls1, kls2, type, object] == list(combined.__mro__)

Reply via email to