diff --git a/pyproject.toml b/pyproject.toml index 8ff3ef19..2b7fc293 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ [dependency-groups] dev = [ + "linkchecker", "pre-commit", "pytest", "pytest-cov", diff --git a/tests/test_docs_links.py b/tests/test_docs_links.py new file mode 100644 index 00000000..8fccc4d8 --- /dev/null +++ b/tests/test_docs_links.py @@ -0,0 +1,159 @@ +"""Test for validating documentation links. + +This module contains tests that validate the links in the project documentation. +It uses the linkchecker tool to verify that links are not broken. + +The tests build the documentation using `uv run --group docs mkdocs build` and then +run linkchecker on the generated site to ensure all links are valid. + +There are two test functions: +1. test_docs_links_are_valid() - Checks internal links only (suitable for CI) +2. test_docs_external_links_are_valid() - Checks external links (may skip in CI) + +Requirements: +- linkchecker must be installed (included in dev dependencies) +- The mkdocs documentation must be buildable +""" + +import os +import subprocess +import tempfile +from pathlib import Path + +import pytest + + +def test_docs_links_are_valid(): + """Test that internal links in the documentation are valid. + + This test builds the documentation and uses linkchecker to validate + that all internal links are not broken. External links are not checked + to avoid issues with network restrictions in CI environments. + """ + # Build the documentation + build_result = subprocess.run( + ["uv", "run", "--group", "docs", "mkdocs", "build"], + cwd=Path.cwd(), + capture_output=True, + text=True, + ) + + if build_result.returncode != 0: + pytest.fail(f"Failed to build documentation: {build_result.stderr}") + + # Check if site directory exists + site_dir = Path.cwd() / "site" + if not site_dir.exists(): + pytest.fail("Site directory not found after build") + + # Run linkchecker on the generated site + # Use a temporary file to capture linkchecker output + with tempfile.NamedTemporaryFile(mode='w+', suffix='.txt', delete=False) as temp_file: + temp_file_path = temp_file.name + + try: + # Run linkchecker with appropriate flags + # Focus on internal links only to avoid network issues in CI + linkchecker_result = subprocess.run( + [ + "linkchecker", + "--no-warnings", # Don't show warnings, only errors + "--output", "text", + "--file-output", f"text/{temp_file_path}", + "--recursion-level", "2", # Limit recursion to avoid infinite checks + "--timeout", "10", # Set timeout to 10 seconds + # Don't check external links to avoid network issues in CI + # "--check-extern", # Commented out for CI stability + str(site_dir / "index.html") # Start from index.html + ], + cwd=Path.cwd(), + capture_output=True, + text=True, + ) + + # Read the output file + with open(temp_file_path, 'r') as f: + output = f.read() + + # Check if there are any broken internal links + if linkchecker_result.returncode != 0: + # Count actual errors vs warnings + error_lines = [line for line in output.split('\n') if 'Error:' in line] + if error_lines: + pytest.fail(f"Linkchecker found broken internal links:\n{output}") + + # Verify that the check actually ran + if "links in" not in output: + pytest.fail("Linkchecker didn't complete successfully") + + finally: + # Clean up temporary file + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +def test_docs_external_links_are_valid(): + """Test that external links in the documentation are valid. + + This test is marked as optional and may fail in CI environments + with network restrictions. It checks both internal and external links. + """ + # This test is marked as optional and may fail in CI environments + # with network restrictions + + # Build the documentation + build_result = subprocess.run( + ["uv", "run", "--group", "docs", "mkdocs", "build"], + cwd=Path.cwd(), + capture_output=True, + text=True, + ) + + if build_result.returncode != 0: + pytest.skip(f"Failed to build documentation: {build_result.stderr}") + + # Check if site directory exists + site_dir = Path.cwd() / "site" + if not site_dir.exists(): + pytest.skip("Site directory not found after build") + + # Run linkchecker on the generated site with external links + # Use a temporary file to capture linkchecker output + with tempfile.NamedTemporaryFile(mode='w+', suffix='.txt', delete=False) as temp_file: + temp_file_path = temp_file.name + + try: + # Run linkchecker with external link checking + linkchecker_result = subprocess.run( + [ + "linkchecker", + "--check-extern", # Check external links + "--no-warnings", # Don't show warnings, only errors + "--output", "text", + "--file-output", f"text/{temp_file_path}", + "--recursion-level", "2", # Limit recursion to avoid infinite checks + "--timeout", "10", # Set timeout to 10 seconds + str(site_dir / "index.html") # Start from index.html + ], + cwd=Path.cwd(), + capture_output=True, + text=True, + ) + + # Read the output file + with open(temp_file_path, 'r') as f: + output = f.read() + + # For external links, we're more lenient due to network issues + # We'll just warn about broken links rather than fail + if linkchecker_result.returncode != 0: + print(f"Warning: Some external links may be broken:\n{output}") + + except Exception as e: + # Skip if there are issues with external link checking + pytest.skip(f"External link checking failed: {e}") + + finally: + # Clean up temporary file + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) \ No newline at end of file diff --git a/uv.lock b/uv.lock index cfe2d091..f86ac19a 100644 --- a/uv.lock +++ b/uv.lock @@ -1063,6 +1063,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + [[package]] name = "einops" version = "0.8.1" @@ -2372,6 +2381,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/c1/31b3184cba7b257a4a3b5ca5b88b9204ccb7aa02fe3c992280899293ed54/lightning_utilities-0.14.3-py3-none-any.whl", hash = "sha256:4ab9066aa36cd7b93a05713808901909e96cc3f187ea6fd3052b2fd91313b468", size = 28894, upload-time = "2025-04-03T15:59:55.658Z" }, ] +[[package]] +name = "linkchecker" +version = "10.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "dnspython" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/8a/20cfbda1a4f5e9fd307cbb68dd15c2f14428deaf1eab89a79b9b7d03bf6e/LinkChecker-10.5.0.tar.gz", hash = "sha256:978b42b803e58b7a8f6ffae1ff88fa7fd1e87b944403b5dc82380dd59f516bb9", size = 546451, upload-time = "2024-09-03T18:42:46.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c6/8d6a8383a92fbd19337b7a3c4ed57042a3f39f57772774a11bd56316af2e/LinkChecker-10.5.0-py3-none-any.whl", hash = "sha256:eb25bf11c795eedc290f93311c497312f4e967e1c5b242b24ce3fc335b4c47c5", size = 280788, upload-time = "2024-09-03T18:42:45.039Z" }, +] + [[package]] name = "llvmlite" version = "0.44.0" @@ -5339,6 +5362,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "linkchecker" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -5371,6 +5395,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "linkchecker" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" },