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
66from pathlib import Path
77
88from sphinx .ext .intersphinx import InventoryAdapter
99
1010from .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:
2324class 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
184139def 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