Skip to content

Commit 8bfc851

Browse files
authored
Add test for forward refs (#20)
1 parent c4aad6e commit 8bfc851

File tree

4 files changed

+115
-18
lines changed

4 files changed

+115
-18
lines changed

.travis.yml

+11-7
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ branches:
55
only:
66
- master # use PR builder only for other branches
77
python:
8-
- '3.6'
9-
- '3.7'
10-
- '3.8'
8+
- '3.6'
9+
# Travis uses old versions if you specify 3.x,
10+
# and elegant_typehints trigger a Python bug in those.
11+
# There seems to be no way to specify the newest patch version,
12+
# so I’ll just use the newest available at the time of writing.
13+
- '3.7.9'
14+
- '3.8.5'
1115

1216
install:
13-
- pip install flit codecov
14-
- flit install --deps develop
17+
- pip install flit codecov
18+
- flit install --deps develop
1519
script:
16-
- PYTHONPATH=. pytest --cov=scanpydoc --black
17-
- rst2html.py --halt=2 README.rst >/dev/null
20+
- PYTHONPATH=. pytest --cov=scanpydoc --black
21+
- rst2html.py --halt=2 README.rst >/dev/null
1822
after_success: codecov

scanpydoc/elegant_typehints/return_tuple.py

+28-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import inspect
22
import re
3+
from logging import getLogger
34
from typing import get_type_hints, Any, Union, Optional, Type, Tuple, List
45

56
from sphinx.application import Sphinx
@@ -8,6 +9,7 @@
89
from .formatting import format_both
910

1011

12+
logger = getLogger(__name__)
1113
re_ret = re.compile("^:returns?: ")
1214

1315

@@ -43,7 +45,15 @@ def process_docstring(
4345
if what in ("class", "exception"):
4446
obj = obj.__init__
4547
obj = inspect.unwrap(obj)
46-
ret_types = get_tuple_annot(get_type_hints(obj).get("return"))
48+
try:
49+
hints = get_type_hints(obj)
50+
except (AttributeError, TypeError):
51+
# Introspecting a slot wrapper will raise TypeError
52+
return
53+
except NameError as e:
54+
check_bpo_34776(obj, e)
55+
return
56+
ret_types = get_tuple_annot(hints.get("return"))
4757
if ret_types is None:
4858
return
4959

@@ -87,3 +97,20 @@ def process_docstring(
8797
# return_annotation: str,
8898
# ) -> Optional[Tuple[Optional[str], Optional[str]]]:
8999
# return signature, return_annotation
100+
101+
102+
def check_bpo_34776(obj: Any, e: NameError):
103+
import sys
104+
105+
ancient = sys.version_info < (3, 7)
106+
old_3_7 = (3, 7) < sys.version_info < (3, 7, 6)
107+
old_3_8 = (3, 8) < sys.version_info < (3, 8, 1)
108+
if ancient or old_3_7 or old_3_8:
109+
v = ".".join(map(str, sys.version_info[:3]))
110+
logger.warning(
111+
f"Error documenting {obj!r}: To avoid this, "
112+
f"your Python version {v} must be at least 3.7.6 or 3.8.1. "
113+
"For more information see https://bugs.python.org/issue34776"
114+
)
115+
else:
116+
raise e # No idea what happened

tests/conftest.py

+21
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import importlib.util
2+
import sys
13
import typing as t
24
from tempfile import NamedTemporaryFile
5+
from textwrap import dedent
36

47
import pytest
58
from docutils.nodes import document
@@ -49,3 +52,21 @@ def _render(app: Sphinx, doc: document) -> str:
4952
return writer.output
5053

5154
return _render
55+
56+
57+
@pytest.fixture
58+
def make_module():
59+
added_modules = []
60+
61+
def make_module(name, code):
62+
assert name not in sys.modules
63+
spec = importlib.util.spec_from_loader(name, loader=None)
64+
mod = sys.modules[name] = importlib.util.module_from_spec(spec)
65+
exec(dedent(code), mod.__dict__)
66+
added_modules.append(name)
67+
return mod
68+
69+
yield make_module
70+
71+
for name in added_modules:
72+
del sys.modules[name]

tests/test_elegant_typehints.py

+55-10
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@
2222
from scanpydoc.elegant_typehints.return_tuple import process_docstring
2323

2424

25-
_testmod = sys.modules["_testmod"] = ModuleType("_testmod")
26-
_testmod.Class = type("Class", (), dict(__module__="_testmod"))
27-
_testmod.SubCl = type("SubCl", (_testmod.Class,), dict(__module__="_testmod"))
28-
_testmod.Excep = type("Excep", (RuntimeError,), dict(__module__="_testmod"))
29-
_testmod.Excep2 = type("Excep2", (_testmod.Excep,), dict(__module__="_testmod"))
25+
@pytest.fixture
26+
def _testmod(make_module):
27+
return make_module(
28+
"_testmod",
29+
"""\
30+
class Class: pass
31+
class SubCl(Class): pass
32+
class Excep(RuntimeError): pass
33+
class Excep2(Excep): pass
34+
""",
35+
)
3036

3137

3238
@pytest.fixture
@@ -150,17 +156,17 @@ def test_literal(app):
150156
)
151157

152158

153-
def test_qualname_overrides_class(app):
159+
def test_qualname_overrides_class(app, _testmod):
154160
assert _testmod.Class.__module__ == "_testmod"
155161
assert _format_terse(_testmod.Class) == ":py:class:`~test.Class`"
156162

157163

158-
def test_qualname_overrides_exception(app):
164+
def test_qualname_overrides_exception(app, _testmod):
159165
assert _testmod.Excep.__module__ == "_testmod"
160166
assert _format_terse(_testmod.Excep) == ":py:exc:`~test.Excep`"
161167

162168

163-
def test_qualname_overrides_recursive(app):
169+
def test_qualname_overrides_recursive(app, _testmod):
164170
assert _format_terse(t.Union[_testmod.Class, str]) == (
165171
r":py:class:`~test.Class`, :py:class:`str`"
166172
)
@@ -172,7 +178,7 @@ def test_qualname_overrides_recursive(app):
172178
)
173179

174180

175-
def test_fully_qualified(app):
181+
def test_fully_qualified(app, _testmod):
176182
assert _format_terse(t.Union[_testmod.Class, str], True) == (
177183
r":py:class:`test.Class`, :py:class:`str`"
178184
)
@@ -237,7 +243,7 @@ def test_typing_class_nested(app):
237243
("autoexception", "Excep", "Excep2"),
238244
],
239245
)
240-
def test_autodoc(app, direc, base, sub):
246+
def test_autodoc(app, _testmod, direc, base, sub):
241247
Path(app.srcdir, "index.rst").write_text(
242248
f"""\
243249
.. {direc}:: _testmod.{sub}
@@ -252,6 +258,45 @@ def test_autodoc(app, direc, base, sub):
252258
assert re.search(rf"Bases: <code[^>]*><span[^>]*>test\.{base}", out), out
253259

254260

261+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="bpo-34776 only fixed on 3.7+")
262+
def test_fwd_ref(app, make_module):
263+
make_module(
264+
"fwd_mod",
265+
"""\
266+
from dataclasses import dataclass
267+
268+
@dataclass
269+
class A:
270+
b: 'B'
271+
272+
@dataclass
273+
class B:
274+
a: A
275+
""",
276+
)
277+
Path(app.srcdir, "index.rst").write_text(
278+
f"""\
279+
.. autosummary::
280+
281+
fwd_mod.A
282+
fwd_mod.B
283+
"""
284+
)
285+
app.setup_extension("sphinx.ext.autosummary")
286+
287+
app.build()
288+
289+
out = Path(app.outdir, "index.html").read_text()
290+
warnings = [
291+
w
292+
for w in app._warning.getvalue().splitlines()
293+
if "Cannot treat a function defined as a local function" not in w
294+
]
295+
assert not warnings, warnings
296+
# TODO: actually reproduce #14
297+
assert "fwd_mod.A" in out, out
298+
299+
255300
@pytest.mark.parametrize(
256301
"docstring",
257302
[

0 commit comments

Comments
 (0)