Skip to content

Commit fe7f9d9

Browse files
committed
ci(rtd): install development dependencies for branch and pull request builds
Read the Docs installs dependencies from PyPI only, so documentation builds of development branches and pull requests fail as soon as PlotPy requires a guidata or PythonQwt version that is not released yet. Add a pre_install step mirroring the GitHub Actions test workflows: for branch and pull request builds, guidata is installed from its develop branch and PythonQwt from master. Tag builds and the master branch keep using PyPI releases. The script is shared with Sigima and SigimaX; only its project-specific settings differ. Assisted-by: Claude Opus 5.5
1 parent 43e7210 commit fe7f9d9

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

‎.readthedocs.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ build:
1010
post_create_environment:
1111
- pip install PyQt5 setuptools numpy Cython
1212
- python setup.py build_ext --inplace
13+
pre_install:
14+
# Development builds may require dependency versions not yet released on PyPI
15+
- python scripts/rtd_install_dev_deps.py
1316
sphinx:
1417
configuration: doc/conf.py
1518
formats:

‎scripts/rtd_install_dev_deps.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Install development versions of dependencies for Read the Docs builds.
4+
5+
This project is developed alongside other PlotPyStack and DataLab Platform
6+
projects: development branches and pull requests may require dependency versions
7+
which are not yet published on PyPI, so installing the documentation requirements
8+
from PyPI alone would fail.
9+
10+
This script mirrors the GitHub Actions test workflows:
11+
12+
- tag builds and release branches: nothing is done, dependencies are installed
13+
from PyPI by the regular ``python.install`` step;
14+
- other branches and pull requests: the dependencies listed in
15+
``DEV_DEPENDENCIES`` are installed from GitHub. The regular ``python.install``
16+
step then keeps these versions as they already satisfy the project
17+
requirements.
18+
19+
It is intended to be run as a ``build.jobs.pre_install`` step and only relies on
20+
the standard library and ``git``.
21+
22+
The same script is used by several projects: only the project-specific settings
23+
below differ between copies.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import os
29+
import subprocess
30+
import sys
31+
32+
# ---- Project-specific settings ----------------------------------------------
33+
PROJECT_REPO = "https://github.com/PlotPyStack/PlotPy.git"
34+
#: Placeholder for the name of the branch being built
35+
SAME_BRANCH = "{branch}"
36+
#: Dependencies to install from GitHub for development builds:
37+
#: package name -> (repository URL, candidate branches, first existing one is used)
38+
DEV_DEPENDENCIES = {
39+
"PythonQwt": ("https://github.com/PlotPyStack/PythonQwt.git", ("master",)),
40+
"guidata": ("https://github.com/PlotPyStack/guidata.git", ("develop",)),
41+
}
42+
# -----------------------------------------------------------------------------
43+
44+
RELEASE_BRANCHES = ("main", "master", "release")
45+
46+
47+
def get_remote_branches(url: str) -> dict[str, str]:
48+
"""Return the branches of a remote Git repository.
49+
50+
Args:
51+
url: Remote repository URL
52+
53+
Returns:
54+
Mapping of branch names to the commit hash of their head
55+
"""
56+
output = subprocess.run(
57+
["git", "ls-remote", "--heads", url],
58+
capture_output=True,
59+
text=True,
60+
check=True,
61+
).stdout
62+
branches = {}
63+
for line in output.splitlines():
64+
commit, ref = line.split("\t", 1)
65+
branches[ref.removeprefix("refs/heads/")] = commit
66+
return branches
67+
68+
69+
def get_built_branch() -> str | None:
70+
"""Return the branch being built, if it can be determined.
71+
72+
For pull request builds, Read the Docs only provides the pull request number,
73+
so the branch is the one of the project repository whose head is the commit
74+
being built.
75+
76+
Returns:
77+
Branch name, or None for tag builds and pull requests from forks
78+
"""
79+
version_type = os.environ.get("READTHEDOCS_VERSION_TYPE")
80+
if version_type == "branch":
81+
return os.environ["READTHEDOCS_GIT_IDENTIFIER"]
82+
if version_type == "external":
83+
commit = os.environ["READTHEDOCS_GIT_COMMIT_HASH"]
84+
for branch, head in get_remote_branches(PROJECT_REPO).items():
85+
if head == commit:
86+
return branch
87+
return None
88+
89+
90+
def select_branch(url: str, candidates: tuple[str, ...], built: str | None) -> str:
91+
"""Return the first candidate branch which exists in a remote repository.
92+
93+
Args:
94+
url: Remote repository URL
95+
candidates: Candidate branch names, ``SAME_BRANCH`` standing for the
96+
branch being built
97+
built: Branch being built, or None if unknown
98+
99+
Returns:
100+
Selected branch name
101+
"""
102+
names = [built if name == SAME_BRANCH else name for name in candidates]
103+
existing = get_remote_branches(url)
104+
for name in names:
105+
if name is not None and name in existing:
106+
return name
107+
raise SystemExit(f"None of the branches {names} exists in {url}")
108+
109+
110+
def main() -> None:
111+
"""Install dependencies from GitHub for development builds."""
112+
version_type = os.environ.get("READTHEDOCS_VERSION_TYPE")
113+
if version_type not in ("branch", "external"):
114+
print(f"Version type {version_type!r}: dependencies installed from PyPI")
115+
return
116+
built = get_built_branch()
117+
if built in RELEASE_BRANCHES:
118+
print(f"Branch {built!r}: dependencies installed from PyPI")
119+
return
120+
print(f"Development build (branch: {built or 'unknown'})")
121+
requirements = []
122+
for name, (url, candidates) in DEV_DEPENDENCIES.items():
123+
branch = select_branch(url, candidates, built)
124+
print(f" {name}: {branch!r} branch")
125+
requirements.append(f"{name} @ git+{url}@{branch}")
126+
# Single pip call, so that dependencies between these packages are resolved
127+
# against their development versions rather than against PyPI releases
128+
subprocess.run(
129+
[sys.executable, "-m", "pip", "install", "--no-cache-dir", *requirements],
130+
check=True,
131+
)
132+
133+
134+
if __name__ == "__main__":
135+
main()

0 commit comments

Comments
 (0)