Source code for esbonio.sphinx_agent.app

from __future__ import annotations

import ast
import logging
import pathlib
import typing

from sphinx.application import Sphinx as _Sphinx
from sphinx.errors import ThemeError
from sphinx.util import console
from sphinx.util import logging as sphinx_logging_module
from sphinx.util.logging import NAMESPACE as SPHINX_LOG_NAMESPACE

from . import types
from .database import Database
from .log import DiagnosticFilter

if typing.TYPE_CHECKING:
    from typing import IO
    from typing import Any
    from typing import Literal

    from docutils.nodes import Element
    from docutils.parsers.rst import Directive

    DirectiveDefinition = tuple[
        str, type[Directive], list[types.Directive.ArgumentProvider]
    ]
    RoleDefinition = tuple[str, Any, list[types.Role.TargetProvider]]


sphinx_logger = logging.getLogger(SPHINX_LOG_NAMESPACE)
logger = sphinx_logger.getChild("esbonio")
sphinx_log_setup = sphinx_logging_module.setup


def setup_logging(app: Sphinx, status: IO, warning: IO):
    # Run the usual setup
    sphinx_log_setup(app, status, warning)

    # Attach our diagnostic filter to the warning handler.
    for handler in sphinx_logger.handlers:
        if handler.level == logging.WARNING:
            handler.addFilter(app.esbonio.log)


[docs] class Esbonio: """Esbonio specific functionality.""" db: Database log: DiagnosticFilter def __init__(self, dbpath: pathlib.Path, app: _Sphinx): self.app: _Sphinx = app self.db = Database(dbpath) self.log = DiagnosticFilter(app) self._roles: list[RoleDefinition] = [] """Roles captured during Sphinx startup.""" self._directives: list[DirectiveDefinition] = [] """Directives captured during Sphinx startup.""" self._config_ast: ast.Module | Literal[False] | None = None """The parsed AST of the user's conf.py file. If ``False``, we already tried parsing the module and were unable to.""" self.diagnostics: dict[types.Uri, set[types.Diagnostic]] = {} """Recorded diagnostics.""" @property def config_uri(self) -> types.Uri: return types.Uri.for_file(pathlib.Path(self.app.confdir, "conf.py")) @property def config_ast(self) -> ast.Module | None: """Return the AST for the user's conf.py (if possible)""" if self._config_ast is not None: return self._config_ast or None # convert ``False`` to ``None`` try: conf_py = pathlib.Path(self.app.confdir, "conf.py") self._config_ast = ast.parse(source=conf_py.read_text()) return self._config_ast except Exception as exc: logger.debug("Unable to parse user's conf.py: %s", exc) self._config_ast = False return None
[docs] def add_directive( self, name: str, directive: type[Directive], argument_providers: list[types.Directive.ArgumentProvider] | None = None, ): """Register a directive with esbonio. Parameters ---------- name The name of the directive, as the user would type in a document directive The directive's implementation argument_providers A list of argument providers for the role """ self._directives.append((name, directive, argument_providers or []))
[docs] @staticmethod def create_directive_argument_provider( name: str, **kwargs ) -> types.Directive.ArgumentProvider: """Create a new directive argument provider Parameters ---------- name The name of the provider kwargs Additional arguments to pass to the provider instance Returns ------- types.Directive.ArgumentProvider The target provider """ return types.Directive.ArgumentProvider(name, kwargs)
[docs] def add_role( self, name: str, role: Any, target_providers: list[types.Role.TargetProvider] | None = None, ): """Register a role with esbonio. Parameters ---------- name The name of the role, as the user would type in a document role The role's implementation target_providers A list of target providers for the role """ self._roles.append((name, role, target_providers or []))
[docs] @staticmethod def create_role_target_provider(name: str, **kwargs) -> types.Role.TargetProvider: """Create a new role target provider Parameters ---------- name The name of the provider kwargs Additional arguments to pass to the provider instance Returns ------- types.Role.TargetProvider The target provider """ return types.Role.TargetProvider(name, kwargs)
[docs] class Sphinx(_Sphinx): """An extended sphinx application that integrates with esbonio.""" esbonio: Esbonio def __init__(self, *args, **kwargs): # Disable color codes console.nocolor() # Add in esbonio specific functionality self.esbonio = Esbonio( dbpath=pathlib.Path(kwargs["outdir"], "esbonio.db").resolve(), app=self, ) # Override sphinx's usual logging setup function sphinx_logging_module.setup = setup_logging # type: ignore # `try_run_init` may call `__init__` more than once, this could lead to spamming # the user with warning messages, so we will suppress these messages if the # retry counter has been set. self._esbonio_retry_count = 0 try_run_init(self, super().__init__, *args, **kwargs) def add_role(self, name: str, role: Any, override: bool = False): super().add_role(name, role, override or self._esbonio_retry_count > 0) self.esbonio.add_role(name, role) def add_directive(self, name: str, cls: type[Directive], override: bool = False): super().add_directive(name, cls, override or self._esbonio_retry_count > 0) self.esbonio.add_directive(name, cls) def add_node(self, node: type[Element], override: bool = False, **kwargs): super().add_node(node, override or self._esbonio_retry_count > 0, **kwargs) def setup_extension(self, extname: str): """Override Sphinx's implementation of `setup_extension` This implementation - Will suppress errors caused by missing extensions - Attempt to report errors where possible, as diagnostics """ try: super().setup_extension(extname) except Exception as exc: # Attempt to produce useful diagnostics. self._report_missing_extension(extname, exc) def _report_missing_extension(self, extname: str, exc: Exception): """Check to see if the given exception corresponds to a missing extension. If so, attempt to produce a diagnostic to highlight this to the user. Parameters ---------- extname The name of the extension that caused the exception exc The exception instance """ if (config := self.esbonio.config_ast) is None: return # Now attempt to find the soure location of the extenison. if (range_ := find_extension_declaration(config, extname)) is None: logger.debug("Unable to locate declaration of extension: %r", extname) return diagnostic = types.Diagnostic( range=range_, message=f"{exc}", severity=types.DiagnosticSeverity.Error ) uri = self.esbonio.config_uri logger.debug("Adding diagnostic %s: %s", uri, diagnostic) self.esbonio.diagnostics.setdefault(uri, set()).add(diagnostic)
def try_run_init(app: Sphinx, init_fn, *args, **kwargs): """Try and run Sphinx's ``__init__`` function. There are occasions where Sphinx will try and throw an error that is recoverable e.g. a missing theme. In these situations we want to suppress the error, record a diagnostic, and try again - which is what this function will do. Some errors however, are not recoverable in which case we will allow the error to proceed as normal. Parameters ---------- app The application instance we are trying to initialize init_fn The application's `__init__` method, as returned by ``super().__init__`` args Positional arguments to ``__init__`` retries Max number of retries, a fallback in case we end up creating infinite recursion kwargs Keyword arguments to ``__init__`` """ if app._esbonio_retry_count >= 100: raise RuntimeError("Unable to initialize Sphinx: max retries exceeded") try: init_fn(*args, **kwargs) except ThemeError as exc: # Fallback to the default theme. kwargs.setdefault("confoverrides", {})["html_theme"] = "alabaster" kwargs["confoverrides"]["html_theme_options"] = {} app._esbonio_retry_count += 1 report_theme_error(app, exc) try_run_init(app, init_fn, *args, **kwargs) except Exception: logger.exception("Unable to initialize Sphinx") raise def report_theme_error(app: Sphinx, exc: ThemeError): """Attempt to convert the given theme error into a useful diagnostic. Parameters ---------- app The Sphinx object being initialized exc The error instance """ if (config := app.esbonio.config_ast) is None: return if (range_ := find_html_theme_declaration(config)) is None: return diagnostic = types.Diagnostic( range=range_, message=f"{exc}", severity=types.DiagnosticSeverity.Error, ) uri = app.esbonio.config_uri logger.debug("Adding diagnostic %s: %s", uri, diagnostic) app.esbonio.diagnostics.setdefault(uri, set()).add(diagnostic) def find_html_theme_declaration(mod: ast.Module) -> types.Range | None: """Attempt to find the location in the user's conf.py file where the ``html_theme`` was declared.""" for node in mod.body: if not isinstance(node, ast.Assign): continue if len(targets := node.targets) != 1: continue if not isinstance(name := targets[0], ast.Name): continue if name.id == "html_theme": break else: # Nothing found, abort logger.debug("Unable to find 'html_theme' node") return None return ast_node_to_range(node) def find_extension_declaration(mod: ast.Module, extname: str) -> types.Range | None: """Attempt to find the location in the user's conf.py file where the given ``extname`` was declared. This function will never be perfect (conf.py is after all, turing complete!). However, it *should* be possible to write something that can handle most cases. """ # First try and locate the node corresponding to `extensions = [ ... ]` for node in mod.body: if not isinstance(node, ast.Assign): continue if len(targets := node.targets) != 1: continue if not isinstance(name := targets[0], ast.Name): continue if name.id == "extensions": break else: # Nothing found, abort logger.debug("Unable to find 'extensions' node") return None # Now try to find the node corresponding to `'extname'` if not isinstance(extlist := node.value, ast.List): return None for element in extlist.elts: if not isinstance(element, ast.Constant): continue if element.value == extname: break else: # Nothing found, abort logger.debug("Unable to find node for extension %r", extname) return None return ast_node_to_range(element) def ast_node_to_range(node: ast.stmt | ast.expr) -> types.Range: """Convert the given ast node to a range.""" # Finally, try and extract the source location. start_line = node.lineno - 1 start_char = node.col_offset if (end_line := (node.end_lineno or 0) - 1) < 0: end_line = start_line + 1 end_char: int | None = 0 elif (end_char := node.end_col_offset) is None: end_line += 1 end_char = 0 return types.Range( start=types.Position(line=start_line, character=start_char), end=types.Position(line=end_line, character=end_char or 0), )