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

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. 

16 

17At ``builder-inited`` time this extension: 

18 

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. 

23 

24Enable it via :func:`~ensembl.utils.docs.config.build_config`:: 

25 

26 configure( 

27 globals(), 

28 ..., 

29 include_entrypoints=True, 

30 ) 

31 

32Optionally override the defaults in ``conf.py`` after the ``configure()`` call:: 

33 

34 entrypoints_target_file = "user_guide/usage.md" # relative to docs source dir 

35 entrypoints_toml_file = "../pyproject.toml" # relative to docs source dir 

36 

37""" 

38 

39import re 

40import sys 

41from pathlib import Path 

42from typing import Any 

43 

44from sphinx.application import Sphinx 

45from sphinx.util import logging as sphinx_logging 

46 

47import ensembl.utils 

48 

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 

56 

57 

58logger = sphinx_logging.getLogger(__name__) 

59 

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) 

65 

66 

67def _read_entry_points(toml_path: Path) -> dict[str, str]: 

68 """Parse ``project.scripts`` from a ``pyproject.toml`` file. 

69 

70 Args: 

71 toml_path: Absolute path to the ``pyproject.toml`` file. 

72 

73 Returns: 

74 Mapping of ``script-name`` to ``package.module:function``. Returns an empty dict when 

75 the section is absent. 

76 

77 """ 

78 with toml_path.open("rb") as fh: 

79 data = tomllib.load(fh) 

80 return data.get("project", {}).get("scripts", {}) 

81 

82 

83def _build_markdown_table(entry_points: dict[str, str]) -> str: 

84 """Render ``entry_points`` as a Markdown table string. 

85 

86 Args: 

87 entry_points: Mapping returned by :func:`_read_entry_points`. 

88 

89 Returns: 

90 A complete Markdown table, or a short italicised notice when ``entry_points`` is empty. 

91 

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]) 

106 

107 

108def _inject_table(usage_path: Path, table_md: str) -> None: 

109 """Replace the sentinel region in ``usage_path`` with ``table_md``. 

110 

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. 

113 

114 Args: 

115 usage_path: Absolute path to the target Markdown file. 

116 table_md: Rendered Markdown table produced by :func:`_build_markdown_table`. 

117 

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") 

135 

136 

137def _on_builder_inited(app: Sphinx) -> None: 

138 """Sphinx event handler for ``builder-inited``. 

139 

140 Args: 

141 app: The Sphinx application object provided by the event system. 

142 

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) 

164 

165 

166def setup(app: Sphinx) -> dict[str, Any]: 

167 """Register the extension with Sphinx. 

168 

169 Adds two optional ``conf.py`` configuration values: 

170 

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. 

174 

175 Args: 

176 app: The Sphinx application object. 

177 

178 Returns: 

179 Sphinx extension metadata. 

180 

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 }