275 lines
9.4 KiB
Python
275 lines
9.4 KiB
Python
|
import inspect
|
||
|
import logging
|
||
|
import os.path
|
||
|
import pathlib
|
||
|
import sys
|
||
|
import typing
|
||
|
from functools import partial
|
||
|
from typing import IO
|
||
|
from typing import Callable
|
||
|
from typing import Dict
|
||
|
from typing import List
|
||
|
from typing import Optional
|
||
|
from typing import Tuple
|
||
|
from typing import Type
|
||
|
from uuid import uuid4
|
||
|
|
||
|
from sphinx import __version__ as __sphinx_version__
|
||
|
from sphinx.application import Sphinx
|
||
|
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 sphinx.util.logging import VERBOSITY_MAP
|
||
|
|
||
|
from . import types
|
||
|
from .config import SphinxConfig
|
||
|
from .log import SphinxLogHandler
|
||
|
from .transforms import LineNumberTransform
|
||
|
from .util import send_error
|
||
|
from .util import send_message
|
||
|
|
||
|
STATIC_DIR = (pathlib.Path(__file__).parent / "static").resolve()
|
||
|
|
||
|
|
||
|
class SphinxHandler:
|
||
|
"""Responsible for implementing the JSON-RPC API exposed by the Sphinx agent."""
|
||
|
|
||
|
def __init__(self):
|
||
|
self.app: Optional[Sphinx] = None
|
||
|
"""The sphinx application instance"""
|
||
|
|
||
|
self.log_handler: Optional[SphinxLogHandler] = None
|
||
|
"""The logging handler"""
|
||
|
|
||
|
self._content_overrides: Dict[str, str] = {}
|
||
|
"""Holds any additional content to inject into a build."""
|
||
|
|
||
|
self._handlers: Dict[str, Tuple[Type, Callable]] = self._register_handlers()
|
||
|
|
||
|
def get(self, method: str) -> Optional[Tuple[Type, Callable]]:
|
||
|
"""Return the handler for the given method - if possible.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
method
|
||
|
The name of the method
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
Optional[Tuple[Type, Callable]]
|
||
|
A tuple where the first element is the type definition
|
||
|
representing the message body, the second element is the method which
|
||
|
implements it.
|
||
|
|
||
|
If ``None``, the given method is unknown.
|
||
|
|
||
|
"""
|
||
|
return self._handlers.get(method)
|
||
|
|
||
|
def _register_handlers(self) -> Dict[str, Tuple[Type, Callable]]:
|
||
|
"""Return a map of all the handlers we provide.
|
||
|
|
||
|
A handler
|
||
|
|
||
|
- must be a method on this class
|
||
|
- the must take a single parameter called ``request``
|
||
|
- the type annotation for that parameter must correspond with a ``XXXRequest``
|
||
|
class definition from the ``types`` module.
|
||
|
|
||
|
Returns
|
||
|
-------
|
||
|
Dict[str, Tuple[Type, Callable]]
|
||
|
A dictonary where the keys are the method names implemented by this class.
|
||
|
Values are a tuple where the first element is the type definition
|
||
|
representing the message body, the second element is the method which
|
||
|
implements it.
|
||
|
"""
|
||
|
handlers: Dict[str, Tuple[Type, Callable]] = {}
|
||
|
|
||
|
for name in dir(self):
|
||
|
method_func = getattr(self, name)
|
||
|
if name.startswith("_") or not inspect.ismethod(method_func):
|
||
|
continue
|
||
|
|
||
|
parameters = inspect.signature(method_func).parameters
|
||
|
if set(parameters.keys()) != {"request"}:
|
||
|
continue
|
||
|
|
||
|
request_type = typing.get_type_hints(method_func)["request"]
|
||
|
if not all(
|
||
|
[hasattr(request_type, "jsonrpc"), hasattr(request_type, "method")]
|
||
|
):
|
||
|
continue
|
||
|
|
||
|
handlers[request_type.method] = (request_type, method_func)
|
||
|
|
||
|
return handlers
|
||
|
|
||
|
def create_sphinx_app(self, request: types.CreateApplicationRequest):
|
||
|
"""Create a new sphinx application instance."""
|
||
|
sphinx_config = SphinxConfig.fromcli(request.params.command)
|
||
|
if sphinx_config is None:
|
||
|
raise ValueError("Invalid build command")
|
||
|
|
||
|
sphinx_args = sphinx_config.to_application_args()
|
||
|
|
||
|
# Override Sphinx's logging setup with our own.
|
||
|
sphinx_logging_module.setup = partial(self.setup_logging, sphinx_config)
|
||
|
self.app = Sphinx(**sphinx_args)
|
||
|
|
||
|
# Connect event handlers.
|
||
|
self.app.connect("env-before-read-docs", self._cb_env_before_read_docs)
|
||
|
self.app.connect("source-read", self._cb_source_read, priority=0)
|
||
|
|
||
|
# TODO: Sphinx 7.x has introduced a `include-read` event
|
||
|
# See: https://github.com/sphinx-doc/sphinx/pull/11657
|
||
|
|
||
|
if request.params.enable_sync_scrolling:
|
||
|
_enable_sync_scrolling(self.app)
|
||
|
|
||
|
response = types.CreateApplicationResponse(
|
||
|
id=request.id,
|
||
|
result=types.SphinxInfo(
|
||
|
id=str(uuid4()),
|
||
|
version=__sphinx_version__,
|
||
|
conf_dir=str(self.app.confdir),
|
||
|
build_dir=str(self.app.outdir),
|
||
|
builder_name=self.app.builder.name,
|
||
|
src_dir=str(self.app.srcdir),
|
||
|
),
|
||
|
jsonrpc=request.jsonrpc,
|
||
|
)
|
||
|
send_message(response)
|
||
|
|
||
|
def _cb_env_before_read_docs(self, app: Sphinx, env, docnames: List[str]):
|
||
|
"""Used to add additional documents to the "to build" list."""
|
||
|
|
||
|
is_building = set(docnames)
|
||
|
|
||
|
for docname in env.found_docs - is_building:
|
||
|
filepath = env.doc2path(docname, base=True)
|
||
|
if filepath in self._content_overrides:
|
||
|
docnames.append(docname)
|
||
|
|
||
|
def _cb_source_read(self, app: Sphinx, docname: str, source):
|
||
|
"""Called whenever sphinx reads a file from disk."""
|
||
|
|
||
|
filepath = app.env.doc2path(docname, base=True)
|
||
|
|
||
|
# Clear diagnostics
|
||
|
if self.log_handler:
|
||
|
self.log_handler.diagnostics.pop(filepath, None)
|
||
|
|
||
|
# Override file contents if necessary
|
||
|
if (content := self._content_overrides.get(filepath)) is not None:
|
||
|
source[0] = content
|
||
|
|
||
|
def setup_logging(self, config: SphinxConfig, app: Sphinx, status: IO, warning: IO):
|
||
|
"""Setup Sphinx's logging so that it integrates well with the parent language
|
||
|
server."""
|
||
|
|
||
|
# Disable color escape codes in Sphinx's log messages
|
||
|
console.nocolor()
|
||
|
|
||
|
if not config.silent:
|
||
|
sphinx_logger = logging.getLogger(SPHINX_LOG_NAMESPACE)
|
||
|
|
||
|
# Be sure to remove any old handlers
|
||
|
for handler in sphinx_logger.handlers:
|
||
|
if isinstance(handler, SphinxLogHandler):
|
||
|
sphinx_logger.handlers.remove(handler)
|
||
|
self.log_handler = None
|
||
|
|
||
|
self.log_handler = SphinxLogHandler(app)
|
||
|
sphinx_logger.addHandler(self.log_handler)
|
||
|
|
||
|
if config.quiet:
|
||
|
level = logging.WARNING
|
||
|
else:
|
||
|
level = VERBOSITY_MAP[app.verbosity]
|
||
|
|
||
|
sphinx_logger.setLevel(level)
|
||
|
self.log_handler.setLevel(level)
|
||
|
|
||
|
formatter = logging.Formatter("%(message)s")
|
||
|
self.log_handler.setFormatter(formatter)
|
||
|
|
||
|
def build_sphinx_app(self, request: types.BuildRequest):
|
||
|
"""Trigger a Sphinx build."""
|
||
|
|
||
|
if self.app is None:
|
||
|
send_error(id=request.id, code=-32803, message="Sphinx app not initialized")
|
||
|
return
|
||
|
|
||
|
self._content_overrides = request.params.content_overrides
|
||
|
|
||
|
try:
|
||
|
self.app.build()
|
||
|
|
||
|
diagnostics = {}
|
||
|
if self.log_handler:
|
||
|
diagnostics = {
|
||
|
fpath: list(items)
|
||
|
for fpath, items in self.log_handler.diagnostics.items()
|
||
|
}
|
||
|
|
||
|
response = types.BuildResponse(
|
||
|
id=request.id,
|
||
|
result=types.BuildResult(
|
||
|
build_file_map=_build_file_mapping(self.app),
|
||
|
diagnostics=diagnostics,
|
||
|
),
|
||
|
jsonrpc=request.jsonrpc,
|
||
|
)
|
||
|
send_message(response)
|
||
|
except Exception:
|
||
|
send_error(id=request.id, code=-32602, message="Sphinx build failed.")
|
||
|
|
||
|
def notify_exit(self, request: types.ExitNotification):
|
||
|
"""Sent from the client to signal that the agent should exit."""
|
||
|
sys.exit(0)
|
||
|
|
||
|
|
||
|
def _build_file_mapping(app: Sphinx) -> Dict[str, str]:
|
||
|
"""Given a Sphinx application, return a mapping of all known source files to their
|
||
|
corresponding output files."""
|
||
|
|
||
|
env = app.env
|
||
|
builder = app.builder
|
||
|
mapping = {env.doc2path(doc): builder.get_target_uri(doc) for doc in env.found_docs}
|
||
|
|
||
|
# Don't forget any included files.
|
||
|
# TODO: How best to handle files included in multiple documents?
|
||
|
for parent_doc, included_docs in env.included.items():
|
||
|
for doc in included_docs:
|
||
|
mapping[env.doc2path(doc)] = mapping[env.doc2path(parent_doc)]
|
||
|
|
||
|
# Ensure any relative paths in included docs are resolved.
|
||
|
mapping = {str(pathlib.Path(d).resolve()): uri for d, uri in mapping.items()}
|
||
|
|
||
|
return mapping
|
||
|
|
||
|
|
||
|
def _enable_sync_scrolling(app: Sphinx):
|
||
|
"""Given a Sphinx application, configure it so that we can support syncronised
|
||
|
scrolling."""
|
||
|
|
||
|
# On OSes like Fedora Silverblue where `/home` is a symlink for `/var/home`
|
||
|
# we could have a situation where `STATIC_DIR` and `app.confdir` have
|
||
|
# different root dirs... which is enough to cause `os.path.relpath` to return
|
||
|
# the wrong path.
|
||
|
#
|
||
|
# Fully resolving both `STATIC_DIR` and `app.confdir` should be enough to
|
||
|
# mitigate this.
|
||
|
confdir = pathlib.Path(app.confdir).resolve()
|
||
|
|
||
|
# Push our folder of static assets into the user's project.
|
||
|
# Path needs to be relative to their project's confdir.
|
||
|
reldir = os.path.relpath(str(STATIC_DIR), start=str(confdir))
|
||
|
app.config.html_static_path.append(reldir)
|
||
|
|
||
|
app.add_js_file("webview.js")
|
||
|
|
||
|
# Inject source line numbers into build output
|
||
|
app.add_transform(LineNumberTransform)
|