Repository: dask/dask
Pull request #11845 (2025-03-24 12:52:46)

Fixed `Expr.__setattr__` for subclasses
I'm seeing some issues in dask-cudf after https://github.com/dask/dask/pull/11836/files and https://github.com/dask/dask/pull/11835/files were merged. I get an infinite recursion error when setting an attribute on a (subclass of) an `Expr` object.

The line I really don't understand is https://github.com/dask/dask/pull/11836/files#diff-224f6706e0d593558390ff212eeb043a9fd411ab927a830c416ad84ff192aa9eR172.

```
            params = object.__getattribute__(type(self), "_parameters")
```

IIUC, that's supposed to get the `Expr._parameters` list of the class. For reasons I don't fully understand, this doesn't work well when `self` is a *non-direct* subclass of `Expr`, like in dask-cudf's case where our class inherits from `ReadParquetFSSpec`. When you're a grandchild of `Expr`, that fails with

```
dask/tests/test_expr.py::test_setattr2 FAILED

======================================================================================================================== FAILURES =========================================================================================================================
______________________________________________________________________________________________________________________ test_setattr2 ______________________________________________________________________________________________________________________

    def test_setattr2():
        e = MyExpr2(foo=1, bar=2)
>       e.bar = 3

dask/tests/test_expr.py:25: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = MyExpr2(foo=1, bar=2), name = 'bar', value = 3

    def __setattr__(self, name: str, value: Any) -> None:
        if name in ["operands", "_determ_token"]:
            object.__setattr__(self, name, value)
            return
        try:
>           params = object.__getattribute__(type(self), "_parameters")
E           AttributeError: 'type' object has no attribute '_parameters'
```

I'm really not sure why Python behaves like this. But I believe that this PR has a compatible change that works for grandchildren too, by using (the simpler?) `type(self).<param>`.

TomAugspurger: cc @fjetter if you're able to take a look when you have a moment.
TomAugspurger: Oh thanks! LMK if you need any help and feel free to ping me for review.
github-actions[bot]: ## Unit Test Results
_See [test report](https://dask.github.io/dask/test_report.html) for an extended history of previous test failures. This is useful for diagnosing flaky tests._

      9 files  ±0        9 suites  ±0   3h 24m 27s ⏱️ + 1m 3s
 17 819 tests +1   16 604 ✅ +2   1 215 💤 ±0  0 ❌  - 1 
159 445 runs  +9  147 330 ✅ +8  12 115 💤 +2  0 ❌  - 1 

Results for commit da3cfe11. ± Comparison against base commit b2a4a215.

[test-results]:data:application/gzip;base64,H4sIACdf4WcC/02MzQ7CIBAGX8Vw9sAWthRfxiCwCbE/hsLJ+O7Spq49zny78xaUxriK28VeL2KtqTCEml1Jy9wQuq43TbWxbDOYAeyP72v1fpN9L/VfPtNr/wRkRy6NzUkWMeclHybXeU+j1RoP5rQ2SkmWnAY+PKV3Ppf9Mk2pNBDBKU8RgKQMiAOooJGsNMEFVNGRpPiAwXrx+QK7F5W6GAEAAA==


Patch:
diff --git a/dask/_expr.py b/dask/_expr.py
index f79da9424..dfb1d9093 100644
--- a/dask/_expr.py
+++ b/dask/_expr.py
@@ -176,7 +176,7 @@ class Expr:
             object.__setattr__(self, name, value)
             return
         try:
-            params = object.__getattribute__(type(self), "_parameters")
+            params = type(self)._parameters
             operands = object.__getattribute__(self, "operands")
             operands[params.index(name)] = value
         except ValueError:


Test patch:
diff --git a/dask/tests/test_expr.py b/dask/tests/test_expr.py
index f7f146915..2907e5e5a 100644
--- a/dask/tests/test_expr.py
+++ b/dask/tests/test_expr.py
@@ -5,12 +5,26 @@ import pytest
 from dask._expr import Expr
 
 
-def test_setattr():
-    class MyExpr(Expr):
-        _parameters = ["foo", "bar"]
+class MyExpr(Expr):
+    _parameters = ["foo", "bar"]
+
+
+class MyExpr2(MyExpr):
+    # A subclass that inherits parameters
+    pass
 
+
+def test_setattr():
     e = MyExpr(foo=1, bar=2)
     e.bar = 3
     assert e.bar == 3
     with pytest.raises(AttributeError):
         e.baz = 4
+
+
+def test_setattr2():
+    e = MyExpr2(foo=1, bar=2)
+    e.bar = 3
+    assert e.bar == 3
+    with pytest.raises(AttributeError):
+        e.baz = 4