From 7510729d7d911c20ed2b01c6e9a8727755bf8d80 Mon Sep 17 00:00:00 2001 From: Sankalp Shrivastava Date: Wed, 4 Mar 2026 20:16:31 +0530 Subject: [PATCH 1/4] Automate generation of switcher.json for Sphinx version dropdown --- .github/workflows/ci.yml | 2 + .gitignore | 1 + README.md | 8 +- docs/source/_static/switcher.json | 44 ----- docs/source/readme.rst | 5 +- run.py | 24 ++- scripts/generate_switcher.py | 317 ++++++++++++++++++++++++++++++ 7 files changed, 352 insertions(+), 49 deletions(-) delete mode 100644 docs/source/_static/switcher.json create mode 100644 scripts/generate_switcher.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0a7769..6d03acc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,8 @@ jobs: steps: - uses: actions/checkout@v5 + with: + fetch-depth: 0 # Fetch all history and tags - name: Set up Python uses: actions/setup-python@v5 diff --git a/.gitignore b/.gitignore index 6e8cbe0..dc5e00b 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ instance/ # Sphinx documentation docs/_build/ +docs/source/_static/switcher.json # PyBuilder .pybuilder/ diff --git a/README.md b/README.md index cf28445..03707f2 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,17 @@ python run.py build python run.py build-docs ``` -> ***Note: When releasing a new version, update ``switcher.json`` in ``docs/source/_static/`` to include the new tag in the version dropdown for documentation.*** +The documentation version switcher (`switcher.json`) is automatically generated from git tags and `version.json` during the build process. Options: - `--skip-build` (`-s`): Skip building before generating docs - `--local` (`-l`): Build documentation locally for a single version (skips multi-version build) +- `--skip-switcher`: Skip generating switcher.json (useful for offline builds or custom switcher configurations) + +**Debug command:** To manually generate `switcher.json` without building docs: +```sh +python run.py generate-switcher +``` The documentation can be accessed locally by serving the docs/build/html/ folder: ```sh diff --git a/docs/source/_static/switcher.json b/docs/source/_static/switcher.json deleted file mode 100644 index 32696ea..0000000 --- a/docs/source/_static/switcher.json +++ /dev/null @@ -1,44 +0,0 @@ -[ - { - "version": "v27.0.0", - "name": "v27.0.0 (latest)", - "url": "../v27.0.0/", - "is_latest": true - }, - { - "version": "v26.0.5", - "name": "v26.0.5", - "url": "../v26.0.5/", - "is_latest": false - }, - { - "version": "v26.0.4", - "name": "v26.0.4", - "url": "../v26.0.4/", - "is_latest": false - }, - { - "version": "v26.0.3", - "name": "v26.0.3", - "url": "../v26.0.3/", - "is_latest": false - }, - { - "version": "v26.0.2", - "name": "v26.0.2", - "url": "../v26.0.2/", - "is_latest": false - }, - { - "version": "v26.0.1", - "name": "v26.0.1", - "url": "../v26.0.1/", - "is_latest": false - }, - { - "version": "v26.0.0", - "name": "v26.0.0", - "url": "../v26.0.0/", - "is_latest": false - } -] diff --git a/docs/source/readme.rst b/docs/source/readme.rst index ad78177..fe6c470 100644 --- a/docs/source/readme.rst +++ b/docs/source/readme.rst @@ -1259,10 +1259,11 @@ The project includes a ``run.py`` script with several useful commands: - ``--skip-build`` (``-s``): Skip building the package before generating docs - ``--local`` (``-l``): Build documentation locally for a single version (skips multi-version build) + - ``--skip-switcher``: Skip generating switcher.json (useful for offline builds or custom switcher configurations) .. note:: - When releasing a new version, update ``switcher.json`` in ``docs/source/_static/`` - to include the new tag in the version dropdown. + The documentation version switcher (``switcher.json``) is automatically generated from + git tags and ``version.json`` during the build process. **Viewing Documentation Locally** diff --git a/run.py b/run.py index 54f7108..dc68374 100644 --- a/run.py +++ b/run.py @@ -8,8 +8,10 @@ run.py clean-up run.py build [-P | --publish] [-i | --install] run.py build-docs [-t | --target=] [-s | --skip-build] [-l | --local] + [--skip-switcher] run.py format [--check] run.py generate-expected-data [...] + run.py generate-switcher run.py install [-s | --skip-build] run.py install-package-requirements run.py lint [-s | --skip-build] @@ -25,6 +27,7 @@ build-docs Build the documentation. format Format all Python files in the repository using black. generate-expected-data Generate expected data for integration tests. + generate-switcher Generate switcher.json from git tags. install Install the moldflow-api package. install-package-requirements Install package dependencies. lint Lint all Python files in the repository. @@ -54,6 +57,7 @@ --repo-url= Custom PyPI repository URL. --github-api-url= Custom GitHub API URL. -l, --local Build documentation locally (single version). + --skip-switcher Skip generating switcher.json for documentation. Markers to filter data generation by: mesh_summary, etc. """ @@ -402,7 +406,7 @@ def version_key(v): shutil.copytree(latest_src, latest_dest) -def build_docs(target, skip_build, local=False): +def build_docs(target, skip_build, local=False, skip_switcher=False): """Build Documentation""" if not skip_build: @@ -416,6 +420,9 @@ def build_docs(target, skip_build, local=False): try: if target == 'html' and not local: + # Generate switcher.json for docs versioning dropdown from git tags and version.json + if not skip_switcher: + generate_switcher() build_output = os.path.join(DOCS_BUILD_DIR, 'html') try: # fmt: off @@ -688,6 +695,13 @@ def generate_expected_data(markers: list[str]): run_command([sys.executable, '-m', generate_data_module] + markers, ROOT_DIR) +def generate_switcher(): + """Generate switcher.json from git tags""" + logging.info('Generating switcher.json from git tags') + switcher_script = os.path.join(ROOT_DIR, 'scripts', 'generate_switcher.py') + run_command([sys.executable, switcher_script], ROOT_DIR) + + def set_version(): """Set current version and write version file to package directory""" @@ -734,6 +748,9 @@ def main(): markers = args.get('') or [] generate_expected_data(markers=markers) + elif args.get('generate-switcher'): + generate_switcher() + elif args.get('test'): tests = args.get('') or [] marker = args.get('--marker') or args.get('-m') @@ -779,8 +796,11 @@ def main(): target = args.get('--target') or args.get('-t') or 'html' skip_build = args.get('--skip-build') or args.get('-s') local = args.get('--local') or args.get('-l') + skip_switcher = args.get('--skip-switcher') - build_docs(target=target, skip_build=skip_build, local=local) + build_docs( + target=target, skip_build=skip_build, local=local, skip_switcher=skip_switcher + ) elif args.get('install-package-requirements'): install_package(target_path=os.path.join(ROOT_DIR, SITE_PACKAGES)) diff --git a/scripts/generate_switcher.py b/scripts/generate_switcher.py new file mode 100644 index 0000000..bcbcc36 --- /dev/null +++ b/scripts/generate_switcher.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025 Autodesk, Inc. +# SPDX-License-Identifier: Apache-2.0 + +""" +Generate switcher.json for documentation version switcher. + +This script fetches git tags from the repository, identifies version tags, +and generates the switcher.json file for the documentation version switcher. +""" + +import json +import logging +import os +import subprocess +import sys +from packaging.version import InvalidVersion, Version + + +# Paths +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +DOCS_STATIC_DIR = os.path.join(ROOT_DIR, 'docs', 'source', '_static') +SWITCHER_JSON = os.path.join(DOCS_STATIC_DIR, 'switcher.json') +VERSION_JSON = os.path.join(ROOT_DIR, 'version.json') + + +def get_git_tags(): + """Fetch all git tags from the repository.""" + try: + result = subprocess.run( + ['git', 'tag', '-l'], cwd=ROOT_DIR, capture_output=True, text=True, check=True + ) + tags = [tag.strip() for tag in result.stdout.split('\n') if tag.strip()] + return tags + except subprocess.CalledProcessError as err: + logging.error("Failed to fetch git tags: %s", err) + raise + + +def parse_version_tags(tags): + """ + Parse version tags and return a list of valid version strings. + + Filters tags that start with 'v' and can be parsed as valid versions. + """ + version_tags = [] + + for tag in tags: + if not tag.startswith('v'): + continue + + version_str = tag[1:] # Remove 'v' prefix + + try: + # Validate that it's a proper version + Version(version_str) + version_tags.append(tag) + except InvalidVersion: + logging.warning("Skipping invalid version tag: %s", tag) + continue + + return version_tags + + +def get_version_from_json(): + """ + Read version from version.json file. + + Returns version string in format 'vX.Y.Z' or None if file doesn't exist. + """ + try: + with open(VERSION_JSON, 'r', encoding='utf-8') as f: + version_data = json.load(f) + + # Validate that major, minor, patch exist and are valid + required_fields = ['major', 'minor', 'patch'] + for field in required_fields: + if field not in version_data: + logging.error("version.json is missing required field: %s", field) + return None + + # Validate that values can be converted to integers + try: + major = int(version_data['major']) + minor = int(version_data['minor']) + patch = int(version_data['patch']) + + # Validate they are non-negative + if major < 0 or minor < 0 or patch < 0: + logging.error( + "version.json contains negative version numbers: %s.%s.%s", major, minor, patch + ) + return None + + except (ValueError, TypeError) as err: + logging.error( + "version.json contains non-numeric version values: major=%s, minor=%s, patch=%s. Error: %s", + version_data.get('major'), + version_data.get('minor'), + version_data.get('patch'), + err, + ) + return None + + version_str = f"v{major}.{minor}.{patch}" + + # Validate it's a proper semantic version + try: + Version(version_str.lstrip('v')) + return version_str + except InvalidVersion as err: + logging.error( + "Invalid semantic version in version.json: %s. Error: %s", version_str, err + ) + return None + + except FileNotFoundError: + logging.warning("version.json file not found at: %s", VERSION_JSON) + return None + except KeyError as err: + logging.error("version.json is missing required field: %s", err) + return None + except json.JSONDecodeError as err: + logging.error("version.json is not valid JSON: %s", err) + return None + + +def sort_versions(version_tags): + """Sort version tags in descending order (latest first).""" + + def version_key(tag): + try: + return Version(tag.lstrip('v')) + except InvalidVersion: + return Version("0.0.0") + + return sorted(version_tags, key=version_key, reverse=True) + + +def generate_switcher_json(version_tags): + """ + Generate the switcher.json structure. + + Returns a list of version entries with the format expected by + pydata-sphinx-theme's version switcher. + + Checks version.json to see if there's a newer version that hasn't been + tagged yet (e.g., working on a branch/PR). If so, that version is added + as the latest at the top of the list. + """ + if not version_tags: + logging.warning("No version tags found!") + return [] + + sorted_tags = sort_versions(version_tags) + switcher_data = [] + + # Check if version.json has a newer version than the latest tag + json_version = get_version_from_json() + latest_tag_version = sorted_tags[0] if sorted_tags else None + + # Determine if version.json version is newer than latest tag + version_json_is_newer = False + if json_version and latest_tag_version: + try: + json_ver = Version(json_version.lstrip('v')) + tag_ver = Version(latest_tag_version.lstrip('v')) + version_json_is_newer = json_ver > tag_ver + + # Check if version.json is skipping versions (this is an ERROR) + if version_json_is_newer: + # Parse version components to check for skipped versions + json_parts = str(json_ver).split('.') + tag_parts = str(tag_ver).split('.') + + if len(json_parts) >= 3 and len(tag_parts) >= 3: + j_major, j_minor, j_patch = ( + int(json_parts[0]), + int(json_parts[1]), + int(json_parts[2]), + ) + t_major, t_minor, t_patch = ( + int(tag_parts[0]), + int(tag_parts[1]), + int(tag_parts[2]), + ) + + # Check if we're skipping versions inappropriately + if j_major == t_major and j_minor == t_minor: + # Same major.minor, check patch difference + if j_patch > t_patch + 1: + logging.error( + "version.json (%s) skips patch versions from latest tag (%s). " + "Expected next version would be v%d.%d.%d", + json_version, + latest_tag_version, + t_major, + t_minor, + t_patch + 1, + ) + raise ValueError( + f"Invalid version progression: {latest_tag_version} -> {json_version}. " + f"Cannot skip versions. Expected v{t_major}.{t_minor}.{t_patch + 1}" + ) + elif j_major == t_major and j_minor > t_minor + 1: + logging.error( + "version.json (%s) skips minor versions from latest tag (%s). " + "Expected next version would be v%d.%d.0", + json_version, + latest_tag_version, + t_major, + t_minor + 1, + ) + raise ValueError( + f"Invalid version progression: {latest_tag_version} -> {json_version}. " + f"Cannot skip versions. Expected v{t_major}.{t_minor + 1}.0" + ) + elif j_major > t_major + 1: + logging.error( + "version.json (%s) skips major versions from latest tag (%s). " + "Expected next version would be v%d.0.0", + json_version, + latest_tag_version, + t_major + 1, + ) + raise ValueError( + f"Invalid version progression: {latest_tag_version} -> {json_version}. " + f"Cannot skip versions. Expected v{t_major + 1}.0.0" + ) + + except InvalidVersion: + pass + + # If version.json has a newer version, add it as the latest + if version_json_is_newer: + logging.info( + "version.json (%s) is newer than latest tag (%s), adding as latest", + json_version, + latest_tag_version, + ) + entry = { + "version": json_version, + "name": f"{json_version} (latest)", + "url": f"../{json_version}/", + "is_latest": True, + } + switcher_data.append(entry) + + # Add all tagged versions + for i, tag in enumerate(sorted_tags): + # If version.json was added as latest, no tag is latest + # Otherwise, the first tag (index 0) is latest + is_latest = (i == 0) and not version_json_is_newer + + entry = { + "version": tag, + "name": f"{tag} (latest)" if is_latest else tag, + "url": f"../{tag}/", + "is_latest": is_latest, + } + + switcher_data.append(entry) + + return switcher_data + + +def write_switcher_json(switcher_data): + """Write the switcher data to switcher.json file.""" + # Ensure the directory exists + os.makedirs(DOCS_STATIC_DIR, exist_ok=True) + + with open(SWITCHER_JSON, 'w', encoding='utf-8') as f: + json.dump(switcher_data, f, indent=2) + + logging.info("Generated switcher.json with %d versions", len(switcher_data)) + logging.info("Latest version: %s", switcher_data[0]['version'] if switcher_data else 'None') + + +def main(): + """Main entry point.""" + logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.INFO) + + try: + logging.info("Fetching git tags...") + tags = get_git_tags() + + if not tags: + logging.error("No git tags found in the repository!") + return 1 + + logging.info("Found %d total tags", len(tags)) + + logging.info("Parsing version tags...") + version_tags = parse_version_tags(tags) + + if not version_tags: + logging.error("No valid version tags found!") + return 1 + + logging.info("Found %d version tags", len(version_tags)) + + logging.info("Generating switcher.json...") + switcher_data = generate_switcher_json(version_tags) + + logging.info("Writing switcher.json to %s", SWITCHER_JSON) + write_switcher_json(switcher_data) + + logging.info("Successfully generated switcher.json!") + return 0 + + except Exception as err: + logging.error("Failed to generate switcher.json: %s", err, exc_info=True) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) From b303972b055ac83916dd8e313795a8364d954839 Mon Sep 17 00:00:00 2001 From: Sankalp Shrivastava Date: Thu, 5 Mar 2026 03:38:18 +0530 Subject: [PATCH 2/4] Adding check to stop invalid minor and patch updates at once --- scripts/generate_switcher.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/scripts/generate_switcher.py b/scripts/generate_switcher.py index bcbcc36..a6a9fd4 100644 --- a/scripts/generate_switcher.py +++ b/scripts/generate_switcher.py @@ -202,7 +202,22 @@ def generate_switcher_json(version_tags): f"Invalid version progression: {latest_tag_version} -> {json_version}. " f"Cannot skip versions. Expected v{t_major}.{t_minor}.{t_patch + 1}" ) + elif j_major == t_major and j_minor == t_minor + 1: + # Valid minor bump, but patch must be 0 + if j_patch != 0: + logging.error( + "version.json (%s) has non-zero patch when bumping minor version. " + "When incrementing minor version, patch must reset to 0. Expected v%d.%d.0", + json_version, + j_major, + j_minor, + ) + raise ValueError( + f"Invalid version: {json_version}. " + f"When bumping minor version from {latest_tag_version}, patch must be 0. Expected v{j_major}.{j_minor}.0" + ) elif j_major == t_major and j_minor > t_minor + 1: + # Skipping minor versions logging.error( "version.json (%s) skips minor versions from latest tag (%s). " "Expected next version would be v%d.%d.0", @@ -215,7 +230,21 @@ def generate_switcher_json(version_tags): f"Invalid version progression: {latest_tag_version} -> {json_version}. " f"Cannot skip versions. Expected v{t_major}.{t_minor + 1}.0" ) + elif j_major == t_major + 1: + # Valid major bump, but minor and patch must be 0 + if j_minor != 0 or j_patch != 0: + logging.error( + "version.json (%s) has non-zero minor/patch when bumping major version. " + "When incrementing major version, minor and patch must reset to 0. Expected v%d.0.0", + json_version, + j_major, + ) + raise ValueError( + f"Invalid version: {json_version}. " + f"When bumping major version from {latest_tag_version}, minor and patch must be 0. Expected v{j_major}.0.0" + ) elif j_major > t_major + 1: + # Skipping major versions logging.error( "version.json (%s) skips major versions from latest tag (%s). " "Expected next version would be v%d.0.0", From 1e17cca1648d78f59ceaf9869ceb7617f6d837ae Mon Sep 17 00:00:00 2001 From: Sankalp Shrivastava Date: Tue, 10 Mar 2026 08:31:49 +0530 Subject: [PATCH 3/4] incremental and current builds added for dev env --- README.md | 31 ++++- docs/source/readme.rst | 20 ++- run.py | 263 +++++++++++++++++++++++++++++++---- scripts/generate_switcher.py | 31 +++-- 4 files changed, 304 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 03707f2..f659df9 100644 --- a/README.md +++ b/README.md @@ -68,18 +68,47 @@ python run.py build python run.py build-docs ``` -The documentation version switcher (`switcher.json`) is automatically generated from git tags and `version.json` during the build process. +The documentation version switcher (`switcher.json`) is automatically generated from git tags during the build process. Only tagged versions are included by default to ensure all links work correctly. Options: - `--skip-build` (`-s`): Skip building before generating docs - `--local` (`-l`): Build documentation locally for a single version (skips multi-version build) - `--skip-switcher`: Skip generating switcher.json (useful for offline builds or custom switcher configurations) +- `--include-current`: Include current working tree version from version.json in switcher (useful during development before tagging) +- `--incremental`: Only build versions that don't have existing output directories (speeds up development by skipping already-built versions) **Debug command:** To manually generate `switcher.json` without building docs: ```sh python run.py generate-switcher ``` +**Development workflow:** When working on a version bump before tagging: +```sh +# Build docs including your current unreleased version +# This will: +# 1. Build all tagged versions via sphinx_multiversion +# 2. Build current working tree version via sphinx +# 3. Add current version to switcher.json as latest +python run.py build-docs --include-current +``` + +**Note:** By default, if `version.json` contains a version newer than the latest git tag, it will be validated but NOT added to the switcher (to prevent broken links). Use `--include-current` to build and include it during development, or create a git tag to include it permanently. + +**Fast development iteration:** +```sh +# First build (builds all versions) +python run.py build-docs --include-current + +# Subsequent builds (only rebuilds current version, skips existing tagged versions) +python run.py build-docs --include-current --incremental +``` + +**How `--incremental` works:** +- Checks which version directories already exist in `docs/build/html/` +- For missing tagged versions: checks out each tag, builds docs, then restores your branch +- For current version (with `--include-current`): only builds if directory doesn't exist +- Useful during development to avoid rebuilding all historical versions every time + The documentation can be accessed locally by serving the docs/build/html/ folder: ```sh cd docs/build/html diff --git a/docs/source/readme.rst b/docs/source/readme.rst index fe6c470..02ec996 100644 --- a/docs/source/readme.rst +++ b/docs/source/readme.rst @@ -1260,10 +1260,28 @@ The project includes a ``run.py`` script with several useful commands: - ``--skip-build`` (``-s``): Skip building the package before generating docs - ``--local`` (``-l``): Build documentation locally for a single version (skips multi-version build) - ``--skip-switcher``: Skip generating switcher.json (useful for offline builds or custom switcher configurations) + - ``--include-current``: Build and include current working tree version from version.json (useful during development before tagging) + - ``--incremental``: Only build versions that don't have existing output directories (speeds up development by skipping already-built versions) .. note:: The documentation version switcher (``switcher.json``) is automatically generated from - git tags and ``version.json`` during the build process. + git tags during the build process. Only tagged versions are included to ensure all + documentation links work correctly. If ``version.json`` is newer than the latest tag, + create a git tag to include it in the version switcher. + +**Incremental Builds for Development:** + +For faster iteration during development, combine ``--include-current`` with ``--incremental``: + +.. code-block:: bash + + # First build (builds all versions) + python run.py build-docs --include-current + + # Subsequent builds (only rebuilds current version) + python run.py build-docs --include-current --incremental + +The ``--incremental`` flag preserves existing version builds and only builds what's missing, significantly speeding up documentation updates during development. **Viewing Documentation Locally** diff --git a/run.py b/run.py index dc68374..1a54578 100644 --- a/run.py +++ b/run.py @@ -2,13 +2,14 @@ # SPDX-FileCopyrightText: 2025 Autodesk, Inc. # SPDX-License-Identifier: Apache-2.0 +# pylint: disable=C0302 """ Usage: run.py clean-up run.py build [-P | --publish] [-i | --install] run.py build-docs [-t | --target=] [-s | --skip-build] [-l | --local] - [--skip-switcher] + [--skip-switcher] [--include-current] [--incremental] run.py format [--check] run.py generate-expected-data [...] run.py generate-switcher @@ -58,6 +59,10 @@ --github-api-url= Custom GitHub API URL. -l, --local Build documentation locally (single version). --skip-switcher Skip generating switcher.json for documentation. + --include-current Build current working tree version from version.json + (useful during development before tagging). + --incremental Only build versions that don't have existing output directories + (speeds up development by skipping already-built versions). Markers to filter data generation by: mesh_summary, etc. """ @@ -406,7 +411,10 @@ def version_key(v): shutil.copytree(latest_src, latest_dest) -def build_docs(target, skip_build, local=False, skip_switcher=False): +# pylint: disable=R0912, R0913, R0914, R0915, R0917 +def build_docs( + target, skip_build, local=False, skip_switcher=False, include_current=False, incremental=False +): """Build Documentation""" if not skip_build: @@ -414,36 +422,182 @@ def build_docs(target, skip_build, local=False, skip_switcher=False): logging.info('Attempting to build moldflow-api documentation') - logging.info('Removing existing Sphinx documentation...') - if os.path.exists(DOCS_BUILD_DIR): - shutil.rmtree(DOCS_BUILD_DIR) + if not incremental: + logging.info('Removing existing Sphinx documentation...') + if os.path.exists(DOCS_BUILD_DIR): + shutil.rmtree(DOCS_BUILD_DIR) + else: + logging.info('Incremental build mode: preserving existing documentation...') + # pylint: disable=R1702 try: if target == 'html' and not local: # Generate switcher.json for docs versioning dropdown from git tags and version.json if not skip_switcher: - generate_switcher() + generate_switcher(include_current=include_current) build_output = os.path.join(DOCS_BUILD_DIR, 'html') - try: - # fmt: off - run_command( - [ - sys.executable, '-m', 'sphinx_multiversion', - DOCS_SOURCE_DIR, build_output - ], - ROOT_DIR, - ) - except Exception as err: - logging.error( - "Failed to build documentation with sphinx_multiversion.\n" - "This can happen if no Git tags or branches match your version pattern.\n" - "Try running 'git fetch --tags' and ensure version tags exist in the repo.\n" - "Underlying error: %s", - str(err), + + # Determine which versions need to be built + skip_multiversion = False + missing_tags = [] + if incremental: + # Get existing version directories + existing_versions = set() + if os.path.exists(build_output): + for item in os.listdir(build_output): + item_path = os.path.join(build_output, item) + if os.path.isdir(item_path) and item.startswith('v'): + # Exclude special directories like 'latest' + if item not in ['latest']: + existing_versions.add(item) + + # Get all version tags from git + result = subprocess.run( + ['git', 'tag', '-l', 'v*', '--sort=-version:refname'], + cwd=ROOT_DIR, + capture_output=True, + text=True, + check=True, ) - # Re-raise so the outer handler can log the general failure as well. - raise - # fmt: on + all_tags = {tag.strip() for tag in result.stdout.split('\n') if tag.strip()} + + missing_tags = list(all_tags - existing_versions) + + if missing_tags: + logging.info( + 'Incremental build: found %d existing versions, %d tags need building: %s', + len(existing_versions), + len(missing_tags), + ', '.join(sorted(missing_tags)), + ) + else: + logging.info( + 'Incremental build: all %d tagged versions already built, ' + 'skipping tag builds', + len(all_tags), + ) + skip_multiversion = True + + # Build tagged versions + if not skip_multiversion: + if incremental and missing_tags: + # Incremental mode: build only missing tags individually + logging.info( + 'Building %d missing version(s) incrementally...', len(missing_tags) + ) + # Store current branch to restore later + current_branch_result = subprocess.run( + ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + cwd=ROOT_DIR, + capture_output=True, + text=True, + check=False, + ) + current_branch = ( + current_branch_result.stdout.strip() + if current_branch_result.returncode == 0 + else None + ) + + # Create build output directory if it doesn't exist + os.makedirs(build_output, exist_ok=True) + + try: + for tag in sorted(missing_tags): + logging.info('Checking out and building tag: %s', tag) + # Checkout the tag + subprocess.run( + ['git', 'checkout', tag], + cwd=ROOT_DIR, + check=True, + capture_output=True, + ) + # Build docs for this tag + tag_output = os.path.join(build_output, tag) + run_command( + [ + sys.executable, + '-m', + 'sphinx', + '-b', + 'html', + DOCS_SOURCE_DIR, + tag_output, + ], + ROOT_DIR, + ) + logging.info('Successfully built documentation for %s', tag) + finally: + # Always restore the original branch + if current_branch and current_branch != 'HEAD': + logging.info('Restoring original branch: %s', current_branch) + subprocess.run( + ['git', 'checkout', current_branch], + cwd=ROOT_DIR, + check=True, + capture_output=True, + ) + else: + # Non-incremental mode: use sphinx_multiversion to build all tags + try: + # fmt: off + run_command( + [ + sys.executable, '-m', 'sphinx_multiversion', + DOCS_SOURCE_DIR, build_output + ], + ROOT_DIR, + ) + except Exception as err: + logging.error( + "Failed to build documentation with " + "sphinx_multiversion.\n" + "This can happen if no Git tags or branches match " + "your version pattern.\n" + "Try running 'git fetch --tags' and ensure version " + "tags exist in the repo.\n" + "Underlying error: %s", + str(err), + ) + # Re-raise so the outer handler can log the general failure as well. + raise + # fmt: on + + # If include_current is True and version.json is newer than latest tag, + # also build docs for the current working tree + if include_current: + logging.info('Checking if current version needs to be built...') + current_version = _get_current_version_if_newer() + if current_version: + current_version_output = os.path.join(build_output, current_version) + + # In incremental mode, check if current version already exists + if incremental and os.path.exists(current_version_output): + logging.info( + 'Incremental build: current version %s already exists, skipping', + current_version, + ) + else: + logging.info( + 'Building documentation for current version: %s', current_version + ) + run_command( + [ + sys.executable, + '-m', + 'sphinx', + '-b', + 'html', + DOCS_SOURCE_DIR, + current_version_output, + ], + ROOT_DIR, + ) + logging.info( + 'Current version documentation built successfully at %s', + current_version_output, + ) + create_latest_alias(build_output) create_root_redirect(build_output) else: @@ -695,11 +849,57 @@ def generate_expected_data(markers: list[str]): run_command([sys.executable, '-m', generate_data_module] + markers, ROOT_DIR) -def generate_switcher(): +def _get_current_version_if_newer(): + """ + Check if version.json has a version newer than the latest git tag. + + Returns version string (e.g., 'v27.0.1') if newer, None otherwise. + """ + try: + # Read version.json + with open(VERSION_FILE, 'r', encoding=ENCODING) as f: + vers_json_dict = json.load(f) + current_version = ( + f"v{vers_json_dict['major']}.{vers_json_dict['minor']}.{vers_json_dict['patch']}" + ) + + # Get latest git tag + result = subprocess.run( + ['git', 'tag', '-l', 'v*', '--sort=-version:refname'], + cwd=ROOT_DIR, + capture_output=True, + text=True, + check=True, + ) + tags = [tag.strip() for tag in result.stdout.split('\n') if tag.strip()] + + if not tags: + return None + + latest_tag = tags[0] + + # Compare versions + + current_ver = Version(current_version.lstrip('v')) + latest_ver = Version(latest_tag.lstrip('v')) + + if current_ver > latest_ver: + return current_version + return None + + except Exception as err: + logging.warning("Could not determine if current version is newer: %s", err) + return None + + +def generate_switcher(include_current=False): """Generate switcher.json from git tags""" logging.info('Generating switcher.json from git tags') switcher_script = os.path.join(ROOT_DIR, 'scripts', 'generate_switcher.py') - run_command([sys.executable, switcher_script], ROOT_DIR) + cmd = [sys.executable, switcher_script] + if include_current: + cmd.append('--include-current') + run_command(cmd, ROOT_DIR) def set_version(): @@ -797,9 +997,16 @@ def main(): skip_build = args.get('--skip-build') or args.get('-s') local = args.get('--local') or args.get('-l') skip_switcher = args.get('--skip-switcher') + include_current = args.get('--include-current') + incremental = args.get('--incremental') build_docs( - target=target, skip_build=skip_build, local=local, skip_switcher=skip_switcher + target=target, + skip_build=skip_build, + local=local, + skip_switcher=skip_switcher, + include_current=include_current, + incremental=incremental, ) elif args.get('install-package-requirements'): diff --git a/scripts/generate_switcher.py b/scripts/generate_switcher.py index a6a9fd4..1cefe38 100644 --- a/scripts/generate_switcher.py +++ b/scripts/generate_switcher.py @@ -5,8 +5,12 @@ """ Generate switcher.json for documentation version switcher. -This script fetches git tags from the repository, identifies version tags, -and generates the switcher.json file for the documentation version switcher. +Usage: + generate_switcher.py [--include-current] + +Options: + --include-current Include the current version from version.json if it is + newer than the latest tag. """ import json @@ -14,6 +18,7 @@ import os import subprocess import sys +import docopt from packaging.version import InvalidVersion, Version @@ -137,16 +142,16 @@ def version_key(tag): return sorted(version_tags, key=version_key, reverse=True) -def generate_switcher_json(version_tags): +def generate_switcher_json(version_tags, include_current=False): """ Generate the switcher.json structure. Returns a list of version entries with the format expected by pydata-sphinx-theme's version switcher. - Checks version.json to see if there's a newer version that hasn't been - tagged yet (e.g., working on a branch/PR). If so, that version is added - as the latest at the top of the list. + When include_current is True, checks version.json to see if there's a + newer version that hasn't been tagged yet (e.g., working on a branch/PR). + If so, that version is added as the latest at the top of the list. """ if not version_tags: logging.warning("No version tags found!") @@ -155,12 +160,13 @@ def generate_switcher_json(version_tags): sorted_tags = sort_versions(version_tags) switcher_data = [] - # Check if version.json has a newer version than the latest tag - json_version = get_version_from_json() + version_json_is_newer = False + json_version = None latest_tag_version = sorted_tags[0] if sorted_tags else None - # Determine if version.json version is newer than latest tag - version_json_is_newer = False + if include_current: + json_version = get_version_from_json() + if json_version and latest_tag_version: try: json_ver = Version(json_version.lstrip('v')) @@ -309,6 +315,9 @@ def main(): """Main entry point.""" logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.INFO) + args = docopt.docopt(__doc__) + include_current = args.get('--include-current') + try: logging.info("Fetching git tags...") tags = get_git_tags() @@ -329,7 +338,7 @@ def main(): logging.info("Found %d version tags", len(version_tags)) logging.info("Generating switcher.json...") - switcher_data = generate_switcher_json(version_tags) + switcher_data = generate_switcher_json(version_tags, include_current=include_current) logging.info("Writing switcher.json to %s", SWITCHER_JSON) write_switcher_json(switcher_data) From dc38cc2f40c42b9b3cdd8197e05cb1393f7eda32 Mon Sep 17 00:00:00 2001 From: Sankalp Shrivastava Date: Tue, 10 Mar 2026 08:51:32 +0530 Subject: [PATCH 4/4] Version name sanitization --- scripts/generate_switcher.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/generate_switcher.py b/scripts/generate_switcher.py index 1cefe38..ee830f5 100644 --- a/scripts/generate_switcher.py +++ b/scripts/generate_switcher.py @@ -16,12 +16,17 @@ import json import logging import os +import re import subprocess import sys import docopt from packaging.version import InvalidVersion, Version +# Must match smv_tag_whitelist in docs/source/conf.py +SMV_TAG_PATTERN = re.compile(r'^v?\d+\.\d+\.\d+$') + + # Paths ROOT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) DOCS_STATIC_DIR = os.path.join(ROOT_DIR, 'docs', 'source', '_static') @@ -44,20 +49,21 @@ def get_git_tags(): def parse_version_tags(tags): """ - Parse version tags and return a list of valid version strings. + Parse version tags and return a list of strict X.Y.Z version strings. - Filters tags that start with 'v' and can be parsed as valid versions. + Uses the same pattern as smv_tag_whitelist in docs/source/conf.py + so that switcher.json stays in sync with the versions that + sphinx-multiversion actually builds. Accepts both vX.Y.Z and X.Y.Z. """ version_tags = [] for tag in tags: - if not tag.startswith('v'): + if not SMV_TAG_PATTERN.match(tag): continue - version_str = tag[1:] # Remove 'v' prefix + version_str = tag.lstrip('v') try: - # Validate that it's a proper version Version(version_str) version_tags.append(tag) except InvalidVersion: