Skip to content

Commit 29aa1bb

Browse files
committed
sphinx-agent: Implement a fallback html_theme
The sphinx agent can now handle the case where the requested `html_theme` is not available in the environment by suppressing the raised error, overriding the value of `html_theme` and attempting to run `Sphinx.__init__` again.
1 parent f2e77a5 commit 29aa1bb

File tree

7 files changed

+236
-14
lines changed

7 files changed

+236
-14
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
When asking for a `html_theme` that is not available in the current environment, the server will now fallback to Sphinx's `alabaster` theme and report the error as a diagnostic

lib/esbonio/esbonio/sphinx_agent/app.py

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import typing
77

88
from sphinx.application import Sphinx as _Sphinx
9+
from sphinx.errors import ThemeError
910
from sphinx.util import console
1011
from sphinx.util import logging as sphinx_logging_module
1112
from sphinx.util.logging import NAMESPACE as SPHINX_LOG_NAMESPACE
@@ -19,6 +20,9 @@
1920
from typing import Any
2021
from typing import Literal
2122

23+
from docutils.nodes import Element
24+
from docutils.parsers.rst import Directive
25+
2226
RoleDefinition = tuple[str, Any, list[types.Role.TargetProvider]]
2327

2428
sphinx_logger = logging.getLogger(SPHINX_LOG_NAMESPACE)
@@ -138,12 +142,22 @@ def __init__(self, *args, **kwargs):
138142
# Override sphinx's usual logging setup function
139143
sphinx_logging_module.setup = setup_logging # type: ignore
140144

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)
142150

143151
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)
145153
self.esbonio.add_role(name, role)
146154

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+
147161
def setup_extension(self, extname: str):
148162
"""Override Sphinx's implementation of `setup_extension`
149163
@@ -188,6 +202,106 @@ def _report_missing_extension(self, extname: str, exc: Exception):
188202
self.esbonio.diagnostics.setdefault(uri, set()).add(diagnostic)
189203

190204

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+
191305
def find_extension_declaration(mod: ast.Module, extname: str) -> types.Range | None:
192306
"""Attempt to find the location in the user's conf.py file where the given
193307
``extname`` was declared.
@@ -230,15 +344,21 @@ def find_extension_declaration(mod: ast.Module, extname: str) -> types.Range | N
230344
logger.debug("Unable to find node for extension %r", extname)
231345
return None
232346

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+
233353
# 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
236356

237-
if (end_line := (element.end_lineno or 0) - 1) < 0:
357+
if (end_line := (node.end_lineno or 0) - 1) < 0:
238358
end_line = start_line + 1
239359
end_char: int | None = 0
240360

241-
elif (end_char := element.end_col_offset) is None:
361+
elif (end_char := node.end_col_offset) is None:
242362
end_line += 1
243363
end_char = 0
244364

lib/esbonio/hatch.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ sphinx = ["8"]
3030

3131
[envs.hatch-test.overrides]
3232
matrix.sphinx.dependencies = [
33-
"sphinx-design",
3433
"myst-parser",
3534
{ value = "sphinx>=6,<7", if = ["6"] },
3635
{ value = "sphinx>=7,<8", if = ["7"] },

lib/esbonio/tests/e2e/conftest.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,6 @@ async def client(lsp_client: LanguageClient, uri_for, tmp_path_factory):
4747
workspace_uri.fs_path,
4848
str(build_dir),
4949
],
50-
"configOverrides": {
51-
"html_theme": "alabaster",
52-
"html_theme_options": {},
53-
},
5450
"pythonCommand": [sys.executable],
5551
},
5652
},

lib/esbonio/tests/e2e/test_e2e_diagnostics.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,19 @@ async def test_workspace_diagnostic(client: LanguageClient, uri_for):
8181

8282
workspace_uri = uri_for("workspaces", "demo")
8383
expected = {
84-
str(workspace_uri / "rst" / "diagnostics.rst"): {message},
85-
str(workspace_uri / "myst" / "diagnostics.md"): {message},
84+
str(workspace_uri / "conf.py"): (
85+
"no theme named 'furo' found (missing theme.toml?)",
86+
"Could not import extension sphinx_design (exception: "
87+
"No module named 'sphinx_design')",
88+
),
89+
str(workspace_uri / "index.rst"): ('Unknown directive type "grid"'),
90+
str(workspace_uri / "rst" / "diagnostics.rst"): (message,),
91+
str(workspace_uri / "myst" / "diagnostics.md"): (message,),
8692
}
8793
assert len(report.items) == len(expected)
8894
for item in report.items:
89-
assert expected[item.uri] == {d.message for d in item.items}
95+
for diagnostic in item.items:
96+
assert diagnostic.message.startswith(expected[item.uri])
9097

9198
assert len(client.diagnostics) == 0, "Server should not publish diagnostics"
9299

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import ast
4+
5+
import pytest
6+
from lsprotocol import types
7+
8+
from esbonio.server.testing import range_from_str
9+
from esbonio.sphinx_agent.app import find_extension_declaration
10+
from esbonio.sphinx_agent.app import find_html_theme_declaration
11+
12+
CONF_PY = """\
13+
# Configuration file for the Sphinx documentation builder.
14+
#
15+
# For the full list of built-in configuration values, see the documentation:
16+
# https://www.sphinx-doc.org/en/master/usage/configuration.html
17+
from docutils import nodes
18+
from sphinx.application import Sphinx
19+
20+
# -- Project information -----------------------------------------------------
21+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
22+
23+
project = "Esbonio Demo"
24+
copyright = "2023, Esbonio Developers"
25+
author = "Esbonio Developers"
26+
release = "1.0"
27+
28+
extensions = [ "a",
29+
"sphinx.ext.intersphinx",
30+
"myst-parser",
31+
]
32+
33+
# -- Options for HTML output -------------------------------------------------
34+
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
35+
36+
html_theme = "furo"
37+
html_title = "Esbonio Demo"
38+
html_theme_options = {
39+
"source_repository": "https://github.com/swyddfa/esbonio",
40+
"source_branch": "develop",
41+
"source_directory": "lib/esbonio/tests/workspaces/demo/",
42+
}
43+
"""
44+
45+
46+
@pytest.mark.parametrize(
47+
"src,expected",
48+
[
49+
("", None),
50+
("a=3", None),
51+
(CONF_PY, range_from_str("23:0-23:19")),
52+
],
53+
)
54+
def test_find_html_theme_declaration(src: str, expected: types.Range | None):
55+
"""Ensure that we can locate the location within a ``conf.py``
56+
file where the ``html_theme`` is defined."""
57+
58+
mod = ast.parse(src)
59+
actual = find_html_theme_declaration(mod)
60+
61+
if expected is None:
62+
assert actual is None
63+
64+
else:
65+
assert actual.start.line == expected.start.line
66+
assert actual.end.line == expected.end.line
67+
68+
assert actual.start.character == expected.start.character
69+
assert actual.end.character == expected.end.character
70+
71+
72+
@pytest.mark.parametrize(
73+
"src,extname,expected",
74+
[
75+
("", "myst-parser", None),
76+
("a=3", "myst-parser", None),
77+
("extensions='a'", "myst-parser", None),
78+
("extensions=['myst-parser']", "myst-parser", range_from_str("0:12-0:25")),
79+
(CONF_PY, "myst-parser", range_from_str("17:6-17:19")),
80+
],
81+
)
82+
def test_find_extension_declaration(
83+
src: str, extname: str, expected: types.Range | None
84+
):
85+
"""Ensure that we can locate the location within a ``conf.py``
86+
file where the ``html_theme`` is defined."""
87+
88+
mod = ast.parse(src)
89+
actual = find_extension_declaration(mod, extname)
90+
91+
if expected is None:
92+
assert actual is None
93+
94+
else:
95+
assert actual.start.line == expected.start.line
96+
assert actual.end.line == expected.end.line
97+
98+
assert actual.start.character == expected.start.character
99+
assert actual.end.character == expected.end.character

0 commit comments

Comments
 (0)