Source code for ensembl.utils.docs.entrypoints_ext
# See the NOTICE file distributed with this work for additional information
# regarding copyright ownership.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Sphinx extension: auto-inject a CLI entry-points reference table into a Markdown page.
At ``builder-inited`` time this extension:
1. Reads ``[project.scripts]`` from the ``pyproject.toml``.
2. Builds a Markdown table mapping each script name to its Python target (``package.module:function``).
3. Writes the table between two sentinel comments inside the target Markdown file, replacing whatever
was there from a previous build so the file stays under version control and is always up-to-date.
Enable it via :func:`~ensembl.utils.docs.config.build_config`::
configure(
globals(),
...,
include_entrypoints=True,
)
Optionally override the defaults in ``conf.py`` after the ``configure()`` call::
entrypoints_target_file = "user_guide/usage.md" # relative to docs source dir
entrypoints_toml_file = "../pyproject.toml" # relative to docs source dir
"""
import re
import sys
from pathlib import Path
from typing import Any
from sphinx.application import Sphinx
from sphinx.util import logging as sphinx_logging
import ensembl.utils
if sys.version_info >= (3, 11):
import tomllib
else:
try:
import tomli as tomllib # type: ignore[no-redef]
except ImportError as exc:
raise ImportError("Python < 3.11 requires the 'tomli' package: pip install tomli") from exc
logger = sphinx_logging.getLogger(__name__)
# Sentinels written into the Markdown file. Everything between them is replaced
_SENTINEL_START = "<!-- entrypoints-table:start -->"
_SENTINEL_END = "<!-- entrypoints-table:end -->"
# Regex that matches the region between the two sentinels (inclusive)
_REGION_RE = re.compile(rf"{re.escape(_SENTINEL_START)}.*?{re.escape(_SENTINEL_END)}", re.DOTALL)
def _read_entry_points(toml_path: Path) -> dict[str, str]:
"""Parse ``project.scripts`` from a ``pyproject.toml`` file.
Args:
toml_path: Absolute path to the ``pyproject.toml`` file.
Returns:
Mapping of ``script-name`` to ``package.module:function``. Returns an empty dict when
the section is absent.
"""
with toml_path.open("rb") as fh:
data = tomllib.load(fh)
return data.get("project", {}).get("scripts", {})
def _build_markdown_table(entry_points: dict[str, str]) -> str:
"""Render ``entry_points`` as a Markdown table string.
Args:
entry_points: Mapping returned by :func:`_read_entry_points`.
Returns:
A complete Markdown table, or a short italicised notice when ``entry_points`` is empty.
"""
if not entry_points:
return "_No entry points are defined in `pyproject.toml`._"
max_cmd = max(len(cmd) for cmd in entry_points)
max_target = max(len(target) for target in entry_points.values())
col_cmd = max(len("Command"), max_cmd)
col_target = max(len("Python target"), max_target)
header = f"| {'Command':<{col_cmd}} | {'Python target':<{col_target}} |"
separator = f"| {'-' * col_cmd} | {'-' * col_target} |"
rows = [
f"| `{cmd}`{' ' * (col_cmd - len(cmd) - 2)} | `{target}`{' ' * (col_target - len(target) - 2)} |"
for cmd, target in sorted(entry_points.items())
]
return "\n".join([header, separator, *rows])
def _inject_table(usage_path: Path, table_md: str) -> None:
"""Replace the sentinel region in ``usage_path`` with ``table_md``.
If the sentinels are not found the table and the sentinels are appended to the end of the file with
a preceding blank line, so the extension is safe to add to a page that has not yet been prepared.
Args:
usage_path: Absolute path to the target Markdown file.
table_md: Rendered Markdown table produced by :func:`_build_markdown_table`.
"""
original = usage_path.read_text(encoding="utf-8")
replacement_block = f"{_SENTINEL_START}\n{table_md}\n{_SENTINEL_END}"
if _REGION_RE.search(original):
updated = _REGION_RE.sub(replacement_block, original)
else:
logger.warning(
"entrypoints_table: sentinels not found in '%s'. Appending the table and a templated "
"section at the end of the file.",
usage_path,
)
cli_section = (
"\n\n## CLI reference\n\nThe following commands are installed as entry points to ease "
f"handling common tasks:\n\n{replacement_block}\n"
)
updated = original.rstrip("\n") + cli_section
usage_path.write_text(updated, encoding="utf-8")
def _on_builder_inited(app: Sphinx) -> None:
"""Sphinx event handler for ``builder-inited``.
Args:
app: The Sphinx application object provided by the event system.
"""
src_dir = Path(app.srcdir)
target_rel: str = app.config.entrypoints_target_file # type: ignore[attr-defined]
toml_rel: str = app.config.entrypoints_toml_file # type: ignore[attr-defined]
usage_path = src_dir / target_rel
toml_path = (src_dir / toml_rel).resolve()
logger.info("entrypoints_table: reading entry points from '%s'", toml_path)
try:
entry_points = _read_entry_points(toml_path)
except FileNotFoundError as exc:
logger.warning("entrypoints_table: %s — skipping table generation.", exc)
return
table_md = _build_markdown_table(entry_points)
if not usage_path.is_file():
logger.warning(
"entrypoints_table: target file '%s' does not exist — skipping injection.",
usage_path,
)
return
_inject_table(usage_path, table_md)
logger.info("entrypoints_table: table injected into '%s'.", usage_path)
[docs]
def setup(app: Sphinx) -> dict[str, Any]:
"""Register the extension with Sphinx.
Adds two optional ``conf.py`` configuration values:
- ``entrypoints_target_file``: Path to the Markdown file to inject into, relative to the Sphinx
source directory.
- ``entrypoints_toml_file``: Path to ``pyproject.toml``, relative to the Sphinx source directory.
Args:
app: The Sphinx application object.
Returns:
Sphinx extension metadata.
"""
app.add_config_value("entrypoints_target_file", "user_guide/usage.md", "env")
app.add_config_value("entrypoints_toml_file", "../pyproject.toml", "env")
app.connect("builder-inited", _on_builder_inited)
return {
"version": ensembl.utils.__version__,
"parallel_read_safe": True,
"parallel_write_safe": True,
}