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
« 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.
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"""
13from __future__ import annotations # not required for Python >= 3.10
15import importlib.metadata
16import inspect
17import pathlib
18import textwrap
19import typing
21from sphinx.application import Sphinx
22from sphinx.config import Config
23from sphinx.util import logging
25from .catalog import BUILTIN_CATALOG, Catalog, LookupCatalog
27logger = logging.getLogger(__name__)
30def oneliner(s: str) -> str:
31 """Transforms a multiline docstring into a single line of text.
33 This method converts the multi-line string into a single line, while also
34 dedenting the text.
37 Arguments:
39 s: The input multiline string
42 Returns:
44 A single line with all text.
45 """
46 return inspect.cleandoc(s).replace("\n", " ")
49def rewrap(s: str) -> str:
50 """Re-wrap a multiline docstring into a 80-character format.
52 This method first converts the multi-line string into a single line. It
53 then wraps the single line into 80-characters width.
56 Arguments:
58 s: The input multiline string
61 Returns:
63 An 80-column wrapped multiline string
64 """
65 return "\n".join(textwrap.wrap(oneliner(s), width=80))
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.
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.
80 Arguments:
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 """
91 if name not in mapping:
92 mapping[name] = (addr, objects_inv)
94 elif mapping[name][0] == addr and mapping[name][1] == objects_inv:
95 logger.info(f"Ignoring repeated setting of `{name}' intersphinx_mapping")
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"
102 newval = addr
103 newval += "/" if not newval.endswith("/") else ""
104 newval += objects_inv if objects_inv else "objects.inv"
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 )
117def populate_intersphinx_mapping(app: Sphinx, config: Config) -> None:
118 """Main extension method.
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.
128 It follows the following search protocol for each package (first match
129 found stops the search procedure):
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
138 Arguments:
140 app: Sphinx application
142 config: Sphinx configuration
143 """
144 m = config.intersphinx_mapping
146 builtin_catalog = Catalog()
147 builtin_catalog.loads(BUILTIN_CATALOG.read_text())
148 builtin_lookup = LookupCatalog(builtin_catalog)
150 user_catalog = Catalog()
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)
162 for k in config.auto_intersphinx_packages:
163 p, v = k if isinstance(k, (tuple, list)) else (k, "stable")
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 )
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 )
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 )
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 )
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 )
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
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 )
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)
297def setup(app: Sphinx) -> dict[str, typing.Any]:
298 """Sphinx extension configuration entry-point.
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")
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")
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")
318 app.connect("config-inited", populate_intersphinx_mapping, priority=700)
320 return {
321 "version": importlib.metadata.version(__package__),
322 "parallel_read_safe": True,
323 }