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

Implement a ${defaultBuildDir} placeholder variable #903

Merged
merged 8 commits into from
Sep 29, 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
2 changes: 2 additions & 0 deletions docs/lsp/howto/use-esbonio-with.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.. _lsp-use-with:

How To Use Esbonio With...
==========================

Expand Down
29 changes: 25 additions & 4 deletions docs/lsp/reference/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -230,21 +230,42 @@ The following options control the creation of the Sphinx application object mana
:scope: project
:type: string[]

The ``sphinx-build`` command to use when invoking the Sphinx subprocess.
The ``sphinx-build`` command ``esbonio`` should use when building your documentation, for example::

["sphinx-build", "-M", "dirhtml", "docs", "${defaultBuildDir}", "--fail-on-warning"]

This can contain any valid :external+sphinx:std:doc:`man/sphinx-build` argument however, the following arguments will be ignored and have no effect.

- ``--color``, ``-P``, ``--pdb``

Additionally, this option supports the following variables

- ``${defaultBuildDir}``: Expands to esbonio's default choice of build directory

.. esbonio:config:: esbonio.sphinx.pythonCommand
:scope: project
:type: string[]

The command to use when launching the Python interpreter for the process hosting the Sphinx application.
Use this to select the Python environment you want to use when building your documentation.
Used to select the Python environment ``esbonio`` should use when building your documentation.
This can be as simple as the full path to the Python executable in your virtual environment::

["/home/user/Projects/example/venv/bin/python"]

Or a complex command with a number of options and arguments::

["hatch", "-e", "docs", "run", "python"]

For more examples see :ref:`lsp-use-with`

.. esbonio:config:: esbonio.sphinx.cwd
:scope: project
:type: string

The working directory from which to launch the Sphinx process.
If not set, this will default to the root of the workspace folder containing the project.
If not set

- ``esbonio`` will use the directory containing the "closest" ``pyproject.toml`` file.
- If no ``pyproject.toml`` file can be found, ``esbonio`` will use workspace folder containing the project.

.. esbonio:config:: esbonio.sphinx.envPassthrough
:scope: project
Expand Down
8 changes: 6 additions & 2 deletions docs/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tool.esbonio.sphinx]
# buildCommand = ["sphinx-build", "-M", "dirhtml", ".", "./_build"]
buildCommand = ["sphinx-build", "-M", "dirhtml", ".", "${defaultBuildDir}"]
pythonCommand = ["hatch", "-e", "docs", "run", "python"]

[tool.hatch.envs.docs]
Expand All @@ -12,8 +12,12 @@ dependencies = [
"furo",
"myst-parser",
"platformdirs",
"pytest_lsp",
"pytest_lsp>=1.0b0",
"pygls>=2.0a0",
]

[tool.hatch.envs.docs.env-vars]
UV_PRERELEASE = "allow"

[tool.hatch.envs.docs.scripts]
build = "sphinx-build -M dirhtml . ./_build"
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import typing
from uuid import uuid4

import platformdirs
from pygls.client import JsonRPCClient
from pygls.protocol import JsonRPCProtocol

Expand Down Expand Up @@ -212,6 +213,9 @@ async def start(self) -> SphinxClient:
params = types.CreateApplicationParams(
command=self.config.build_command,
config_overrides=self.config.config_overrides,
context={
"cacheDir": platformdirs.user_cache_dir("esbonio", "swyddfa"),
},
)
self.sphinx_info = await self.protocol.send_request_async(
"sphinx/createApp", params
Expand Down
13 changes: 7 additions & 6 deletions lib/esbonio/esbonio/server/features/sphinx_manager/config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
from __future__ import annotations

import hashlib
import importlib.util
import logging
import pathlib
from typing import Any
from typing import Optional

import attrs
import platformdirs
from pygls.workspace import Workspace

from esbonio.server import Uri
Expand Down Expand Up @@ -227,9 +225,12 @@ def _resolve_build_command(self, uri: Uri, logger: logging.Logger) -> list[str]:
conf_py = current / "conf.py"
logger.debug("Trying path: %s", current)
if conf_py.exists():
cache = platformdirs.user_cache_dir("esbonio", "swyddfa")
project = hashlib.md5(str(current).encode()).hexdigest() # noqa: S324
build_dir = str(pathlib.Path(cache, project))
return ["sphinx-build", "-M", "dirhtml", str(current), str(build_dir)]
return [
"sphinx-build",
"-M",
"dirhtml",
str(current),
"${defaultBuildDir}",
]

return []
14 changes: 12 additions & 2 deletions lib/esbonio/esbonio/server/features/sphinx_manager/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,18 @@ async def get_client(self, uri: Uri) -> SphinxClient | None:
partial(self._create_or_replace_client, uri),
scope=uri,
)
# The first few callers in a given scope will miss out, but that shouldn't matter
# too much

# It's possible for this code path to be hit multiple times in quick
# succession e.g. on a fresh server start with N .rst files already open,
# creating the opportunity to accidentally spawn N duplicated clients!
#
# To prevent this, store a `None` at this scope, all being well it will be
# replaced with the actual client instance when the
# `_create_or_replace_client` callback runs.
self.clients[scope] = None

# The first few callers in a given scope will miss out, but that shouldn't
# matter too much
return None

if (client := self.clients[scope]) is None:
Expand Down
4 changes: 3 additions & 1 deletion lib/esbonio/esbonio/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ def get_text_document(self, doc_uri: str) -> TextDocument:
uri = str(Uri.parse(doc_uri).resolve())
return super().get_text_document(uri)

def put_text_document(self, text_document: types.TextDocumentItem):
def put_text_document(
self, text_document: types.TextDocumentItem, notebook_uri: str | None = None
):
text_document.uri = str(Uri.parse(text_document.uri).resolve())
return super().put_text_document(text_document)

Expand Down
2 changes: 1 addition & 1 deletion lib/esbonio/esbonio/server/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async def on_document_save(
):
# Record the version number of the document
doc = ls.workspace.get_text_document(params.text_document.uri)
doc.saved_version = doc.version or 0
doc.saved_version = doc.version or 0 # type: ignore[attr-defined]

await call_features(ls, "document_save", params)

Expand Down
42 changes: 41 additions & 1 deletion lib/esbonio/esbonio/sphinx_agent/config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import dataclasses
import hashlib
import inspect
import pathlib
import re
import sys
from typing import Any
from typing import Literal
Expand All @@ -13,6 +15,8 @@
from sphinx.application import Sphinx
from sphinx.cmd.build import main as sphinx_build

VARIABLE = re.compile(r"\$\{(\w+)\}")


@dataclasses.dataclass
class SphinxConfig:
Expand Down Expand Up @@ -128,7 +132,7 @@ def fromcli(cls, args: list[str]):
warning_is_error=sphinx_args.get("warningiserror", False),
)

def to_application_args(self) -> dict[str, Any]:
def to_application_args(self, context: dict[str, Any]) -> dict[str, Any]:
"""Convert this into the equivalent Sphinx application arguments."""

# On OSes like Fedora Silverblue, `/home` is symlinked to `/var/home`. This
Expand All @@ -139,6 +143,27 @@ def to_application_args(self) -> dict[str, Any]:
# Resolving these paths here, should ensure that the agent always
# reports the true location of any given directory.
conf_dir = pathlib.Path(self.conf_dir).resolve()
self.conf_dir = str(conf_dir)

# Resolve any config variables.
#
# This is a bit hacky, but let's go with it for now. The only config variable
# we currently support is 'defaultBuildDir' which is derived from the value
# of `conf_dir`. So we resolve the full path of `conf_dir` above, then resolve
# the configuration variables here, before finally calling resolve() on the
# remaining paths below.
for name, value in dataclasses.asdict(self).items():
if not isinstance(value, str):
continue

if (match := VARIABLE.match(value)) is None:
continue

replacement = self.resolve_config_variable(match.group(1), context)
result = VARIABLE.sub(re.escape(replacement), value)

setattr(self, name, result)

build_dir = pathlib.Path(self.build_dir).resolve()
doctree_dir = pathlib.Path(self.doctree_dir).resolve()
src_dir = pathlib.Path(self.src_dir).resolve()
Expand All @@ -159,3 +184,18 @@ def to_application_args(self) -> dict[str, Any]:
"warning": sys.stderr,
"warningiserror": self.warning_is_error,
}

def resolve_config_variable(self, name: str, context: dict[str, str]):
"""Resolve the value for the given configuration variable."""

if name.lower() == "defaultbuilddir":
if (cache_dir := context.get("cacheDir")) is None:
raise RuntimeError(
f"Unable to resolve config variable {name!r}, "
"missing context value: 'cacheDir'"
)

project = hashlib.md5(self.conf_dir.encode()).hexdigest() # noqa: S324
return str(pathlib.Path(cache_dir, project))

raise ValueError(f"Unknown configuration variable {name!r}")
8 changes: 5 additions & 3 deletions lib/esbonio/esbonio/sphinx_agent/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,14 @@ class definition from the ``types`` module.

def create_sphinx_app(self, request: types.CreateApplicationRequest):
"""Create a new sphinx application instance."""
sphinx_config = SphinxConfig.fromcli(request.params.command)
params = request.params

sphinx_config = SphinxConfig.fromcli(params.command)
if sphinx_config is None:
raise ValueError("Invalid build command")

sphinx_config.config_overrides.update(request.params.config_overrides)
sphinx_args = sphinx_config.to_application_args()
sphinx_config.config_overrides.update(params.config_overrides)
sphinx_args = sphinx_config.to_application_args(params.context)
self.app = Sphinx(**sphinx_args)

# Connect event handlers.
Expand Down
Loading