Repository: apache/iceberg-python

Issue:
copy.deepcopy() raises TypeError on And, Or, Not expressions
### Apache Iceberg version

None

### Please describe the bug 🐞

`copy.deepcopy()` on `And`, `Or`, or `Not` raises `TypeError` because Pydantic v2's `BaseModel.__deepcopy__` calls `cls.__new__(cls)` with no args, but these classes require positional arguments in `__new__`.

  ```python
  import copy
  from pyiceberg.expressions import And, EqualTo

  copy.deepcopy(And(EqualTo("x", 1), EqualTo("y", 2)))
  # TypeError: And.__new__() missing 2 required positional arguments: 'left' and 'right'
```

**Use case**
We use PyIceberg to load data for model training in [Ray](https://docs.ray.io/en/latest/index.html). As part of this we have PyIceberg filters that are pushed down to dataset reads as part of a config object. Ray is doing a deep copy and we hit this issue. Other PyIceberg filters don't have this issue because they don't override `__new__`.

```
File "/export/apps/python/3.12/lib/python3.12/site-packages/ray/train/v2/api/data_parallel_trainer.py", line 179, in fit
      callbacks=self._create_default_callbacks(),
    File "/export/apps/python/3.12/lib/python3.12/site-packages/ray/train/v2/api/data_parallel_trainer.py", line 206, in _create_default_callbacks
      datasets_callback = DatasetsSetupCallback(
    File "/export/apps/python/3.12/lib/python3.12/site-packages/ray/train/v2/_internal/callbacks/datasets.py", line 54, in __init__
      self._data_config = copy.deepcopy(train_run_context.dataset_config)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 162, in deepcopy
      y = _reconstruct(x, memo, *rv)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 259, in _reconstruct
      state = deepcopy(state, memo)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 136, in deepcopy
      y = copier(x, memo)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 221, in _deepcopy_dict
      y[deepcopy(key, memo)] = deepcopy(value, memo)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 136, in deepcopy
      y = copier(x, memo)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 196, in _deepcopy_list
      append(deepcopy(a, memo))
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 162, in deepcopy
      y = _reconstruct(x, memo, *rv)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 259, in _reconstruct
      state = deepcopy(state, memo)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 136, in deepcopy
      y = copier(x, memo)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 221, in _deepcopy_dict
      y[deepcopy(key, memo)] = deepcopy(value, memo)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 162, in deepcopy
      y = _reconstruct(x, memo, *rv)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 253, in _reconstruct
      y = func(*args)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 252, in <genexpr>
      args = (deepcopy(arg, memo) for arg in args)
    File "/export/apps/python/3.12/lib/python3.12/copy.py", line 143, in deepcopy
      y = copier(memo)
    File "/export/apps/python/3.12/lib/python3.12/site-packages/pydantic/main.py", line 977, in __deepcopy__
      m = cls.__new__(cls)
  TypeError: And.__new__() missing 2 required positional arguments: 'left' and 'right'
```
### Willingness to contribute

- [ ] I can contribute a fix for this bug independently
- [ ] I would be willing to contribute a fix for this bug with guidance from the Iceberg community
- [ ] I cannot contribute a fix for this bug at this time

Discussion:

Fix (patch):
diff --git a/pyiceberg/expressions/__init__.py b/pyiceberg/expressions/__init__.py
index 3910a146c7..ef4cb2506e 100644
--- a/pyiceberg/expressions/__init__.py
+++ b/pyiceberg/expressions/__init__.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+import copy
 from abc import ABC, abstractmethod
 from collections.abc import Callable, Iterable, Sequence
 from functools import cached_property
@@ -339,6 +340,10 @@ def __invert__(self) -> BooleanExpression:
         # De Morgan's law: not (A and B) = (not A) or (not B)
         return Or(~self.left, ~self.right)
 
+    def __deepcopy__(self, memo: dict[int, Any]) -> And:
+        """Return a deep copy of the And expression."""
+        return And(copy.deepcopy(self.left, memo), copy.deepcopy(self.right, memo))
+
     def __getnewargs__(self) -> tuple[BooleanExpression, BooleanExpression]:
         """Pickle the And class."""
         return (self.left, self.right)
@@ -386,6 +391,10 @@ def __invert__(self) -> BooleanExpression:
         # De Morgan's law: not (A or B) = (not A) and (not B)
         return And(~self.left, ~self.right)
 
+    def __deepcopy__(self, memo: dict[int, Any]) -> Or:
+        """Return a deep copy of the Or expression."""
+        return Or(copy.deepcopy(self.left, memo), copy.deepcopy(self.right, memo))
+
     def __getnewargs__(self) -> tuple[BooleanExpression, BooleanExpression]:
         """Pickle the Or class."""
         return (self.left, self.right)
@@ -428,6 +437,10 @@ def __invert__(self) -> BooleanExpression:
         """Transform the Expression into its negated version."""
         return self.child
 
+    def __deepcopy__(self, memo: dict[int, Any]) -> Not:
+        """Return a deep copy of the Not expression."""
+        return Not(copy.deepcopy(self.child, memo))
+
     def __getnewargs__(self) -> tuple[BooleanExpression]:
         """Pickle the Not class."""
         return (self.child,)


Test changes:
diff --git a/tests/expressions/test_expressions.py b/tests/expressions/test_expressions.py
index 157c1adaf1..d6c0ce1f51 100644
--- a/tests/expressions/test_expressions.py
+++ b/tests/expressions/test_expressions.py
@@ -16,6 +16,7 @@
 # under the License.
 # pylint:disable=redefined-outer-name,eval-used
 
+import copy
 import pickle
 import uuid
 from decimal import Decimal
@@ -1292,6 +1293,75 @@ def test_bind_ambiguous_name() -> None:
     assert "Invalid schema, multiple fields for name foo.bar: 2 and 3" in str(exc_info)
 
 
+def test_deepcopy_and() -> None:
+    expr = And(EqualTo("x", 1), EqualTo("y", 2))
+    copied = copy.deepcopy(expr)
+    assert copied == expr
+    assert copied is not expr
+
+
+def test_deepcopy_or() -> None:
+    expr = Or(EqualTo("x", 1), EqualTo("y", 2))
+    copied = copy.deepcopy(expr)
+    assert copied == expr
+    assert copied is not expr
+
+
+def test_deepcopy_not() -> None:
+    expr = Not(EqualTo("x", 1))
+    copied = copy.deepcopy(expr)
+    assert copied == expr
+    assert copied is not expr
+
+
+def test_deepcopy_equal_to() -> None:
+    expr = EqualTo("x", 1)
+    copied = copy.deepcopy(expr)
+    assert copied == expr
+    assert copied is not expr
+
+
+def test_deepcopy_always_true() -> None:
+    copied = copy.deepcopy(AlwaysTrue())
+    assert copied is AlwaysTrue()
+
+
+def test_deepcopy_always_false() -> None:
+    copied = copy.deepcopy(AlwaysFalse())
+    assert copied is AlwaysFalse()
+
+
+def test_deepcopy_always_true_then_pickle() -> None:
+    copied = copy.deepcopy(AlwaysTrue())
+    restored = pickle.loads(pickle.dumps(copied))
+    assert restored is AlwaysTrue()
+
+
+def test_deepcopy_balanced_and() -> None:
+    expr = And(EqualTo("a", 1), EqualTo("b", 2), EqualTo("c", 3), EqualTo("d", 4))
+    copied = copy.deepcopy(expr)
+    assert copied == expr
+
+
+def test_deepcopy_balanced_or() -> None:
+    expr = Or(EqualTo("a", 1), EqualTo("b", 2), EqualTo("c", 3), EqualTo("d", 4))
+    copied = copy.deepcopy(expr)
+    assert copied == expr
+
+
+def test_deepcopy_nested_expression() -> None:
+    expr = And(Or(EqualTo("a", 1), EqualTo("b", 2)), Not(EqualTo("c", 3)))
+    copied = copy.deepcopy(expr)
+    assert copied == expr
+
+
+def test_deepcopy_then_pickle() -> None:
+    expr = And(EqualTo("x", 1), EqualTo("y", 2))
+    copied = copy.deepcopy(expr)
+    restored = pickle.loads(pickle.dumps(copied))
+    assert restored == expr
+
+
 #   __  __      ___
 #  |  \/  |_  _| _ \_  _
 #  | |\/| | || |  _/ || |