Skip to content

Add more type hints #274

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 12, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 61 additions & 48 deletions build_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@

TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator, Sequence, Set
from collections.abc import Collection, Iterator, Sequence, Set
from typing import Literal

try:
Expand Down Expand Up @@ -101,7 +101,7 @@ def __reversed__(self) -> Iterator[Version]:
return reversed(self._seq)

@classmethod
def from_json(cls, data) -> Versions:
def from_json(cls, data: dict) -> Versions:
versions = sorted(
[Version.from_json(name, release) for name, release in data.items()],
key=Version.as_tuple,
Expand Down Expand Up @@ -158,7 +158,9 @@ class Version:
"prerelease": "pre-release",
}

def __init__(self, name, *, status, branch_or_tag=None):
def __init__(
self, name: str, *, status: str, branch_or_tag: str | None = None
) -> None:
status = self.SYNONYMS.get(status, status)
if status not in self.STATUSES:
raise ValueError(
Expand All @@ -169,22 +171,22 @@ def __init__(self, name, *, status, branch_or_tag=None):
self.branch_or_tag = branch_or_tag
self.status = status

def __repr__(self):
def __repr__(self) -> str:
return f"Version({self.name})"

def __eq__(self, other):
def __eq__(self, other: Version) -> bool:
return self.name == other.name

def __gt__(self, other):
def __gt__(self, other: Version) -> bool:
return self.as_tuple() > other.as_tuple()

@classmethod
def from_json(cls, name, values):
def from_json(cls, name: str, values: dict) -> Version:
"""Loads a version from devguide's json representation."""
return cls(name, status=values["status"], branch_or_tag=values["branch"])

@property
def requirements(self):
def requirements(self) -> list[str]:
"""Generate the right requirements for this version.

Since CPython 3.8 a Doc/requirements.txt file can be used.
Expand Down Expand Up @@ -213,9 +215,10 @@ def requirements(self):
return reqs + ["sphinx==2.3.1"]
if self.name == "3.5":
return reqs + ["sphinx==1.8.4"]
raise ValueError("unreachable")

@property
def changefreq(self):
def changefreq(self) -> str:
"""Estimate this version change frequency, for the sitemap."""
return {"EOL": "never", "security-fixes": "yearly"}.get(self.status, "daily")

Expand All @@ -224,17 +227,17 @@ def as_tuple(self) -> tuple[int, ...]:
return version_to_tuple(self.name)

@property
def url(self):
def url(self) -> str:
"""The doc URL of this version in production."""
return f"https://docs.python.org/{self.name}/"

@property
def title(self):
def title(self) -> str:
"""The title of this version's doc, for the sidebar."""
return f"Python {self.name} ({self.status})"

@property
def picker_label(self):
def picker_label(self) -> str:
"""Forge the label of a version picker."""
if self.status == "in development":
return f"dev ({self.name})"
Expand All @@ -254,7 +257,7 @@ def __reversed__(self) -> Iterator[Language]:
return reversed(self._seq)

@classmethod
def from_json(cls, defaults, languages) -> Languages:
def from_json(cls, defaults: dict, languages: dict) -> Languages:
default_translated_name = defaults.get("translated_name", "")
default_in_prod = defaults.get("in_prod", True)
default_sphinxopts = defaults.get("sphinxopts", [])
Expand Down Expand Up @@ -290,17 +293,19 @@ class Language:
html_only: bool = False

@property
def tag(self):
def tag(self) -> str:
return self.iso639_tag.replace("_", "-").lower()

@property
def switcher_label(self):
def switcher_label(self) -> str:
if self.translated_name:
return f"{self.name} | {self.translated_name}"
return self.name


def run(cmd, cwd=None) -> subprocess.CompletedProcess:
def run(
cmd: Sequence[str | Path], cwd: Path | None = None
) -> subprocess.CompletedProcess:
"""Like subprocess.run, with logging before and after the command execution."""
cmd = list(map(str, cmd))
cmdstring = shlex.join(cmd)
Expand All @@ -326,7 +331,7 @@ def run(cmd, cwd=None) -> subprocess.CompletedProcess:
return result


def run_with_logging(cmd, cwd=None):
def run_with_logging(cmd: Sequence[str | Path], cwd: Path | None = None) -> None:
"""Like subprocess.check_call, with logging before the command execution."""
cmd = list(map(str, cmd))
logging.debug("Run: '%s'", shlex.join(cmd))
Expand All @@ -348,13 +353,13 @@ def run_with_logging(cmd, cwd=None):
raise subprocess.CalledProcessError(return_code, cmd[0])


def changed_files(left, right):
def changed_files(left: Path, right: Path) -> list[str]:
"""Compute a list of different files between left and right, recursively.
Resulting paths are relative to left.
"""
changed = []

def traverse(dircmp_result):
def traverse(dircmp_result: filecmp.dircmp) -> None:
base = Path(dircmp_result.left).relative_to(left)
for file in dircmp_result.diff_files:
changed.append(str(base / file))
Expand All @@ -374,11 +379,11 @@ class Repository:
remote: str
directory: Path

def run(self, *args):
def run(self, *args: str) -> subprocess.CompletedProcess:
"""Run git command in the clone repository."""
return run(("git", "-C", self.directory) + args)

def get_ref(self, pattern):
def get_ref(self, pattern: str) -> str:
"""Return the reference of a given tag or branch."""
try:
# Maybe it's a branch
Expand All @@ -387,7 +392,7 @@ def get_ref(self, pattern):
# Maybe it's a tag
return self.run("show-ref", "-s", "tags/" + pattern).stdout.strip()

def fetch(self):
def fetch(self) -> subprocess.CompletedProcess:
"""Try (and retry) to run git fetch."""
try:
return self.run("fetch")
Expand All @@ -396,12 +401,12 @@ def fetch(self):
sleep(5)
return self.run("fetch")

def switch(self, branch_or_tag):
def switch(self, branch_or_tag: str) -> None:
"""Reset and cleans the repository to the given branch or tag."""
self.run("reset", "--hard", self.get_ref(branch_or_tag), "--")
self.run("clean", "-dfqx")

def clone(self):
def clone(self) -> bool:
"""Maybe clone the repository, if not already cloned."""
if (self.directory / ".git").is_dir():
return False # Already cloned
Expand All @@ -410,21 +415,23 @@ def clone(self):
run(["git", "clone", self.remote, self.directory])
return True

def update(self):
def update(self) -> None:
self.clone() or self.fetch()


def version_to_tuple(version) -> tuple[int, ...]:
def version_to_tuple(version: str) -> tuple[int, ...]:
"""Transform a version string to a tuple, for easy comparisons."""
return tuple(int(part) for part in version.split("."))


def tuple_to_version(version_tuple):
def tuple_to_version(version_tuple: tuple[int, ...]) -> str:
"""Reverse version_to_tuple."""
return ".".join(str(part) for part in version_tuple)


def locate_nearest_version(available_versions, target_version):
def locate_nearest_version(
available_versions: Collection[str], target_version: str
) -> str:
"""Look for the nearest version of target_version in available_versions.
Versions are to be given as tuples, like (3, 7) for 3.7.

Expand Down Expand Up @@ -468,7 +475,7 @@ def edit(file: Path):
temporary.rename(file)


def setup_switchers(versions: Versions, languages: Languages, html_root: Path):
def setup_switchers(versions: Versions, languages: Languages, html_root: Path) -> None:
"""Setup cross-links between CPython versions:
- Cross-link various languages in a language switcher
- Cross-link various versions in a version switcher
Expand Down Expand Up @@ -499,12 +506,12 @@ def setup_switchers(versions: Versions, languages: Languages, html_root: Path):
ofile.write(line)


def head(text, lines=10):
def head(text: str, lines: int = 10) -> str:
"""Return the first *lines* lines from the given text."""
return "\n".join(text.split("\n")[:lines])


def version_info():
def version_info() -> None:
"""Handler for --version."""
try:
platex_version = head(
Expand Down Expand Up @@ -554,15 +561,15 @@ class DocBuilder:
theme: Path

@property
def html_only(self):
def html_only(self) -> bool:
return (
self.select_output in {"only-html", "only-html-en"}
or self.quick
or self.language.html_only
)

@property
def includes_html(self):
def includes_html(self) -> bool:
"""Does the build we are running include HTML output?"""
return self.select_output != "no-html"

Expand Down Expand Up @@ -601,12 +608,12 @@ def checkout(self) -> Path:
"""Path to CPython git clone."""
return self.build_root / _checkout_name(self.select_output)

def clone_translation(self):
def clone_translation(self) -> None:
self.translation_repo.update()
self.translation_repo.switch(self.translation_branch)

@property
def translation_repo(self):
def translation_repo(self) -> Repository:
"""See PEP 545 for translations repository naming convention."""

locale_repo = f"https://github.com/python/python-docs-{self.language.tag}.git"
Expand All @@ -620,7 +627,7 @@ def translation_repo(self):
return Repository(locale_repo, locale_clone_dir)

@property
def translation_branch(self):
def translation_branch(self) -> str:
"""Some CPython versions may be untranslated, being either too old or
too new.

Expand All @@ -633,7 +640,7 @@ def translation_branch(self):
branches = re.findall(r"/([0-9]+\.[0-9]+)$", remote_branches, re.M)
return locate_nearest_version(branches, self.version.name)

def build(self):
def build(self) -> None:
"""Build this version/language doc."""
logging.info("Build start.")
start_time = perf_counter()
Expand Down Expand Up @@ -702,7 +709,7 @@ def build(self):
)
logging.info("Build done (%s).", format_seconds(perf_counter() - start_time))

def build_venv(self):
def build_venv(self) -> None:
"""Build a venv for the specific Python version.

So we can reuse them from builds to builds, while they contain
Expand Down Expand Up @@ -819,7 +826,7 @@ def copy_build_to_webroot(self, http: urllib3.PoolManager) -> None:
"Publishing done (%s).", format_seconds(perf_counter() - start_time)
)

def should_rebuild(self, force: bool):
def should_rebuild(self, force: bool) -> str | Literal[False]:
state = self.load_state()
if not state:
logging.info("Should rebuild: no previous state found.")
Expand Down Expand Up @@ -865,7 +872,9 @@ def load_state(self) -> dict:
except (KeyError, FileNotFoundError):
return {}

def save_state(self, build_start: dt.datetime, build_duration: float, trigger: str):
def save_state(
self, build_start: dt.datetime, build_duration: float, trigger: str
) -> None:
"""Save current CPython sha1 and current translation sha1.

Using this we can deduce if a rebuild is needed or not.
Expand Down Expand Up @@ -911,14 +920,16 @@ def format_seconds(seconds: float) -> str:
case h, m, s:
return f"{h}h {m}m {s}s"

raise ValueError("unreachable")


def _checkout_name(select_output: str | None) -> str:
if select_output is not None:
return f"cpython-{select_output}"
return "cpython"


def main():
def main() -> None:
"""Script entry point."""
args = parse_args()
setup_logging(args.log_directory, args.select_output)
Expand All @@ -934,7 +945,7 @@ def main():
build_docs_with_lock(args, "build_docs_html_en.lock")


def parse_args():
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""

parser = argparse.ArgumentParser(
Expand Down Expand Up @@ -1028,7 +1039,7 @@ def parse_args():
return args


def setup_logging(log_directory: Path, select_output: str | None):
def setup_logging(log_directory: Path, select_output: str | None) -> None:
"""Setup logging to stderr if run by a human, or to a file if run from a cron."""
log_format = "%(asctime)s %(levelname)s: %(message)s"
if sys.stderr.isatty():
Expand Down Expand Up @@ -1174,7 +1185,9 @@ def parse_languages_from_config() -> Languages:
return Languages.from_json(config["defaults"], config["languages"])


def build_sitemap(versions: Versions, languages: Languages, www_root: Path, group):
def build_sitemap(
versions: Versions, languages: Languages, www_root: Path, group: str
) -> None:
"""Build a sitemap with all live versions and translations."""
if not www_root.exists():
logging.info("Skipping sitemap generation (www root does not even exist).")
Expand All @@ -1189,7 +1202,7 @@ def build_sitemap(versions: Versions, languages: Languages, www_root: Path, grou
run(["chgrp", group, sitemap_path])


def build_404(www_root: Path, group):
def build_404(www_root: Path, group: str) -> None:
"""Build a nice 404 error page to display in case PDFs are not built yet."""
if not www_root.exists():
logging.info("Skipping 404 page generation (www root does not even exist).")
Expand All @@ -1203,8 +1216,8 @@ def build_404(www_root: Path, group):

def copy_robots_txt(
www_root: Path,
group,
skip_cache_invalidation,
group: str,
skip_cache_invalidation: bool,
http: urllib3.PoolManager,
) -> None:
"""Copy robots.txt to www_root."""
Expand Down Expand Up @@ -1322,7 +1335,7 @@ def proofread_canonicals(
)


def _check_canonical_rel(file: Path, www_root: Path):
def _check_canonical_rel(file: Path, www_root: Path) -> Path | None:
# Check for a canonical relation link in the HTML.
# If one exists, ensure that the target exists
# or otherwise remove the canonical link element.
Expand Down