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/__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/config.py b/plux/build/config.py index ec5109c..cd76cc0 100644 --- a/plux/build/config.py +++ b/plux/build/config.py @@ -8,6 +8,7 @@ import os import sys from importlib.util import find_spec +from typing import Any class EntrypointBuildMode(enum.Enum): @@ -24,6 +25,17 @@ class EntrypointBuildMode(enum.Enum): BUILD_HOOK = "build-hook" +class BuildBackend(enum.Enum): + """ + The build backend integration to use. Currently, we support setuptools and hatchling. If set to ``auto``, there + is an algorithm to detect the build backend automatically from the config. + """ + + AUTO = "auto" + SETUPTOOLS = "setuptools" + HATCHLING = "hatchling" + + @dataclasses.dataclass class PluxConfiguration: """ @@ -47,6 +59,9 @@ class PluxConfiguration: entrypoint_static_file: str = "plux.ini" """The name of the entrypoint ini file if entrypoint_build_mode is set to MANUAL.""" + build_backend: BuildBackend = BuildBackend.AUTO + """The build backend to use. If set to ``auto``, the build backend will be detected automatically from the config.""" + def merge( self, path: str = None, @@ -54,6 +69,7 @@ def merge( include: list[str] = None, entrypoint_build_mode: EntrypointBuildMode = None, entrypoint_static_file: str = None, + build_backend: BuildBackend = None, ) -> "PluxConfiguration": """ Merges or overwrites the given values into the current configuration and returns a new configuration object. @@ -69,6 +85,7 @@ def merge( entrypoint_static_file=entrypoint_static_file if entrypoint_static_file is not None else self.entrypoint_static_file, + build_backend=build_backend if build_backend is not None else self.build_backend, ) @@ -81,8 +98,7 @@ def read_plux_config_from_workdir(workdir: str = None) -> PluxConfiguration: :return: A plux configuration object """ try: - pyproject_file = os.path.join(workdir or os.getcwd(), "pyproject.toml") - return parse_pyproject_toml(pyproject_file) + return parse_pyproject_toml(workdir or os.getcwd()) except FileNotFoundError: return PluxConfiguration() @@ -96,18 +112,7 @@ def parse_pyproject_toml(path: str | os.PathLike[str]) -> PluxConfiguration: :return: A plux configuration object containing the parsed values. :raises FileNotFoundError: If the file does not exist. """ - if find_spec("tomllib"): - from tomllib import load as load_toml - elif find_spec("tomli"): - from tomli import load as load_toml - else: - raise ImportError("Could not find a TOML parser. Please install either tomllib or tomli.") - - # read the file - if not os.path.exists(path): - raise FileNotFoundError(f"No pyproject.toml found at {path}") - with open(path, "rb") as file: - pyproject_config = load_toml(file) + pyproject_config = load_pyproject_toml(path) # find the [tool.plux] section tool_table = pyproject_config.get("tool", {}) @@ -127,4 +132,92 @@ def parse_pyproject_toml(path: str | os.PathLike[str]) -> PluxConfiguration: # will raise a ValueError exception if the mode is invalid kwargs["entrypoint_build_mode"] = EntrypointBuildMode(mode) + # parse build_backend + if build_backend := kwargs.get("build_backend"): + # will raise a ValueError exception if the build backend is invalid + kwargs["build_backend"] = BuildBackend(build_backend) + return PluxConfiguration(**kwargs) + + +def determine_build_backend_from_pyproject_config(pyproject_config: dict[str, Any]) -> BuildBackend | None: + """ + Determine the build backend to use based on the pyproject.toml configuration. + """ + build_backend = pyproject_config.get("build-system", {}).get("build-backend", "") + if build_backend.startswith("setuptools."): + return BuildBackend.SETUPTOOLS + if build_backend.startswith("hatchling."): + return BuildBackend.HATCHLING + else: + return None + + +def load_pyproject_toml(pyproject_file_or_workdir: str | os.PathLike[str] = None) -> dict[str, Any]: + """ + Loads a pyproject.toml file from the given path or the current working directory. Uses tomli or tomllib to parse. + + :param pyproject_file_or_workdir: Path to the pyproject.toml file or the directory containing it. Defaults to the current working directory. + :return: The parsed pyproject.toml file as a dictionary. + """ + if pyproject_file_or_workdir is None: + pyproject_file_or_workdir = os.getcwd() + if os.path.isfile(pyproject_file_or_workdir): + pyproject_file = pyproject_file_or_workdir + else: + pyproject_file = os.path.join(pyproject_file_or_workdir, "pyproject.toml") + + if find_spec("tomllib"): + from tomllib import load as load_toml + elif find_spec("tomli"): + from tomli import load as load_toml + else: + raise ImportError("Could not find a TOML parser. Please install either tomllib or tomli.") + + # read the file + if not os.path.exists(pyproject_file): + raise FileNotFoundError(f"No .toml file found at {pyproject_file}") + with open(pyproject_file, "rb") as file: + pyproject_config = load_toml(file) + + return pyproject_config + + +def determine_build_backend_from_config(workdir: str) -> BuildBackend: + """ + Algorithm to determine the build backend to use based on the given workdir. First, it checks the pyproject.toml to + see whether there's a [tool.plux] build_backend =... is configured directly. If not found, it checks the + ``build-backend`` attribute in the pyproject.toml. Then, as a fallback, it tries to import both setuptools and + hatchling, and uses the first one that works + """ + # parse config to get build backend + plux_config = read_plux_config_from_workdir(workdir) + + if plux_config.build_backend != BuildBackend.AUTO: + # first, check if the user configured one + return plux_config.build_backend + + # otherwise, try to determine it from the build-backend attribute in the pyproject.toml + try: + backend = determine_build_backend_from_pyproject_config(load_pyproject_toml(workdir)) + if backend is not None: + return backend + except FileNotFoundError: + pass + + # if that also fails, just try to import both build backends and return the first one that works + try: + import setuptools # noqa + + return BuildBackend.SETUPTOOLS + except ImportError: + pass + + try: + import hatchling # noqa + + return BuildBackend.HATCHLING + except ImportError: + pass + + raise ValueError("No supported build backend found. Plux needs either setuptools or hatchling to work.") diff --git a/plux/build/discovery.py b/plux/build/discovery.py index 2a8b44f..15f73cf 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,107 @@ 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. + """ + + DEFAULT_EXCLUDES = "__pycache__" + + 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 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 + + 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..b9afc9d 100644 --- a/plux/build/hatchling.py +++ b/plux/build/hatchling.py @@ -1,6 +1,146 @@ +import logging +import os +import sys +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__) + 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" + ) + + # 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. + + TODO: include/exclude configuration of packages in hatch 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)] diff --git a/plux/cli/cli.py b/plux/cli/cli.py index fcf769b..6408756 100644 --- a/plux/cli/cli.py +++ b/plux/cli/cli.py @@ -14,38 +14,23 @@ LOG = logging.getLogger(__name__) -def _get_build_backend() -> str | None: - # TODO: should read this from the project configuration instead somehow. - try: - import setuptools # noqa - - return "setuptools" - except ImportError: - pass - - try: - import hatchling # noqa - - return "hatchling" - except ImportError: - pass - - return None - - def _load_project(args: argparse.Namespace) -> Project: - backend = _get_build_backend() workdir = args.workdir if args.verbose: - print(f"loading project config from {workdir}, determined build backend is: {backend}") + print(f"loading project config from {workdir}") - if backend == "setuptools": + # TODO: this is maybe a bit redundant since we parse the config once here, and then again when we create the Project + backend = config.determine_build_backend_from_config(workdir) + + if backend == config.BuildBackend.SETUPTOOLS: from plux.build.setuptools import SetuptoolsProject return SetuptoolsProject(workdir) - elif backend == "hatchling": - raise NotImplementedError("Hatchling is not yet supported as build backend") + elif backend == config.BuildBackend.HATCHLING: + 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/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/build/__init__.py b/tests/build/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/build/test_config.py b/tests/build/test_config.py new file mode 100644 index 0000000..4445c71 --- /dev/null +++ b/tests/build/test_config.py @@ -0,0 +1,60 @@ +import os +import textwrap +import uuid + +import pytest + +from plux.build.config import read_plux_config_from_workdir, BuildBackend, determine_build_backend_from_config + + +@pytest.fixture +def create_config_workdir(tmp_path): + """ + Factory fixture that creates a temporary directory with a pyproject.toml file in it using the given string. + When the factory is invoked, it also changes the working directory to that temp directory. + """ + cur_dir = os.getcwd() + + def _create(config: str): + tmp_dir = tmp_path / f"project.{str(uuid.uuid4())[-8:]}" + tmp_dir.mkdir() + config_path = tmp_dir / "pyproject.toml" + config_path.write_text(config) + + os.chdir(tmp_dir) + + return config_path + + yield _create + + os.chdir(cur_dir) + + +@pytest.mark.parametrize("backend", ["setuptools", "hatchling"]) +def test_config_with_explicit_build_backend(backend, create_config_workdir): + config = textwrap.dedent(f""" + [tool.plux] + entrypoint_build_mode = "manual" + build_backend = "{backend}" + """) + + create_config_workdir(config) + + cfg = read_plux_config_from_workdir() + assert cfg.build_backend == BuildBackend(backend) + + +def test_build_backend_is_read_from_config(create_config_workdir): + # this test makes sure the build-system.build-backend extraction works + config = textwrap.dedent(""" + [build-system] + requires = ["hatchling", "wheel"] + build-backend = "hatchling.build" + """) + + create_config_workdir(config) + + cfg = read_plux_config_from_workdir() + assert cfg.build_backend == BuildBackend.AUTO + + assert determine_build_backend_from_config(os.getcwd()) == BuildBackend.HATCHLING 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/hatchling/manual_build_mode/pyproject.toml b/tests/cli/projects/hatchling/manual_build_mode/pyproject.toml new file mode 100644 index 0000000..bc03426 --- /dev/null +++ b/tests/cli/projects/hatchling/manual_build_mode/pyproject.toml @@ -0,0 +1,29 @@ +[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", + "hatchling", + "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/hatchling/manual_build_mode/test_project/__init__.py b/tests/cli/projects/hatchling/manual_build_mode/test_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/projects/hatchling/manual_build_mode/test_project/plugins.py b/tests/cli/projects/hatchling/manual_build_mode/test_project/plugins.py new file mode 100644 index 0000000..3a0b1b8 --- /dev/null +++ b/tests/cli/projects/hatchling/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/hatchling/manual_build_mode/test_project/subpkg/__init__.py b/tests/cli/projects/hatchling/manual_build_mode/test_project/subpkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/projects/hatchling/manual_build_mode/test_project/subpkg/plugins.py b/tests/cli/projects/hatchling/manual_build_mode/test_project/subpkg/plugins.py new file mode 100644 index 0000000..163edf8 --- /dev/null +++ b/tests/cli/projects/hatchling/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/hatchling/namespace_package/pyproject.toml b/tests/cli/projects/hatchling/namespace_package/pyproject.toml new file mode 100644 index 0000000..85c13fb --- /dev/null +++ b/tests/cli/projects/hatchling/namespace_package/pyproject.toml @@ -0,0 +1,32 @@ +[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", + "hatchling", + "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/hatchling/namespace_package/test_project/plugins.py b/tests/cli/projects/hatchling/namespace_package/test_project/plugins.py new file mode 100644 index 0000000..3a0b1b8 --- /dev/null +++ b/tests/cli/projects/hatchling/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/hatchling/namespace_package/test_project/subpkg/__init__.py b/tests/cli/projects/hatchling/namespace_package/test_project/subpkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/projects/hatchling/namespace_package/test_project/subpkg/plugins.py b/tests/cli/projects/hatchling/namespace_package/test_project/subpkg/plugins.py new file mode 100644 index 0000000..163edf8 --- /dev/null +++ b/tests/cli/projects/hatchling/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/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", + ]