Skip to content

Automate generation of switcher.json for Sphinx version dropdown#68

Open
sankalps0549 wants to merge 2 commits intomainfrom
feature/switcher-automation
Open

Automate generation of switcher.json for Sphinx version dropdown#68
sankalps0549 wants to merge 2 commits intomainfrom
feature/switcher-automation

Conversation

@sankalps0549
Copy link
Collaborator

Description

This PR introduces the generate_switcher.py script which reads the git tags available to generate the switcher.json file required by sphinx documentation version switcher. The script also checks the version.json file for version bumping, it has safeguards so faulty version upgrades can be blocked like 27.0.0 -> 27.0.2.

Fixes # (issue)
#67 - [FEATURE] Automatic generation of switcher.json for sphinx documentation

Type of change

  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

Checklist

Please delete options that are not relevant.

  • I have read the CONTRIBUTING document
  • My code follows the style guidelines of this project
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly
  • I have added a note to CHANGELOG.md describing my changes
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

Changes in this PR

  • Added generate_switcher.py for generating the switcher file required by sphinx documentation version switcher
  • Updated run.py build-docs command to generate the switcher before generating the documentation pages.
  • Added the --skip-switcher flag to skip the generation of switcher file (for debugging scenarios)
  • Added generate-switcher option to run.py (for debugging)

Version Rules

From Version (git tag latest) To Version (version.json) Valid? Rule Error Message / Notes
27.0.0 27.0.0 ⚠️ Same No change from latest tag Uses tag as latest, version.json ignored
27.0.0 27.0.1 ✅ Pass Sequential patch increment Valid progression
27.0.0 27.0.2 ❌ FAIL Cannot skip patch versions Expected v27.0.1
27.0.0 26.0.5 ⚠️ Older Version older than latest tag Uses tag v27.0.0 as latest, version.json ignored
27.0.0 26.0.9 ⚠️ Older Version older than latest tag Uses tag v27.0.0 as latest, version.json ignored
27.0.0 27.1.0 ✅ Pass Valid minor bump with patch reset to 0 Correct progression
27.0.0 27.1.1 ❌ FAIL Patch must be 0 when bumping minor Expected v27.1.0
27.0.0 27.2.0 ❌ FAIL Cannot skip minor versions Expected v27.1.0
27.0.0 26.2.3 ⚠️ Older Version older than latest tag Uses tag v27.0.0 as latest, version.json ignored
27.0.0 26.1.0 ⚠️ Older Version older than latest tag Uses tag v27.0.0 as latest, version.json ignored
27.0.0 28.0.0 ✅ Pass Valid major bump with minor=0, patch=0 Correct progression
27.0.0 28.0.1 ❌ FAIL Patch must be 0 when bumping major Expected v28.0.0
27.0.0 28.1.0 ❌ FAIL Minor must be 0 when bumping major Expected v28.0.0
27.0.0 28.1.1 ❌ FAIL Minor and patch must be 0 when bumping major Expected v28.0.0
27.0.0 29.0.0 ❌ FAIL Cannot skip major versions Expected v28.0.0
27.0.0 26.0.3 ⚠️ Older Version older than latest tag Uses tag v27.0.0 as latest, version.json ignored
27.0.0 24.0.4 ⚠️ Older Version older than latest tag Uses tag v27.0.0 as latest, version.json ignored

Legend

  • Pass: Valid version progression, script succeeds, version added to switcher.json
  • FAIL: Invalid progression, script exits with error code 1, no switcher.json generated
  • ⚠️ Same: version.json matches latest tag, uses tag as latest, version.json NOT added
  • ⚠️ Older: version.json is older than latest tag, uses tag as latest, version.json NOT added

@sankalps0549 sankalps0549 requested a review from Copilot March 4, 2026 22:09
@sankalps0549 sankalps0549 self-assigned this Mar 4, 2026
@sankalps0549 sankalps0549 added the enhancement New feature or request label Mar 4, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py to generate docs/source/_static/switcher.json from git tags (with version progression checks against version.json).
  • Extends run.py build-docs to generate the switcher before building docs, plus adds run.py generate-switcher and --skip-switcher.
  • Updates docs/README guidance and CI checkout behavior; removes the committed switcher.json and 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.

Comment on lines +75 to +125
# 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
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +186
# 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]),
)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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]

Copilot uses AI. Check for mistakes.
Comment on lines +263 to +282
# 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
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines 422 to +425
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()
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +293
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
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +52
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
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +60
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
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Automatic generation of switcher.json for sphinx documentation

2 participants