Skip to content
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ python run.py build
python run.py build-docs
```

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.
The documentation version switcher (`switcher.json`) is automatically generated from git tags matching the `vX.Y.Z` format 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
Expand Down Expand Up @@ -118,10 +118,11 @@ python -m http.server 8000
Then open http://localhost:8000 in your browser. The root automatically redirects to the latest version documentation.

**Versioned Documentation:**
- Each git tag creates a separate documentation version (e.g., `/v26.0.5/`)
- Each git tag matching `vX.Y.Z` creates a separate documentation version (e.g., `/v26.0.5/`)
- A `/latest/` directory points to the newest version
- Root (`/`) automatically redirects to `/latest/`
- Run `git fetch --tags` before building to ensure all version tags are available
- If no version tags matching `vX.Y.Z` are found in the repository, the build automatically falls back to a single-version Sphinx build instead of attempting the multi-version build

### Running the Formatter

Expand Down
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
]

templates_path = ['_templates']
smv_tag_whitelist = r'^v?\d+\.\d+\.\d+$'
smv_tag_whitelist = r'^v\d+\.\d+\.\d+$'
smv_branch_whitelist = r'^$'
smv_remote_whitelist = r'^origin$'
smv_latest_version = 'latest'
Expand Down
16 changes: 10 additions & 6 deletions docs/source/readme.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1254,8 +1254,9 @@ The project includes a ``run.py`` script with several useful commands:
- ``python run.py test`` - Run tests
- ``python run.py lint`` - Run code linting
- ``python run.py format`` - Format code with black
- ``python run.py build-docs`` - Build versioned documentation (HTML uses git tags for the
navigation dropdown; run ``git fetch --tags`` locally before building)
- ``python run.py build-docs`` - Build versioned documentation (HTML uses git tags matching
``vX.Y.Z`` for the navigation dropdown; run ``git fetch --tags`` locally before building).
Falls back to a single-version build when no matching version tags are found.

- ``--skip-build`` (``-s``): Skip building the package before generating docs
- ``--local`` (``-l``): Build documentation locally for a single version (skips multi-version build)
Expand All @@ -1265,9 +1266,9 @@ The project includes a ``run.py`` script with several useful commands:

.. note::
The documentation version switcher (``switcher.json``) is automatically generated from
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.
git tags matching the ``vX.Y.Z`` format 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:**

Expand Down Expand Up @@ -1296,10 +1297,13 @@ Then open http://localhost:8000 in your browser.

**Versioned Documentation Features:**

- Each git tag creates a separate documentation version (e.g., ``/v26.0.5/``)
- Each git tag matching ``vX.Y.Z`` creates a separate documentation version (e.g., ``/v26.0.5/``)
- A ``/latest/`` directory points to the newest version (symlink on Unix, copy on Windows)
- Root (``/``) automatically redirects to ``/latest/`` for convenience
- Version switcher dropdown in the navigation bar allows switching between versions
- If no version tags matching ``vX.Y.Z`` are found in the repository, the build
automatically falls back to a single-version Sphinx build instead of attempting the
multi-version build

Contributing
============
Expand Down
129 changes: 103 additions & 26 deletions run.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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.
generate-switcher Generate switcher.json from git tags (vX.Y.Z format).
install Install the moldflow-api package.
install-package-requirements Install package dependencies.
lint Lint all Python files in the repository.
Expand Down Expand Up @@ -67,6 +67,7 @@
"""

import os
import re
import sys
import json
import logging
Expand Down Expand Up @@ -118,6 +119,14 @@
PYTHON_FILES = [MOLDFLOW_DIR, DOCS_SOURCE_DIR, TEST_DIR, "run.py"]
SWITCHER_JSON = os.path.join(DOCS_STATIC_DIR, 'switcher.json')

# Must match smv_tag_whitelist in docs/source/conf.py
VERSION_TAG_RE = re.compile(r'^v\d+\.\d+\.\d+$')


def _is_version_tag(name):
"""Return True if *name* matches the vX.Y.Z version tag format."""
return VERSION_TAG_RE.match(name) is not None


def run_command(args, cwd=os.getcwd(), extra_env=None):
"""Runs native executable command, args is an array of strings"""
Expand Down Expand Up @@ -367,7 +376,7 @@ def create_root_redirect(build_output: str) -> None:

def create_latest_alias(build_output: str) -> None:
"""Create a 'latest' alias pointing to the newest version using symlinks when possible."""
version_dirs = [d for d in os.listdir(build_output) if d.startswith('v')]
version_dirs = [d for d in os.listdir(build_output) if _is_version_tag(d)]
if not version_dirs:
return

Expand Down Expand Up @@ -429,9 +438,9 @@ def _build_html_docs_full(build_output, skip_switcher, include_current):
"Failed to build documentation with "
"sphinx_multiversion.\n"
"This can happen if no Git tags or branches match "
"your version pattern.\n"
"the required vX.Y.Z version pattern.\n"
"Try running 'git fetch --tags' and ensure version "
"tags exist in the repo.\n"
"tags (e.g. v1.2.3) exist in the repo.\n"
"Underlying error: %s",
str(err),
)
Expand All @@ -445,11 +454,10 @@ def _get_missing_version_tags(build_output):
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'):
if item != 'latest':
existing_versions.add(item)
if os.path.isdir(item_path) and _is_version_tag(item):
existing_versions.add(item)

all_tags = {tag.name for tag in GIT_REPO.tags if tag.name.startswith('v')}
all_tags = {tag.name for tag in GIT_REPO.tags if _is_version_tag(tag.name)}

missing = list(all_tags - existing_versions)

Expand Down Expand Up @@ -562,6 +570,69 @@ def _build_html_docs_incremental(build_output, skip_switcher, include_current):


# pylint: disable=R0913, R0917
def _run_sphinx_build(target):
"""Run a standard single-version Sphinx build."""
run_command(
[sys.executable, '-m', 'sphinx', 'build', '-M', target, DOCS_SOURCE_DIR, DOCS_BUILD_DIR],
ROOT_DIR,
)


def _remove_stale_switcher():
"""Remove leftover switcher.json from the source tree.

Prevents Sphinx from copying a stale version switcher into the build
output when no version tags exist to populate it.
"""
if os.path.exists(SWITCHER_JSON):
os.remove(SWITCHER_JSON)
logging.info('Removed stale %s', SWITCHER_JSON)


def _clean_multiversion_artifacts(build_output):
"""Selectively remove multi-version artifacts from a previous build.

Removes vX.Y.Z/ directories, the latest/ alias, the root redirect
index.html, and any distributed switcher.json copies while preserving
single-version Sphinx output so that incremental builds remain effective.
"""
if not os.path.isdir(build_output):
return

removed = []

for item in os.listdir(build_output):
item_path = os.path.join(build_output, item)
if os.path.isdir(item_path) and _is_version_tag(item):
shutil.rmtree(item_path)
removed.append(item)

latest_path = os.path.join(build_output, 'latest')
if os.path.islink(latest_path):
os.unlink(latest_path)
removed.append('latest (symlink)')
elif os.path.isdir(latest_path):
shutil.rmtree(latest_path)
removed.append('latest')

redirect = os.path.join(build_output, 'index.html')
if os.path.isfile(redirect):
os.remove(redirect)
removed.append('index.html')

for switcher in glob.glob(
os.path.join(build_output, '**', '_static', 'switcher.json'), recursive=True
):
os.remove(switcher)
removed.append(os.path.relpath(switcher, build_output))

if removed:
logging.info(
'Cleaned multi-version artifacts from %s: %s', build_output, ', '.join(removed)
)


# pylint: disable=R0912
def build_docs(
target, skip_build, local=False, skip_switcher=False, include_current=False, incremental=False
):
Expand All @@ -580,7 +651,9 @@ def build_docs(
logging.info('Incremental build mode: preserving existing documentation...')

try:
if target == 'html' and not local:
has_version_tags = any(_is_version_tag(tag.name) for tag in GIT_REPO.tags)

if target == 'html' and not local and has_version_tags:
build_output = os.path.join(DOCS_BUILD_DIR, 'html')

if incremental:
Expand All @@ -590,22 +663,26 @@ def build_docs(

create_latest_alias(build_output)
create_root_redirect(build_output)
elif target == 'html' and not local and not has_version_tags:
logging.warning(
'No version tags matching vX.Y.Z found in the repository. '
'Falling back to a single-version documentation build.'
)
if not skip_switcher:
_remove_stale_switcher()
_clean_multiversion_artifacts(os.path.join(DOCS_BUILD_DIR, 'html'))
_run_sphinx_build(target)
else:
if target == 'html' and not skip_switcher:
generate_switcher(include_current=include_current)
run_command(
[
sys.executable,
'-m',
'sphinx',
'build',
'-M',
target,
DOCS_SOURCE_DIR,
DOCS_BUILD_DIR,
],
ROOT_DIR,
)
if has_version_tags:
generate_switcher(include_current=include_current)
else:
logging.warning(
'No version tags matching vX.Y.Z found in the repository. '
'Skipping switcher.json generation.'
)
_remove_stale_switcher()
_run_sphinx_build(target)
logging.info('Sphinx documentation built successfully.')
except Exception as err:
logging.error(
Expand Down Expand Up @@ -856,7 +933,7 @@ def _get_current_version_if_newer():
)

# Get latest git tag
tags = [tag.name for tag in GIT_REPO.tags if tag.name.startswith('v')]
tags = [tag.name for tag in GIT_REPO.tags if _is_version_tag(tag.name)]

if not tags:
return None
Expand All @@ -878,8 +955,8 @@ def _get_current_version_if_newer():


def generate_switcher(include_current=False):
"""Generate switcher.json from git tags"""
logging.info('Generating switcher.json from git tags')
"""Generate switcher.json from git tags (vX.Y.Z format)."""
logging.info('Generating switcher.json from git tags (vX.Y.Z)')
switcher_script = os.path.join(ROOT_DIR, 'scripts', 'generate_switcher.py')
cmd = [sys.executable, switcher_script]
if include_current:
Expand Down
8 changes: 4 additions & 4 deletions scripts/generate_switcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@


# Must match smv_tag_whitelist in docs/source/conf.py
SMV_TAG_PATTERN = re.compile(r'^v?\d+\.\d+\.\d+$')
SMV_TAG_PATTERN = re.compile(r'^v\d+\.\d+\.\d+$')


# Paths
Expand All @@ -49,13 +49,13 @@ def get_git_tags():

def parse_version_tags(tags):
"""
Filter tags to those matching strict X.Y.Z releases.
Filter tags to those matching the required vX.Y.Z format.

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.
sphinx-multiversion actually builds.

Returns the original tag strings (preserving any 'v' prefix).
Returns the matching tag strings.
"""
version_tags = []

Expand Down
28 changes: 15 additions & 13 deletions tests/core/test_generate_switcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,19 @@
@pytest.mark.scripts
@pytest.mark.generate_switcher
class TestParseVersionTags:
"""Tests for tag filtering against the sphinx-multiversion whitelist."""
"""Tests for tag filtering against the vX.Y.Z version tag pattern."""

def test_accepts_v_prefixed_tags(self):
tags = ['v1.0.0', 'v2.3.4', 'v27.0.0']
assert parse_version_tags(tags) == tags

def test_accepts_tags_without_v_prefix(self):
def test_rejects_tags_without_v_prefix(self):
tags = ['1.0.0', '2.3.4', '27.0.0']
assert parse_version_tags(tags) == tags
assert parse_version_tags(tags) == []

def test_accepts_mixed_prefix_tags(self):
def test_filters_tags_without_v_prefix(self):
tags = ['v1.0.0', '2.0.0', 'v3.1.2']
assert parse_version_tags(tags) == tags
assert parse_version_tags(tags) == ['v1.0.0', 'v3.1.2']

def test_rejects_prerelease_tags(self):
tags = ['v1.0.0rc1', 'v1.0.0a1', 'v1.0.0b2']
Expand All @@ -70,7 +70,7 @@ def test_rejects_non_version_tags(self):

def test_filters_mixed_valid_and_invalid(self):
tags = ['v1.0.0', 'v2.0.0rc1', 'latest', '3.0.0', 'v4.0.0.dev1']
assert parse_version_tags(tags) == ['v1.0.0', '3.0.0']
assert parse_version_tags(tags) == ['v1.0.0']

def test_empty_input(self):
assert parse_version_tags([]) == []
Expand All @@ -95,9 +95,9 @@ def test_patch_ordering(self):
tags = ['v1.0.2', 'v1.0.0', 'v1.0.1']
assert sort_versions(tags) == ['v1.0.2', 'v1.0.1', 'v1.0.0']

def test_mixed_prefix_ordering(self):
tags = ['1.0.0', 'v2.0.0', '3.0.0']
assert sort_versions(tags) == ['3.0.0', 'v2.0.0', '1.0.0']
def test_many_versions_ordering(self):
tags = ['v1.0.0', 'v2.0.0', 'v3.0.0']
assert sort_versions(tags) == ['v3.0.0', 'v2.0.0', 'v1.0.0']

def test_single_tag(self):
assert sort_versions(['v1.0.0']) == ['v1.0.0']
Expand Down Expand Up @@ -138,10 +138,12 @@ def test_url_uses_tag_name(self, _):
result = generate_switcher_json(['v1.0.0'])
assert result[0]['url'] == '../v1.0.0/'

@patch(MOCK_VERSION_JSON, return_value='1.0.0')
def test_url_preserves_no_v_prefix(self, _):
result = generate_switcher_json(['1.0.0'])
assert result[0]['url'] == '../1.0.0/'
@patch(MOCK_VERSION_JSON, return_value='v2.0.0')
def test_non_latest_url_uses_tag_name(self, _):
result = generate_switcher_json(['v1.0.0', 'v2.0.0'])
older = [e for e in result if e['version'] == 'v1.0.0'][0]
assert older['url'] == '../v1.0.0/'
assert older['is_latest'] is False

@patch(MOCK_VERSION_JSON, return_value='v2.0.0')
def test_all_entries_have_required_keys(self, _):
Expand Down
Loading