diff --git a/.readthedocs.yaml b/.readthedocs.yaml index b35710b..ce86a0a 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -18,8 +18,8 @@ build: - libxcb-xfixes0 jobs: pre_install: - # Development builds may require a Sigima version not yet released on PyPI - - "python scripts/rtd_install_sigima.py" + # Development builds may require dependency versions not yet released on PyPI + - "python scripts/rtd_install_dev_deps.py" pre_build: # Start Xvfb before building docs - "Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &" diff --git a/scripts/rtd_install_dev_deps.py b/scripts/rtd_install_dev_deps.py new file mode 100644 index 0000000..91cedf1 --- /dev/null +++ b/scripts/rtd_install_dev_deps.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +""" +Install development versions of dependencies for Read the Docs builds. + +This project is developed alongside other PlotPyStack and DataLab Platform +projects: development branches and pull requests may require dependency versions +which are not yet published on PyPI, so installing the documentation requirements +from PyPI alone would fail. + +This script mirrors the GitHub Actions test workflows: + +- tag builds and release branches: nothing is done, dependencies are installed + from PyPI by the regular ``python.install`` step; +- other branches and pull requests: the dependencies listed in + ``DEV_DEPENDENCIES`` are installed from GitHub. The regular ``python.install`` + step then keeps these versions as they already satisfy the project + requirements. + +It is intended to be run as a ``build.jobs.pre_install`` step and only relies on +the standard library and ``git``. + +The same script is used by several projects: only the project-specific settings +below differ between copies. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +# ---- Project-specific settings ---------------------------------------------- +PROJECT_REPO = "https://github.com/DataLab-Platform/SigimaX.git" +#: Placeholder for the name of the branch being built +SAME_BRANCH = "{branch}" +#: Dependencies to install from GitHub for development builds: +#: package name -> (repository URL, candidate branches, first existing one is used) +DEV_DEPENDENCIES = { + "PythonQwt": ("https://github.com/PlotPyStack/PythonQwt.git", ("master",)), + "guidata": ("https://github.com/PlotPyStack/guidata.git", ("develop",)), + "PlotPy": ("https://github.com/PlotPyStack/PlotPy.git", ("develop",)), + "Sigima": ( + "https://github.com/DataLab-Platform/Sigima.git", + (SAME_BRANCH, "develop"), + ), +} +# ----------------------------------------------------------------------------- + +RELEASE_BRANCHES = ("main", "master", "release") + + +def get_remote_branches(url: str) -> dict[str, str]: + """Return the branches of a remote Git repository. + + Args: + url: Remote repository URL + + Returns: + Mapping of branch names to the commit hash of their head + """ + output = subprocess.run( + ["git", "ls-remote", "--heads", url], + capture_output=True, + text=True, + check=True, + ).stdout + branches = {} + for line in output.splitlines(): + commit, ref = line.split("\t", 1) + branches[ref.removeprefix("refs/heads/")] = commit + return branches + + +def get_built_branch() -> str | None: + """Return the branch being built, if it can be determined. + + For pull request builds, Read the Docs only provides the pull request number, + so the branch is the one of the project repository whose head is the commit + being built. + + Returns: + Branch name, or None for tag builds and pull requests from forks + """ + version_type = os.environ.get("READTHEDOCS_VERSION_TYPE") + if version_type == "branch": + return os.environ["READTHEDOCS_GIT_IDENTIFIER"] + if version_type == "external": + commit = os.environ["READTHEDOCS_GIT_COMMIT_HASH"] + for branch, head in get_remote_branches(PROJECT_REPO).items(): + if head == commit: + return branch + return None + + +def select_branch(url: str, candidates: tuple[str, ...], built: str | None) -> str: + """Return the first candidate branch which exists in a remote repository. + + Args: + url: Remote repository URL + candidates: Candidate branch names, ``SAME_BRANCH`` standing for the + branch being built + built: Branch being built, or None if unknown + + Returns: + Selected branch name + """ + names = [built if name == SAME_BRANCH else name for name in candidates] + existing = get_remote_branches(url) + for name in names: + if name is not None and name in existing: + return name + raise SystemExit(f"None of the branches {names} exists in {url}") + + +def main() -> None: + """Install dependencies from GitHub for development builds.""" + version_type = os.environ.get("READTHEDOCS_VERSION_TYPE") + if version_type not in ("branch", "external"): + print(f"Version type {version_type!r}: dependencies installed from PyPI") + return + built = get_built_branch() + if built in RELEASE_BRANCHES: + print(f"Branch {built!r}: dependencies installed from PyPI") + return + print(f"Development build (branch: {built or 'unknown'})") + requirements = [] + for name, (url, candidates) in DEV_DEPENDENCIES.items(): + branch = select_branch(url, candidates, built) + print(f" {name}: {branch!r} branch") + requirements.append(f"{name} @ git+{url}@{branch}") + # Single pip call, so that dependencies between these packages are resolved + # against their development versions rather than against PyPI releases + subprocess.run( + [sys.executable, "-m", "pip", "install", "--no-cache-dir", *requirements], + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/rtd_install_sigima.py b/scripts/rtd_install_sigima.py deleted file mode 100644 index 2f76be8..0000000 --- a/scripts/rtd_install_sigima.py +++ /dev/null @@ -1,106 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Install the development version of Sigima for Read the Docs builds. - -SigimaX is developed alongside Sigima: development branches and pull requests -may require a Sigima version which is not yet published on PyPI, so installing -``sigimax[doc]`` from PyPI alone would fail. - -This script mirrors the GitHub Actions test workflows: - -- tag builds and the ``main`` branch: nothing is done, Sigima is installed from - PyPI by the regular ``python.install`` step; -- other branches and pull requests: Sigima is installed from the GitHub branch - with the same name as the SigimaX branch if it exists, otherwise from - ``develop``. The regular ``python.install`` step then keeps this version as it - already satisfies SigimaX requirements. - -It is intended to be run as a ``build.jobs.pre_install`` step and only relies -on the standard library and ``git``. -""" - -from __future__ import annotations - -import os -import subprocess -import sys - -SIGIMA_REPO = "https://github.com/DataLab-Platform/Sigima.git" -SIGIMAX_REPO = "https://github.com/DataLab-Platform/SigimaX.git" -DEFAULT_BRANCH = "develop" -RELEASE_BRANCH = "main" - - -def get_remote_branches(url: str) -> dict[str, str]: - """Return the branches of a remote Git repository. - - Args: - url: Remote repository URL - - Returns: - Mapping of branch names to the commit hash of their head - """ - output = subprocess.run( - ["git", "ls-remote", "--heads", url], - capture_output=True, - text=True, - check=True, - ).stdout - branches = {} - for line in output.splitlines(): - commit, ref = line.split("\t", 1) - branches[ref.removeprefix("refs/heads/")] = commit - return branches - - -def get_sigimax_branch() -> str | None: - """Return the SigimaX branch being built, if it can be determined. - - For pull request builds, Read the Docs only provides the pull request number, - so the branch is the one of the SigimaX repository whose head is the commit - being built. - - Returns: - Branch name, or None for tag builds and pull requests from forks - """ - version_type = os.environ.get("READTHEDOCS_VERSION_TYPE") - if version_type == "branch": - return os.environ["READTHEDOCS_GIT_IDENTIFIER"] - if version_type == "external": - commit = os.environ["READTHEDOCS_GIT_COMMIT_HASH"] - for branch, head in get_remote_branches(SIGIMAX_REPO).items(): - if head == commit: - return branch - return None - - -def main() -> None: - """Install Sigima from GitHub for development builds.""" - version_type = os.environ.get("READTHEDOCS_VERSION_TYPE") - if version_type not in ("branch", "external"): - print(f"Version type {version_type!r}: Sigima will be installed from PyPI") - return - sigimax_branch = get_sigimax_branch() - if sigimax_branch == RELEASE_BRANCH: - print(f"Branch {RELEASE_BRANCH!r}: Sigima will be installed from PyPI") - return - if sigimax_branch in get_remote_branches(SIGIMA_REPO): - sigima_branch = sigimax_branch - else: - sigima_branch = DEFAULT_BRANCH - print(f"SigimaX branch: {sigimax_branch!r} -> Sigima branch: {sigima_branch!r}") - subprocess.run( - [ - sys.executable, - "-m", - "pip", - "install", - "--no-cache-dir", - f"sigima @ git+{SIGIMA_REPO}@{sigima_branch}", - ], - check=True, - ) - - -if __name__ == "__main__": - main()