From 75a68bc088f2663cd744efab99dd84dbab845970 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 13 Oct 2024 18:01:07 +0100 Subject: [PATCH 1/3] sphinx-agent: Fix Sphinx argument parsing for v8.1+ --- lib/esbonio/changes/912.fix.md | 1 + lib/esbonio/esbonio/sphinx_agent/config.py | 5 +++++ lib/esbonio/tests/sphinx-agent/test_sa_unit.py | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 lib/esbonio/changes/912.fix.md diff --git a/lib/esbonio/changes/912.fix.md b/lib/esbonio/changes/912.fix.md new file mode 100644 index 00000000..d016a68c --- /dev/null +++ b/lib/esbonio/changes/912.fix.md @@ -0,0 +1 @@ +Esbonio should once again be able to parse `sphinx-build` command line arguments for versions of Sphinx `>=8.1` diff --git a/lib/esbonio/esbonio/sphinx_agent/config.py b/lib/esbonio/esbonio/sphinx_agent/config.py index e491dcd4..687c4f3e 100644 --- a/lib/esbonio/esbonio/sphinx_agent/config.py +++ b/lib/esbonio/esbonio/sphinx_agent/config.py @@ -112,6 +112,11 @@ def fromcli(cls, args: list[str]): values = m_Sphinx.call_args[0] sphinx_args = {k: v for k, v in zip(keys, values)} + # Sphinx 8.1 changed the way arguments are passed to the `Sphinx` class. + # See: https://github.com/swyddfa/esbonio/issues/912 + if len(values) == 0: + sphinx_args = m_Sphinx.call_args.kwargs + if sphinx_args is None: return None diff --git a/lib/esbonio/tests/sphinx-agent/test_sa_unit.py b/lib/esbonio/tests/sphinx-agent/test_sa_unit.py index 7e73b3f3..7d965ce2 100644 --- a/lib/esbonio/tests/sphinx-agent/test_sa_unit.py +++ b/lib/esbonio/tests/sphinx-agent/test_sa_unit.py @@ -9,6 +9,7 @@ from unittest import mock import pytest +from sphinx import version_info as sphinx_version from esbonio.sphinx_agent.config import SphinxConfig from esbonio.sphinx_agent.log import DiagnosticFilter @@ -22,6 +23,11 @@ logger = logging.getLogger(__name__) +sphinx_lt_81 = sphinx_version[0] < 8 or ( + sphinx_version[0] == 8 and sphinx_version[1] < 1 +) + + def application_args(**kwargs) -> dict[str, Any]: defaults = { "confoverrides": {}, @@ -231,7 +237,9 @@ def application_args(**kwargs) -> dict[str, Any]: doctreedir=os.path.join("out", "doctrees"), buildername="html", warningiserror=True, - keep_going=True, + # --keep-going ignored since v8.1 + # https://github.com/sphinx-doc/sphinx/pull/12743/files#diff-4aea2ac365ab5325b530ac42efc67feac98db110e7f943ea13b5cf88f4260e59 + keep_going=sphinx_lt_81, ), ), ( @@ -421,7 +429,9 @@ def application_args(**kwargs) -> dict[str, Any]: doctreedir=os.path.join("out", ".doctrees"), buildername="html", warningiserror=True, - keep_going=True, + # --keep-going ignored since v8.1 + # https://github.com/sphinx-doc/sphinx/pull/12743/files#diff-4aea2ac365ab5325b530ac42efc67feac98db110e7f943ea13b5cf88f4260e59 + keep_going=sphinx_lt_81, ), ), ], From 4c164060095bcb7d63b9c2e159838c0e64c6b02f Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 13 Oct 2024 19:19:42 +0100 Subject: [PATCH 2/3] sphinx-agent: Suppress and report extension errors The sphinx-agent will no longer crash if the configured environment is missing an extension. Instead the error is suppressed and the agent will attempt to report it to the user as a diagnostic However, the diagnostic will only work if the extensions are declared in `conf.py` using the "standard" Sphinx format i.e. ```python extensions = [ 'a', 'b', ... ] ``` --- lib/esbonio/changes/913.enhancement.md | 1 + lib/esbonio/esbonio/sphinx_agent/app.py | 114 ++++++++++++++++++ .../sphinx_agent/handlers/diagnostics.py | 5 +- 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 lib/esbonio/changes/913.enhancement.md diff --git a/lib/esbonio/changes/913.enhancement.md b/lib/esbonio/changes/913.enhancement.md new file mode 100644 index 00000000..754d06d3 --- /dev/null +++ b/lib/esbonio/changes/913.enhancement.md @@ -0,0 +1 @@ +The language server should now also work with an incomplete Python environment. If one or more Sphinx extensions are missing, esbonio will still be able to run a build and report the missing extensions as a diagnostic diff --git a/lib/esbonio/esbonio/sphinx_agent/app.py b/lib/esbonio/esbonio/sphinx_agent/app.py index f0518e42..b61df93c 100644 --- a/lib/esbonio/esbonio/sphinx_agent/app.py +++ b/lib/esbonio/esbonio/sphinx_agent/app.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import logging import pathlib import typing @@ -112,3 +113,116 @@ def __init__(self, *args, **kwargs): def add_role(self, name: str, role: Any, override: bool = False): super().add_role(name, role, override) self.esbonio.add_role(name, role) + + 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 excetion + + exc + The exception instance + """ + + if not isinstance(cause := exc.__cause__, ImportError): + return + + # Parse the user's config file + # TODO: Move this somewhere more central. + try: + conf_py = pathlib.Path(self.confdir, "conf.py") + config = ast.parse(source=conf_py.read_text()) + except Exception: + logger.debug("Unable to parse user's conf.py") + 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=str(cause), severity=types.DiagnosticSeverity.Error + ) + + # TODO: Move the set of diagnostics somewhere more central. + uri = types.Uri.for_file(conf_py) + logger.debug("Adding diagnostic %s: %s", uri, diagnostic) + self.esbonio.log.diagnostics.setdefault(uri, set()).add(diagnostic) + + +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 + + # Finally, try and extract the source location. + start_line = element.lineno - 1 + start_char = element.col_offset + + if (end_line := (element.end_lineno or 0) - 1) < 0: + end_line = start_line + 1 + end_char: int | None = 0 + + elif (end_char := element.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), + ) diff --git a/lib/esbonio/esbonio/sphinx_agent/handlers/diagnostics.py b/lib/esbonio/esbonio/sphinx_agent/handlers/diagnostics.py index cc7b6024..124b1aff 100644 --- a/lib/esbonio/esbonio/sphinx_agent/handlers/diagnostics.py +++ b/lib/esbonio/esbonio/sphinx_agent/handlers/diagnostics.py @@ -1,5 +1,3 @@ -from typing import Optional - from sphinx.config import Config from ..app import Database @@ -18,6 +16,7 @@ def init_db(app: Sphinx, config: Config): app.esbonio.db.ensure_table(DIAGNOSTICS_TABLE) + sync_diagnostics(app) def clear_diagnostics(app: Sphinx, docname: str, source): @@ -26,7 +25,7 @@ def clear_diagnostics(app: Sphinx, docname: str, source): app.esbonio.log.diagnostics.pop(uri, None) -def sync_diagnostics(app: Sphinx, exc: Optional[Exception]): +def sync_diagnostics(app: Sphinx, *args): app.esbonio.db.clear_table(DIAGNOSTICS_TABLE) results = [] From f4fb3f9b47d9db67ff4354a97ae6eb428eaa9436 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 13 Oct 2024 19:27:29 +0100 Subject: [PATCH 3/3] vscode: Clean up launch.json --- .vscode/launch.json | 62 +++++---------------------------------------- 1 file changed, 6 insertions(+), 56 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 3bd2b184..fdc52dd4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,27 +1,6 @@ { "version": "0.2.0", - "inputs": [ - { - "id": "workspace", - "type": "pickString", - "description": "Pick the workspace root for this session", - "default": "code/", - "options": [ - { - "label": "code", - "value": "code" - }, - { - "label": "docs", - "value": "docs" - }, - { - "label": "demo", - "value": "lib/esbonio/tests/workspaces/demo" - }, - ] - } - ], + "inputs": [], "configurations": [ { "name": "VSCode Extension", @@ -38,39 +17,9 @@ ], "preLaunchTask": "${defaultBuildTask}", }, - { - "name": "VSCode Web Extension", - "type": "extensionHost", - "debugWebWorkerHost": true, - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceRoot}/code", - "--extensionDevelopmentKind=web", - "--folder-uri=${workspaceRoot}/${input:workspace}" - ], - "outFiles": [ - "${workspaceRoot}/code/dist/browser/**/*.js" - ], - "preLaunchTask": "${defaultBuildTask}", - }, - { - "name": "Docs", - "type": "python", - "request": "launch", - "module": "sphinx.cmd.build", - "args": [ - "-M", - "html", - ".", - "_build", - "-Ea" - ], - "python": "${command:python.interpreterPath}", - "cwd": "${workspaceFolder}/docs" - }, { "name": "pytest: esbonio", - "type": "python", + "type": "debugpy", "request": "launch", "module": "pytest", "justMyCode": false, @@ -84,7 +33,7 @@ }, { "name": "Python: Attach", - "type": "python", + "type": "debugpy", "request": "attach", "connect": { "host": "localhost", @@ -92,11 +41,12 @@ }, "pathMappings": [ { - "localRoot": "${workspaceFolder}/lib/esbonio", + "localRoot": "${workspaceFolder}", "remoteRoot": "." } ], - "justMyCode": false + "justMyCode": false, + "subProcess": true, }, ], }