Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions backend/src/hatchling/builders/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import shutil
import stat
from base64 import urlsafe_b64encode
from typing import TYPE_CHECKING

Expand All @@ -19,11 +20,49 @@ def replace_file(src: str, dst: str) -> None:
os.remove(src)


def _is_dir_link(path: str) -> bool:
try:
st = os.lstat(path)
except OSError:
return False
if stat.S_ISLNK(st.st_mode):
return True
# Windows junctions are reparse points; ``os.path.islink`` is false for them
# on some Python versions.
file_attributes = getattr(st, "st_file_attributes", 0)
return bool(file_attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400))


def _is_inside_directory(root: str, path: str) -> bool:
try:
common = os.path.commonpath([root, path])
except ValueError:
return False
return os.path.normcase(common) == os.path.normcase(root)


def safe_walk(path: str) -> Iterable[tuple[str, list[str], list[str]]]:
seen = set()
walk_root = os.path.realpath(path)
walk_root_abs = os.path.abspath(path)
for root, dirs, files in os.walk(path, followlinks=True):
stat = os.stat(root)
identifier = stat.st_dev, stat.st_ino
# Following an in-tree directory link records the target inode as seen.
# If the alias sorts first, the real directory is then skipped as a cycle.
if (
_is_dir_link(root)
and os.path.normcase(os.path.abspath(root)) != os.path.normcase(walk_root_abs)
and _is_inside_directory(walk_root, os.path.realpath(root))
):
del dirs[:]
continue

try:
root_stat = os.stat(root)
except OSError:
del dirs[:]
continue

identifier = root_stat.st_dev, root_stat.st_ino
if identifier in seen:
del dirs[:]
continue
Expand Down
47 changes: 47 additions & 0 deletions tests/backend/builders/plugin/test_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,53 @@ def test_infinite_loop_prevention(self, temp_dir):
str(project_dir / "foo" / "bar.txt"),
]

def test_in_tree_dir_link_does_not_hide_real_directory(self, temp_dir):
"""A directory link that sorts before its target must not drop the real package."""
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()

foo = project_dir / "foo"
foo.ensure_dir_exists()
(foo / "__init__.py").touch()
(foo / "pkg.py").touch()

bar = project_dir / "bar"
try:
bar.symlink_to(foo, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink/junction not available: {exc}")

with project_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"include": ["foo"]}}}}
builder = MockBuilder(str(project_dir), config=config)

relative_paths = sorted(f.relative_path.replace("\\", "/") for f in builder.recurse_included_files())

assert relative_paths == ["foo/__init__.py", "foo/pkg.py"]

def test_excluding_dir_link_keeps_real_directory(self, temp_dir):
"""Excluding the alias must not exclude the real package."""
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()

foo = project_dir / "foo"
foo.ensure_dir_exists()
(foo / "__init__.py").touch()

bar = project_dir / "bar"
try:
bar.symlink_to(foo, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink/junction not available: {exc}")

with project_dir.as_cwd():
config = {"tool": {"hatch": {"build": {"include": ["foo", "bar"], "exclude": ["bar"]}}}}
builder = MockBuilder(str(project_dir), config=config)

relative_paths = sorted(f.relative_path.replace("\\", "/") for f in builder.recurse_included_files())

assert relative_paths == ["foo/__init__.py"]

def test_only_include(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.ensure_dir_exists()
Expand Down
Loading