usse/scrape/venv/lib/python3.10/site-packages/esbonio/sphinx_agent/handlers.py

275 lines
9.4 KiB
Python
Raw Normal View History

2023-12-22 14:26:01 +00:00
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)