From 71ff05be4fddba5e2c85683460c1993c8bf05a8b Mon Sep 17 00:00:00 2001 From: Kazim Date: Thu, 13 Aug 2026 01:59:13 -0500 Subject: [PATCH 1/2] Skip in-tree directory symlinks in safe_walk so the real path is packed. A junction or symlink that sorts before its target was marking the target inode as seen, which dropped packages from sdists. --- backend/src/hatchling/builders/utils.py | 45 +++++++++++++++- .../backend/builders/plugin/test_interface.py | 51 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/backend/src/hatchling/builders/utils.py b/backend/src/hatchling/builders/utils.py index f462073b8..32a95e3f5 100644 --- a/backend/src/hatchling/builders/utils.py +++ b/backend/src/hatchling/builders/utils.py @@ -2,6 +2,7 @@ import os import shutil +import stat from base64 import urlsafe_b64encode from typing import TYPE_CHECKING @@ -19,11 +20,51 @@ 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 + # An in-tree directory symlink/junction that sorts before its target + # would otherwise mark the target inode as seen and skip the real + # directory (issues #1197, #2008). Skip the alias so the real path is + # still walked. Out-of-tree links are still followed. + 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 diff --git a/tests/backend/builders/plugin/test_interface.py b/tests/backend/builders/plugin/test_interface.py index 6af095444..55425cf01 100644 --- a/tests/backend/builders/plugin/test_interface.py +++ b/tests/backend/builders/plugin/test_interface.py @@ -166,6 +166,57 @@ 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. + + https://github.com/pypa/hatch/issues/1197 + https://github.com/pypa/hatch/issues/2008 + """ + 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 (#2008).""" + 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() From ef34209fe0f19763653eafd0f6b3eb502ece5b0b Mon Sep 17 00:00:00 2001 From: Kazim Date: Tue, 18 Aug 2026 20:18:15 -0500 Subject: [PATCH 2/2] Drop issue numbers from comments. Review asked that comments explain only the inode first-seen-wins trap, without ticket links. --- backend/src/hatchling/builders/utils.py | 6 ++---- tests/backend/builders/plugin/test_interface.py | 8 ++------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/backend/src/hatchling/builders/utils.py b/backend/src/hatchling/builders/utils.py index 32a95e3f5..72a578907 100644 --- a/backend/src/hatchling/builders/utils.py +++ b/backend/src/hatchling/builders/utils.py @@ -46,10 +46,8 @@ def safe_walk(path: str) -> Iterable[tuple[str, list[str], list[str]]]: walk_root = os.path.realpath(path) walk_root_abs = os.path.abspath(path) for root, dirs, files in os.walk(path, followlinks=True): - # An in-tree directory symlink/junction that sorts before its target - # would otherwise mark the target inode as seen and skip the real - # directory (issues #1197, #2008). Skip the alias so the real path is - # still walked. Out-of-tree links are still followed. + # 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) diff --git a/tests/backend/builders/plugin/test_interface.py b/tests/backend/builders/plugin/test_interface.py index 55425cf01..a2a0a826d 100644 --- a/tests/backend/builders/plugin/test_interface.py +++ b/tests/backend/builders/plugin/test_interface.py @@ -167,11 +167,7 @@ def test_infinite_loop_prevention(self, temp_dir): ] 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. - - https://github.com/pypa/hatch/issues/1197 - https://github.com/pypa/hatch/issues/2008 - """ + """A directory link that sorts before its target must not drop the real package.""" project_dir = temp_dir / "project" project_dir.ensure_dir_exists() @@ -195,7 +191,7 @@ def test_in_tree_dir_link_does_not_hide_real_directory(self, temp_dir): 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 (#2008).""" + """Excluding the alias must not exclude the real package.""" project_dir = temp_dir / "project" project_dir.ensure_dir_exists()