| 
1 | 1 | from __future__ import annotations  | 
2 | 2 | 
 
  | 
 | 3 | +import re  | 
3 | 4 | import string  | 
4 | 5 | from itertools import repeat  | 
5 |  | -from typing import Any  | 
 | 6 | +from typing import TYPE_CHECKING, Any, TypeVar  | 
6 | 7 | 
 
  | 
7 | 8 | import pytest  | 
8 | 9 | 
 
  | 
9 | 10 | from narwhals._plan._immutable import Immutable  | 
10 | 11 | 
 
  | 
 | 12 | +if TYPE_CHECKING:  | 
 | 13 | +    from collections.abc import Iterator  | 
 | 14 | +T_co = TypeVar("T_co", covariant=True)  | 
 | 15 | + | 
11 | 16 | 
 
  | 
12 | 17 | class Empty(Immutable): ...  | 
13 | 18 | 
 
  | 
@@ -147,3 +152,52 @@ def test_immutable_hash_cache() -> None:  | 
147 | 152 |     cached = obj.__immutable_hash_value__  | 
148 | 153 |     hash_cache_hit = hash(obj)  | 
149 | 154 |     assert hash_cache_miss == cached == hash_cache_hit  | 
 | 155 | + | 
 | 156 | + | 
 | 157 | +def _collect_immutable_descendants() -> list[type[Immutable]]:  | 
 | 158 | +    # NOTE: Will populate `__subclasses__` by bringing the defs into scope  | 
 | 159 | +    from narwhals._plan import (  | 
 | 160 | +        _expansion,  | 
 | 161 | +        _expr_ir,  | 
 | 162 | +        _function,  | 
 | 163 | +        expressions,  | 
 | 164 | +        options,  | 
 | 165 | +        schema,  | 
 | 166 | +        when_then,  | 
 | 167 | +    )  | 
 | 168 | + | 
 | 169 | +    _ = expressions, schema, options, _expansion, _expr_ir, _function, when_then  | 
 | 170 | +    return sorted(set(_iter_descendants(Immutable)), key=repr)  | 
 | 171 | + | 
 | 172 | + | 
 | 173 | +def _iter_descendants(*bases: type[T_co]) -> Iterator[type[T_co]]:  | 
 | 174 | +    seen = set[T_co]()  | 
 | 175 | +    for base in bases:  | 
 | 176 | +        yield base  | 
 | 177 | +        if (children := (base.__subclasses__())) and (  | 
 | 178 | +            unseen := set(children).difference(seen)  | 
 | 179 | +        ):  | 
 | 180 | +            yield from _iter_descendants(*unseen)  | 
 | 181 | + | 
 | 182 | + | 
 | 183 | +@pytest.fixture(  | 
 | 184 | +    params=_collect_immutable_descendants(), ids=lambda tp: tp.__name__, scope="session"  | 
 | 185 | +)  | 
 | 186 | +def immutable_type(request: pytest.FixtureRequest) -> type[Immutable]:  | 
 | 187 | +    return request.param  # type: ignore[no-any-return]  | 
 | 188 | + | 
 | 189 | + | 
 | 190 | +def test_immutable___slots___(immutable_type: type[Immutable]) -> None:  | 
 | 191 | +    featureless_instance = object.__new__(immutable_type)  | 
 | 192 | + | 
 | 193 | +    # NOTE: If this fails, `__setattr__` has been overriden  | 
 | 194 | +    with pytest.raises(AttributeError, match=r"immutable"):  | 
 | 195 | +        featureless_instance.i_dont_exist = 999  # type: ignore[assignment]  | 
 | 196 | + | 
 | 197 | +    # NOTE: If this fails, `__slots__` lose the size benefit  | 
 | 198 | +    with pytest.raises(AttributeError, match=re.escape("has no attribute '__dict__'")):  | 
 | 199 | +        _ = featureless_instance.__dict__  | 
 | 200 | + | 
 | 201 | +    slots = immutable_type.__slots__  | 
 | 202 | +    if slots:  | 
 | 203 | +        assert len(slots) != 0, slots  | 
0 commit comments