diff --git a/README.md b/README.md index f659df9..bc076f8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/source/conf.py b/docs/source/conf.py index 71053fd..9adc10d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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' diff --git a/docs/source/readme.rst b/docs/source/readme.rst index 02ec996..2d6c84e 100644 --- a/docs/source/readme.rst +++ b/docs/source/readme.rst @@ -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) @@ -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:** @@ -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 ============ diff --git a/run.py b/run.py index d4ada1a..9f6ea09 100644 --- a/run.py +++ b/run.py @@ -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. @@ -67,6 +67,7 @@ """ import os +import re import sys import json import logging @@ -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""" @@ -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 @@ -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), ) @@ -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) @@ -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 ): @@ -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: @@ -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( @@ -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 @@ -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: diff --git a/scripts/generate_switcher.py b/scripts/generate_switcher.py index c6c6eac..70e9d17 100644 --- a/scripts/generate_switcher.py +++ b/scripts/generate_switcher.py @@ -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 @@ -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 = [] diff --git a/tests/core/test_generate_switcher.py b/tests/core/test_generate_switcher.py index 2c7b98b..0b12c7b 100644 --- a/tests/core/test_generate_switcher.py +++ b/tests/core/test_generate_switcher.py @@ -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'] @@ -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([]) == [] @@ -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'] @@ -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, _):