From cdf1f65656a09e5fa574c0a0cafd168fac983f53 Mon Sep 17 00:00:00 2001 From: rajashidattapy Date: Wed, 12 Aug 2026 20:56:28 +0100 Subject: [PATCH] test: probe-and-skip symlink tests where symlink creation is unavailable (#2642) --- tests/conftest.py | 41 +++++++++++++++++++++++++++++++++++++ tests/test_atomic_writes.py | 2 +- tests/test_detect.py | 16 +++++++-------- tests/test_extract.py | 8 ++++---- tests/test_image_vision.py | 4 ++-- 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 33dc81a0a..f581a7c1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,52 @@ from __future__ import annotations +import tempfile from pathlib import Path from typing import Any import pytest +@pytest.fixture(scope="session") +def _can_symlink() -> bool: + """Whether this machine can create symlinks at all (#2642). + + Probed rather than inferred from ``sys.platform``: Windows *can* create + symlinks from an elevated shell or with Developer Mode enabled, and those + runs should still get the coverage. A plain non-elevated Windows shell + raises ``OSError: [WinError 1314] A required privilege is not held by the + client``, which pytest reports as a FAILURE — 15 of them, drowning out real + defects — when what it means is "unsupported here". + + One file symlink is enough to probe: Windows gates file and directory + symlinks behind the same ``SeCreateSymbolicLinkPrivilege`` check. + """ + with tempfile.TemporaryDirectory() as d: + src = Path(d) / "probe-src" + src.write_text("x", encoding="utf-8") + try: + (Path(d) / "probe-link").symlink_to(src) + except (OSError, NotImplementedError): + return False + return True + + +@pytest.fixture +def requires_symlinks(_can_symlink) -> None: + """Skip a test that must create symlinks when the platform won't allow it. + + Take this as a parameter rather than wrapping each ``symlink_to()`` call in + try/except: the guard then sits in the signature where it is visible, and + an OSError from the code UNDER test is still a real failure instead of + being swallowed into a skip. + """ + if not _can_symlink: + pytest.skip( + "symlink creation unavailable on this machine " + "(Windows requires an elevated shell or Developer Mode)" + ) + + @pytest.fixture(autouse=True) def _sandbox_home(tmp_path_factory, monkeypatch): """Every test gets a throwaway HOME so installers/uninstallers can never diff --git a/tests/test_atomic_writes.py b/tests/test_atomic_writes.py index 65e68101e..53f1b05c8 100644 --- a/tests/test_atomic_writes.py +++ b/tests/test_atomic_writes.py @@ -99,7 +99,7 @@ def test_write_text_atomic_new_file_respects_umask(tmp_path): assert (os.stat(p).st_mode & 0o777) == (0o666 & ~umask) -def test_write_text_atomic_writes_through_symlink(tmp_path): +def test_write_text_atomic_writes_through_symlink(requires_symlinks, tmp_path): # Shared-output setups symlink graph.json to shared storage; the atomic write # must update the target and keep the link, not replace it with a real file. target = tmp_path / "real.json" diff --git a/tests/test_detect.py b/tests/test_detect.py index a17b182f6..b7bc93d4b 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -308,7 +308,7 @@ def test_git_info_exclude_utf8_bom(tmp_path): assert any("real.py" in f for f in all_files) -def test_detect_follows_symlinked_directory(tmp_path): +def test_detect_follows_symlinked_directory(requires_symlinks, tmp_path): real_dir = tmp_path / "real_lib" real_dir.mkdir() (real_dir / "util.py").write_text("x = 1") @@ -322,7 +322,7 @@ def test_detect_follows_symlinked_directory(tmp_path): assert any("linked_lib" in f for f in result_yes["files"]["code"]) -def test_detect_follows_symlinked_file(tmp_path): +def test_detect_follows_symlinked_file(requires_symlinks, tmp_path): (tmp_path / "real.py").write_text("x = 1") (tmp_path / "link.py").symlink_to(tmp_path / "real.py") @@ -484,7 +484,7 @@ def test_nested_ignore_overrides_git_info_exclude_and_root(tmp_path): assert not any(f.endswith("drop.py") for f in code) -def test_detect_handles_circular_symlinks(tmp_path): +def test_detect_handles_circular_symlinks(requires_symlinks, tmp_path): sub = tmp_path / "a" sub.mkdir() (sub / "main.py").write_text("x = 1") @@ -494,7 +494,7 @@ def test_detect_handles_circular_symlinks(tmp_path): assert any("main.py" in f for f in result["files"]["code"]) -def test_detect_default_does_not_auto_follow_direct_symlink_child(tmp_path): +def test_detect_default_does_not_auto_follow_direct_symlink_child(requires_symlinks, tmp_path): """Symlink directory following is explicit opt-in.""" real_dir = tmp_path / "real_lib" real_dir.mkdir() @@ -518,7 +518,7 @@ def test_detect_default_does_not_follow_when_no_symlinks(tmp_path): assert any("other.py" in f for f in result["files"]["code"]) -def test_detect_explicit_false_overrides_auto_detect(tmp_path): +def test_detect_explicit_false_overrides_auto_detect(requires_symlinks, tmp_path): """An explicit follow_symlinks=False skips symlinked directories.""" real_dir = tmp_path / "real_lib" real_dir.mkdir() @@ -530,7 +530,7 @@ def test_detect_explicit_false_overrides_auto_detect(tmp_path): assert not any("linked_lib" in f for f in result["files"]["code"]) -def test_detect_skips_out_of_root_symlinked_directory_even_when_following(tmp_path): +def test_detect_skips_out_of_root_symlinked_directory_even_when_following(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" @@ -544,7 +544,7 @@ def test_detect_skips_out_of_root_symlinked_directory_even_when_following(tmp_pa assert any("symlink target outside scan root" in item for item in result["skipped_sensitive"]) -def test_detect_skips_out_of_root_symlinked_file_by_default(tmp_path): +def test_detect_skips_out_of_root_symlinked_file_by_default(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" @@ -558,7 +558,7 @@ def test_detect_skips_out_of_root_symlinked_file_by_default(tmp_path): assert any("symlink target outside scan root" in item for item in result["skipped_sensitive"]) -def test_detect_incremental_propagates_follow_symlinks(tmp_path, monkeypatch): +def test_detect_incremental_propagates_follow_symlinks(requires_symlinks, tmp_path, monkeypatch): """detect_incremental must forward follow_symlinks so symlinked sub-trees appear in incremental scans the same way they appear in full scans.""" monkeypatch.chdir(tmp_path) diff --git a/tests/test_extract.py b/tests/test_extract.py index 8dc7a0278..c7d1071a4 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -390,7 +390,7 @@ def test_collect_files_skips_hidden(): assert not any(part.startswith(".") for part in f.parts) -def test_collect_files_follows_symlinked_directory(tmp_path): +def test_collect_files_follows_symlinked_directory(requires_symlinks, tmp_path): real_dir = tmp_path / "real_src" real_dir.mkdir() (real_dir / "lib.py").write_text("x = 1") @@ -403,7 +403,7 @@ def test_collect_files_follows_symlinked_directory(tmp_path): assert [f.name for f in files_yes].count("lib.py") == 2 -def test_collect_files_skips_out_of_root_symlinked_directory(tmp_path): +def test_collect_files_skips_out_of_root_symlinked_directory(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" @@ -416,7 +416,7 @@ def test_collect_files_skips_out_of_root_symlinked_directory(tmp_path): assert not any("linked_secret" in str(f) for f in files) -def test_collect_files_skips_out_of_root_symlinked_file_by_default(tmp_path): +def test_collect_files_skips_out_of_root_symlinked_file_by_default(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" @@ -429,7 +429,7 @@ def test_collect_files_skips_out_of_root_symlinked_file_by_default(tmp_path): assert not any(f.name == "secret_link.py" for f in files) -def test_collect_files_handles_circular_symlinks(tmp_path): +def test_collect_files_handles_circular_symlinks(requires_symlinks, tmp_path): sub = tmp_path / "pkg" sub.mkdir() (sub / "mod.py").write_text("x = 1") diff --git a/tests/test_image_vision.py b/tests/test_image_vision.py index e50d0bc43..107145027 100644 --- a/tests/test_image_vision.py +++ b/tests/test_image_vision.py @@ -70,7 +70,7 @@ def test_non_pdf_still_read_as_plain_text(tmp_path): assert "# hello" in llm._file_to_text(md) -def test_read_files_skips_out_of_root_symlink(tmp_path): +def test_read_files_skips_out_of_root_symlink(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" @@ -104,7 +104,7 @@ def test_build_image_refs_sets_rel_media_and_bytes(tmp_path): assert ref.bedrock_format == "png" -def test_build_image_refs_skips_out_of_root_symlink(tmp_path): +def test_build_image_refs_skips_out_of_root_symlink(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside"