Repository: explosion/spaCy

Issue:
[feature request] Factory default for extension attributes
## Feature description
As usual with Python, mutable defaults are a big no-no for extension attributes, since they are shared by all instances, which leads to subtle and ~~quick to anger~~ hard to root out bugs (see e.g. #2581).
The documentation mentions that pitfall, but doesn't offer a convenient solution: if I want to keep a static list of interesting spans in my document in a `Doc._.interesting`, it is not clear where the getter and setter that I am supposed to use for this property should store the state. (From what I understand, it should probably be somewhere in `Doc.user_data`, but I have not found a lot of doc on that either)

I propose a `factory` argument to `set_extension` that would be called the first time that the value for the corresponding extension property is retrieved for a given instance (as `collections.defaultdict` does), so one could just write

```python
spacy.tokens.Doc.set_extension('interesting', factory=list)
```


Discussion:
Thanks, this is a nice idea!

> From what I understand, it should probably be somewhere in `Doc.user_data`, but I have not found a lot of doc on that either

`Doc.user_data` is a dict that you can write to. So you can do something like this:

```python
doc = nlp("This is a doc")
doc.user_data["interesting"] = []
doc.user_data["interesting"].append("cool stuff")
```

Resolving patch:
diff --git a/spacy/tokens/underscore.py b/spacy/tokens/underscore.py
--- a/spacy/tokens/underscore.py
+++ b/spacy/tokens/underscore.py
@@ -2,11 +2,13 @@
 from __future__ import unicode_literals
 
 import functools
+import copy
 
 from ..errors import Errors
 
 
 class Underscore(object):
+    mutable_types = (dict, list, set)
     doc_extensions = {}
     span_extensions = {}
     token_extensions = {}
@@ -32,7 +34,15 @@ def __getattr__(self, name):
         elif method is not None:
             return functools.partial(method, self._obj)
         else:
-            return self._doc.user_data.get(self._get_key(name), default)
+            key = self._get_key(name)
+            if key in self._doc.user_data:
+                return self._doc.user_data[key]
+            elif isinstance(default, self.mutable_types):
+                # Handle mutable default arguments (see #2581)
+                new_default = copy.copy(default)
+                self.__setattr__(name, new_default)
+                return new_default
+            return default
 
     def __setattr__(self, name, value):
         if name not in self._extensions:


Test patch:
diff --git a/spacy/tests/doc/test_underscore.py b/spacy/tests/doc/test_underscore.py
--- a/spacy/tests/doc/test_underscore.py
+++ b/spacy/tests/doc/test_underscore.py
@@ -106,3 +106,37 @@ def test_underscore_raises_for_invalid(invalid_kwargs):
 def test_underscore_accepts_valid(valid_kwargs):
     valid_kwargs["force"] = True
     Doc.set_extension("test", **valid_kwargs)
+
+
+def test_underscore_mutable_defaults_list(en_vocab):
+    """Test that mutable default arguments are handled correctly (see #2581)."""
+    Doc.set_extension("mutable", default=[])
+    doc1 = Doc(en_vocab, words=["one"])
+    doc2 = Doc(en_vocab, words=["two"])
+    doc1._.mutable.append("foo")
+    assert len(doc1._.mutable) == 1
+    assert doc1._.mutable[0] == "foo"
+    assert len(doc2._.mutable) == 0
+    doc1._.mutable = ["bar", "baz"]
+    doc1._.mutable.append("foo")
+    assert len(doc1._.mutable) == 3
+    assert len(doc2._.mutable) == 0
+
+
+def test_underscore_mutable_defaults_dict(en_vocab):
+    """Test that mutable default arguments are handled correctly (see #2581)."""
+    Token.set_extension("mutable", default={})
+    token1 = Doc(en_vocab, words=["one"])[0]
+    token2 = Doc(en_vocab, words=["two"])[0]
+    token1._.mutable["foo"] = "bar"
+    assert len(token1._.mutable) == 1
+    assert token1._.mutable["foo"] == "bar"
+    assert len(token2._.mutable) == 0
+    token1._.mutable["foo"] = "baz"
+    assert len(token1._.mutable) == 1
+    assert token1._.mutable["foo"] == "baz"
+    token1._.mutable["x"] = []
+    token1._.mutable["x"].append("y")
+    assert len(token1._.mutable) == 2
+    assert token1._.mutable["x"] == ["y"]
+    assert len(token2._.mutable) == 0