diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index 1bfc906e..f0ca0d3b 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -39,6 +39,7 @@ The current members of the MkDocs-NG team. * Fix `validation.links.not_found` is always reported as INFO for excluded pages. #32 * Fix `mkdocs serve` cleanup so temporary site directories are removed when the process receives `SIGTERM`. #36 * Fix mkdocs theme color mode switching when `highlightjs` is disabled. #39 +* Fix a crash when a dangling symlink exists in the docs directory, so `mkdocs build` and `mkdocs serve` log a warning and continue instead of aborting. #43 ### Maintenance diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py index 98822377..5bffa4a9 100644 --- a/mkdocs/structure/files.py +++ b/mkdocs/structure/files.py @@ -514,6 +514,8 @@ def copy_file(self, dirty: bool = False) -> None: utils.copy_file(self.abs_src_path, output_path) except shutil.SameFileError: pass # Let plugins write directly into site_dir. + except OSError as e: + log.warning(f"Error copying '{self.src_uri}': {e}") elif isinstance(content, str): with open(output_path, "w", encoding="utf-8") as output_file: output_file.write(content) @@ -526,9 +528,12 @@ def is_modified(self) -> bool: return True assert self.abs_src_path is not None if os.path.isfile(self.abs_dest_path): - return os.path.getmtime(self.abs_dest_path) < os.path.getmtime( - self.abs_src_path - ) + try: + return os.path.getmtime(self.abs_dest_path) < os.path.getmtime( + self.abs_src_path + ) + except OSError: + return False return True def is_documentation_page(self) -> bool: diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py index b01af37a..c218c078 100644 --- a/mkdocs/tests/structure/file_tests.py +++ b/mkdocs/tests/structure/file_tests.py @@ -803,6 +803,35 @@ def test_copy_file_from_content(self, dest_dir): with open(dest_path, encoding="utf-8") as f: self.assertEqual(f.read(), "รถ") + @tempdir() + @tempdir() + def test_copy_file_dangling_symlink(self, src_dir, dest_dir): + dangling = os.path.join(src_dir, "dangling.jpg") + try: + os.symlink(os.path.join(src_dir, "nonexistent"), dangling) + except (OSError, NotImplementedError): + self.skipTest("Creating symlinks not supported") + + file = File("dangling.jpg", src_dir, dest_dir, use_directory_urls=False) + with self.assertLogs("mkdocs.structure.files") as cm: + file.copy_file() + self.assertRegex( + "\n".join(cm.output), + r"^WARNING:mkdocs.structure.files:Error copying 'dangling.jpg'", + ) + + @tempdir() + @tempdir() + def test_copy_file_missing_source(self, src_dir, dest_dir): + """File deleted between discovery and copy should warn, not crash.""" + file = File("missing.txt", src_dir, dest_dir, use_directory_urls=False) + with self.assertLogs("mkdocs.structure.files") as cm: + file.copy_file() + self.assertRegex( + "\n".join(cm.output), + r"^WARNING:mkdocs.structure.files:Error copying 'missing.txt'", + ) + def test_files_append_remove_src_paths(self): fs = [ File("index.md", "/path/to/docs", "/path/to/site", use_directory_urls=True), diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py index 940902a8..ca004682 100644 --- a/mkdocs/tests/utils/utils_tests.py +++ b/mkdocs/tests/utils/utils_tests.py @@ -386,15 +386,11 @@ def test_copy_files_without_permissions(self, src_dir, dst_dir): @tempdir() def test_copy_broken_symlink(self, src_dir, dst_dir): # Regression test for mkdocs/mkdocs#3785: dangling symlinks should - # not crash the build. A warning should be logged and the file skipped. + # not crash the build. A warning should be raised and the file skipped. broken_link = os.path.join(src_dir, "broken_link") os.symlink("/nonexistent/path", broken_link) - with self.assertLogs("mkdocs", level="WARNING") as cm: + with self.assertRaises(FileNotFoundError): utils.copy_file(broken_link, os.path.join(dst_dir, "broken_link")) - self.assertTrue( - any("Symlink broken" in m for m in cm.output), - f"Expected a warning about broken symlink, got: {cm.output}", - ) # The broken symlink should not have been copied. self.assertFalse(os.path.exists(os.path.join(dst_dir, "broken_link"))) diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py index 5e56b5e9..9536dffb 100644 --- a/mkdocs/utils/__init__.py +++ b/mkdocs/utils/__init__.py @@ -122,8 +122,7 @@ def copy_file(source_path: str, output_path: str) -> None: if os.path.isdir(output_path): output_path = os.path.join(output_path, os.path.basename(source_path)) if os.path.islink(source_path) and not os.path.exists(os.readlink(source_path)): - log.warning(f"Symlink broken, could not copy file: {source_path}") - return + raise FileNotFoundError(f"Broken symlink: {source_path}") shutil.copyfile(source_path, output_path)