diff --git a/plux/build/hatchling.py b/plux/build/hatchling.py index 04773cb..685630c 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 @@ -114,9 +115,9 @@ def find_packages(self) -> t.Iterable[str]: for relative_package_path in package_paths: package_name = os.path.basename(relative_package_path) - package_path = os.path.join( - self.path, relative_package_path - ) # build package path within sources root + # 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)) if not os.path.isdir(package_path): continue @@ -141,16 +142,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}``: + - ``{"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 no sources are configured, we assume the sources root is the project root if not self.builder_config.sources: - where = self.builder_config.root - else: - if self.builder_config.sources[""]: - where = self.builder_config.sources[""] + return root + + # 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: + 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: - LOG.warning("plux doesn't know how to resolve multiple sources directories") - where = self.builder_config.root + return os.path.join(root, str(source_dir)) - return where + if source_dirs: + 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]: return [item for item in packages if not self.exclude(item) and self.include(item)] 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 fa5dd36..58d4878 100644 --- a/tests/build/test_hatchling.py +++ b/tests/build/test_hatchling.py @@ -372,3 +372,65 @@ 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_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)