Expand to long paths when resolving collection arguments
This commit is contained in:
parent
f5dc4c9d39
commit
2e8f9572a4
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue