From 49eb16a5eede310e8347dffd686f2b13449498b1 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Fri, 28 Nov 2025 01:59:22 +0100 Subject: [PATCH 1/4] hatchling support wip, package scanner, first steps with testing --- .gitignore | 5 +- plux/build/discovery.py | 100 +++++++++++- plux/build/hatchling.py | 135 +++++++++++++++- plux/cli/cli.py | 5 +- tests/build/__init__.py | 0 tests/build/test_discovery.py | 146 ++++++++++++++++++ .../hatch/namespace_package/pyproject.toml | 31 ++++ .../namespace_package/test_project/plugins.py | 6 + .../test_project/subpkg/__init__.py | 0 .../test_project/subpkg/plugins.py | 6 + .../hatch_manual_build_mode/pyproject.toml | 28 ++++ .../test_project/__init__.py | 0 .../test_project/plugins.py | 6 + .../test_project/subpkg/__init__.py | 0 .../test_project/subpkg/plugins.py | 6 + tests/cli/projects/manual_build_mode/plux.ini | 4 - tests/cli/test_hatch.py | 52 +++++++ 17 files changed, 520 insertions(+), 10 deletions(-) create mode 100644 tests/build/__init__.py create mode 100644 tests/build/test_discovery.py create mode 100644 tests/cli/projects/hatch/namespace_package/pyproject.toml create mode 100644 tests/cli/projects/hatch/namespace_package/test_project/plugins.py create mode 100644 tests/cli/projects/hatch/namespace_package/test_project/subpkg/__init__.py create mode 100644 tests/cli/projects/hatch/namespace_package/test_project/subpkg/plugins.py create mode 100644 tests/cli/projects/hatch_manual_build_mode/pyproject.toml create mode 100644 tests/cli/projects/hatch_manual_build_mode/test_project/__init__.py create mode 100644 tests/cli/projects/hatch_manual_build_mode/test_project/plugins.py create mode 100644 tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/__init__.py create mode 100644 tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/plugins.py create mode 100644 tests/cli/test_hatch.py diff --git a/.gitignore b/.gitignore index f72ab41..4b2ecb6 100644 --- a/.gitignore +++ b/.gitignore @@ -136,5 +136,8 @@ venv.bak/ # don't ignore build package !plux/build +!tests/build +plux.ini + # Ignore dynamically generated version.py -plux/version.py \ No newline at end of file +plux/version.py diff --git a/plux/build/discovery.py b/plux/build/discovery.py index 2a8b44f..c94b9a3 100644 --- a/plux/build/discovery.py +++ b/plux/build/discovery.py @@ -5,11 +5,12 @@ import importlib import inspect import logging +import os +import pkgutil import typing as t from fnmatch import fnmatchcase +from pathlib import Path from types import ModuleType -import os -import pkgutil from plux import PluginFinder, PluginSpecResolver, PluginSpec @@ -56,6 +57,101 @@ def path(self) -> str: raise NotImplementedError +class SimplePackageFinder(PackageFinder): + """ + A package finder that uses a heuristic to find python packages within a given path. It iterates over all + subdirectories in the path and returns every directory that contains a ``__init__.py`` file. It will include the + root package in the list of results, so if your tree looks like this:: + + mypkg + ├── __init__.py + ├── subpkg1 + │ ├── __init__.py + │ └── nested_subpkg1 + │ └── __init__.py + └── subpkg2 + └── __init__.py + + and you instantiate SimplePackageFinder("mypkg"), it will return:: + + [ + "mypkg", + "mypkg.subpkg1", + "mypkg.subpkg2", + "mypkg.subpkg1.nested_subpkg1, + ] + + If the root is not a package, say if you have a ``src/`` layout, and you pass "src/mypkg" as ``path`` it will omit + everything in the preceding path that's not a package. + """ + + def __init__(self, path: str): + self._path = path + + @property + def path(self) -> str: + return self._path + + def find_packages(self) -> t.Iterable[str]: + """ + Find all Python packages in the given path. + + Returns a list of package names in the format "pkg", "pkg.subpkg", etc. + """ + path = self.path + if not os.path.isdir(path): + return [] + + result = [] + + # Get the absolute path to handle relative paths correctly + abs_path = os.path.abspath(path) + + # Check if the root directory is a package + root_is_package = self._looks_like_package(abs_path) + + # Walk through the directory tree + for root, dirs, files in os.walk(abs_path): + # Skip directories that don't look like packages + if not self._looks_like_package(root): + continue + + # Determine the base directory for relative path calculation + # If the root is not a package, we use the root directory itself as the base + # This ensures we don't include the root directory name in the package names + if root_is_package: + base_dir = os.path.dirname(abs_path) + else: + base_dir = abs_path + + # Convert the path to a module name + rel_path = os.path.relpath(root, base_dir) + if rel_path == ".": + # If we're at the root and it's a package, use the directory name + rel_path = os.path.basename(abs_path) + + # Skip invalid package names (those containing dots in the path) + if "." in os.path.basename(rel_path): + continue + + module_name = self._path_to_module(rel_path) + result.append(module_name) + + # Sort the results for consistent output + return sorted(result) + + def _looks_like_package(self, path: str) -> bool: + return os.path.exists(os.path.join(path, "__init__.py")) + + @staticmethod + def _path_to_module(path: str): + """ + Convert a path to a Python module to its module representation + Example: plux/core/test -> plux.core.test + """ + return ".".join(Path(path).with_suffix("").parts) + + class PluginFromPackageFinder(PluginFinder): """ Finds Plugins from packages that are resolved by the given ``PackageFinder``. Under the hood this uses a diff --git a/plux/build/hatchling.py b/plux/build/hatchling.py index 10509b5..a405610 100644 --- a/plux/build/hatchling.py +++ b/plux/build/hatchling.py @@ -1,6 +1,137 @@ +import logging +import os +import typing as t +from pathlib import Path + +from hatchling.builders.config import BuilderConfig +from hatchling.builders.wheel import WheelBuilder + +from plux.build.config import EntrypointBuildMode +from plux.build.discovery import PackageFinder, Filter, MatchAllFilter, SimplePackageFinder from plux.build.project import Project +LOG = logging.getLogger(__name__) + + +def _path_to_module(path): + """ + Convert a path to a Python module to its module representation. + + Example: plux/core/test -> plux.core.test + """ + return ".".join(Path(path).with_suffix("").parts) + + +class HatchlingPackageFinder(PackageFinder): + """ + Uses hatchling's BuilderConfig abstraction to enumerate packages. + + TODO: this might not be 100% correct and needs more thorough testing with different scenarios. + """ + + builder_config: BuilderConfig + exclude: Filter + include: Filter + + def __init__( + self, + builder_config: BuilderConfig, + exclude: list[str] | None = None, + include: list[str] | None = None, + ): + self.builder_config = builder_config + self.exclude = Filter(exclude or []) + self.include = Filter(include) if include else MatchAllFilter() + + def find_packages(self) -> t.Iterable[str]: + """ + Hatchling-specific algorithm to find packages. Unlike setuptools, hatchling does not provide a package discovery + and only provides a config, so this implements our own heuristic to detect packages and namespace packages. + + :return: An Iterable of Packages + """ + # packages in hatch are defined as file system paths, whereas find_packages expects modules + package_paths = list(self.builder_config.packages) + + # unlike setuptools, hatch does not return all subpackages by default. instead, these are + # top-level package paths, so we need to recurse and use the ``__init__.py`` heuristic to find + # packages. + all_packages = [] + 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 + if not os.path.isdir(package_path): + continue + + is_namespace_package = not os.path.exists(os.path.join(package_path, "__init__.py")) + found_packages = SimplePackageFinder(package_path).find_packages() + + if is_namespace_package: + # If it's a namespace package, we need to do two things. First, we include it explicitly as a + # top-level package to the list of found packages. Second, since``SimplePackageFinder`` will not + # consider it a package, it will only return subpackages, so we need to prepend the namespace package + # as a namespace to the package names. + all_packages.append(package_name) + found_packages = [f"{package_name}.{found_package}" for found_package in found_packages] + + all_packages.extend(found_packages) + + # now do the filtering like the plux PackageFinder API expects + packages = self.filter_packages(all_packages) + + # de-duplicate and sort + return sorted(set(packages)) + + @property + def path(self) -> str: + 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 + + 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)] + class HatchlingProject(Project): - # TODO: implement me - pass + def __init__(self, workdir: str = None): + super().__init__(workdir) + + if self.config.entrypoint_build_mode != EntrypointBuildMode.MANUAL: + raise NotImplementedError( + "Hatchling integration currently only works with entrypoint_build_mode=manual" + ) + + # FIXME it's unclear whether this will really hold for all configs. most configs we assume will build a wheel, + # and any associated package configuration will come from there. so currently we make the wheel build config + # the source of truth, but we should revisit this once we know more about hatch build configurations. + self.builder = WheelBuilder(workdir) + + def create_package_finder(self) -> PackageFinder: + return HatchlingPackageFinder( + self.hatchling_config, + exclude=self.config.exclude, + include=self.config.include, + ) + + @property + def hatchling_config(self) -> BuilderConfig: + return self.builder.config + + def find_plux_index_file(self) -> Path: + # TODO: extend as soon as we support EntryPointBuildMode = build-hook + return Path(self.hatchling_config.root, self.config.entrypoint_static_file) + + def find_entry_point_file(self) -> Path: + # TODO: we'll assume that `pip install -e .` is used, and therefore the entrypoints file will be in the + # .dist-info metadata directory + raise NotImplementedError diff --git a/plux/cli/cli.py b/plux/cli/cli.py index fcf769b..164a4e5 100644 --- a/plux/cli/cli.py +++ b/plux/cli/cli.py @@ -16,6 +16,7 @@ def _get_build_backend() -> str | None: # TODO: should read this from the project configuration instead somehow. + return "hatchling" try: import setuptools # noqa @@ -45,7 +46,9 @@ def _load_project(args: argparse.Namespace) -> Project: return SetuptoolsProject(workdir) elif backend == "hatchling": - raise NotImplementedError("Hatchling is not yet supported as build backend") + from plux.build.hatchling import HatchlingProject + + return HatchlingProject(workdir) else: raise RuntimeError( "No supported build backend found. Plux needs either setuptools or hatchling to work." diff --git a/tests/build/__init__.py b/tests/build/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/build/test_discovery.py b/tests/build/test_discovery.py new file mode 100644 index 0000000..ddb1fd2 --- /dev/null +++ b/tests/build/test_discovery.py @@ -0,0 +1,146 @@ +import os +import shutil +from pathlib import Path + +import pytest + +from plux.build.discovery import SimplePackageFinder + + +@pytest.fixture +def chdir(): + """Change the working directory to the given path temporarily for the test.""" + prev = os.getcwd() + yield os.chdir + os.chdir(prev) + + +class TestSimplePackageFinder: + @pytest.fixture + def nested_package_tree(self, tmp_path) -> Path: + # nested directory structure + dirs = [ + tmp_path / "src" / "mypkg" / "subpkg1", + tmp_path / "src" / "mypkg" / "subpkg2", + tmp_path / "src" / "mypkg" / "subpkg1" / "nested_subpkg1", + tmp_path / "src" / "mypkg_sibling", + tmp_path / "src" / "notapkg", + tmp_path / "src" / "not.a.pkg", # this is an invalid package name and will break imports + ] + files = [ + tmp_path / "src" / "mypkg" / "__init__.py", + tmp_path / "src" / "mypkg" / "subpkg1" / "__init__.py", + tmp_path / "src" / "mypkg" / "subpkg2" / "__init__.py", + tmp_path / "src" / "mypkg" / "subpkg1" / "nested_subpkg1" / "__init__.py", + tmp_path / "src" / "mypkg_sibling" / "__init__.py", + tmp_path / "src" / "notapkg" / "__init__.txt", + tmp_path / "src" / "not.a.pkg" / "__init__.py", + ] + + for d in dirs: + d.mkdir(parents=True, exist_ok=True) + for f in files: + f.touch(exist_ok=True) + + return tmp_path + + def test_package_discovery(self, nested_package_tree, chdir): + # change into the directory for the test + chdir(nested_package_tree / "src") + + # this emulates what a typical hatchling src-tree config would look like + finder = SimplePackageFinder("mypkg") + + assert finder.find_packages() == [ + "mypkg", + "mypkg.subpkg1", + "mypkg.subpkg1.nested_subpkg1", + "mypkg.subpkg2", + ] + + def test_package_discovery_in_current_dir(self, nested_package_tree, chdir): + # change into the actual package directory, so the path just becomes "." + chdir(nested_package_tree / "src" / "mypkg") + + # this might be equivalent to os.curdir + finder = SimplePackageFinder(".") + + assert finder.find_packages() == [ + "mypkg", + "mypkg.subpkg1", + "mypkg.subpkg1.nested_subpkg1", + "mypkg.subpkg2", + ] + + def test_package_discovery_with_src_folder(self, nested_package_tree, chdir): + # change into the directory for the test + chdir(nested_package_tree) + + # this emulates what a typical hatchling src-tree config would look like + finder = SimplePackageFinder("src") + + assert finder.find_packages() == [ + "mypkg", + "mypkg.subpkg1", + "mypkg.subpkg1.nested_subpkg1", + "mypkg.subpkg2", + "mypkg_sibling", + ] + + def test_package_discovery_with_src_folder_unconventional(self, nested_package_tree, chdir): + # change into the directory for the test + chdir(nested_package_tree) + + # make sure there's no special consideration of "src" as a convention + shutil.move(nested_package_tree / "src", nested_package_tree / "sources") + + # this emulates what a typical hatchling src-tree config would look like + finder = SimplePackageFinder("sources") + + assert finder.find_packages() == [ + "mypkg", + "mypkg.subpkg1", + "mypkg.subpkg1.nested_subpkg1", + "mypkg.subpkg2", + "mypkg_sibling", + ] + + def test_package_discovery_with_nested_src_dir(self, nested_package_tree, chdir): + chdir(nested_package_tree) + + # create a root path + root = nested_package_tree / "root" + root.mkdir(parents=True, exist_ok=True) + + # move everything s.t. the path is now "root/src/" + shutil.move(nested_package_tree / "src", root) + + # should still work in the same way + finder = SimplePackageFinder("root/src/mypkg") + + assert finder.find_packages() == [ + "mypkg", + "mypkg.subpkg1", + "mypkg.subpkg1.nested_subpkg1", + "mypkg.subpkg2", + ] + + # make sure it finds the sibling if not pointed to the pkg dir directly + finder = SimplePackageFinder("root/src") + + assert finder.find_packages() == [ + "mypkg", + "mypkg.subpkg1", + "mypkg.subpkg1.nested_subpkg1", + "mypkg.subpkg2", + "mypkg_sibling", + ] + + def test_package_discovery_in_empty_dir(self, tmp_path, chdir): + path = tmp_path / "empty" + path.mkdir() + chdir(tmp_path) + + finder = SimplePackageFinder("empty") + + assert finder.find_packages() == [] diff --git a/tests/cli/projects/hatch/namespace_package/pyproject.toml b/tests/cli/projects/hatch/namespace_package/pyproject.toml new file mode 100644 index 0000000..1326fd0 --- /dev/null +++ b/tests/cli/projects/hatch/namespace_package/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling", "wheel"] +build-backend = "hatchling.build" + +[project] +name = "test-project" +authors = [ + { name = "LocalStack Contributors", email = "info@localstack.cloud" } +] +version = "0.1.0" +description = "A test project to test plux with pyproject.toml projects and manual build mode" +dependencies = [ + "plux", + "build", +] +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", +] +dynamic = [ + "entry-points", +] + +[tool.hatch.build.targets.wheel] +packages = ["test_project"] + +[tool.hatch.dynamic] +entry-points = { file = ["plux.ini"] } + +[tool.plux] +entrypoint_build_mode = "manual" diff --git a/tests/cli/projects/hatch/namespace_package/test_project/plugins.py b/tests/cli/projects/hatch/namespace_package/test_project/plugins.py new file mode 100644 index 0000000..3a0b1b8 --- /dev/null +++ b/tests/cli/projects/hatch/namespace_package/test_project/plugins.py @@ -0,0 +1,6 @@ +from plugin import Plugin + + +class MyPlugin(Plugin): + namespace = "plux.test.plugins" + name = "myplugin" diff --git a/tests/cli/projects/hatch/namespace_package/test_project/subpkg/__init__.py b/tests/cli/projects/hatch/namespace_package/test_project/subpkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/projects/hatch/namespace_package/test_project/subpkg/plugins.py b/tests/cli/projects/hatch/namespace_package/test_project/subpkg/plugins.py new file mode 100644 index 0000000..163edf8 --- /dev/null +++ b/tests/cli/projects/hatch/namespace_package/test_project/subpkg/plugins.py @@ -0,0 +1,6 @@ +from plugin import Plugin + + +class MyNestedPlugin(Plugin): + namespace = "plux.test.plugins" + name = "mynestedplugin" diff --git a/tests/cli/projects/hatch_manual_build_mode/pyproject.toml b/tests/cli/projects/hatch_manual_build_mode/pyproject.toml new file mode 100644 index 0000000..b03f740 --- /dev/null +++ b/tests/cli/projects/hatch_manual_build_mode/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["hatchling", "wheel"] +build-backend = "hatchling.build" + +[project] +name = "test-project" +authors = [ + { name = "LocalStack Contributors", email = "info@localstack.cloud" } +] +version = "0.1.0" +description = "A test project to test plux with pyproject.toml projects and manual build mode" +dependencies = [ + "plux", + "build", +] +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", +] +dynamic = [ + "entry-points", +] + +[tool.hatch.dynamic] +entry-points = { file = ["plux.ini"] } + +[tool.plux] +entrypoint_build_mode = "manual" diff --git a/tests/cli/projects/hatch_manual_build_mode/test_project/__init__.py b/tests/cli/projects/hatch_manual_build_mode/test_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/projects/hatch_manual_build_mode/test_project/plugins.py b/tests/cli/projects/hatch_manual_build_mode/test_project/plugins.py new file mode 100644 index 0000000..3a0b1b8 --- /dev/null +++ b/tests/cli/projects/hatch_manual_build_mode/test_project/plugins.py @@ -0,0 +1,6 @@ +from plugin import Plugin + + +class MyPlugin(Plugin): + namespace = "plux.test.plugins" + name = "myplugin" diff --git a/tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/__init__.py b/tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/plugins.py b/tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/plugins.py new file mode 100644 index 0000000..163edf8 --- /dev/null +++ b/tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/plugins.py @@ -0,0 +1,6 @@ +from plugin import Plugin + + +class MyNestedPlugin(Plugin): + namespace = "plux.test.plugins" + name = "mynestedplugin" diff --git a/tests/cli/projects/manual_build_mode/plux.ini b/tests/cli/projects/manual_build_mode/plux.ini index 9d11ce7..e69de29 100644 --- a/tests/cli/projects/manual_build_mode/plux.ini +++ b/tests/cli/projects/manual_build_mode/plux.ini @@ -1,4 +0,0 @@ -[plux.test.plugins] -mynestedplugin = mysrc.subpkg.plugins:MyNestedPlugin -myplugin = mysrc.plugins:MyPlugin - diff --git a/tests/cli/test_hatch.py b/tests/cli/test_hatch.py new file mode 100644 index 0000000..9ad838c --- /dev/null +++ b/tests/cli/test_hatch.py @@ -0,0 +1,52 @@ +import os.path +import sys + + +def test_discover_with_ini_output(tmp_path): + from plux.__main__ import main + + project = os.path.join(os.path.dirname(__file__), "projects", "hatch_manual_build_mode") + os.chdir(project) + + out_path = tmp_path / "plux.ini" + + sys.path.append(project) + try: + try: + main(["--workdir", project, "discover", "--format", "ini", "--output", str(out_path)]) + except SystemExit: + pass + finally: + sys.path.remove(project) + + lines = out_path.read_text().strip().splitlines() + assert lines == [ + "[plux.test.plugins]", + "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", + "myplugin = test_project.plugins:MyPlugin", + ] + + +def test_discover_with_ini_output_namespace_Package(tmp_path): + from plux.__main__ import main + + project = os.path.join(os.path.dirname(__file__), "projects", "hatch", "namespace_package") + os.chdir(project) + + out_path = tmp_path / "plux.ini" + + sys.path.append(project) + try: + try: + main(["--workdir", project, "discover", "--format", "ini", "--output", str(out_path)]) + except SystemExit: + pass + finally: + sys.path.remove(project) + + lines = out_path.read_text().strip().splitlines() + assert lines == [ + "[plux.test.plugins]", + "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", + "myplugin = test_project.plugins:MyPlugin", + ] From a7bac7b793368c69a0432b6626ea52b1b3e5ccf9 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Fri, 28 Nov 2025 20:42:03 +0100 Subject: [PATCH 2/4] implement rest of HatchlingProject --- plux/build/discovery.py | 6 +++++ plux/build/hatchling.py | 49 ++++++++++++++++++++++++----------------- plux/core/entrypoint.py | 2 +- tests/cli/test_hatch.py | 31 +++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 22 deletions(-) diff --git a/plux/build/discovery.py b/plux/build/discovery.py index c94b9a3..15f73cf 100644 --- a/plux/build/discovery.py +++ b/plux/build/discovery.py @@ -85,6 +85,8 @@ class SimplePackageFinder(PackageFinder): everything in the preceding path that's not a package. """ + DEFAULT_EXCLUDES = "__pycache__" + def __init__(self, path: str): self._path = path @@ -130,6 +132,10 @@ def find_packages(self) -> t.Iterable[str]: # If we're at the root and it's a package, use the directory name rel_path = os.path.basename(abs_path) + # skip excludes TODO: should re-use Filter API + if os.path.basename(rel_path).strip(os.pathsep) in self.DEFAULT_EXCLUDES: + continue + # Skip invalid package names (those containing dots in the path) if "." in os.path.basename(rel_path): continue diff --git a/plux/build/hatchling.py b/plux/build/hatchling.py index a405610..38f9161 100644 --- a/plux/build/hatchling.py +++ b/plux/build/hatchling.py @@ -1,5 +1,6 @@ import logging import os +import sys import typing as t from pathlib import Path @@ -13,20 +14,11 @@ LOG = logging.getLogger(__name__) -def _path_to_module(path): - """ - Convert a path to a Python module to its module representation. - - Example: plux/core/test -> plux.core.test - """ - return ".".join(Path(path).with_suffix("").parts) - - class HatchlingPackageFinder(PackageFinder): """ Uses hatchling's BuilderConfig abstraction to enumerate packages. - TODO: this might not be 100% correct and needs more thorough testing with different scenarios. + TODO: include/exclude configuration of packages in hatch needs more thorough testing with different scenarios. """ builder_config: BuilderConfig @@ -111,11 +103,15 @@ def __init__(self, workdir: str = None): "Hatchling integration currently only works with entrypoint_build_mode=manual" ) - # FIXME it's unclear whether this will really hold for all configs. most configs we assume will build a wheel, - # and any associated package configuration will come from there. so currently we make the wheel build config - # the source of truth, but we should revisit this once we know more about hatch build configurations. + # we assume that a wheel will be the source of truth for the packages finally ending up in the distribution. + # therefore we care foremost about the wheel configuration. this also builds on the assumption that building + # the wheel from the local sources, and the sdist, will be the same. self.builder = WheelBuilder(workdir) + @property + def hatchling_config(self) -> BuilderConfig: + return self.builder.config + def create_package_finder(self) -> PackageFinder: return HatchlingPackageFinder( self.hatchling_config, @@ -123,15 +119,28 @@ def create_package_finder(self) -> PackageFinder: include=self.config.include, ) - @property - def hatchling_config(self) -> BuilderConfig: - return self.builder.config - def find_plux_index_file(self) -> Path: # TODO: extend as soon as we support EntryPointBuildMode = build-hook return Path(self.hatchling_config.root, self.config.entrypoint_static_file) def find_entry_point_file(self) -> Path: - # TODO: we'll assume that `pip install -e .` is used, and therefore the entrypoints file will be in the - # .dist-info metadata directory - raise NotImplementedError + # we assume that `pip install -e .` is used, and therefore the entrypoints file used during local execution + # will be in the .dist-info metadata directory in the sys path + metadata_dir = f"{self.builder.artifact_project_id}.dist-info" + + for path in sys.path: + metadata_path = os.path.join(path, metadata_dir) + if not os.path.exists(metadata_path): + continue + + return Path(metadata_path) / "entry_points.txt" + + raise FileNotFoundError(f"No metadata found for {self.builder.artifact_project_id} in sys path") + + def build_entrypoints(self): + # TODO: currently this just replicates the manual build mode. + path = os.path.join(os.getcwd(), self.config.entrypoint_static_file) + print(f"discovering plugins and writing to {path} ...") + builder = self.create_plugin_index_builder() + with open(path, "w") as fd: + builder.write(fd, output_format="ini") diff --git a/plux/core/entrypoint.py b/plux/core/entrypoint.py index 2ac6c33..75fcc8d 100644 --- a/plux/core/entrypoint.py +++ b/plux/core/entrypoint.py @@ -34,7 +34,7 @@ def discover_entry_points(finder: PluginFinder) -> EntryPointDict: return to_entry_point_dict([spec_to_entry_point(spec) for spec in finder.find_plugins()]) -def to_entry_point_dict(eps: list[EntryPoint]) -> EntryPointDict: +def to_entry_point_dict(eps: t.Iterable[EntryPoint]) -> EntryPointDict: """ Convert the list of EntryPoint objects to a dictionary that maps entry point groups to their respective list of ``name=value`` entry points. Each pair is represented as a string. diff --git a/tests/cli/test_hatch.py b/tests/cli/test_hatch.py index 9ad838c..75d4f58 100644 --- a/tests/cli/test_hatch.py +++ b/tests/cli/test_hatch.py @@ -1,5 +1,6 @@ import os.path import sys +from pathlib import Path def test_discover_with_ini_output(tmp_path): @@ -27,7 +28,7 @@ def test_discover_with_ini_output(tmp_path): ] -def test_discover_with_ini_output_namespace_Package(tmp_path): +def test_discover_with_ini_output_namespace_package(tmp_path): from plux.__main__ import main project = os.path.join(os.path.dirname(__file__), "projects", "hatch", "namespace_package") @@ -50,3 +51,31 @@ def test_discover_with_ini_output_namespace_Package(tmp_path): "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", "myplugin = test_project.plugins:MyPlugin", ] + + +def test_entrypoints_manual_build_mode(): + from plux.__main__ import main + + project = os.path.join(os.path.dirname(__file__), "projects", "hatch", "namespace_package") + os.chdir(project) + + index_file = Path(project, "plux.ini") + index_file.unlink(missing_ok=True) + + sys.path.append(project) + try: + try: + main(["--workdir", project, "entrypoints"]) + except SystemExit: + pass + finally: + sys.path.remove(project) + + assert index_file.exists() + + lines = index_file.read_text().strip().splitlines() + assert lines == [ + "[plux.test.plugins]", + "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", + "myplugin = test_project.plugins:MyPlugin", + ] From 00b16407ef2db61fdf0d1265f56023bf6243d528 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Wed, 28 Jan 2026 22:13:36 +0100 Subject: [PATCH 3/4] reorganize code --- plux/__init__.py | 2 +- plux/build/hatchling.py | 104 ++++++++++++++++++++-------------------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/plux/__init__.py b/plux/__init__.py index c665258..7809a4a 100644 --- a/plux/__init__.py +++ b/plux/__init__.py @@ -33,5 +33,5 @@ "PluginSpecResolver", "PluginType", "plugin", - "__version__" + "__version__", ] diff --git a/plux/build/hatchling.py b/plux/build/hatchling.py index 38f9161..b9afc9d 100644 --- a/plux/build/hatchling.py +++ b/plux/build/hatchling.py @@ -14,6 +14,58 @@ LOG = logging.getLogger(__name__) +class HatchlingProject(Project): + def __init__(self, workdir: str = None): + super().__init__(workdir) + + if self.config.entrypoint_build_mode != EntrypointBuildMode.MANUAL: + raise NotImplementedError( + "Hatchling integration currently only works with entrypoint_build_mode=manual" + ) + + # we assume that a wheel will be the source of truth for the packages finally ending up in the distribution. + # therefore, we care foremost about the wheel configuration. this also builds on the assumption that building + # the wheel from the local sources, and the sdist, will be the same. + self.builder = WheelBuilder(workdir) + + @property + def hatchling_config(self) -> BuilderConfig: + return self.builder.config + + def create_package_finder(self) -> PackageFinder: + return HatchlingPackageFinder( + self.hatchling_config, + exclude=self.config.exclude, + include=self.config.include, + ) + + def find_plux_index_file(self) -> Path: + # TODO: extend as soon as we support EntryPointBuildMode = build-hook + return Path(self.hatchling_config.root, self.config.entrypoint_static_file) + + def find_entry_point_file(self) -> Path: + # we assume that `pip install -e .` is used, and therefore the entrypoints file used during local execution + # will be in the .dist-info metadata directory in the sys path + metadata_dir = f"{self.builder.artifact_project_id}.dist-info" + + for path in sys.path: + metadata_path = os.path.join(path, metadata_dir) + if not os.path.exists(metadata_path): + continue + + return Path(metadata_path) / "entry_points.txt" + + raise FileNotFoundError(f"No metadata found for {self.builder.artifact_project_id} in sys path") + + def build_entrypoints(self): + # TODO: currently this just replicates the manual build mode. + path = os.path.join(os.getcwd(), self.config.entrypoint_static_file) + print(f"discovering plugins and writing to {path} ...") + builder = self.create_plugin_index_builder() + with open(path, "w") as fd: + builder.write(fd, output_format="ini") + + class HatchlingPackageFinder(PackageFinder): """ Uses hatchling's BuilderConfig abstraction to enumerate packages. @@ -92,55 +144,3 @@ def path(self) -> str: 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)] - - -class HatchlingProject(Project): - def __init__(self, workdir: str = None): - super().__init__(workdir) - - if self.config.entrypoint_build_mode != EntrypointBuildMode.MANUAL: - raise NotImplementedError( - "Hatchling integration currently only works with entrypoint_build_mode=manual" - ) - - # we assume that a wheel will be the source of truth for the packages finally ending up in the distribution. - # therefore we care foremost about the wheel configuration. this also builds on the assumption that building - # the wheel from the local sources, and the sdist, will be the same. - self.builder = WheelBuilder(workdir) - - @property - def hatchling_config(self) -> BuilderConfig: - return self.builder.config - - def create_package_finder(self) -> PackageFinder: - return HatchlingPackageFinder( - self.hatchling_config, - exclude=self.config.exclude, - include=self.config.include, - ) - - def find_plux_index_file(self) -> Path: - # TODO: extend as soon as we support EntryPointBuildMode = build-hook - return Path(self.hatchling_config.root, self.config.entrypoint_static_file) - - def find_entry_point_file(self) -> Path: - # we assume that `pip install -e .` is used, and therefore the entrypoints file used during local execution - # will be in the .dist-info metadata directory in the sys path - metadata_dir = f"{self.builder.artifact_project_id}.dist-info" - - for path in sys.path: - metadata_path = os.path.join(path, metadata_dir) - if not os.path.exists(metadata_path): - continue - - return Path(metadata_path) / "entry_points.txt" - - raise FileNotFoundError(f"No metadata found for {self.builder.artifact_project_id} in sys path") - - def build_entrypoints(self): - # TODO: currently this just replicates the manual build mode. - path = os.path.join(os.getcwd(), self.config.entrypoint_static_file) - print(f"discovering plugins and writing to {path} ...") - builder = self.create_plugin_index_builder() - with open(path, "w") as fd: - builder.write(fd, output_format="ini") From dedb84e0107dd6b769d13a144d8ba50dbad2dec0 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Thu, 29 Jan 2026 00:41:29 +0100 Subject: [PATCH 4/4] add isolated testing mode for hatch --- plux/cli/cli.py | 5 +- .../manual_build_mode}/pyproject.toml | 1 + .../test_project}/__init__.py | 0 .../test_project/plugins.py | 0 .../test_project/subpkg}/__init__.py | 0 .../test_project/subpkg/plugins.py | 0 .../namespace_package/pyproject.toml | 1 + .../test_project/plugins.py | 0 .../test_project/subpkg/__init__.py | 0 .../test_project/subpkg/plugins.py | 0 tests/cli/projects/manual_build_mode/plux.ini | 4 + tests/cli/test_hatch.py | 81 ------------ tests/cli/test_hatchling.py | 121 ++++++++++++++++++ 13 files changed, 131 insertions(+), 82 deletions(-) rename tests/cli/projects/{hatch_manual_build_mode => hatchling/manual_build_mode}/pyproject.toml (97%) rename tests/cli/projects/{hatch/namespace_package/test_project/subpkg => hatchling/manual_build_mode/test_project}/__init__.py (100%) rename tests/cli/projects/{hatch/namespace_package => hatchling/manual_build_mode}/test_project/plugins.py (100%) rename tests/cli/projects/{hatch_manual_build_mode/test_project => hatchling/manual_build_mode/test_project/subpkg}/__init__.py (100%) rename tests/cli/projects/{hatch/namespace_package => hatchling/manual_build_mode}/test_project/subpkg/plugins.py (100%) rename tests/cli/projects/{hatch => hatchling}/namespace_package/pyproject.toml (97%) rename tests/cli/projects/{hatch_manual_build_mode => hatchling/namespace_package}/test_project/plugins.py (100%) rename tests/cli/projects/{hatch_manual_build_mode => hatchling/namespace_package}/test_project/subpkg/__init__.py (100%) rename tests/cli/projects/{hatch_manual_build_mode => hatchling/namespace_package}/test_project/subpkg/plugins.py (100%) delete mode 100644 tests/cli/test_hatch.py create mode 100644 tests/cli/test_hatchling.py diff --git a/plux/cli/cli.py b/plux/cli/cli.py index 164a4e5..7a1b787 100644 --- a/plux/cli/cli.py +++ b/plux/cli/cli.py @@ -15,8 +15,11 @@ def _get_build_backend() -> str | None: + """ + Returns the name of the build backend to use. Currently, we only support setuptools and hatchling, and we prefer + setuptools over hatchling if both are available. + """ # TODO: should read this from the project configuration instead somehow. - return "hatchling" try: import setuptools # noqa diff --git a/tests/cli/projects/hatch_manual_build_mode/pyproject.toml b/tests/cli/projects/hatchling/manual_build_mode/pyproject.toml similarity index 97% rename from tests/cli/projects/hatch_manual_build_mode/pyproject.toml rename to tests/cli/projects/hatchling/manual_build_mode/pyproject.toml index b03f740..bc03426 100644 --- a/tests/cli/projects/hatch_manual_build_mode/pyproject.toml +++ b/tests/cli/projects/hatchling/manual_build_mode/pyproject.toml @@ -11,6 +11,7 @@ version = "0.1.0" description = "A test project to test plux with pyproject.toml projects and manual build mode" dependencies = [ "plux", + "hatchling", "build", ] requires-python = ">=3.8" diff --git a/tests/cli/projects/hatch/namespace_package/test_project/subpkg/__init__.py b/tests/cli/projects/hatchling/manual_build_mode/test_project/__init__.py similarity index 100% rename from tests/cli/projects/hatch/namespace_package/test_project/subpkg/__init__.py rename to tests/cli/projects/hatchling/manual_build_mode/test_project/__init__.py diff --git a/tests/cli/projects/hatch/namespace_package/test_project/plugins.py b/tests/cli/projects/hatchling/manual_build_mode/test_project/plugins.py similarity index 100% rename from tests/cli/projects/hatch/namespace_package/test_project/plugins.py rename to tests/cli/projects/hatchling/manual_build_mode/test_project/plugins.py diff --git a/tests/cli/projects/hatch_manual_build_mode/test_project/__init__.py b/tests/cli/projects/hatchling/manual_build_mode/test_project/subpkg/__init__.py similarity index 100% rename from tests/cli/projects/hatch_manual_build_mode/test_project/__init__.py rename to tests/cli/projects/hatchling/manual_build_mode/test_project/subpkg/__init__.py diff --git a/tests/cli/projects/hatch/namespace_package/test_project/subpkg/plugins.py b/tests/cli/projects/hatchling/manual_build_mode/test_project/subpkg/plugins.py similarity index 100% rename from tests/cli/projects/hatch/namespace_package/test_project/subpkg/plugins.py rename to tests/cli/projects/hatchling/manual_build_mode/test_project/subpkg/plugins.py diff --git a/tests/cli/projects/hatch/namespace_package/pyproject.toml b/tests/cli/projects/hatchling/namespace_package/pyproject.toml similarity index 97% rename from tests/cli/projects/hatch/namespace_package/pyproject.toml rename to tests/cli/projects/hatchling/namespace_package/pyproject.toml index 1326fd0..85c13fb 100644 --- a/tests/cli/projects/hatch/namespace_package/pyproject.toml +++ b/tests/cli/projects/hatchling/namespace_package/pyproject.toml @@ -11,6 +11,7 @@ version = "0.1.0" description = "A test project to test plux with pyproject.toml projects and manual build mode" dependencies = [ "plux", + "hatchling", "build", ] requires-python = ">=3.8" diff --git a/tests/cli/projects/hatch_manual_build_mode/test_project/plugins.py b/tests/cli/projects/hatchling/namespace_package/test_project/plugins.py similarity index 100% rename from tests/cli/projects/hatch_manual_build_mode/test_project/plugins.py rename to tests/cli/projects/hatchling/namespace_package/test_project/plugins.py diff --git a/tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/__init__.py b/tests/cli/projects/hatchling/namespace_package/test_project/subpkg/__init__.py similarity index 100% rename from tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/__init__.py rename to tests/cli/projects/hatchling/namespace_package/test_project/subpkg/__init__.py diff --git a/tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/plugins.py b/tests/cli/projects/hatchling/namespace_package/test_project/subpkg/plugins.py similarity index 100% rename from tests/cli/projects/hatch_manual_build_mode/test_project/subpkg/plugins.py rename to tests/cli/projects/hatchling/namespace_package/test_project/subpkg/plugins.py diff --git a/tests/cli/projects/manual_build_mode/plux.ini b/tests/cli/projects/manual_build_mode/plux.ini index e69de29..9d11ce7 100644 --- a/tests/cli/projects/manual_build_mode/plux.ini +++ b/tests/cli/projects/manual_build_mode/plux.ini @@ -0,0 +1,4 @@ +[plux.test.plugins] +mynestedplugin = mysrc.subpkg.plugins:MyNestedPlugin +myplugin = mysrc.plugins:MyPlugin + diff --git a/tests/cli/test_hatch.py b/tests/cli/test_hatch.py deleted file mode 100644 index 75d4f58..0000000 --- a/tests/cli/test_hatch.py +++ /dev/null @@ -1,81 +0,0 @@ -import os.path -import sys -from pathlib import Path - - -def test_discover_with_ini_output(tmp_path): - from plux.__main__ import main - - project = os.path.join(os.path.dirname(__file__), "projects", "hatch_manual_build_mode") - os.chdir(project) - - out_path = tmp_path / "plux.ini" - - sys.path.append(project) - try: - try: - main(["--workdir", project, "discover", "--format", "ini", "--output", str(out_path)]) - except SystemExit: - pass - finally: - sys.path.remove(project) - - lines = out_path.read_text().strip().splitlines() - assert lines == [ - "[plux.test.plugins]", - "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", - "myplugin = test_project.plugins:MyPlugin", - ] - - -def test_discover_with_ini_output_namespace_package(tmp_path): - from plux.__main__ import main - - project = os.path.join(os.path.dirname(__file__), "projects", "hatch", "namespace_package") - os.chdir(project) - - out_path = tmp_path / "plux.ini" - - sys.path.append(project) - try: - try: - main(["--workdir", project, "discover", "--format", "ini", "--output", str(out_path)]) - except SystemExit: - pass - finally: - sys.path.remove(project) - - lines = out_path.read_text().strip().splitlines() - assert lines == [ - "[plux.test.plugins]", - "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", - "myplugin = test_project.plugins:MyPlugin", - ] - - -def test_entrypoints_manual_build_mode(): - from plux.__main__ import main - - project = os.path.join(os.path.dirname(__file__), "projects", "hatch", "namespace_package") - os.chdir(project) - - index_file = Path(project, "plux.ini") - index_file.unlink(missing_ok=True) - - sys.path.append(project) - try: - try: - main(["--workdir", project, "entrypoints"]) - except SystemExit: - pass - finally: - sys.path.remove(project) - - assert index_file.exists() - - lines = index_file.read_text().strip().splitlines() - assert lines == [ - "[plux.test.plugins]", - "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", - "myplugin = test_project.plugins:MyPlugin", - ] diff --git a/tests/cli/test_hatchling.py b/tests/cli/test_hatchling.py new file mode 100644 index 0000000..e8d3421 --- /dev/null +++ b/tests/cli/test_hatchling.py @@ -0,0 +1,121 @@ +""" +These tests are currently running in a completely different way than setuptools. Currently, setuptools is part of the +sys path when running plux from the source repo (setuptools is in the dev extras), but hatchling is not. To make sure +we test plux without setuptools on hatchling projects, we copy the test projects into temporary folders, install a new +venv, and run all necessary commands with subprocess from this process. Unfortunately, this means it's not possible to +easily use the debugger when running these tests. + +TODO: as soon as we want to test a build-hook mode, where plux sits in the build system, we need a different way + to reference the local plux version, since `pip install -e .` doesn't work for build system dependencies. +""" + +import os.path +import shutil +import subprocess +import sys +import uuid +from pathlib import Path + +import pytest + + +@pytest.fixture +def isolate_project(tmp_path): + """ + Creates a factory fixture that copies the given project directory into a temporary directory and prepares it for + running a plux test (installing a venv, and installing plux with editable install). + """ + + def _isolate(project_name: str) -> Path: + project = os.path.join(os.path.dirname(__file__), "projects", "hatchling", project_name) + tmp_dir = tmp_path / f"{project_name}.{str(uuid.uuid4())[-8:]}" + shutil.copytree(project, tmp_dir) + + prepare_project(tmp_dir) + + return tmp_dir + + yield _isolate + + +def _find_project_root() -> Path: + """ + Returns the path of the plux source root from the test file. + """ + path = Path(__file__) + while path != path.parent: + if (path / "pyproject.toml").exists(): + return path + path = path.parent + + raise ValueError("no project root found") + + +def prepare_project(project_directory: Path): + """ + Prepares the project directory for testing by creating a new venv and installing plux with editable install. + """ + # create a new venv in the project directory (bootstrapped using the python binary running pytest) + subprocess.check_output([sys.executable, "-m", "venv", ".venv"], cwd=project_directory) + + # install the package into its own venv (using the python binary from the venv) + python_bin = project_directory / ".venv/bin/python" + subprocess.check_output([python_bin, "-m", "pip", "install", "-e", "."], cwd=project_directory) + + # overwrite plux with the local version + plux_root = _find_project_root() + subprocess.check_output([python_bin, "-m", "pip", "install", "-e", plux_root], cwd=project_directory) + + +def test_discover_with_ini_output(isolate_project): + project = isolate_project("manual_build_mode") + out_path = project / "my_plux.ini" + + python_bin = project / ".venv/bin/python" + subprocess.check_output( + [python_bin, "-m", "plux", "--workdir", project, "discover", "--format", "ini", "--output", out_path] + ) + + lines = out_path.read_text().strip().splitlines() + assert lines == [ + "[plux.test.plugins]", + "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", + "myplugin = test_project.plugins:MyPlugin", + ] + + +def test_discover_with_ini_output_namespace_package(isolate_project): + project = isolate_project("namespace_package") + out_path = project / "my_plux.ini" + + python_bin = project / ".venv/bin/python" + subprocess.check_output( + [python_bin, "-m", "plux", "--workdir", project, "discover", "--format", "ini", "--output", out_path] + ) + + lines = out_path.read_text().strip().splitlines() + assert lines == [ + "[plux.test.plugins]", + "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", + "myplugin = test_project.plugins:MyPlugin", + ] + + +def test_entrypoints_manual_build_mode(isolate_project): + project = isolate_project("namespace_package") + index_file = project / "plux.ini" + + index_file.unlink(missing_ok=True) + + python_bin = project / ".venv/bin/python" + + subprocess.check_output([python_bin, "-m", "plux", "--workdir", project, "entrypoints"]) + + assert index_file.exists() + + lines = index_file.read_text().strip().splitlines() + assert lines == [ + "[plux.test.plugins]", + "mynestedplugin = test_project.subpkg.plugins:MyNestedPlugin", + "myplugin = test_project.plugins:MyPlugin", + ]