Repository: vega/altair

Issue:
`DatumType` and `Selection` instances are not deep copyable.
Thanks for your work on Altair, which makes generating VegaLite plots in Python super easy.

One issue I've come across is that you cannot deep-copy some of the object types because the `__getattr__` implementations of some objects are too broad (i.e. https://github.com/altair-viz/altair/blob/master/altair/expr/core.py#L14, https://github.com/altair-viz/altair/blob/d862ad494ecc0a5cff7e8b114df14e0796efa108/altair/vegalite/v3/api.py#L196, https://github.com/altair-viz/altair/blob/d862ad494ecc0a5cff7e8b114df14e0796efa108/altair/vegalite/v4/api.py#L196). These methods return a result for *any* request, including Python magic attributes of form `__<name>__`, which causes issues for modules like `copy.deepcopy` which looks for a `__deepcopy__` method on the object being copied to see if the copying behaviour has been overridden.

Would you be willing to accept patches to fix this? Or is this done intentionally?


Discussion:
This was done intentionally, becuase charts may have a column named `__deepcopy__` in which case `alt.datum.__deepcopy__` is how you would access that attribute within an expression.

But that's probably a rare case, and `alt.datum['__deepcopy__']` is a reasonable workaround. I'd be open to a change that makes `__getattr__` return an `AttributeError` for dunder attributes.

Resolving patch:
diff --git a/altair/expr/core.py b/altair/expr/core.py
--- a/altair/expr/core.py
+++ b/altair/expr/core.py
@@ -8,6 +8,8 @@ def __repr__(self):
         return "datum"
 
     def __getattr__(self, attr):
+        if attr.startswith("__") and attr.endswith("__"):
+            raise AttributeError(attr)
         return GetAttrExpression("datum", attr)
 
     def __getitem__(self, attr):
diff --git a/altair/vegalite/v3/api.py b/altair/vegalite/v3/api.py
--- a/altair/vegalite/v3/api.py
+++ b/altair/vegalite/v3/api.py
@@ -194,6 +194,8 @@ def __or__(self, other):
         return Selection(core.SelectionOr(**{"or": [self.name, other]}), self.selection)
 
     def __getattr__(self, field_name):
+        if field_name.startswith("__") and field_name.endswith("__"):
+            raise AttributeError(field_name)
         return expr.core.GetAttrExpression(self.name, field_name)
 
     def __getitem__(self, field_name):
diff --git a/altair/vegalite/v4/api.py b/altair/vegalite/v4/api.py
--- a/altair/vegalite/v4/api.py
+++ b/altair/vegalite/v4/api.py
@@ -194,6 +194,8 @@ def __or__(self, other):
         return Selection(core.SelectionOr(**{"or": [self.name, other]}), self.selection)
 
     def __getattr__(self, field_name):
+        if field_name.startswith("__") and field_name.endswith("__"):
+            raise AttributeError(field_name)
         return expr.core.GetAttrExpression(self.name, field_name)
 
     def __getitem__(self, field_name):


Test patch:
diff --git a/altair/expr/tests/test_expr.py b/altair/expr/tests/test_expr.py
--- a/altair/expr/tests/test_expr.py
+++ b/altair/expr/tests/test_expr.py
@@ -1,5 +1,7 @@
 import operator
 
+import pytest
+
 from ... import expr
 from .. import datum
 
@@ -95,6 +97,9 @@ def test_datum_getattr():
     x = datum["foo"]
     assert repr(x) == "datum['foo']"
 
+    with pytest.raises(AttributeError):
+        datum.__magic__
+
 
 def test_expression_getitem():
     x = datum.foo[0]
diff --git a/altair/vegalite/v3/tests/test_api.py b/altair/vegalite/v3/tests/test_api.py
--- a/altair/vegalite/v3/tests/test_api.py
+++ b/altair/vegalite/v3/tests/test_api.py
@@ -249,6 +249,9 @@ def test_selection_expression():
     assert isinstance(selection["value"], alt.expr.Expression)
     assert selection["value"].to_dict() == "{0}['value']".format(selection.name)
 
+    with pytest.raises(AttributeError):
+        selection.__magic__
+
 
 @pytest.mark.parametrize("format", ["html", "json", "png", "svg", "pdf"])
 def test_save(format, basic_chart):
diff --git a/altair/vegalite/v4/tests/test_api.py b/altair/vegalite/v4/tests/test_api.py
--- a/altair/vegalite/v4/tests/test_api.py
+++ b/altair/vegalite/v4/tests/test_api.py
@@ -249,6 +249,9 @@ def test_selection_expression():
     assert isinstance(selection["value"], alt.expr.Expression)
     assert selection["value"].to_dict() == "{0}['value']".format(selection.name)
 
+    with pytest.raises(AttributeError):
+        selection.__magic__
+
 
 @pytest.mark.parametrize("format", ["html", "json", "png", "svg", "pdf"])
 def test_save(format, basic_chart):