|
6 | 6 | import typing |
7 | 7 |
|
8 | 8 | from sphinx.application import Sphinx as _Sphinx |
| 9 | +from sphinx.errors import ThemeError |
9 | 10 | from sphinx.util import console |
10 | 11 | from sphinx.util import logging as sphinx_logging_module |
11 | 12 | from sphinx.util.logging import NAMESPACE as SPHINX_LOG_NAMESPACE |
|
19 | 20 | from typing import Any |
20 | 21 | from typing import Literal |
21 | 22 |
|
| 23 | + from docutils.nodes import Element |
| 24 | + from docutils.parsers.rst import Directive |
| 25 | + |
22 | 26 | RoleDefinition = tuple[str, Any, list[types.Role.TargetProvider]] |
23 | 27 |
|
24 | 28 | sphinx_logger = logging.getLogger(SPHINX_LOG_NAMESPACE) |
@@ -138,12 +142,22 @@ def __init__(self, *args, **kwargs): |
138 | 142 | # Override sphinx's usual logging setup function |
139 | 143 | sphinx_logging_module.setup = setup_logging # type: ignore |
140 | 144 |
|
141 | | - super().__init__(*args, **kwargs) |
| 145 | + # `try_run_init` may call `__init__` more than once, this could lead to spamming |
| 146 | + # the user with warning messages, so we will suppress these messages if the |
| 147 | + # retry counter has been set. |
| 148 | + self._esbonio_retry_count = 0 |
| 149 | + try_run_init(self, super().__init__, *args, **kwargs) |
142 | 150 |
|
143 | 151 | def add_role(self, name: str, role: Any, override: bool = False): |
144 | | - super().add_role(name, role, override) |
| 152 | + super().add_role(name, role, override or self._esbonio_retry_count > 0) |
145 | 153 | self.esbonio.add_role(name, role) |
146 | 154 |
|
| 155 | + def add_directive(self, name: str, cls: type[Directive], override: bool = False): |
| 156 | + super().add_directive(name, cls, override or self._esbonio_retry_count > 0) |
| 157 | + |
| 158 | + def add_node(self, node: type[Element], override: bool = False, **kwargs): |
| 159 | + super().add_node(node, override or self._esbonio_retry_count > 0, **kwargs) |
| 160 | + |
147 | 161 | def setup_extension(self, extname: str): |
148 | 162 | """Override Sphinx's implementation of `setup_extension` |
149 | 163 |
|
@@ -188,6 +202,106 @@ def _report_missing_extension(self, extname: str, exc: Exception): |
188 | 202 | self.esbonio.diagnostics.setdefault(uri, set()).add(diagnostic) |
189 | 203 |
|
190 | 204 |
|
| 205 | +def try_run_init(app: Sphinx, init_fn, *args, **kwargs): |
| 206 | + """Try and run Sphinx's ``__init__`` function. |
| 207 | +
|
| 208 | + There are occasions where Sphinx will try and throw an error that is recoverable |
| 209 | + e.g. a missing theme. In these situations we want to suppress the error, record a |
| 210 | + diagnostic, and try again - which is what this function will do. |
| 211 | +
|
| 212 | + Some errors however, are not recoverable in which case we will allow the error to |
| 213 | + proceed as normal. |
| 214 | +
|
| 215 | + Parameters |
| 216 | + ---------- |
| 217 | + app |
| 218 | + The application instance we are trying to initialize |
| 219 | +
|
| 220 | + init_fn |
| 221 | + The application's `__init__` method, as returned by ``super().__init__`` |
| 222 | +
|
| 223 | + args |
| 224 | + Positional arguments to ``__init__`` |
| 225 | +
|
| 226 | + retries |
| 227 | + Max number of retries, a fallback in case we end up creating infinite recursion |
| 228 | +
|
| 229 | + kwargs |
| 230 | + Keyword arguments to ``__init__`` |
| 231 | + """ |
| 232 | + |
| 233 | + if app._esbonio_retry_count >= 100: |
| 234 | + raise RuntimeError("Unable to initialize Sphinx: max retries exceeded") |
| 235 | + |
| 236 | + try: |
| 237 | + init_fn(*args, **kwargs) |
| 238 | + except ThemeError as exc: |
| 239 | + # Fallback to the default theme. |
| 240 | + kwargs.setdefault("confoverrides", {})["html_theme"] = "alabaster" |
| 241 | + kwargs["confoverrides"]["html_theme_options"] = {} |
| 242 | + |
| 243 | + app._esbonio_retry_count += 1 |
| 244 | + report_theme_error(app, exc) |
| 245 | + try_run_init(app, init_fn, *args, **kwargs) |
| 246 | + except Exception: |
| 247 | + logger.exception("Unable to initialize Sphinx") |
| 248 | + raise |
| 249 | + |
| 250 | + |
| 251 | +def report_theme_error(app: Sphinx, exc: ThemeError): |
| 252 | + """Attempt to convert the given theme error into a useful diagnostic. |
| 253 | +
|
| 254 | + Parameters |
| 255 | + ---------- |
| 256 | + app |
| 257 | + The Sphinx object being initialized |
| 258 | +
|
| 259 | + exc |
| 260 | + The error instance |
| 261 | + """ |
| 262 | + |
| 263 | + if (config := app.esbonio.config_ast) is None: |
| 264 | + return |
| 265 | + |
| 266 | + if (range_ := find_html_theme_declaration(config)) is None: |
| 267 | + return |
| 268 | + |
| 269 | + diagnostic = types.Diagnostic( |
| 270 | + range=range_, |
| 271 | + message=f"{exc}", |
| 272 | + severity=types.DiagnosticSeverity.Error, |
| 273 | + ) |
| 274 | + |
| 275 | + uri = app.esbonio.config_uri |
| 276 | + logger.debug("Adding diagnostic %s: %s", uri, diagnostic) |
| 277 | + app.esbonio.diagnostics.setdefault(uri, set()).add(diagnostic) |
| 278 | + |
| 279 | + |
| 280 | +def find_html_theme_declaration(mod: ast.Module) -> types.Range | None: |
| 281 | + """Attempt to find the location in the user's conf.py file where the ``html_theme`` |
| 282 | + was declared.""" |
| 283 | + |
| 284 | + for node in mod.body: |
| 285 | + if not isinstance(node, ast.Assign): |
| 286 | + continue |
| 287 | + |
| 288 | + if len(targets := node.targets) != 1: |
| 289 | + continue |
| 290 | + |
| 291 | + if not isinstance(name := targets[0], ast.Name): |
| 292 | + continue |
| 293 | + |
| 294 | + if name.id == "html_theme": |
| 295 | + break |
| 296 | + |
| 297 | + else: |
| 298 | + # Nothing found, abort |
| 299 | + logger.debug("Unable to find 'html_theme' node") |
| 300 | + return None |
| 301 | + |
| 302 | + return ast_node_to_range(node) |
| 303 | + |
| 304 | + |
191 | 305 | def find_extension_declaration(mod: ast.Module, extname: str) -> types.Range | None: |
192 | 306 | """Attempt to find the location in the user's conf.py file where the given |
193 | 307 | ``extname`` was declared. |
@@ -230,15 +344,21 @@ def find_extension_declaration(mod: ast.Module, extname: str) -> types.Range | N |
230 | 344 | logger.debug("Unable to find node for extension %r", extname) |
231 | 345 | return None |
232 | 346 |
|
| 347 | + return ast_node_to_range(element) |
| 348 | + |
| 349 | + |
| 350 | +def ast_node_to_range(node: ast.stmt | ast.expr) -> types.Range: |
| 351 | + """Convert the given ast node to a range.""" |
| 352 | + |
233 | 353 | # Finally, try and extract the source location. |
234 | | - start_line = element.lineno - 1 |
235 | | - start_char = element.col_offset |
| 354 | + start_line = node.lineno - 1 |
| 355 | + start_char = node.col_offset |
236 | 356 |
|
237 | | - if (end_line := (element.end_lineno or 0) - 1) < 0: |
| 357 | + if (end_line := (node.end_lineno or 0) - 1) < 0: |
238 | 358 | end_line = start_line + 1 |
239 | 359 | end_char: int | None = 0 |
240 | 360 |
|
241 | | - elif (end_char := element.end_col_offset) is None: |
| 361 | + elif (end_char := node.end_col_offset) is None: |
242 | 362 | end_line += 1 |
243 | 363 | end_char = 0 |
244 | 364 |
|
|
0 commit comments