Skip to content

Commit 5bb927f

Browse files
authored
Drop 3.6, simplify code, and add prettify terse Callable typehint (#23)
1 parent fd953ac commit 5bb927f

File tree

7 files changed

+79
-57
lines changed

7 files changed

+79
-57
lines changed

.readthedocs.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
version: 2
12
build:
23
image: latest
3-
4+
sphinx:
5+
configuration: docs/conf.py
46
python:
5-
version: 3.6
6-
7-
requirements_file: docs/requirements.txt
7+
version: 3.8
8+
install:
9+
- method: pip
10+
path: .
11+
extra_requirements:
12+
- doc

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ branches:
55
only:
66
- master # use PR builder only for other branches
77
python:
8-
- '3.6'
98
# Travis uses old versions if you specify 3.x,
109
# and elegant_typehints trigger a Python bug in those.
1110
# There seems to be no way to specify the newest patch version,
1211
# so I’ll just use the newest available at the time of writing.
1312
- '3.7.9'
1413
- '3.8.5'
14+
- '3.9'
1515

1616
install:
1717
- pip install flit codecov

docs/requirements.txt

Lines changed: 0 additions & 5 deletions
This file was deleted.

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[build-system]
2-
requires = ['flit>=1.3']
3-
build-backend = 'flit.buildapi'
2+
requires = ['flit_core >=2,<4']
3+
build-backend = 'flit_core.buildapi'
44

55
[tool.flit.metadata]
66
module = 'scanpydoc'
@@ -17,7 +17,7 @@ classifiers = [
1717
'Topic :: Software Development :: Libraries :: Python Modules',
1818
'Framework :: Sphinx :: Extension',
1919
]
20-
requires-python = '>=3.6'
20+
requires-python = '>=3.7'
2121
requires = [
2222
'sphinx>=3.0',
2323
'get_version',
@@ -39,12 +39,12 @@ doc = [
3939
scanpydoc = 'scanpydoc.theme'
4040

4141
[tool.black]
42-
target-version = ['py36']
42+
target-version = ['py37']
4343

4444
[tool.tox]
4545
legacy_tox_ini = """
4646
[tox]
47-
envlist = py36,py37,py38
47+
envlist = py37,py38,py39
4848
skipsdist = True
4949
5050
[testenv]

scanpydoc/elegant_typehints/formatting.py

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import inspect
2-
from collections import abc
2+
import collections.abc as cabc
3+
from functools import partial
34
from typing import Any, Union # Meta
4-
from typing import Type, Mapping, Sequence, Iterable # ABC
5+
from typing import Type, Sequence, Iterable # ABC
56
from typing import Dict, List, Tuple # Concrete
67

7-
from scanpydoc import elegant_typehints
8-
9-
try:
10-
from typing import Literal
8+
try: # 3.8 additions
9+
from typing import Literal, get_args, get_origin
1110
except ImportError:
12-
try:
13-
from typing_extensions import Literal
14-
except ImportError:
15-
Literal = object()
11+
from typing_extensions import Literal, get_args, get_origin
1612

1713
import sphinx_autodoc_typehints
1814
from sphinx_autodoc_typehints import format_annotation as _format_orig
@@ -22,12 +18,14 @@
2218
from docutils.parsers.rst.states import Inliner, Struct
2319
from docutils.utils import SystemMessage, unescape
2420

21+
from scanpydoc import elegant_typehints
22+
2523

2624
def _format_full(annotation: Type[Any], fully_qualified: bool = False):
2725
if inspect.isclass(annotation) and annotation.__module__ == "builtins":
2826
return _format_orig(annotation, fully_qualified)
2927

30-
origin = getattr(annotation, "__origin__", None)
28+
origin = get_origin(annotation)
3129
tilde = "" if fully_qualified else "~"
3230

3331
annotation_cls = annotation if inspect.isclass(annotation) else type(annotation)
@@ -46,31 +44,35 @@ def _format_full(annotation: Type[Any], fully_qualified: bool = False):
4644

4745

4846
def _format_terse(annotation: Type[Any], fully_qualified: bool = False) -> str:
49-
origin = getattr(annotation, "__origin__", None)
47+
origin = get_origin(annotation)
48+
args = get_args(annotation)
49+
tilde = "" if fully_qualified else "~"
50+
fmt = partial(_format_terse, fully_qualified=fully_qualified)
5051

51-
union_params = getattr(annotation, "__union_params__", None)
5252
# display `Union[A, B]` as `A, B`
53-
if origin is Union or union_params:
54-
params = union_params or getattr(annotation, "__args__", None)
53+
if origin is Union:
5554
# Never use the `Optional` keyword in the displayed docs.
56-
# Use the more verbose `, None` instead,
57-
# as is the convention in the other large numerical packages
58-
# if len(params or []) == 2 and getattr(params[1], '__qualname__', None) == 'NoneType':
59-
# return fa_orig(annotation) # Optional[...]
60-
return ", ".join(_format_terse(p, fully_qualified) for p in params)
55+
# Use the more verbose `, None` instead, like other numerical packages.
56+
return ", ".join(map(fmt, args))
6157

6258
# do not show the arguments of Mapping
63-
if origin in (abc.Mapping, Mapping):
64-
return f":py:class:`{'' if fully_qualified else '~'}typing.Mapping`"
59+
if origin is cabc.Mapping:
60+
return f":py:class:`{tilde}typing.Mapping`"
6561

6662
# display dict as {k: v}
67-
if origin in (dict, Dict):
68-
k, v = annotation.__args__
69-
return f"{{{_format_terse(k, fully_qualified)}: {_format_terse(v, fully_qualified)}}}"
70-
71-
if origin is Literal or hasattr(annotation, "__values__"):
72-
values = getattr(annotation, "__args__", ()) or annotation.__values__
73-
return f"{{{', '.join(map(repr, values))}}}"
63+
if origin is dict:
64+
k, v = get_args(annotation)
65+
return f"{{{fmt(k)}: {fmt(v)}}}"
66+
67+
# display Callable[[a1, a2], r] as (a1, a2) -> r
68+
if origin is cabc.Callable and len(args) == 2:
69+
params, ret = args
70+
params = ["…"] if params is Ellipsis else map(fmt, params)
71+
return f"({', '.join(params)}) → {fmt(ret)}"
72+
73+
# display Literal as {'a', 'b', ...}
74+
if origin is Literal:
75+
return f"{{{', '.join(map(repr, args))}}}"
7476

7577
return _format_full(annotation, fully_qualified)
7678

scanpydoc/elegant_typehints/return_tuple.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
from logging import getLogger
44
from typing import get_type_hints, Any, Union, Optional, Type, Tuple, List
55

6+
try: # 3.8 additions
7+
from typing import get_args, get_origin
8+
except ImportError:
9+
from typing_extensions import get_args, get_origin
10+
611
from sphinx.application import Sphinx
712
from sphinx.ext.autodoc import Options
813

@@ -16,17 +21,17 @@
1621
def get_tuple_annot(annotation: Optional[Type]) -> Optional[Tuple[Type, ...]]:
1722
if annotation is None:
1823
return None
19-
origin = getattr(annotation, "__origin__", None)
24+
origin = get_origin(annotation)
2025
if not origin:
2126
return None
2227
if origin is Union:
23-
for annotation in annotation.__args__:
24-
origin = getattr(annotation, "__origin__", None)
28+
for annotation in get_args(annotation):
29+
origin = get_origin(annotation)
2530
if origin in (tuple, Tuple):
2631
break
2732
else:
2833
return None
29-
return annotation.__args__
34+
return get_args(annotation)
3035

3136

3237
def process_docstring(

tests/test_elegant_typehints.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
import typing as t
55
from pathlib import Path
66

7-
try:
8-
from typing import Literal
7+
try: # 3.8 additions
8+
from typing import Literal, get_args, get_origin
99
except ImportError:
10-
from typing_extensions import Literal
10+
from typing_extensions import Literal, get_args, get_origin
1111

1212
import pytest
1313
import sphinx_autodoc_typehints as sat
@@ -148,6 +148,20 @@ def test_dict(app):
148148
)
149149

150150

151+
@pytest.mark.parametrize(
152+
"annotation,expected",
153+
[
154+
(t.Callable[..., t.Any], "(…) → :py:data:`~typing.Any`"),
155+
(
156+
t.Callable[[str, int], None],
157+
"(:py:class:`str`, :py:class:`int`) → :py:obj:`None`",
158+
),
159+
],
160+
)
161+
def test_callable_terse(app, annotation, expected):
162+
assert _format_terse(annotation) == expected
163+
164+
151165
def test_literal(app):
152166
assert _format_terse(Literal["str", 1, None]) == "{'str', 1, None}"
153167
assert _format_full(Literal["str", 1, None]) == (
@@ -208,22 +222,23 @@ def test_classes_get_added(app, parse):
208222
t.Tuple[int, str],
209223
t.Tuple[float, ...],
210224
t.Union[int, str],
225+
t.Union[int, str, None],
211226
],
212227
ids=lambda p: str(p).replace("typing.", ""),
213228
)
214229
def test_typing_classes(app, annotation, formatter):
215230
name = (
216231
getattr(annotation, "_name", None)
217232
or getattr(annotation, "__name__", None)
218-
or getattr(getattr(annotation, "__origin__", None), "_name", None)
233+
or getattr(get_origin(annotation), "_name", None)
219234
# 3.6 _Any and _Union
220235
or annotation.__class__.__name__[1:]
221236
)
222-
if name == "Union":
223-
if formatter is _format_terse:
224-
pytest.skip("Tested elsewhere")
225-
elif len(annotation.__args__) == 2 and type(None) in annotation.__args__:
226-
name = "Optional"
237+
if formatter is _format_terse and name in {"Union", "Callable"}:
238+
pytest.skip("Tested elsewhere")
239+
args = get_args(annotation)
240+
if name == "Union" and len(args) == 2 and type(None) in args:
241+
name = "Optional"
227242
assert formatter(annotation, True).startswith(f":py:data:`typing.{name}")
228243

229244

0 commit comments

Comments
 (0)