Skip to content
Merged
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: 33 additions & 10 deletions plux/build/hatchling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)]
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ setuptools = [
"setuptools"
]
dev = [
"plux[hatchling,setuptools]",
"build",
"setuptools",
"pytest==8.4.1",
"ruff==0.9.1",
"mypy",
Expand Down
62 changes: 62 additions & 0 deletions tests/build/test_hatchling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)