From 2e8f9572a4aab7b0156fa4c448ee5c88a726915b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 9 Feb 2024 18:00:08 -0300 Subject: [PATCH] Expand to long paths when resolving collection arguments --- src/_pytest/compat.py | 35 ++++++++++++++++++++- src/_pytest/main.py | 7 ++--- testing/test_collection.py | 63 ++++++++++++++++++++++++-------------- 3 files changed, 77 insertions(+), 28 deletions(-) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index fa387f6db..e1a33ec43 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -1,5 +1,5 @@ # mypy: allow-untyped-defs -"""Python version compatibility code.""" +"""Python version and platform compatibility code.""" from __future__ import annotations import dataclasses @@ -301,6 +301,39 @@ def get_user_id() -> int | None: return uid if uid != ERROR else None +if sys.platform == "win32": + from ctypes import create_unicode_buffer + from ctypes import windll + + def ensure_long_path(p: Path) -> Path: + """ + Returns the given path in its long form in Windows. + + Short-paths follow the DOS restriction of 8 characters + 3 chars for file extension, + and are still supported by Windows. + """ + # If the path does not exist, we cannot discover its long path. + if not p.exists(): + return p + short_path = os.fspath(p) + # Use a buffer twice the size of the original path size to (reasonably) ensure we will be able + # to hold the long path. + buffer_size = len(short_path) * 2 + buffer = create_unicode_buffer(buffer_size) + windll.kernel32.GetLongPathNameW(short_path, buffer, buffer_size) + long_path_str = buffer.value + # If we could not convert it, probably better to hard-crash this now rather + # than later. + assert long_path_str, f"Failed to convert short path to long path form:\n(size: {len(short_path)}):{short_path}" + return Path(buffer.value) + +else: + + def ensure_long_path(p: Path) -> Path: + """No-op in other platforms.""" + return p + + # Perform exhaustiveness checking. # # Consider this example: diff --git a/src/_pytest/main.py b/src/_pytest/main.py index edb1a69e2..154ff2710 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -28,6 +28,7 @@ import pluggy from _pytest import nodes import _pytest._code +from _pytest.compat import ensure_long_path from _pytest.config import Config from _pytest.config import directory_arg from _pytest.config import ExitCode @@ -901,10 +902,6 @@ class Session(nodes.Collector): # Path part e.g. `/a/b/` in `/a/b/test_file.py::TestIt::test_it`. if isinstance(matchparts[0], Path): is_match = node.path == matchparts[0] - if sys.platform == "win32" and not is_match: - # In case the file paths do not match, fallback to samefile() to - # account for short-paths on Windows (#11895). - is_match = os.path.samefile(node.path, matchparts[0]) # Name part e.g. `TestIt` in `/a/b/test_file.py::TestIt::test_it`. else: # TODO: Remove parametrized workaround once collection structure contains @@ -1012,4 +1009,6 @@ def resolve_collection_argument( else "directory argument cannot contain :: selection parts: {arg}" ) raise UsageError(msg.format(arg=arg)) + # Ensure we expand short paths to long paths on Windows (#11895). + fspath = ensure_long_path(fspath) return fspath, parts diff --git a/testing/test_collection.py b/testing/test_collection.py index 5cd9208f5..78e3672d6 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -9,6 +9,7 @@ import textwrap from typing import List from _pytest.assertion.util import running_on_ci +from _pytest.compat import ensure_long_path from _pytest.config import ExitCode from _pytest.fixtures import FixtureRequest from _pytest.main import _in_venv @@ -1763,27 +1764,43 @@ def test_does_not_crash_on_recursive_symlink(pytester: Pytester) -> None: assert result.parseoutcomes() == {"passed": 1} -@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") -def test_collect_short_file_windows(pytester: Pytester) -> None: - """Reproducer for #11895: short paths not colleced on Windows.""" - short_path = tempfile.mkdtemp() - if "~" not in short_path: # pragma: no cover - if running_on_ci(): - # On CI, we are expecting that under the current GitHub actions configuration, - # tempfile.mkdtemp() is producing short paths, so we want to fail to prevent - # this from silently changing without us noticing. - pytest.fail( - f"tempfile.mkdtemp() failed to produce a short path on CI: {short_path}" - ) - else: - # We want to skip failing this test locally in this situation because - # depending on the local configuration tempfile.mkdtemp() might not produce a short path: - # For example, user might have configured %TEMP% exactly to avoid generating short paths. - pytest.skip( - f"tempfile.mkdtemp() failed to produce a short path: {short_path}, skipping" - ) +class TestCollectionShortPaths: + @pytest.fixture + def short_path(self) -> Path: + short_path = tempfile.mkdtemp() + if "~" not in short_path: # pragma: no cover + if running_on_ci(): + # On CI, we are expecting that under the current GitHub actions configuration, + # tempfile.mkdtemp() is producing short paths, so we want to fail to prevent + # this from silently changing without us noticing. + pytest.fail( + f"tempfile.mkdtemp() failed to produce a short path on CI: {short_path}" + ) + else: + # We want to skip failing this test locally in this situation because + # depending on the local configuration tempfile.mkdtemp() might not produce a short path: + # For example, user might have configured %TEMP% exactly to avoid generating short paths. + pytest.skip( + f"tempfile.mkdtemp() failed to produce a short path: {short_path}, skipping" + ) + return Path(short_path) - test_file = Path(short_path).joinpath("test_collect_short_file_windows.py") - test_file.write_text("def test(): pass", encoding="UTF-8") - result = pytester.runpytest(short_path) - assert result.parseoutcomes() == {"passed": 1} + @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") + def test_ensure_long_path_win(self, short_path: Path) -> None: + long_path = ensure_long_path(short_path) + assert len(os.fspath(long_path)) > len(os.fspath(short_path)) + + @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") + def test_collect_short_file_windows( + self, pytester: Pytester, short_path: Path + ) -> None: + """Reproducer for #11895: short paths not collected on Windows.""" + test_file = short_path.joinpath("test_collect_short_file_windows.py") + test_file.write_text("def test(): pass", encoding="UTF-8") + result = pytester.runpytest(short_path) + assert result.parseoutcomes() == {"passed": 1} + + def test_ensure_long_path_general(self, tmp_path: Path) -> None: + """Sanity check: a normal path to ensure_long_path works on all platforms.""" + assert ensure_long_path(tmp_path) == tmp_path + assert ensure_long_path(tmp_path / "non-existent") == tmp_path / "non-existent"