Coverage for src/ensembl/utils/docs/entrypoints_ext.py: 94%
64 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 09:43 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 09:43 +0000
1# See the NOTICE file distributed with this work for additional information
2# regarding copyright ownership.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15"""Sphinx extension: auto-inject a CLI entry-points reference table into a Markdown page.
17At ``builder-inited`` time this extension:
191. Reads ``[project.scripts]`` from the ``pyproject.toml``.
202. Builds a Markdown table mapping each script name to its Python target (``package.module:function``).
213. Writes the table between two sentinel comments inside the target Markdown file, replacing whatever
22 was there from a previous build so the file stays under version control and is always up-to-date.
24Enable it via :func:`~ensembl.utils.docs.config.build_config`::
26 configure(
27 globals(),
28 ...,
29 include_entrypoints=True,
30 )
32Optionally override the defaults in ``conf.py`` after the ``configure()`` call::
34 entrypoints_target_file = "user_guide/usage.md" # relative to docs source dir
35 entrypoints_toml_file = "../pyproject.toml" # relative to docs source dir
37"""
39import re
40import sys
41from pathlib import Path
42from typing import Any
44from sphinx.application import Sphinx
45from sphinx.util import logging as sphinx_logging
47import ensembl.utils
49if sys.version_info >= (3, 11): 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 import tomllib
51else:
52 try:
53 import tomli as tomllib # type: ignore[no-redef]
54 except ImportError as exc:
55 raise ImportError("Python < 3.11 requires the 'tomli' package: pip install tomli") from exc
58logger = sphinx_logging.getLogger(__name__)
60# Sentinels written into the Markdown file. Everything between them is replaced
61_SENTINEL_START = "<!-- entrypoints-table:start -->"
62_SENTINEL_END = "<!-- entrypoints-table:end -->"
63# Regex that matches the region between the two sentinels (inclusive)
64_REGION_RE = re.compile(rf"{re.escape(_SENTINEL_START)}.*?{re.escape(_SENTINEL_END)}", re.DOTALL)
67def _read_entry_points(toml_path: Path) -> dict[str, str]:
68 """Parse ``project.scripts`` from a ``pyproject.toml`` file.
70 Args:
71 toml_path: Absolute path to the ``pyproject.toml`` file.
73 Returns:
74 Mapping of ``script-name`` to ``package.module:function``. Returns an empty dict when
75 the section is absent.
77 """
78 with toml_path.open("rb") as fh:
79 data = tomllib.load(fh)
80 return data.get("project", {}).get("scripts", {})
83def _build_markdown_table(entry_points: dict[str, str]) -> str:
84 """Render ``entry_points`` as a Markdown table string.
86 Args:
87 entry_points: Mapping returned by :func:`_read_entry_points`.
89 Returns:
90 A complete Markdown table, or a short italicised notice when ``entry_points`` is empty.
92 """
93 if not entry_points:
94 return "_No entry points are defined in `pyproject.toml`._"
95 max_cmd = max(len(cmd) for cmd in entry_points)
96 max_target = max(len(target) for target in entry_points.values())
97 col_cmd = max(len("Command"), max_cmd)
98 col_target = max(len("Python target"), max_target)
99 header = f"| {'Command':<{col_cmd}} | {'Python target':<{col_target}} |"
100 separator = f"| {'-' * col_cmd} | {'-' * col_target} |"
101 rows = [
102 f"| `{cmd}`{' ' * (col_cmd - len(cmd) - 2)} | `{target}`{' ' * (col_target - len(target) - 2)} |"
103 for cmd, target in sorted(entry_points.items())
104 ]
105 return "\n".join([header, separator, *rows])
108def _inject_table(usage_path: Path, table_md: str) -> None:
109 """Replace the sentinel region in ``usage_path`` with ``table_md``.
111 If the sentinels are not found the table and the sentinels are appended to the end of the file with
112 a preceding blank line, so the extension is safe to add to a page that has not yet been prepared.
114 Args:
115 usage_path: Absolute path to the target Markdown file.
116 table_md: Rendered Markdown table produced by :func:`_build_markdown_table`.
118 """
119 original = usage_path.read_text(encoding="utf-8")
120 replacement_block = f"{_SENTINEL_START}\n{table_md}\n{_SENTINEL_END}"
121 if _REGION_RE.search(original):
122 updated = _REGION_RE.sub(replacement_block, original)
123 else:
124 logger.warning(
125 "entrypoints_table: sentinels not found in '%s'. Appending the table and a templated "
126 "section at the end of the file.",
127 usage_path,
128 )
129 cli_section = (
130 "\n\n## CLI reference\n\nThe following commands are installed as entry points to ease "
131 f"handling common tasks:\n\n{replacement_block}\n"
132 )
133 updated = original.rstrip("\n") + cli_section
134 usage_path.write_text(updated, encoding="utf-8")
137def _on_builder_inited(app: Sphinx) -> None:
138 """Sphinx event handler for ``builder-inited``.
140 Args:
141 app: The Sphinx application object provided by the event system.
143 """
144 src_dir = Path(app.srcdir)
145 target_rel: str = app.config.entrypoints_target_file # type: ignore[attr-defined]
146 toml_rel: str = app.config.entrypoints_toml_file # type: ignore[attr-defined]
147 usage_path = src_dir / target_rel
148 toml_path = (src_dir / toml_rel).resolve()
149 logger.info("entrypoints_table: reading entry points from '%s'", toml_path)
150 try:
151 entry_points = _read_entry_points(toml_path)
152 except FileNotFoundError as exc:
153 logger.warning("entrypoints_table: %s — skipping table generation.", exc)
154 return
155 table_md = _build_markdown_table(entry_points)
156 if not usage_path.is_file():
157 logger.warning(
158 "entrypoints_table: target file '%s' does not exist — skipping injection.",
159 usage_path,
160 )
161 return
162 _inject_table(usage_path, table_md)
163 logger.info("entrypoints_table: table injected into '%s'.", usage_path)
166def setup(app: Sphinx) -> dict[str, Any]:
167 """Register the extension with Sphinx.
169 Adds two optional ``conf.py`` configuration values:
171 - ``entrypoints_target_file``: Path to the Markdown file to inject into, relative to the Sphinx
172 source directory.
173 - ``entrypoints_toml_file``: Path to ``pyproject.toml``, relative to the Sphinx source directory.
175 Args:
176 app: The Sphinx application object.
178 Returns:
179 Sphinx extension metadata.
181 """
182 app.add_config_value("entrypoints_target_file", "user_guide/usage.md", "env")
183 app.add_config_value("entrypoints_toml_file", "../pyproject.toml", "env")
184 app.connect("builder-inited", _on_builder_inited)
185 return {
186 "version": ensembl.utils.__version__,
187 "parallel_read_safe": True,
188 "parallel_write_safe": True,
189 }