Coverage for tsfpga/tools/sphinx_doc.py: 0%
74 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 20:51 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 20:51 +0000
1# --------------------------------------------------------------------------------------------------
2# Copyright (c) Lukas Vik. All rights reserved.
3#
4# This file is part of the tsfpga project, a project platform for modern FPGA development.
5# https://tsfpga.com
6# https://github.com/tsfpga/tsfpga
7# --------------------------------------------------------------------------------------------------
8# A set of methods for building sphinx docs. Should be reusable between projects.
9# --------------------------------------------------------------------------------------------------
11from __future__ import annotations
13import sys
14from datetime import datetime
15from subprocess import CalledProcessError
16from typing import TYPE_CHECKING
18from git.repo import Repo
19from packaging.version import Version, parse
21from tsfpga.system_utils import read_file, run_command
23if TYPE_CHECKING:
24 from collections.abc import Iterable
25 from pathlib import Path
28def generate_release_notes(
29 repo_root: Path, release_notes_directory: Path, project_name: str
30) -> str:
31 """
32 Generate release notes in RST format based on a directory full of release note files.
33 Will match each file to a git tag.
35 Arguments:
36 repo_root: Git commands will be executed here.
37 release_notes_directory: Location of release notes files.
38 project_name: Name of project will be used for the GitHub link.
40 Return:
41 RST code with release notes.
42 """
43 rst = ""
45 for release, previous_release_git_tag in _get_release_notes_files(
46 repo_root=repo_root, release_notes_directory=release_notes_directory
47 ):
48 heading = f"{release.version} ({release.date})"
49 rst += heading + "\n"
50 rst += "-" * len(heading) + "\n"
51 rst += "\n"
52 if previous_release_git_tag is not None:
53 diff_url = (
54 f"https://github.com/{project_name}/{project_name}/compare/"
55 f"{previous_release_git_tag}...{release.git_tag}"
56 )
57 rst += f"`Changes since previous release <{diff_url}>`__\n"
58 rst += "\n"
59 rst += read_file(release.release_notes_file)
60 rst += "\n"
62 return rst
65def _get_release_notes_files(
66 repo_root: Path, release_notes_directory: Path
67) -> Iterable[tuple[Release, str | None]]:
68 """
69 Iterate the release notes.
70 """
71 unreleased_notes_file = release_notes_directory / "unreleased.rst"
73 # All versioned release notes files.
74 release_notes = [
75 release_notes_file
76 for release_notes_file in release_notes_directory.glob("*.rst")
77 if release_notes_file != unreleased_notes_file
78 ]
80 # Sort by parsing the version number in the file name. Newest to oldest.
81 def sort_key(path: Path) -> Version:
82 return parse(path.stem)
84 release_notes.sort(key=sort_key, reverse=True)
86 # The "Unreleased" shall be first
87 release_notes.insert(0, unreleased_notes_file)
89 repo = Repo(repo_root)
90 releases = [
91 Release(repo=repo, release_notes_file=release_notes_file)
92 for release_notes_file in release_notes
93 ]
95 for idx, release in enumerate(releases):
96 previous_release_git_tag = None if idx == len(releases) - 1 else releases[idx + 1].git_tag
98 yield release, previous_release_git_tag
101class Release:
102 """
103 Used to represent a release.
104 """
106 def __init__(self, repo: Repo, release_notes_file: Path) -> None:
107 self.release_notes_file = release_notes_file
109 version = release_notes_file.stem
110 if version == "unreleased":
111 self.version = "Unreleased"
112 self.git_tag = "main"
113 self.date = "YYYY-MM-DD"
114 else:
115 self.version = version
116 self.git_tag = "v" + self.version
117 self.date = self.get_git_date_from_tag(repo=repo, tag=self.git_tag)
119 @staticmethod
120 def get_git_date_from_tag(repo: Repo, tag: str) -> str:
121 """
122 Get a formatted date string, gathered from git log based on tag name.
123 """
124 timestamp = repo.tag(f"refs/tags/{tag}").commit.committed_date
125 # This call will more or less guess the user's timezone.
126 # In a general use case this is not reliable, hence the rule, but in our case
127 # the date information is not critical in any way.
128 # It is just there for extra info.
129 # https://docs.astral.sh/ruff/rules/call-datetime-fromtimestamp/
130 time = datetime.fromtimestamp(timestamp) # noqa: DTZ006
131 return f"{time.day} {time:%B} {time.year}".lower()
134def build_sphinx(
135 build_path: Path, output_path: Path, turn_warnings_into_errors: bool = True
136) -> None:
137 """
138 Execute sphinx on command line to build HTML documentation.
140 Arguments:
141 build_path: The location that contains ``conf.py`` and ``index.rst``.
142 output_path: Where to place the generated HTML.
143 turn_warnings_into_errors: Needed while Pygments has a bug when rendering TCL.
144 Will be removed in the future without API deprecation warning.
145 """
146 # Since we set the working directory when making the system call, paths must be absolute.
147 build_path = build_path.resolve()
148 output_path = output_path.resolve()
150 print("Building Sphinx documentation...")
152 cmd = [
153 sys.executable,
154 "-m",
155 "sphinx",
156 # Show full traceback upon error.
157 "-T",
158 str(build_path),
159 str(output_path),
160 ]
161 if turn_warnings_into_errors:
162 cmd.append("-W")
164 try:
165 run_command(cmd, cwd=build_path, capture_output=True)
166 except CalledProcessError as exception:
167 print(f"ERROR: Sphinx build command failed with exit code {exception.returncode}.")
168 if exception.stdout:
169 print("-" * 80)
170 print("ERROR: Command STDOUT:\n")
171 print(exception.stdout)
172 if exception.stderr:
173 print("-" * 80)
174 print("ERROR: Command STDERR:\n")
175 print(exception.stderr)
176 raise
178 index_html = output_path / "index.html"
179 if not index_html.exists():
180 raise FileNotFoundError(f"Creating HTML failed: {index_html}")
182 print(f"Documentation build done. Open with:\n firefox {index_html} &")