Automate generation of switcher.json for Sphinx version dropdown#68
Automate generation of switcher.json for Sphinx version dropdown#68sankalps0549 wants to merge 2 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds automation to generate the Sphinx version-switcher data (switcher.json) from git tags (and version.json), and wires it into the docs build so the dropdown is no longer maintained manually.
Changes:
- Introduces
scripts/generate_switcher.pyto generatedocs/source/_static/switcher.jsonfrom git tags (with version progression checks againstversion.json). - Extends
run.py build-docsto generate the switcher before building docs, plus addsrun.py generate-switcherand--skip-switcher. - Updates docs/README guidance and CI checkout behavior; removes the committed
switcher.jsonand ignores it going forward.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/generate_switcher.py | New script to generate switcher.json and validate version progression |
| run.py | Adds generate-switcher command + integrates switcher generation into build-docs |
| docs/source/readme.rst | Updates docs instructions to reflect auto-generation and new flag |
| docs/source/_static/switcher.json | Removes previously committed switcher.json (now generated) |
| README.md | Updates top-level docs build notes + adds debug command |
| .gitignore | Ignores generated switcher.json |
| .github/workflows/ci.yml | Ensures tags are available in CI docs build via fetch-depth: 0 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 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 |
There was a problem hiding this comment.
get_version_from_json() logs errors and returns None for malformed version.json, which allows switcher generation (and docs builds) to proceed even though version metadata is invalid. If the intent is to block faulty version upgrades, consider failing fast (raise/return non-zero) when version.json exists but is invalid, while still allowing the FileNotFoundError case to be non-fatal.
| # 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]), | ||
| ) |
There was a problem hiding this comment.
The version progression checks parse Version objects by converting to str(...) and then int(...)-casting the dot-separated parts. This will raise an uncaught ValueError for valid PEP 440 versions like 1.0.0rc1 (where the patch segment becomes 0rc1). To make this robust, use json_ver.release / tag_ver.release (numeric tuples) instead of string parsing, or otherwise ensure prerelease tags are filtered out before this block.
| # 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]), | |
| ) | |
| # Use numeric release components to check for skipped versions | |
| json_release = json_ver.release | |
| tag_release = tag_ver.release | |
| if len(json_release) >= 2 and len(tag_release) >= 2: | |
| # Pad to at least (major, minor, patch) | |
| j_major, j_minor, j_patch = (json_release + (0, 0, 0))[:3] | |
| t_major, t_minor, t_patch = (tag_release + (0, 0, 0))[:3] |
| # 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 |
There was a problem hiding this comment.
When version.json is newer than the latest git tag, the script adds an entry pointing to ../<json_version>/ and marks it as latest. But the docs build (sphinx_multiversion with an empty smv_branch_whitelist) only produces output directories for tags, so this URL will not exist and the switcher will link to a non-existent version. Consider either (a) not adding untagged versions, or (b) also building docs for the working tree/branch and ensuring the output directory matches the generated URL.
| # 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 | |
| # If version.json has a newer version, do not add it to the switcher | |
| # because docs are only built for tagged versions. Instead, log and | |
| # treat the latest tag as the latest version in the switcher. | |
| if version_json_is_newer: | |
| logging.warning( | |
| "version.json (%s) is newer than latest tag (%s); " | |
| "skipping untagged version in switcher.json. Ensure docs are " | |
| "built for this version before including it in the switcher.", | |
| json_version, | |
| latest_tag_version, | |
| ) | |
| # Ensure the latest tagged version is still marked as latest below. | |
| version_json_is_newer = False | |
| # Add all tagged versions | |
| for i, tag in enumerate(sorted_tags): | |
| # The first tag (index 0) is the latest | |
| is_latest = i == 0 |
| 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() |
There was a problem hiding this comment.
build_docs() only generates switcher.json when target == 'html' and not local, but this PR also removes the committed docs/source/_static/switcher.json (and .gitignores it). As a result, run.py build-docs --local will produce HTML without _static/switcher.json, breaking the version switcher configured in docs/source/conf.py. Consider generating the switcher for local HTML builds too (unless --skip-switcher is set), or update the docs config to use a remote json_url for local builds.
| 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: | ||
| # 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", | ||
| 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: | ||
| # 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", | ||
| 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 |
There was a problem hiding this comment.
The version/tag parsing and progression rules in generate_switcher.py are non-trivial and directly affect CI docs publishing. Adding pytest coverage for the main cases (valid sequential bump, skipped patch/minor/major, older/same version.json, prerelease tags) would help prevent regressions and ensure the safeguards behave as intended.
| 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 |
There was a problem hiding this comment.
parse_version_tags() only accepts tags that start with 'v', but Sphinx multiversion is configured to accept both vX.Y.Z and X.Y.Z (see smv_tag_whitelist = r'^v?\d+\.\d+\.\d+$' in docs/source/conf.py). This mismatch can cause switcher.json to omit versions that are actually built. Consider aligning the tag filter with the docs whitelist (e.g., accept optional v prefix and filter using the same regex).
| 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 |
There was a problem hiding this comment.
parse_version_tags() currently accepts any PEP 440 version after the v prefix (e.g., v1.0.0rc1). However, sphinx-multiversion is configured to only build tags matching strict X.Y.Z numeric versions, so generating entries for prerelease/other tags will produce broken links in the dropdown. Consider filtering to the same numeric-only pattern used by the docs build (or explicitly excluding prerelease/local versions via Version(...).pre / .local).
Description
This PR introduces the
generate_switcher.pyscript which reads the git tags available to generate theswitcher.jsonfile required by sphinx documentation version switcher. The script also checks theversion.jsonfile for version bumping, it has safeguards so faulty version upgrades can be blocked like27.0.0->27.0.2.Fixes # (issue)
#67 - [FEATURE] Automatic generation of switcher.json for sphinx documentation
Type of change
Checklist
Please delete options that are not relevant.
Changes in this PR
generate_switcher.pyfor generating the switcher file required by sphinx documentation version switcherrun.py build-docscommand to generate the switcher before generating the documentation pages.--skip-switcherflag to skip the generation of switcher file (for debugging scenarios)generate-switcheroption to run.py (for debugging)Version Rules
Legend