Repository: explosion/spaCy

Issue:
Confusing error when using Displacy with Span instead of Doc object.
I tried using Displacy, however i did not notice that i accidentally passed in a Span object in stead of a Doc object. 

The error i got was this:
```
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-29-69085a7f22c5> in <module>()
      2 
      3 sent = list(post.content.sents)[0]
----> 4 html = displacy.render(sent, style='dep')
      5 display(HTML(html))

~/.pyenv/versions/3.6.5/envs/nudge-fe/lib/python3.6/site-packages/spacy/displacy/__init__.py in render(docs, style, page, minify, jupyter, options, manual)
     34     renderer, converter = factories[style]
     35     renderer = renderer(options=options)
---> 36     parsed = [converter(doc, options) for doc in docs] if not manual else docs
     37     _html['parsed'] = renderer.render(parsed, page=page, minify=minify).strip()
     38     html = _html['parsed']

~/.pyenv/versions/3.6.5/envs/nudge-fe/lib/python3.6/site-packages/spacy/displacy/__init__.py in <listcomp>(.0)
     34     renderer, converter = factories[style]
     35     renderer = renderer(options=options)
---> 36     parsed = [converter(doc, options) for doc in docs] if not manual else docs
     37     _html['parsed'] = renderer.render(parsed, page=page, minify=minify).strip()
     38     html = _html['parsed']

~/.pyenv/versions/3.6.5/envs/nudge-fe/lib/python3.6/site-packages/spacy/displacy/__init__.py in parse_deps(orig_doc, options)
     84     RETURNS (dict): Generated dependency parse keyed by words and arcs.
     85     """
---> 86     doc = Doc(orig_doc.vocab).from_bytes(orig_doc.to_bytes())
     87     if not doc.is_parsed:
     88         user_warning(Warnings.W005)

AttributeError: 'spacy.tokens.token.Token' object has no attribute 'to_bytes'
```
My code: 
```
from IPython.core.display import display, HTML

sent = list(post.content.sents)[0]
html = displacy.render(sent, style='dep')
display(HTML(html))
```

I needed to change `sent` to `sent.as_doc()`. This can ether be mentioned in the doc or Spacy should give a better error message when passing in Spans to Displacy.

## Which page or section is this issue related to?
https://spacy.io/usage/visualizers



Discussion:
Thanks, good point! I think that calling `span.as_doc()` by default would be fine, because this is almost always what a user wants to accomplish if they pass a `Span` object to `displacy`. So we could do something like this:

```python
if not isinstance(obj, Span) and not isinstance(obj, Doc):
    raise ValueError("Need Span or Doc!")  # TODO: proper error
if isinstance(obj, Span):
    obj = obj.as_doc()
# etc...
```

Feel free to submit a PR if you like. Otherwise, I'm also happy to implement this 🙂
I’m not sure where to add this logic. Is this a good place?:
https://github.com/explosion/spaCy/blob/master/spacy/displacy/__init__.py#L32

Combined with the existing logic on line 32 the code would be quite convoluted.

There should maybe be some more duck typing here, so that both span and doc elements behave similarly. 

By adding the method `to_bytes` to Span, both Span and Doc would work. We can then use a try /except block to catch everything else and raise a more meaningful error message. This would also allow elements derived from Doc or Span objects to be used by displacy.

I guess a quick hack of adding to_bytes to Span would be something like this:

```
to_bytes(self, **exclude):
    return self.as_doc().to_bytes(**exclude)
```

But it should maybe be implemented properly?

The only problem I see with this solution is that a `Span` is only a *view* of the `Doc` and doesn't hold any data itself. So even if we implemented a method or workaround like this for displaCy, the input/output wouldn't be consistent: you'd serialize a `Span`, i.e. a view of the `Doc` with references to the original document, and get out a serialized `Doc` with the same content as the `Span`, but no references.

> Combined with the existing logic on line 32 the code would be quite convoluted.

Ah, I forgot that `displacy.render` also takes a list of `Doc` objects and even dictionaries if set to `manual=True`. I'll think about this some more, maybe we can find a clever solution here.

Resolving patch:
diff --git a/spacy/displacy/__init__.py b/spacy/displacy/__init__.py
--- a/spacy/displacy/__init__.py
+++ b/spacy/displacy/__init__.py
@@ -2,7 +2,7 @@
 from __future__ import unicode_literals
 
 from .render import DependencyRenderer, EntityRenderer
-from ..tokens import Doc
+from ..tokens import Doc, Span
 from ..compat import b_to_str
 from ..errors import Errors, Warnings, user_warning
 from ..util import prints, is_in_jupyter
@@ -29,8 +29,11 @@ def render(docs, style='dep', page=False, minify=False, jupyter=IS_JUPYTER,
                  'ent': (EntityRenderer, parse_ents)}
     if style not in factories:
         raise ValueError(Errors.E087.format(style=style))
-    if isinstance(docs, Doc) or isinstance(docs, dict):
+    if isinstance(docs, (Doc, Span, dict)):
         docs = [docs]
+    docs = [obj if not isinstance(obj, Span) else obj.as_doc() for obj in docs]
+    if not all(isinstance(obj, (Doc, Span, dict)) for obj in docs):
+        raise ValueError(Errors.E096)
     renderer, converter = factories[style]
     renderer = renderer(options=options)
     parsed = [converter(doc, options) for doc in docs] if not manual else docs
diff --git a/spacy/errors.py b/spacy/errors.py
--- a/spacy/errors.py
+++ b/spacy/errors.py
@@ -247,6 +247,8 @@ class Errors(object):
     E094 = ("Error reading line {line_num} in vectors file {loc}.")
     E095 = ("Can't write to frozen dictionary. This is likely an internal "
             "error. Are you writing to a default function argument?")
+    E096 = ("Invalid object passed to displaCy: Can only visualize Doc or "
+             "Span objects, or dicts if set to manual=True.")
 
 
 @add_codes


Test patch:
diff --git a/spacy/tests/test_misc.py b/spacy/tests/test_misc.py
--- a/spacy/tests/test_misc.py
+++ b/spacy/tests/test_misc.py
@@ -3,7 +3,7 @@
 
 from ..util import ensure_path
 from .. import util
-from ..displacy import parse_deps, parse_ents
+from .. import displacy
 from ..tokens import Span
 from .util import get_doc
 from .._ml import PrecomputableAffine
@@ -38,7 +38,7 @@ def test_displacy_parse_ents(en_vocab):
     """Test that named entities on a Doc are converted into displaCy's format."""
     doc = get_doc(en_vocab, words=["But", "Google", "is", "starting", "from", "behind"])
     doc.ents = [Span(doc, 1, 2, label=doc.vocab.strings[u'ORG'])]
-    ents = parse_ents(doc)
+    ents = displacy.parse_ents(doc)
     assert isinstance(ents, dict)
     assert ents['text'] == 'But Google is starting from behind '
     assert ents['ents'] == [{'start': 4, 'end': 10, 'label': 'ORG'}]
@@ -53,7 +53,7 @@ def test_displacy_parse_deps(en_vocab):
     deps = ['nsubj', 'ROOT', 'det', 'attr']
     doc = get_doc(en_vocab, words=words, heads=heads, pos=pos, tags=tags,
                   deps=deps)
-    deps = parse_deps(doc)
+    deps = displacy.parse_deps(doc)
     assert isinstance(deps, dict)
     assert deps['words'] == [{'text': 'This', 'tag': 'DET'},
                             {'text': 'is', 'tag': 'VERB'},
@@ -64,6 +64,19 @@ def test_displacy_parse_deps(en_vocab):
                             {'start': 1, 'end': 3, 'label': 'attr', 'dir': 'right'}]
 
 
+def test_displacy_spans(en_vocab):
+    """Test that displaCy can render Spans."""
+    doc = get_doc(en_vocab, words=["But", "Google", "is", "starting", "from", "behind"])
+    doc.ents = [Span(doc, 1, 2, label=doc.vocab.strings[u'ORG'])]
+    html = displacy.render(doc[1:4], style='ent')
+    assert html.startswith('<div')
+
+
+def test_displacy_raises_for_wrong_type(en_vocab):
+    with pytest.raises(ValueError):
+        html = displacy.render('hello world')
+
+
 def test_PrecomputableAffine(nO=4, nI=5, nF=3, nP=2):
     model = PrecomputableAffine(nO=nO, nI=nI, nF=nF, nP=nP)
     assert model.W.shape == (nF, nO, nP, nI)