Coverage for tsfpga/tools/sphinx_doc.py: 0%
75 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-21 20:51 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-21 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(build_path: Path, output_path: Path) -> None:
135 """
136 Execute sphinx on command line to build HTML documentation.
138 Arguments:
139 build_path: The location that contains ``conf.py`` and ``index.rst``.
140 output_path: Where to place the generated HTML.
141 """
142 # Since we set the working directory when making the system call, paths must be absolute.
143 build_path = build_path.resolve()
144 output_path = output_path.resolve()
146 print("Building Sphinx documentation...")
148 cmd = [
149 sys.executable,
150 "-m",
151 "sphinx",
152 # Turn warnings into errors.
153 "-W",
154 # Show full traceback upon error.
155 "-T",
156 str(build_path),
157 str(output_path),
158 ]
159 try:
160 run_command(cmd, cwd=build_path, capture_output=True)
161 except CalledProcessError as exception:
162 print(f"ERROR: Sphinx build command failed with exit code {exception.returncode}.")
163 if exception.stdout:
164 print("-" * 80)
165 print("ERROR: Command STDOUT:\n")
166 print(exception.stdout)
167 if exception.stderr:
168 print("-" * 80)
169 print("ERROR: Command STDERR:\n")
170 print(exception.stderr)
171 raise
173 index_html = output_path / "index.html"
174 if not index_html.exists():
175 raise FileNotFoundError(f"Creating HTML failed: {index_html}")
177 print(f"Documentation build done. Open with:\n firefox {index_html} &")