From 26f26301d5cd7346afd2e0a31d554f6ee39b196e Mon Sep 17 00:00:00 2001 From: Alexander Rashed Date: Thu, 26 Mar 2026 13:55:34 +0100 Subject: [PATCH 1/3] fix hatchling source path detection --- plux/build/hatchling.py | 46 ++++++++++++++++++++++------- tests/build/test_hatchling.py | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/plux/build/hatchling.py b/plux/build/hatchling.py index 04773cb..92d0211 100644 --- a/plux/build/hatchling.py +++ b/plux/build/hatchling.py @@ -114,9 +114,11 @@ def find_packages(self) -> t.Iterable[str]: for relative_package_path in package_paths: package_name = os.path.basename(relative_package_path) + # Package paths in hatchling are always relative to the project root, so we join + # with the project root (not self.path, which is the sources root). package_path = os.path.join( - self.path, relative_package_path - ) # build package path within sources root + self.builder_config.root, relative_package_path + ) if not os.path.isdir(package_path): continue @@ -141,16 +143,38 @@ def find_packages(self) -> t.Iterable[str]: @property def path(self) -> str: + """Return the sources root — the directory under which the package names are located. + + This is used by ``PluginFromPackageFinder._list_module_names`` to construct the + file-system path for each package name, so it must point to the directory that + *contains* the top-level packages (not the project root in general). + + Hatchling's ``sources`` dict maps ``{source_dir: dest_dir_in_wheel}``: + + - ``{"": "src"}`` — explicit src-layout: source root is ``src/`` + - ``{"localstack-core/": ""}`` — packages in a subdirectory: source root is + ``localstack-core/`` (the key, not the value) + - ``{"": ""}`` — packages directly in the project root + """ + root = self.builder_config.root + if not self.builder_config.sources: - where = self.builder_config.root - else: - if self.builder_config.sources[""]: - where = self.builder_config.sources[""] - else: - LOG.warning("plux doesn't know how to resolve multiple sources directories") - where = self.builder_config.root - - return where + return root + + source_root = self.builder_config.sources.get("") + if source_root: + # Explicit mapping: "" -> "src" (or similar). The value is the source dir. + return os.path.join(root, source_root) + + # No "" key. The keys themselves are source directories (e.g. "localstack-core/"). + # Filter out any empty-string key that slipped through, strip trailing separators. + source_dirs = [k.rstrip("/") for k in self.builder_config.sources.keys() if k] + if len(source_dirs) == 1: + return os.path.join(root, source_dirs[0]) + + if source_dirs: + LOG.warning("plux doesn't know how to resolve multiple sources directories") + return root def filter_packages(self, packages: t.Iterable[str]) -> t.Iterable[str]: return [item for item in packages if not self.exclude(item) and self.include(item)] diff --git a/tests/build/test_hatchling.py b/tests/build/test_hatchling.py index fa5dd36..494980f 100644 --- a/tests/build/test_hatchling.py +++ b/tests/build/test_hatchling.py @@ -372,3 +372,58 @@ def test_hatch_register_metadata_hook(): hook_class = hatch_register_metadata_hook() assert hook_class is PluxMetadataHook + + +class TestHatchlingPackageFinderPath: + """Tests for HatchlingPackageFinder.path property.""" + + def _make_finder(self, sources, root, packages=None): + pytest.importorskip("hatchling") + from unittest.mock import MagicMock + from plux.build.hatchling import HatchlingPackageFinder + + builder_config = MagicMock() + builder_config.sources = sources + builder_config.root = root + builder_config.packages = packages or [] + return HatchlingPackageFinder(builder_config) + + def test_path_with_packages_in_subdirectory(self, tmp_path): + """Regression test: KeyError when packages live in a subdirectory. + + When hatchling is configured with ``packages = ["localstack-core/localstack"]``, + builder_config.sources becomes ``{'localstack-core/': ''}`` — the key is the + source directory, not ``''``. The old code did ``sources[""]`` which raised a + KeyError. The fix must return the sources root (``{root}/localstack-core``) + so that ``_list_module_names`` can build correct file-system paths for each + discovered package name. + """ + finder = self._make_finder( + sources={"localstack-core/": ""}, + root=str(tmp_path), + packages=["localstack-core/localstack"], + ) + + # Must not raise KeyError, and must return the sources root directory + path = finder.path + import os + assert path == os.path.join(str(tmp_path), "localstack-core") + + def test_path_with_empty_sources(self, tmp_path): + """When sources is empty, path falls back to the project root.""" + finder = self._make_finder(sources={}, root=str(tmp_path)) + + assert finder.path == str(tmp_path) + + def test_path_with_packages_in_root(self, tmp_path): + """When packages are in the project root (sources = {'': ''}), path returns root.""" + finder = self._make_finder(sources={"": ""}, root=str(tmp_path)) + + assert finder.path == str(tmp_path) + + def test_path_with_src_layout(self, tmp_path): + """When sources maps '' -> 'src', path returns the sources root joined to the project root.""" + import os + finder = self._make_finder(sources={"": "src"}, root=str(tmp_path)) + + assert finder.path == os.path.join(str(tmp_path), "src") From ea10b59668eaae8d89ea70cecf3321d690998513 Mon Sep 17 00:00:00 2001 From: Alexander Rashed Date: Fri, 27 Mar 2026 10:25:07 +0100 Subject: [PATCH 2/3] remove wrong source dist mapping assumption --- plux/build/hatchling.py | 14 ++++---------- pyproject.toml | 2 +- tests/build/test_hatchling.py | 9 +-------- 3 files changed, 6 insertions(+), 19 deletions(-) diff --git a/plux/build/hatchling.py b/plux/build/hatchling.py index 92d0211..8213839 100644 --- a/plux/build/hatchling.py +++ b/plux/build/hatchling.py @@ -81,6 +81,7 @@ class HatchlingPackageFinder(PackageFinder): Uses hatchling's BuilderConfig abstraction to enumerate packages. TODO: include/exclude configuration of packages in hatch needs more thorough testing with different scenarios. + TODO: hatchling supports path rewrites with the sources config, which is currently not supported """ builder_config: BuilderConfig @@ -116,9 +117,9 @@ def find_packages(self) -> t.Iterable[str]: # Package paths in hatchling are always relative to the project root, so we join # with the project root (not self.path, which is the sources root). - package_path = os.path.join( + package_path = str(os.path.join( self.builder_config.root, relative_package_path - ) + )) if not os.path.isdir(package_path): continue @@ -150,8 +151,6 @@ def path(self) -> str: *contains* the top-level packages (not the project root in general). Hatchling's ``sources`` dict maps ``{source_dir: dest_dir_in_wheel}``: - - - ``{"": "src"}`` — explicit src-layout: source root is ``src/`` - ``{"localstack-core/": ""}`` — packages in a subdirectory: source root is ``localstack-core/`` (the key, not the value) - ``{"": ""}`` — packages directly in the project root @@ -161,16 +160,11 @@ def path(self) -> str: if not self.builder_config.sources: return root - source_root = self.builder_config.sources.get("") - if source_root: - # Explicit mapping: "" -> "src" (or similar). The value is the source dir. - return os.path.join(root, source_root) - # No "" key. The keys themselves are source directories (e.g. "localstack-core/"). # Filter out any empty-string key that slipped through, strip trailing separators. source_dirs = [k.rstrip("/") for k in self.builder_config.sources.keys() if k] if len(source_dirs) == 1: - return os.path.join(root, source_dirs[0]) + return os.path.join(root, str(source_dirs[0])) if source_dirs: LOG.warning("plux doesn't know how to resolve multiple sources directories") diff --git a/pyproject.toml b/pyproject.toml index 1cab7cb..132f511 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,8 @@ setuptools = [ "setuptools" ] dev = [ + "plux[hatchling,setuptools]", "build", - "setuptools", "pytest==8.4.1", "ruff==0.9.1", "mypy", diff --git a/tests/build/test_hatchling.py b/tests/build/test_hatchling.py index 494980f..a780f09 100644 --- a/tests/build/test_hatchling.py +++ b/tests/build/test_hatchling.py @@ -419,11 +419,4 @@ def test_path_with_packages_in_root(self, tmp_path): """When packages are in the project root (sources = {'': ''}), path returns root.""" finder = self._make_finder(sources={"": ""}, root=str(tmp_path)) - assert finder.path == str(tmp_path) - - def test_path_with_src_layout(self, tmp_path): - """When sources maps '' -> 'src', path returns the sources root joined to the project root.""" - import os - finder = self._make_finder(sources={"": "src"}, root=str(tmp_path)) - - assert finder.path == os.path.join(str(tmp_path), "src") + assert finder.path == str(tmp_path) \ No newline at end of file From ca892b106fa45803814d873d4c23c9c3c46c540b Mon Sep 17 00:00:00 2001 From: Alexander Rashed Date: Mon, 30 Mar 2026 09:20:57 +0200 Subject: [PATCH 3/3] check dist dir --- plux/build/hatchling.py | 21 +++++++++++++-------- tests/build/test_hatchling.py | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/plux/build/hatchling.py b/plux/build/hatchling.py index 8213839..685630c 100644 --- a/plux/build/hatchling.py +++ b/plux/build/hatchling.py @@ -117,9 +117,7 @@ def find_packages(self) -> t.Iterable[str]: # Package paths in hatchling are always relative to the project root, so we join # with the project root (not self.path, which is the sources root). - package_path = str(os.path.join( - self.builder_config.root, relative_package_path - )) + package_path = str(os.path.join(self.builder_config.root, relative_package_path)) if not os.path.isdir(package_path): continue @@ -157,17 +155,24 @@ def path(self) -> str: """ root = self.builder_config.root + # If no sources are configured, we assume the sources root is the project root if not self.builder_config.sources: return root - # No "" key. The keys themselves are source directories (e.g. "localstack-core/"). - # Filter out any empty-string key that slipped through, strip trailing separators. - source_dirs = [k.rstrip("/") for k in self.builder_config.sources.keys() if k] + # The keys themselves are source directories (e.g. "localstack-core/"). + # Filter out any empty-string keys, strip trailing separators. + source_dirs = {k.rstrip("/"): v for k, v in self.builder_config.sources.items() if k} if len(source_dirs) == 1: - return os.path.join(root, str(source_dirs[0])) + source_dir, dest_dir = next(iter(source_dirs.items())) + if dest_dir: + LOG.warning( + "plux doesn't know how to resolve sources with non-empty destination directories, using root dir." + ) + else: + return os.path.join(root, str(source_dir)) if source_dirs: - LOG.warning("plux doesn't know how to resolve multiple sources directories") + LOG.warning("plux doesn't know how to resolve multiple sources directories, using root dir.") return root def filter_packages(self, packages: t.Iterable[str]) -> t.Iterable[str]: diff --git a/tests/build/test_hatchling.py b/tests/build/test_hatchling.py index a780f09..58d4878 100644 --- a/tests/build/test_hatchling.py +++ b/tests/build/test_hatchling.py @@ -407,6 +407,7 @@ def test_path_with_packages_in_subdirectory(self, tmp_path): # Must not raise KeyError, and must return the sources root directory path = finder.path import os + assert path == os.path.join(str(tmp_path), "localstack-core") def test_path_with_empty_sources(self, tmp_path): @@ -419,4 +420,17 @@ def test_path_with_packages_in_root(self, tmp_path): """When packages are in the project root (sources = {'': ''}), path returns root.""" finder = self._make_finder(sources={"": ""}, root=str(tmp_path)) - assert finder.path == str(tmp_path) \ No newline at end of file + assert finder.path == str(tmp_path) + + def test_path_single_source_non_empty_dest_dir_falls_back_to_root(self, tmp_path): + """When a single source has a non-empty dest dir, fall back to project root. + + A mapping like ``{"src/": "lib/"}`` means the wheel destination is remapped, + which plux cannot reason about — so it must return the project root and warn. + """ + + finder = self._make_finder(sources={"src/": "lib/"}, root=str(tmp_path)) + + path = finder.path + + assert path == str(tmp_path)