Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Bug fixes

- The "Edit on GitHub" button now links to the correct file. The in-repository path is resolved from the Sphinx **source** directory rather than the directory containing `conf.py`, so projects that build with `sphinx-build -c . docs _build/html` (where those two directories differ) no longer produce links that 404. Projects whose source directory is the repository root also no longer get a stray `./` in the URL. This path is computed by a new `documenteer.ext.githubeditlink` extension, which the user-guide preset enables automatically.

- Building a user guide outside a Git checkout — from an sdist, or in a Docker image built without the `.git` directory — no longer fails at configuration time with `InvalidGitRepositoryError`. The "Edit on GitHub" button is omitted instead and the build proceeds, so setting `show_github_edit_link = false` is no longer needed to work around this. Note that such a build still draws a `git.subprocess_error` warning from sphinx-last-updated-by-git (loaded through sphinx-sitemap), so a project that builds with `-W` also needs `suppress_warnings = ["git.subprocess_error"]` in `conf.py`.
11 changes: 11 additions & 0 deletions docs/guides/toml-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,17 @@ This configuration requires information about the GitHub repository from these o
- :ref:`project.github_url <guide-project-github-url>`
- :ref:`project.github_default_branch <guide-project-github-default-branch>`

.. seealso::

:doc:`/sphinx-extensions/github-edit-link` for how the edit URL is assembled.

The in-repository path of the documentation is detected automatically from where the Sphinx **source** directory sits in the Git working tree, so there is nothing to configure for it.
Builds that keep :file:`conf.py` outside the source directory — ``sphinx-build -c . docs _build/html``, for instance — link to the right file.

When the documentation isn't being built from a Git checkout (an sdist, or a Docker image built without the :file:`.git` directory) the path can't be determined, so the button is omitted from every page — noted in the build log at the informational level — and the build proceeds.
Such a build does still draw a ``git.subprocess_error`` warning from sphinx-last-updated-by-git, so if you build with ``-W`` see :doc:`/sphinx-extensions/github-edit-link` for the warning to suppress.
To keep the button in that situation, set the path yourself with ``html_context["doc_path"]`` in :file:`conf.py`; see :doc:`/sphinx-extensions/github-edit-link`.

.. _guide-project-show-last-updated:

show_last_updated
Expand Down
113 changes: 113 additions & 0 deletions docs/sphinx-extensions/github-edit-link.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
.. _documenteer-ext-githubeditlink:

######################
"Edit on GitHub" links
######################

Documenteer's ``documenteer.ext.githubeditlink`` extension resolves the in-repository path that pydata-sphinx-theme's "Edit on GitHub" button links to.
It determines where the Sphinx **source** directory sits inside the Git working tree and publishes that path as the ``doc_path`` value in the HTML context.

.. tip::

If you use Documenteer's user-guide configuration preset, this extension is already enabled and the button is shown by default.
Toggle it with the :ref:`show_github_edit_link <guide-project-show-github-edit-link>` setting in :file:`documenteer.toml`; you don't need to edit :file:`conf.py`.

How the edit URL is built
=========================

pydata-sphinx-theme assembles the edit URL from several HTML context values:

.. code-block:: text

{github_url}/{github_user}/{github_repo}/edit/{github_version}/{doc_path}{file_name}

The user-guide preset fills in ``github_user``, ``github_repo``, and ``github_version`` from the :ref:`project.github_url <guide-project-github-url>` and :ref:`project.github_default_branch <guide-project-github-default-branch>` settings in :file:`documenteer.toml`.

``file_name`` is supplied by the theme as the page's document name plus its source suffix — for example :file:`index.rst`.
That name is relative to the Sphinx **source** directory, so ``doc_path`` has to be the source directory's own path within the Git working tree for the two halves to join into a real file path.
This extension computes that path.

Why an extension rather than :file:`conf.py`
============================================

The rest of the "Edit on GitHub" context is assembled while :file:`conf.py` is executed, but ``doc_path`` cannot be.
At that moment there is no Sphinx application yet, and therefore no source directory to consult; the only path available is the current working directory, which Sphinx sets to the directory holding :file:`conf.py` — the *configuration* directory.

For many projects the configuration and source directories are the same, and the distinction doesn't matter.
They differ whenever a build passes ``-c``:

.. code-block:: shell

sphinx-build -b html -c . docs _build/html

Here the configuration directory is the repository root while the source directory is :file:`docs/`.
A ``doc_path`` taken from the configuration directory would produce a URL that looks plausible but points at a file that doesn't exist.

This extension therefore computes ``doc_path`` from the source directory at Sphinx's ``config-inited`` event — the earliest point where the source directory is known.
That is also the safest point at which to *switch the button off*, because a theme may copy ``html_theme_options`` when the builder initializes, and a change made after that copy wouldn't reach the template.

Overriding the detected path
============================

Auto-detection covers every layout Rubin projects are known to use, but you can set ``doc_path`` yourself in :file:`conf.py` to override it:

.. code-block:: python
:caption: conf.py

html_context["doc_path"] = "docs"

When ``doc_path`` is already set, the extension uses that value and doesn't consult Git at all.
The button therefore keeps working outside a Git checkout — supplying the path answers the question that auto-detection would have used the working tree to answer.

This is the escape hatch for source layouts that a path within the working tree can't describe, such as a generated source directory, or documentation published from a different repository than the one being built.

Builds outside a Git checkout
=============================

The path can only be derived from a Git working tree.
When the source directory isn't inside one — an sdist, a vendored documentation tree, or a Docker image built without the :file:`.git` directory — the extension omits the "Edit on GitHub" button from every page and notes this in the build log at the informational level.

The message is deliberately *not* a warning: building outside a checkout is legitimate, and a warning would fail any build run with ``-W`` (which turns warnings into errors).

.. note::

This extension stays quiet, but it isn't the only part of the user-guide stack that reads Git.
The preset also loads `sphinx-last-updated-by-git <https://github.com/mgeier/sphinx-last-updated-by-git>`__ (through sphinx-sitemap), which *does* warn when it can't read the history:

.. code-block:: text

WARNING: Error getting data from Git (no "last updated" dates will be shown
for source files from …): fatal: not a git repository [git.subprocess_error]

A project that builds with ``-W`` outside a Git checkout therefore needs to suppress that warning as well:

.. code-block:: python
:caption: conf.py

suppress_warnings = ["git.subprocess_error"]

Without ``-W`` the warning is harmless and the build succeeds either way.

Reference
=========

The user-guide configuration preset enables this extension automatically.
To use it in a standalone Sphinx project, add ``"documenteer.ext.githubeditlink"`` to the extensions list in :file:`conf.py` and provide the repository context that pydata-sphinx-theme expects:

.. code-block:: python
:caption: conf.py

extensions = ["documenteer.ext.githubeditlink", ...]

html_theme = "pydata_sphinx_theme"
html_theme_options = {
"use_edit_page_button": True,
}
html_context = {
"github_user": "lsst-sqre",
"github_repo": "documenteer",
"github_version": "main",
}

The extension fills in ``doc_path`` itself, so you don't need to provide it — but it honors the value if you do (see `Overriding the detected path`_).
It takes no configuration of its own, and is inert unless ``use_edit_page_button`` is enabled.
1 change: 1 addition & 0 deletions docs/sphinx-extensions/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ Sphinx extensions
:caption: Page metadata

last-updated
github-edit-link
26 changes: 8 additions & 18 deletions src/documenteer/conf/_toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
)
from sphinx.errors import ConfigError

from ._utils import GitRepository

__all__ = [
"ConfigRoot",
"DocumenteerConfig",
Expand Down Expand Up @@ -519,7 +517,13 @@ def set_edit_on_github(
html_theme_options: MutableMapping[str, Any],
html_context: MutableMapping[str, Any],
) -> None:
"""Configure the Edit on GitHub functionality, if possible."""
"""Configure the Edit on GitHub functionality, if possible.

The in-repository path of the documentation source is *not* set here;
it is resolved from the Sphinx source directory by the
``documenteer.ext.githubeditlink`` extension, which is the earliest
point where the source directory is known.
"""
if (
self.conf.sphinx
and self.conf.sphinx.theme.show_github_edit_link is False
Expand All @@ -528,7 +532,7 @@ def set_edit_on_github(

if self.github_url is None:
raise ConfigError(
"sphinx.show_github_edit_link is True by the "
"sphinx.show_github_edit_link is True but the "
"project.github_url is not set."
)

Expand All @@ -543,26 +547,12 @@ def set_edit_on_github(
f"Could not parse GitHub repo URL: {self.github_url}"
) from e

repo = GitRepository(Path.cwd())
try:
# the current working directory for sphinx config is always
# the same as the directory containing the conf.py file.
doc_dir = str(Path.cwd().relative_to(repo.working_tree_dir))
except ValueError as e:
raise ConfigError(
"Cannot determine the path of the documentation directory "
"relative to the Git repository root. Set "
"sphinx.show_github_edit_link to false if this is not a "
"git repository."
) from e

html_theme_options["use_edit_page_button"] = True
html_context["github_user"] = github_owner
html_context["github_repo"] = github_repo
html_context["github_version"] = (
self.conf.project.github_default_branch
)
html_context["doc_path"] = doc_dir

@property
def header_links_before_dropdown(self) -> int:
Expand Down
33 changes: 33 additions & 0 deletions src/documenteer/conf/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,39 @@ def working_tree_dir(self) -> Path:
raise RuntimeError("Git repository is not available.")
return Path(path)

def compute_relative_path(self, path: Path | str) -> str | None:
"""Compute the path of a directory relative to the repository root.

Parameters
----------
path
A path inside the Git working tree.

Returns
-------
str or None
The POSIX-style path of ``path`` relative to the root of the Git
working tree. The empty string is returned when ``path`` *is* the
repository root (`pathlib.Path.relative_to` yields ``"."`` there,
which would inject a literal ``./`` into a URL built from this
value). `None` is returned when ``path`` lies outside the working
tree.

Notes
-----
Both the repository root and ``path`` are resolved before they are
compared, so a symlinked path (such as macOS's :file:`/tmp`, which
links to :file:`/private/tmp`) is still recognized as being inside the
working tree.
"""
root = self.working_tree_dir.resolve()
try:
relative = Path(path).resolve().relative_to(root)
except ValueError:
return None
posix = relative.as_posix()
return "" if posix == "." else posix

@property
def is_shallow(self) -> bool:
"""Whether the repository is a shallow clone.
Expand Down
1 change: 1 addition & 0 deletions src/documenteer/conf/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@
"sphinx_sitemap",
"documenteer.ext.robots",
"documenteer.ext.lastmodified",
"documenteer.ext.githubeditlink",
"documenteer.ext.jira",
"documenteer.ext.lsstdocushare",
"documenteer.ext.mockcoderefs",
Expand Down
147 changes: 147 additions & 0 deletions src/documenteer/ext/githubeditlink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Sphinx extension that resolves the in-repository path used by the "Edit on
GitHub" button.

pydata-sphinx-theme builds the edit URL as::

{github_url}/{github_user}/{github_repo}/edit/{github_version}/{doc_path}{file_name}

where ``file_name`` is the page's docname plus its source suffix — a path
relative to the Sphinx **source** directory. ``doc_path`` must therefore be the
*source* directory's path within the Git working tree.

That value cannot be computed in :file:`conf.py`, which is where the rest of
the "Edit on GitHub" context is set. At ``conf.py`` exec time there is no
Sphinx application and hence no ``srcdir``; the only path available is the
current working directory, which Sphinx sets to the directory containing
``conf.py`` (the *config* directory). Those two directories are the same in
many projects, but not all: ``sphinx-build -c . docs _build/html`` puts the
config directory at the repository root and the source directory in
:file:`docs/`. Deriving ``doc_path`` from the config directory there yields a
plausible-looking URL that 404s.

This extension therefore computes ``doc_path`` from ``app.srcdir`` at
``config-inited``, the earliest event where the source directory is known.
That is also the safest event for *disabling* the button: ``builder.init()``
runs before ``builder-inited`` is emitted, and a theme may take its own copy of
``html_theme_options`` there rather than reading the live config, in which case
a later change wouldn't reach the template. (pydata-sphinx-theme's
``get_theme_options_dict`` documents exactly that "sometimes the copy never
occurs" ambiguity.) Settling it at ``config-inited`` avoids depending on which
behavior a given Sphinx or theme version has.

Ordering against pydata-sphinx-theme needs no special handling: that theme
connects its own handlers to ``builder-inited`` and ``html-page-context``, and
does nothing at ``config-inited``.

Outside a Git checkout (an sdist, a vendored documentation tree, a Docker image
built without :file:`.git`) the path can't be determined, so the button is
silently omitted rather than rendered with a wrong path.
"""

from __future__ import annotations

from pathlib import Path

import git
from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.util import logging
from sphinx.util.typing import ExtensionMetadata

from ..conf._utils import GitRepository
from ..version import __version__

__all__ = ["set_doc_path", "setup"]

logger = logging.getLogger(__name__)


def _disable_edit_button(config: Config) -> None:
"""Suppress the "Edit on GitHub" button for this build.

Turning off ``use_edit_page_button`` is sufficient: pydata-sphinx-theme's
``edit-this-page`` component is gated on the resulting
``theme_use_edit_page_button`` template variable, so the theme never tries
to build an edit URL (and never raises for the missing context).
"""
config.html_theme_options["use_edit_page_button"] = False
# Drop any stale value so a leftover path can't leak into the context.
config.html_context.pop("doc_path", None)


def set_doc_path(app: Sphinx, config: Config) -> None:
"""Set ``html_context["doc_path"]`` from the source directory's location
in the Git working tree.

Parameters
----------
app
The Sphinx application. Its ``srcdir`` is already resolved by Sphinx.
config
The Sphinx configuration, modified in place.
"""
if not config.html_theme_options.get("use_edit_page_button"):
# Presets that don't use the edit button (such as the technote preset)
# leave this extension inert.
return

if "doc_path" in config.html_context:
# An explicitly-configured path wins. This handler runs *after*
# conf.py, so without this an author could never override the
# auto-detected value. It is the escape hatch for the layouts a
# working-tree path can't describe: a generated source directory, or
# documentation published from a repository other than the one being
# built.
logger.debug(
"documenteer.ext.githubeditlink: using the configured doc_path "
"%r; skipping auto-detection.",
config.html_context["doc_path"],
)
return

try:
repo = GitRepository(Path(app.srcdir))
except (git.InvalidGitRepositoryError, git.NoSuchPathError):
# Deliberately info, not warning: Rubin projects build with ``-W``, and
# building outside a Git checkout is legitimate, so a warning would
# turn a cosmetic degradation into a build failure. (This also diverges
# from documenteer.ext.lastmodified, which logs at debug; this message
# explains a *missing* UI element, so it's worth showing by default.)
logger.info(
"documenteer.ext.githubeditlink: %s is not in a Git repository; "
"omitting the 'Edit on GitHub' button.",
app.srcdir,
)
_disable_edit_button(config)
return

doc_path = repo.compute_relative_path(app.srcdir)
if doc_path is None:
# A repository was found from the source directory, yet the source
# directory isn't inside its working tree. Both paths are resolved, so
# this shouldn't be reachable in a normal checkout; warn because it
# signals a genuinely odd setup (a bind mount, say) rather than the
# expected non-Git case.
logger.warning(
"documenteer.ext.githubeditlink: could not determine the path of "
"%s relative to the Git working tree at %s; omitting the 'Edit on "
"GitHub' button.",
app.srcdir,
repo.working_tree_dir,
)
_disable_edit_button(config)
return

config.html_context["doc_path"] = doc_path


def setup(app: Sphinx) -> ExtensionMetadata:
"""Set up the ``documenteer.ext.githubeditlink`` Sphinx extension."""
app.connect("config-inited", set_doc_path)

return {
"version": __version__,
# The handler runs once, at config-inited, in the main process.
"parallel_read_safe": True,
"parallel_write_safe": True,
}
Loading
Loading