Coverage for src/auto_intersphinx/__init__.py: 90%

96 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-04-22 14:48 +0200

1# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute <contact@idiap.ch> 

2# 

3# SPDX-License-Identifier: BSD-3-Clause 

4"""Sphinx extension to automatically link package documentation from their 

5names. 

6 

7This package contains a Sphinx plugin that can fill intersphinx mappings 

8based on package names. It simplifies the use of that plugin by 

9removing the need of knowing URLs for various API catologs you may want 

10to cross-reference. 

11""" 

12 

13from __future__ import annotations # not required for Python >= 3.10 

14 

15import importlib.metadata 

16import inspect 

17import pathlib 

18import textwrap 

19import typing 

20 

21from sphinx.application import Sphinx 

22from sphinx.config import Config 

23from sphinx.util import logging 

24 

25from .catalog import BUILTIN_CATALOG, Catalog, LookupCatalog 

26 

27logger = logging.getLogger(__name__) 

28 

29 

30def oneliner(s: str) -> str: 

31 """Transforms a multiline docstring into a single line of text. 

32 

33 This method converts the multi-line string into a single line, while also 

34 dedenting the text. 

35 

36 

37 Arguments: 

38 

39 s: The input multiline string 

40 

41 

42 Returns: 

43 

44 A single line with all text. 

45 """ 

46 return inspect.cleandoc(s).replace("\n", " ") 

47 

48 

49def rewrap(s: str) -> str: 

50 """Re-wrap a multiline docstring into a 80-character format. 

51 

52 This method first converts the multi-line string into a single line. It 

53 then wraps the single line into 80-characters width. 

54 

55 

56 Arguments: 

57 

58 s: The input multiline string 

59 

60 

61 Returns: 

62 

63 An 80-column wrapped multiline string 

64 """ 

65 return "\n".join(textwrap.wrap(oneliner(s), width=80)) 

66 

67 

68def _add_index( 

69 mapping: dict[str, tuple[str, str | None]], 

70 name: str, 

71 addr: str, 

72 objects_inv: str | None = None, 

73) -> None: 

74 """Helper to add a new doc index to the intersphinx mapping catalog. 

75 

76 This function will also verify if repeated entries are being inserted, and 

77 if will issue a warning or error in case it must ignore overrides. 

78 

79 

80 Arguments: 

81 

82 mapping: A pointer to the currently used ``intersphinx_mapping`` 

83 variable 

84 name: Name of the new package to add 

85 addr: The URL that contains the ``object.inv`` file, to load for 

86 mapping objects from that package 

87 objects_inv: The name of the file to use with the catalog to load on 

88 the remote address (if different than ``objects.inv``.) 

89 """ 

90 

91 if name not in mapping: 

92 mapping[name] = (addr, objects_inv) 

93 

94 elif mapping[name][0] == addr and mapping[name][1] == objects_inv: 

95 logger.info(f"Ignoring repeated setting of `{name}' intersphinx_mapping") 

96 

97 else: 

98 curr = mapping[name][0] 

99 curr += "/" if not curr.endswith("/") else "" 

100 curr += mapping[name][1] if mapping[name][1] else "objects.inv" 

101 

102 newval = addr 

103 newval += "/" if not newval.endswith("/") else "" 

104 newval += objects_inv if objects_inv else "objects.inv" 

105 

106 logger.error( 

107 rewrap( 

108 f""" 

109 Ignoring reset of `{name}' intersphinx_mapping, because it 

110 currently already points to `{curr}', and that is different 

111 from the new value `{newval}' 

112 """ 

113 ) 

114 ) 

115 

116 

117def populate_intersphinx_mapping(app: Sphinx, config: Config) -> None: 

118 """Main extension method. 

119 

120 This function is called by Sphinx once it is :py:func:`setup`. It executes 

121 the lookup procedure for all packages listed on the configuration parameter 

122 ``auto_intersphinx_packages``. If a catalog name is provided at 

123 ``auto_intersphinx_catalog``, and package information is not found on the 

124 catalogs, but discovered elsewhere (environment, readthedocs.org, or 

125 pypi.org), then it is saved on that file, so that the next lookup is 

126 faster. 

127 

128 It follows the following search protocol for each package (first match 

129 found stops the search procedure): 

130 

131 1. User catalog (if available) 

132 2. Built-in catalog distributed with the package 

133 3. The current Python environment 

134 4. https://readthedocs.org 

135 5. https://pypi.org 

136 

137 

138 Arguments: 

139 

140 app: Sphinx application 

141 

142 config: Sphinx configuration 

143 """ 

144 m = config.intersphinx_mapping 

145 

146 builtin_catalog = Catalog() 

147 builtin_catalog.loads(BUILTIN_CATALOG.read_text()) 

148 builtin_lookup = LookupCatalog(builtin_catalog) 

149 

150 user_catalog = Catalog() 

151 

152 if config.auto_intersphinx_catalog: 

153 user_catalog_file = pathlib.Path(config.auto_intersphinx_catalog) 

154 if not user_catalog_file.is_absolute(): 

155 user_catalog_file = ( 

156 pathlib.Path(app.confdir) / config.auto_intersphinx_catalog 

157 ) 

158 if user_catalog_file.exists(): 

159 user_catalog.loads(user_catalog_file.read_text()) 

160 user_lookup = LookupCatalog(user_catalog) 

161 

162 for k in config.auto_intersphinx_packages: 

163 p, v = k if isinstance(k, (tuple, list)) else (k, "stable") 

164 

165 addr = user_lookup.get(p, v) 

166 if addr is not None: 

167 _add_index(m, p, addr, None) 

168 continue # got an URL, continue to next package 

169 elif p in user_catalog: 

170 # The user receiving the message has access to their own catalog. 

171 # Warn because it may trigger a voluntary update action. 

172 logger.warning( 

173 rewrap( 

174 f""" 

175 Package {p} is available in user catalog, however version 

176 {v} is not. You may want to fix or update the catalog? 

177 """ 

178 ) 

179 ) 

180 

181 addr = builtin_lookup.get(p, v) 

182 if addr is not None: 

183 _add_index(m, p, addr, None) 

184 continue # got an URL, continue to next package 

185 elif p in builtin_catalog: 

186 # The user receiving the message may not have access to the 

187 # built-in catalog. Downgrade message importance to INFO 

188 logger.info( 

189 rewrap( 

190 f""" 

191 Package {p} is available in builtin catalog, however 

192 version {v} is not. You may want to fix or update that 

193 catalog? 

194 """ 

195 ) 

196 ) 

197 

198 # try to see if the package is installed using the user catalog 

199 user_catalog.update_versions_from_environment(p, None) 

200 user_lookup.reset() 

201 addr = user_lookup.get(p, v) 

202 if addr is not None: 

203 _add_index(m, p, addr, None) 

204 continue # got an URL, continue to next package 

205 else: 

206 logger.info( 

207 rewrap( 

208 f""" 

209 Package {p} is not available at your currently installed 

210 environment. If the name of the installed package differs 

211 from that you specified, you may tweak your catalog using 

212 ['{p}']['sources']['environment'] = <NAME> so that the 

213 package can be properly found. 

214 """ 

215 ) 

216 ) 

217 

218 # try to see if the package is available on readthedocs.org 

219 user_catalog.update_versions_from_rtd(p, None) 

220 user_lookup.reset() 

221 addr = user_lookup.get(p, v) 

222 if addr is not None: 

223 _add_index(m, p, addr, None) 

224 continue # got an URL, continue to next package 

225 else: 

226 logger.info( 

227 rewrap( 

228 f""" 

229 Package {p} is not available at readthedocs.org. If the 

230 name of the installed package differs from that you 

231 specify, you may patch your catalog using 

232 ['{p}']['sources']['environment'] = <NAME> so that the 

233 package can be properly found. 

234 """ 

235 ) 

236 ) 

237 

238 # try to see if the package is available on readthedocs.org 

239 user_catalog.update_versions_from_pypi(p, None, max_entries=0) 

240 user_lookup.reset() 

241 addr = user_lookup.get(p, v) 

242 if addr is not None: 

243 _add_index(m, p, addr, None) 

244 continue # got an URL, continue to next package 

245 else: 

246 logger.info( 

247 rewrap( 

248 f""" 

249 Package {p} is not available at your currently installed 

250 environment. If the name of the installed package differs 

251 from that you specify, you may patch your catalog using 

252 ['{p}']['sources']['environment'] = <NAME> so that the 

253 package can be properly found. 

254 """ 

255 ) 

256 ) 

257 

258 # if you get to this point, then the package name was not 

259 # resolved - emit an error and continue 

260 if v is not None: 

261 name = f"{p}@{v}" 

262 else: 

263 name = p 

264 

265 logger.error( 

266 rewrap( 

267 f""" 

268 Cannot find suitable catalog entry for `{name}'. I searched 

269 both internally and online without access. To remedy this, 

270 provide the links on your own catalog, be less selective with 

271 the version to bind documentation to, or simply remove this 

272 entry from the auto-intersphinx package list. May be this 

273 package has no Sphinx documentation at all? 

274 """ 

275 ) 

276 ) 

277 

278 # by the end of the processing, save the user catalog file if a path was 

279 # given, so that the user does not have to do this again on the next 

280 # rebuild, making it work like a cache. 

281 if config.auto_intersphinx_catalog and user_catalog: 

282 user_catalog_file = pathlib.Path(config.auto_intersphinx_catalog) 

283 if not user_catalog_file.is_absolute(): 

284 user_catalog_file = ( 

285 pathlib.Path(app.confdir) / config.auto_intersphinx_catalog 

286 ) 

287 current_contents: str = "" 

288 if user_catalog_file.exists(): 

289 current_contents = user_catalog_file.read_text() 

290 if current_contents != user_catalog.dumps(): 

291 logger.info( 

292 f"Recording {len(user_catalog)} entries to {str(user_catalog_file)}..." 

293 ) 

294 user_catalog.dump(user_catalog_file) 

295 

296 

297def setup(app: Sphinx) -> dict[str, typing.Any]: 

298 """Sphinx extension configuration entry-point. 

299 

300 This function defines the main function to be executed, other 

301 extensions to be loaded, the loading relative order of this 

302 extension, and configuration options with their own defaults. 

303 """ 

304 # we need intersphinx 

305 app.setup_extension("sphinx.ext.intersphinx") 

306 

307 # List of packages to link, in the format: str | tuple[str, str|None] 

308 # that indicate either the package name, or a tuple with (package, 

309 # version), for pointing to a specific version number user guide. 

310 app.add_config_value("auto_intersphinx_packages", [], "html") 

311 

312 # Where the user catalog file will be placed, if any. If a value is set, 

313 # then it is updated if we discover resources remotely. It works like a 

314 # local cache, you can edit to complement the internal catalog. A relative 

315 # path is taken w.r.t. the sphinx documentation configuration. 

316 app.add_config_value("auto_intersphinx_catalog", None, "html") 

317 

318 app.connect("config-inited", populate_intersphinx_mapping, priority=700) 

319 

320 return { 

321 "version": importlib.metadata.version(__package__), 

322 "parallel_read_safe": True, 

323 }