Skip to content

Commit e77e430

Browse files
[BUG] Fix: Handle no-tag repositories in build-docs by falling back to current Sphinx build (#72)
* Handling no-tag presence * documentation updates * Review comments * lint fix * stale switcher remover * Review comments * test update * Adding multiversion artifact cleaner * handling switchers * removing latest comparison Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test updates --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 6127587 commit e77e430

File tree

6 files changed

+136
-52
lines changed

6 files changed

+136
-52
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ python run.py build
6868
python run.py build-docs
6969
```
7070

71-
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.
71+
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.
7272

7373
Options:
7474
- `--skip-build` (`-s`): Skip building before generating docs
@@ -118,10 +118,11 @@ python -m http.server 8000
118118
Then open http://localhost:8000 in your browser. The root automatically redirects to the latest version documentation.
119119

120120
**Versioned Documentation:**
121-
- Each git tag creates a separate documentation version (e.g., `/v26.0.5/`)
121+
- Each git tag matching `vX.Y.Z` creates a separate documentation version (e.g., `/v26.0.5/`)
122122
- A `/latest/` directory points to the newest version
123123
- Root (`/`) automatically redirects to `/latest/`
124124
- Run `git fetch --tags` before building to ensure all version tags are available
125+
- 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
125126

126127
### Running the Formatter
127128

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
]
3535

3636
templates_path = ['_templates']
37-
smv_tag_whitelist = r'^v?\d+\.\d+\.\d+$'
37+
smv_tag_whitelist = r'^v\d+\.\d+\.\d+$'
3838
smv_branch_whitelist = r'^$'
3939
smv_remote_whitelist = r'^origin$'
4040
smv_latest_version = 'latest'

docs/source/readme.rst

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,8 +1254,9 @@ The project includes a ``run.py`` script with several useful commands:
12541254
- ``python run.py test`` - Run tests
12551255
- ``python run.py lint`` - Run code linting
12561256
- ``python run.py format`` - Format code with black
1257-
- ``python run.py build-docs`` - Build versioned documentation (HTML uses git tags for the
1258-
navigation dropdown; run ``git fetch --tags`` locally before building)
1257+
- ``python run.py build-docs`` - Build versioned documentation (HTML uses git tags matching
1258+
``vX.Y.Z`` for the navigation dropdown; run ``git fetch --tags`` locally before building).
1259+
Falls back to a single-version build when no matching version tags are found.
12591260

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

12661267
.. note::
12671268
The documentation version switcher (``switcher.json``) is automatically generated from
1268-
git tags during the build process. Only tagged versions are included to ensure all
1269-
documentation links work correctly. If ``version.json`` is newer than the latest tag,
1270-
create a git tag to include it in the version switcher.
1269+
git tags matching the ``vX.Y.Z`` format during the build process. Only tagged versions
1270+
are included to ensure all documentation links work correctly. If ``version.json`` is
1271+
newer than the latest tag, create a git tag to include it in the version switcher.
12711272

12721273
**Incremental Builds for Development:**
12731274

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

12971298
**Versioned Documentation Features:**
12981299

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

13041308
Contributing
13051309
============

run.py

Lines changed: 103 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
build-docs Build the documentation.
2929
format Format all Python files in the repository using black.
3030
generate-expected-data Generate expected data for integration tests.
31-
generate-switcher Generate switcher.json from git tags.
31+
generate-switcher Generate switcher.json from git tags (vX.Y.Z format).
3232
install Install the moldflow-api package.
3333
install-package-requirements Install package dependencies.
3434
lint Lint all Python files in the repository.
@@ -67,6 +67,7 @@
6767
"""
6868

6969
import os
70+
import re
7071
import sys
7172
import json
7273
import logging
@@ -118,6 +119,14 @@
118119
PYTHON_FILES = [MOLDFLOW_DIR, DOCS_SOURCE_DIR, TEST_DIR, "run.py"]
119120
SWITCHER_JSON = os.path.join(DOCS_STATIC_DIR, 'switcher.json')
120121

122+
# Must match smv_tag_whitelist in docs/source/conf.py
123+
VERSION_TAG_RE = re.compile(r'^v\d+\.\d+\.\d+$')
124+
125+
126+
def _is_version_tag(name):
127+
"""Return True if *name* matches the vX.Y.Z version tag format."""
128+
return VERSION_TAG_RE.match(name) is not None
129+
121130

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

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

@@ -429,9 +438,9 @@ def _build_html_docs_full(build_output, skip_switcher, include_current):
429438
"Failed to build documentation with "
430439
"sphinx_multiversion.\n"
431440
"This can happen if no Git tags or branches match "
432-
"your version pattern.\n"
441+
"the required vX.Y.Z version pattern.\n"
433442
"Try running 'git fetch --tags' and ensure version "
434-
"tags exist in the repo.\n"
443+
"tags (e.g. v1.2.3) exist in the repo.\n"
435444
"Underlying error: %s",
436445
str(err),
437446
)
@@ -445,11 +454,10 @@ def _get_missing_version_tags(build_output):
445454
if os.path.exists(build_output):
446455
for item in os.listdir(build_output):
447456
item_path = os.path.join(build_output, item)
448-
if os.path.isdir(item_path) and item.startswith('v'):
449-
if item != 'latest':
450-
existing_versions.add(item)
457+
if os.path.isdir(item_path) and _is_version_tag(item):
458+
existing_versions.add(item)
451459

452-
all_tags = {tag.name for tag in GIT_REPO.tags if tag.name.startswith('v')}
460+
all_tags = {tag.name for tag in GIT_REPO.tags if _is_version_tag(tag.name)}
453461

454462
missing = list(all_tags - existing_versions)
455463

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

563571

564572
# pylint: disable=R0913, R0917
573+
def _run_sphinx_build(target):
574+
"""Run a standard single-version Sphinx build."""
575+
run_command(
576+
[sys.executable, '-m', 'sphinx', 'build', '-M', target, DOCS_SOURCE_DIR, DOCS_BUILD_DIR],
577+
ROOT_DIR,
578+
)
579+
580+
581+
def _remove_stale_switcher():
582+
"""Remove leftover switcher.json from the source tree.
583+
584+
Prevents Sphinx from copying a stale version switcher into the build
585+
output when no version tags exist to populate it.
586+
"""
587+
if os.path.exists(SWITCHER_JSON):
588+
os.remove(SWITCHER_JSON)
589+
logging.info('Removed stale %s', SWITCHER_JSON)
590+
591+
592+
def _clean_multiversion_artifacts(build_output):
593+
"""Selectively remove multi-version artifacts from a previous build.
594+
595+
Removes vX.Y.Z/ directories, the latest/ alias, the root redirect
596+
index.html, and any distributed switcher.json copies while preserving
597+
single-version Sphinx output so that incremental builds remain effective.
598+
"""
599+
if not os.path.isdir(build_output):
600+
return
601+
602+
removed = []
603+
604+
for item in os.listdir(build_output):
605+
item_path = os.path.join(build_output, item)
606+
if os.path.isdir(item_path) and _is_version_tag(item):
607+
shutil.rmtree(item_path)
608+
removed.append(item)
609+
610+
latest_path = os.path.join(build_output, 'latest')
611+
if os.path.islink(latest_path):
612+
os.unlink(latest_path)
613+
removed.append('latest (symlink)')
614+
elif os.path.isdir(latest_path):
615+
shutil.rmtree(latest_path)
616+
removed.append('latest')
617+
618+
redirect = os.path.join(build_output, 'index.html')
619+
if os.path.isfile(redirect):
620+
os.remove(redirect)
621+
removed.append('index.html')
622+
623+
for switcher in glob.glob(
624+
os.path.join(build_output, '**', '_static', 'switcher.json'), recursive=True
625+
):
626+
os.remove(switcher)
627+
removed.append(os.path.relpath(switcher, build_output))
628+
629+
if removed:
630+
logging.info(
631+
'Cleaned multi-version artifacts from %s: %s', build_output, ', '.join(removed)
632+
)
633+
634+
635+
# pylint: disable=R0912
565636
def build_docs(
566637
target, skip_build, local=False, skip_switcher=False, include_current=False, incremental=False
567638
):
@@ -580,7 +651,9 @@ def build_docs(
580651
logging.info('Incremental build mode: preserving existing documentation...')
581652

582653
try:
583-
if target == 'html' and not local:
654+
has_version_tags = any(_is_version_tag(tag.name) for tag in GIT_REPO.tags)
655+
656+
if target == 'html' and not local and has_version_tags:
584657
build_output = os.path.join(DOCS_BUILD_DIR, 'html')
585658

586659
if incremental:
@@ -590,22 +663,26 @@ def build_docs(
590663

591664
create_latest_alias(build_output)
592665
create_root_redirect(build_output)
666+
elif target == 'html' and not local and not has_version_tags:
667+
logging.warning(
668+
'No version tags matching vX.Y.Z found in the repository. '
669+
'Falling back to a single-version documentation build.'
670+
)
671+
if not skip_switcher:
672+
_remove_stale_switcher()
673+
_clean_multiversion_artifacts(os.path.join(DOCS_BUILD_DIR, 'html'))
674+
_run_sphinx_build(target)
593675
else:
594676
if target == 'html' and not skip_switcher:
595-
generate_switcher(include_current=include_current)
596-
run_command(
597-
[
598-
sys.executable,
599-
'-m',
600-
'sphinx',
601-
'build',
602-
'-M',
603-
target,
604-
DOCS_SOURCE_DIR,
605-
DOCS_BUILD_DIR,
606-
],
607-
ROOT_DIR,
608-
)
677+
if has_version_tags:
678+
generate_switcher(include_current=include_current)
679+
else:
680+
logging.warning(
681+
'No version tags matching vX.Y.Z found in the repository. '
682+
'Skipping switcher.json generation.'
683+
)
684+
_remove_stale_switcher()
685+
_run_sphinx_build(target)
609686
logging.info('Sphinx documentation built successfully.')
610687
except Exception as err:
611688
logging.error(
@@ -856,7 +933,7 @@ def _get_current_version_if_newer():
856933
)
857934

858935
# Get latest git tag
859-
tags = [tag.name for tag in GIT_REPO.tags if tag.name.startswith('v')]
936+
tags = [tag.name for tag in GIT_REPO.tags if _is_version_tag(tag.name)]
860937

861938
if not tags:
862939
return None
@@ -878,8 +955,8 @@ def _get_current_version_if_newer():
878955

879956

880957
def generate_switcher(include_current=False):
881-
"""Generate switcher.json from git tags"""
882-
logging.info('Generating switcher.json from git tags')
958+
"""Generate switcher.json from git tags (vX.Y.Z format)."""
959+
logging.info('Generating switcher.json from git tags (vX.Y.Z)')
883960
switcher_script = os.path.join(ROOT_DIR, 'scripts', 'generate_switcher.py')
884961
cmd = [sys.executable, switcher_script]
885962
if include_current:

scripts/generate_switcher.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525

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

2929

3030
# Paths
@@ -49,13 +49,13 @@ def get_git_tags():
4949

5050
def parse_version_tags(tags):
5151
"""
52-
Filter tags to those matching strict X.Y.Z releases.
52+
Filter tags to those matching the required vX.Y.Z format.
5353
5454
Uses the same pattern as smv_tag_whitelist in docs/source/conf.py
5555
so that switcher.json stays in sync with the versions that
56-
sphinx-multiversion actually builds. Accepts both vX.Y.Z and X.Y.Z.
56+
sphinx-multiversion actually builds.
5757
58-
Returns the original tag strings (preserving any 'v' prefix).
58+
Returns the matching tag strings.
5959
"""
6060
version_tags = []
6161

tests/core/test_generate_switcher.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,19 @@
3737
@pytest.mark.scripts
3838
@pytest.mark.generate_switcher
3939
class TestParseVersionTags:
40-
"""Tests for tag filtering against the sphinx-multiversion whitelist."""
40+
"""Tests for tag filtering against the vX.Y.Z version tag pattern."""
4141

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

46-
def test_accepts_tags_without_v_prefix(self):
46+
def test_rejects_tags_without_v_prefix(self):
4747
tags = ['1.0.0', '2.3.4', '27.0.0']
48-
assert parse_version_tags(tags) == tags
48+
assert parse_version_tags(tags) == []
4949

50-
def test_accepts_mixed_prefix_tags(self):
50+
def test_filters_tags_without_v_prefix(self):
5151
tags = ['v1.0.0', '2.0.0', 'v3.1.2']
52-
assert parse_version_tags(tags) == tags
52+
assert parse_version_tags(tags) == ['v1.0.0', 'v3.1.2']
5353

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

7171
def test_filters_mixed_valid_and_invalid(self):
7272
tags = ['v1.0.0', 'v2.0.0rc1', 'latest', '3.0.0', 'v4.0.0.dev1']
73-
assert parse_version_tags(tags) == ['v1.0.0', '3.0.0']
73+
assert parse_version_tags(tags) == ['v1.0.0']
7474

7575
def test_empty_input(self):
7676
assert parse_version_tags([]) == []
@@ -95,9 +95,9 @@ def test_patch_ordering(self):
9595
tags = ['v1.0.2', 'v1.0.0', 'v1.0.1']
9696
assert sort_versions(tags) == ['v1.0.2', 'v1.0.1', 'v1.0.0']
9797

98-
def test_mixed_prefix_ordering(self):
99-
tags = ['1.0.0', 'v2.0.0', '3.0.0']
100-
assert sort_versions(tags) == ['3.0.0', 'v2.0.0', '1.0.0']
98+
def test_many_versions_ordering(self):
99+
tags = ['v1.0.0', 'v2.0.0', 'v3.0.0']
100+
assert sort_versions(tags) == ['v3.0.0', 'v2.0.0', 'v1.0.0']
101101

102102
def test_single_tag(self):
103103
assert sort_versions(['v1.0.0']) == ['v1.0.0']
@@ -138,10 +138,12 @@ def test_url_uses_tag_name(self, _):
138138
result = generate_switcher_json(['v1.0.0'])
139139
assert result[0]['url'] == '../v1.0.0/'
140140

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

146148
@patch(MOCK_VERSION_JSON, return_value='v2.0.0')
147149
def test_all_entries_have_required_keys(self, _):

0 commit comments

Comments
 (0)