Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for missing extensions #913

Merged
merged 3 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 6 additions & 56 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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,
Expand All @@ -84,19 +33,20 @@
},
{
"name": "Python: Attach",
"type": "python",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "localhost",
"port": 5678
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}/lib/esbonio",
"localRoot": "${workspaceFolder}",
"remoteRoot": "."
}
],
"justMyCode": false
"justMyCode": false,
"subProcess": true,
},
],
}
1 change: 1 addition & 0 deletions lib/esbonio/changes/912.fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Esbonio should once again be able to parse `sphinx-build` command line arguments for versions of Sphinx `>=8.1`
1 change: 1 addition & 0 deletions lib/esbonio/changes/913.enhancement.md
Original file line number Diff line number Diff line change
@@ -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
114 changes: 114 additions & 0 deletions lib/esbonio/esbonio/sphinx_agent/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import ast
import logging
import pathlib
import typing
Expand Down Expand Up @@ -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),
)
5 changes: 5 additions & 0 deletions lib/esbonio/esbonio/sphinx_agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions lib/esbonio/esbonio/sphinx_agent/handlers/diagnostics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from typing import Optional

from sphinx.config import Config

from ..app import Database
Expand All @@ -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):
Expand All @@ -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 = []
Expand Down
14 changes: 12 additions & 2 deletions lib/esbonio/tests/sphinx-agent/test_sa_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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": {},
Expand Down Expand Up @@ -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,
),
),
(
Expand Down Expand Up @@ -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,
),
),
],
Expand Down