Skip to content

Commit 12a68e8

Browse files
authored
Find type hints via imports, fix partial builds (#22)
1 parent 5620f90 commit 12a68e8

File tree

10 files changed

+194
-129
lines changed

10 files changed

+194
-129
lines changed

MANIFEST.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
graft src/sphinx_codeautolink
22
graft docs
33
prune docs/build
4-
exclude docs/src/sphinx-codeautolink-refs.json
4+
exclude docs/src/codeautolink-cache.json
55
graft tests
66
include readme_pypi.rst
77
global-exclude *.py[cod] __pycache__ *.so

docs/src/about.rst

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
About
44
=====
5-
Here's some additional information about sphinx-codeautolink!
5+
sphinx-codeautolink is built with a few major components: code analysis,
6+
import and type hint resolving, and HTML injection.
7+
Code analysis is performed with the builtin ``ast`` parsing tool to generate
8+
a set of reference chains to imported modules.
9+
That information is fed to the name resolver, which attempts to match a series
10+
of attributes and calls to the concrete type in question by following
11+
type hints and other information accessible via imports of the library.
12+
If a match is found, a link to the correct reference documentation entry
13+
is injected after the ordinary Sphinx build is finished.
614

715
Caveats
816
-------
@@ -17,13 +25,15 @@ Caveats
1725
- **Parsing and type hint resolving is incomplete**. While all Python syntax is
1826
supported, some ambiguous cases might produce unintuitive results or even
1927
incorrect results when compared to runtime behavior. We try to err on the
20-
side of caution, but here are some examples of compromises and limitations:
28+
side of caution, but here are some of the compromises and limitations:
2129

2230
- Only simple assignments of names, attributes and calls to a single name
2331
are tracked and used to resolve later values.
2432
- Only simple return type hints that consists of a single resolved type
2533
(not a string) are tracked through call and attribute access chains.
26-
- Type hints of intersphinx-linked definitions are not available.
34+
- Type hints of intersphinx-linked definitions are not necessarily available.
35+
Resolving names using type hints is only possible if the package is
36+
installed, but simple usage can be tracked via documentation entries alone.
2737
- Deleting or assigning to a global variable from an inner scope is
2838
not recognised in outer scopes. This is because the value depends on when
2939
the function is called, which is not tracked. Additionally, variable values

docs/src/reference.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Reference
55
The public API of sphinx-codeautolink consists only of the configuration
66
and directives made available to Sphinx.
77
The extension is enabled with the name ``sphinx_codeautolink``.
8-
During the build phase, a JSON file called ``sphinx-codeautolink-refs.json``
8+
During the build phase, a JSON file called ``codeautolink-cache.json``
99
is saved to the source folder to track code references during partial builds.
1010

1111
Configuration

docs/src/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Unreleased
1313
- Improve code analysis and follow simple type hints (:issue:`5`)
1414
- Improve directive arguments and behavior (:issue:`16`)
1515
- Correctly consume :code:`autolink-skip:: next` (:issue:`17`)
16+
- Find type hints via imports, fix links in partial builds (:issue:`18`)
1617

1718
0.1.1 (2021-09-22)
1819
------------------

src/sphinx_codeautolink/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
def setup(app: Sphinx):
1717
"""Set up extension, directives and events."""
1818
app.add_css_file('sphinx-codeautolink.css')
19-
app.add_config_value('codeautolink_concat_blocks', 'none', 'html', types=[str])
2019
app.add_config_value('codeautolink_autodoc_inject', True, 'html', types=[bool])
2120

2221
app.add_directive('concat-blocks', ConcatBlocks)
Lines changed: 130 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""Sphinx extension implementation."""
2-
import json
3-
4-
from dataclasses import dataclass, asdict
5-
from typing import Dict, List, Optional
2+
from dataclasses import dataclass
3+
from functools import lru_cache
4+
from importlib import import_module
5+
from typing import Dict, List, Optional, Tuple, Any, Set
66
from pathlib import Path
77

88
from sphinx.ext.intersphinx import InventoryAdapter
99

1010
from .backref import CodeRefsVisitor, CodeExample
11-
from .block import CodeBlockAnalyser, link_html, Name, NameBreak
11+
from .block import CodeBlockAnalyser, SourceTransform, link_html, Name, NameBreak
12+
from .cache import DataCache
1213

1314

1415
@dataclass
@@ -23,106 +24,28 @@ class DocumentedObject:
2324
class SphinxCodeAutoLink:
2425
"""Provide functionality and manage state between events."""
2526

26-
code_refs_file = 'sphinx-codeautolink-refs.json'
27-
2827
def __init__(self):
29-
self.code_refs: Dict[str, Dict[str, List[CodeExample]]] = {}
30-
self._flat_refs: Dict[str, List[CodeExample]] = {}
31-
self.block_visitors: List[CodeBlockAnalyser] = []
3228
self.do_nothing = False
33-
self.objects: Dict[str, DocumentedObject] = {}
29+
self.cache: Optional[DataCache] = None
30+
self.code_refs: Dict[str, List[CodeExample]] = {}
3431
self._inventory = {}
35-
36-
def make_flat_refs(self, app):
37-
"""Flattened version of :attr:`code_refs`."""
38-
if self._flat_refs:
39-
return self._flat_refs
40-
41-
self.parse_transforms(app)
42-
43-
for refs in self.code_refs.values():
44-
for doc, examples in refs.items():
45-
self._flat_refs.setdefault(doc, []).extend(examples)
46-
47-
return self._flat_refs
48-
49-
def parse_transforms(self, app):
50-
"""Construct code_refs and try to link name chains."""
51-
inventory = self.make_inventory(app)
52-
for visitor in self.block_visitors:
53-
refs = {}
54-
for transform in visitor.source_transforms:
55-
filtered = []
56-
for name in transform.names:
57-
key = self.find_in_objects(name)
58-
if not key or key not in inventory:
59-
continue
60-
name.resolved_location = key
61-
filtered.append(name)
62-
refs.setdefault(key, []).append(transform.example)
63-
transform.names = filtered
64-
self.code_refs[visitor.current_document] = refs
65-
66-
def find_in_objects(self, chain: Name) -> Optional[str]:
67-
"""Find the final type that a name refers to."""
68-
comps = []
69-
for comp in chain.import_components:
70-
if comp.name == NameBreak.call:
71-
name = '.'.join(comps)
72-
if name in self.objects and self.objects[name].return_type:
73-
comps = [self.objects[name].return_type]
74-
continue
75-
else:
76-
return
77-
comps.append(comp.name)
78-
return '.'.join(comps)
79-
80-
def make_inventory(self, app):
81-
"""Create object inventory from local info and intersphinx."""
82-
if self._inventory:
83-
return self._inventory
84-
85-
inv_parts = {
86-
k: str(
87-
Path(app.outdir)
88-
/ (app.builder.get_target_uri(v.docname) + f'#{v.node_id}')
89-
)
90-
for k, v in app.env.domains['py'].objects.items()
91-
}
92-
inventory = {'py:class': {
93-
k: (None, None, v, None) for k, v in inv_parts.items()
94-
}}
95-
inter_inv = InventoryAdapter(app.env).main_inventory
96-
transposed = transpose_inventory(inter_inv, relative_to=app.outdir)
97-
transposed.update(transpose_inventory(inventory, relative_to=app.outdir))
98-
self._inventory = transposed
99-
return self._inventory
32+
self.outdated_docs: Set[str] = set()
10033

10134
def build_inited(self, app):
10235
"""Handle initial setup."""
10336
if app.builder.name != 'html':
10437
self.do_nothing = True
10538
return
10639

40+
self.cache = DataCache(app.srcdir)
41+
self.cache.read()
42+
self.outdated_docs = {str(Path(d)) for d in app.builder.get_outdated_docs()}
43+
10744
# Append static resources path so references in setup() are valid
10845
app.config.html_static_path.append(
10946
str(Path(__file__).parent.with_name('static').absolute())
11047
)
11148

112-
# Read serialised references from last build
113-
refs_file = Path(app.srcdir) / self.code_refs_file
114-
if not refs_file.exists():
115-
return
116-
content = json.loads(refs_file.read_text('utf-8'))
117-
for file, ref in content.items():
118-
full_path = Path(app.srcdir) / (file + '.rst')
119-
if not full_path.exists():
120-
continue
121-
self.code_refs[file] = {
122-
obj: [CodeExample(**e) for e in examples]
123-
for obj, examples in ref.items()
124-
}
125-
12649
def autodoc_process_docstring(self, app, what, name, obj, options, lines):
12750
"""Handle autodoc-process-docstring event."""
12851
if self.do_nothing:
@@ -131,54 +54,86 @@ def autodoc_process_docstring(self, app, what, name, obj, options, lines):
13154
if app.config.codeautolink_autodoc_inject:
13255
lines.append(f'.. code-refs:: {name}')
13356

134-
d_obj = DocumentedObject(what, obj)
135-
if what in ('class', 'exception'):
136-
d_obj.annotation = name
137-
elif what in ('function', 'method'):
138-
ret_annotation = obj.__annotations__.get('return', None)
139-
if ret_annotation and not hasattr(ret_annotation, '__origin__'):
140-
d_obj.annotation = getattr(ret_annotation, '__name__', None)
141-
self.objects[name] = d_obj
142-
14357
def parse_blocks(self, app, doctree):
14458
"""Parse code blocks for later link substitution."""
14559
if self.do_nothing:
14660
return
14761

14862
visitor = CodeBlockAnalyser(doctree, source_dir=app.srcdir)
14963
doctree.walkabout(visitor)
150-
self.block_visitors.append(visitor)
64+
self.cache.transforms[visitor.current_document] = visitor.source_transforms
65+
66+
def once_on_doctree_resolved(self, app):
67+
"""Clean source transforms and create code references."""
68+
if self.code_refs:
69+
return
70+
71+
for transforms in self.cache.transforms.values():
72+
self.filter_and_resolve(transforms, app)
73+
74+
for transforms in self.cache.transforms.values():
75+
for transform in transforms:
76+
for name in transform.names:
77+
self.code_refs.setdefault(name.resolved_location, []).append(
78+
transform.example
79+
)
15180

15281
def generate_backref_tables(self, app, doctree, docname):
15382
"""Generate backreference tables."""
83+
self.once_on_doctree_resolved(app)
15484
visitor = CodeRefsVisitor(
15585
doctree,
156-
code_refs=self.make_flat_refs(app),
86+
code_refs=self.code_refs,
15787
remove_directives=self.do_nothing,
15888
)
15989
doctree.walk(visitor)
16090

91+
def filter_and_resolve(self, transforms: List[SourceTransform], app):
92+
"""Try to link name chains to objects."""
93+
inventory = self.make_inventory(app)
94+
for transform in transforms:
95+
filtered = []
96+
for name in transform.names:
97+
key = resolve_location(name)
98+
if not key or key not in inventory:
99+
continue
100+
name.resolved_location = key
101+
filtered.append(name)
102+
transform.names = filtered
103+
104+
def make_inventory(self, app):
105+
"""Create object inventory from local info and intersphinx."""
106+
if self._inventory:
107+
return self._inventory
108+
109+
inv_parts = {
110+
k: str(
111+
Path(app.outdir)
112+
/ (app.builder.get_target_uri(v.docname) + f'#{v.node_id}')
113+
)
114+
for k, v in app.env.domains['py'].objects.items()
115+
}
116+
inventory = {'py:class': {
117+
k: (None, None, v, None) for k, v in inv_parts.items()
118+
}}
119+
inter_inv = InventoryAdapter(app.env).main_inventory
120+
transposed = transpose_inventory(inter_inv, relative_to=app.outdir)
121+
transposed.update(transpose_inventory(inventory, relative_to=app.outdir))
122+
self._inventory = transposed
123+
return self._inventory
124+
161125
def apply_links(self, app, exception):
162126
"""Apply links to HTML output and write refs file."""
163127
if self.do_nothing or exception is not None:
164128
return
165129

166-
for visitor in self.block_visitors:
167-
if not visitor.source_transforms:
130+
for doc, transforms in self.cache.transforms.items():
131+
if not transforms or str(Path(doc)) not in self.outdated_docs:
168132
continue
169-
file = Path(app.outdir) / (visitor.current_document + '.html')
170-
link_html(
171-
file, app.outdir, visitor.source_transforms, self.make_inventory(app)
172-
)
133+
file = (Path(app.outdir) / doc).with_suffix('.html')
134+
link_html(file, app.outdir, transforms, self.make_inventory(app))
173135

174-
refs_file = Path(app.srcdir) / self.code_refs_file
175-
refs = {}
176-
for file, ref in self.code_refs.items():
177-
refs[file] = {
178-
obj: [asdict(e) for e in examples]
179-
for obj, examples in ref.items()
180-
}
181-
refs_file.write_text(json.dumps(refs, indent=4), 'utf-8')
136+
self.cache.write()
182137

183138

184139
def transpose_inventory(inv: dict, relative_to: str):
@@ -204,3 +159,63 @@ def transpose_inventory(inv: dict, relative_to: str):
204159
location = str(Path(location).relative_to(relative_to))
205160
transposed[item] = location
206161
return transposed
162+
163+
164+
def resolve_location(chain: Name) -> Optional[str]:
165+
"""Find the final type that a name refers to."""
166+
comps = []
167+
for comp in chain.import_components:
168+
if comp == NameBreak.call:
169+
new = locate_type(tuple(comps))
170+
if new is None:
171+
return
172+
comps = new.split('.')
173+
else:
174+
comps.append(comp)
175+
return '.'.join(comps)
176+
177+
178+
@lru_cache(maxsize=None)
179+
def locate_type(components: Tuple[str]) -> Optional[str]:
180+
"""Find type hint and resolve to new location."""
181+
value, index = closest_module(components)
182+
if index is None or index == len(components) - 1:
183+
return
184+
remaining = components[index:]
185+
real_location = '.'.join(components[:index])
186+
for component in remaining:
187+
value = getattr(value, component, None)
188+
real_location += '.' + component
189+
if value is None:
190+
return
191+
192+
if isinstance(value, type):
193+
# We don't differentiate between classmethods and ordinary methods,
194+
# as we can't guarantee correct runtime behavior anyway.
195+
real_location = fully_qualified_name(value)
196+
197+
# A possible function / method call needs to be last in the chain.
198+
# Otherwise we might follow return types on function attribute access.
199+
if callable(value):
200+
ret_annotation = value.__annotations__.get('return', None)
201+
if not ret_annotation or hasattr(ret_annotation, '__origin__'):
202+
return
203+
real_location = fully_qualified_name(ret_annotation)
204+
205+
return real_location
206+
207+
208+
def fully_qualified_name(type_: type) -> str:
209+
"""Construct the fully qualified name of a type."""
210+
return getattr(type_, '__module__', '') + '.' + getattr(type_, '__qualname__', '')
211+
212+
213+
@lru_cache(maxsize=None)
214+
def closest_module(components: Tuple[str]) -> Tuple[Any, Optional[int]]:
215+
"""Find closest importable module."""
216+
mod = None
217+
for i in range(len(components)):
218+
try:
219+
mod = import_module('.'.join(components[:i + 1]))
220+
except ImportError:
221+
return mod, i

0 commit comments

Comments
 (0)