diff --git a/plux/build/hatchling.py b/plux/build/hatchling.py index b9afc9d..aa1a68b 100644 --- a/plux/build/hatchling.py +++ b/plux/build/hatchling.py @@ -1,3 +1,11 @@ +""" +Hatchling build backend integration for plux. + +This module provides integration with hatchling's build system, including a metadata hook plugin +that enriches project entry-points with data from plux.ini in manual build mode. +""" + +import configparser import logging import os import sys @@ -6,8 +14,10 @@ from hatchling.builders.config import BuilderConfig from hatchling.builders.wheel import WheelBuilder +from hatchling.metadata.plugin.interface import MetadataHookInterface +from hatchling.plugin import hookimpl -from plux.build.config import EntrypointBuildMode +from plux.build.config import EntrypointBuildMode, read_plux_config_from_workdir from plux.build.discovery import PackageFinder, Filter, MatchAllFilter, SimplePackageFinder from plux.build.project import Project @@ -144,3 +154,156 @@ 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)] + + +def _parse_plux_ini(path: str) -> dict[str, dict[str, str]]: + """Parse a plux.ini file and return entry points as a nested dictionary. + + The parser uses ``delimiters=('=',)`` to ensure that only the equals sign is treated as a delimiter. + This is critical because plugin names may contain colons, which are the default delimiter in + configparser along with equals. + """ + if not os.path.exists(path): + raise FileNotFoundError(f"plux.ini file not found at {path}") + + # Use delimiters=('=',) to prevent colons in plugin names from being treated as delimiters + parser = configparser.ConfigParser(delimiters=("=",)) + parser.read(path) + + # Convert ConfigParser to nested dict format + result = {} + for section in parser.sections(): + result[section] = dict(parser.items(section)) + + return result + + +def _merge_entry_points(target: dict, source: dict) -> None: + """Merge entry points from source into target dictionary. + + For each group in source: + - If the group doesn't exist in target, it's added + - If the group exists, entries are merged (source entries overwrite target entries with same name) + """ + for group, entries in source.items(): + if group not in target: + target[group] = {} + target[group].update(entries) + + +class PluxMetadataHook(MetadataHookInterface): + """Hatchling metadata hook that enriches entry-points with data from plux.ini. + + This hook only activates when ``entrypoint_build_mode = "manual"`` is set in the ``[tool.plux]`` + section of pyproject.toml. When active, it reads the plux.ini file (default location or as + specified by ``entrypoint_static_file``) and merges the discovered entry points into the + project metadata. + + Configuration in consumer projects:: + + [tool.plux] + entrypoint_build_mode = "manual" + entrypoint_static_file = "plux.ini" # optional, defaults to "plux.ini" + + [tool.hatch.metadata.hooks.plux] + # Empty section is sufficient to activate the hook + + The plux.ini file format:: + + [entry.point.group] + entry_name = module.path:object + another_entry = module.path:AnotherObject + + When parsing plux.ini, the hook uses ``ConfigParser(delimiters=('=',))`` to ensure that only + the equals sign is treated as a delimiter. This is critical because plugin names may contain + colons. + """ + + PLUGIN_NAME = "plux" + + def update(self, metadata: dict) -> None: + """Update project metadata by enriching entry-points with data from plux.ini. + + This method performs the following steps: + + 1. Reads the plux configuration from ``[tool.plux]`` in pyproject.toml + 2. Checks if ``entrypoint_build_mode`` is ``"manual"`` + 3. If not manual mode, raises an exception + 4. Reads and parses the plux.ini file + 5. Merges the parsed entry points into ``metadata["entry-points"]`` + + :param metadata: The project metadata dictionary to update in-place. Entry points are + stored in ``metadata["entry-points"]`` as a nested dict where keys are + entry point groups and values are dicts of entry name -> value. + :type metadata: dict + :raises RuntimeError: If the build mode is not ``"manual"`` + :raises ValueError: If plux.ini has invalid syntax + """ + # Read plux configuration from pyproject.toml + try: + cfg = read_plux_config_from_workdir(self.root) + except Exception as e: + # If we can't read config, use defaults and log warning + LOG.warning(f"Failed to read plux configuration, using defaults: {e}") + from plux.build.config import PluxConfiguration + + cfg = PluxConfiguration() + + # Only activate hook in manual mode + if cfg.entrypoint_build_mode != EntrypointBuildMode.MANUAL: + raise RuntimeError( + "The Hatchling metadata build hook is currently only supported for " + "`entrypoint_build_mode=manual`" + ) + + # Construct path to plux.ini + plux_ini_path = os.path.join(self.root, cfg.entrypoint_static_file) + + # Parse plux.ini + try: + entry_points = _parse_plux_ini(plux_ini_path) + except FileNotFoundError: + # Log warning but don't fail build - allows incremental adoption + LOG.warning( + f"plux.ini not found at {plux_ini_path}. " + f"In manual mode, you should generate it with: python -m plux entrypoints" + ) + return + except configparser.Error as e: + # Invalid format is a user error - fail the build with clear message + raise ValueError( + f"Failed to parse plux.ini at {plux_ini_path}. " + f"Please check the file format. Error: {e}" + ) from e + + if not entry_points: + LOG.info(f"No entry points found in {plux_ini_path}") + return + + # Initialize entry-points in metadata if not present + if "entry-points" not in metadata: + metadata["entry-points"] = {} + + # Merge entry points from plux.ini + _merge_entry_points(metadata["entry-points"], entry_points) + + LOG.info( + f"Enriched entry-points from {plux_ini_path}: " + f"added {sum(len(v) for v in entry_points.values())} entry points " + f"across {len(entry_points)} groups" + ) + +@hookimpl +def hatch_register_metadata_hook(): + """Register the PluxMetadataHook with hatchling. + + This function is called by hatchling's plugin system to discover and register + the metadata hook. The hook is registered via the entry point:: + + [project.entry-points.hatch] + plux = "plux.build.hatchling" + + :return: The PluxMetadataHook class + :rtype: type[PluxMetadataHook] + """ + return PluxMetadataHook diff --git a/pyproject.toml b/pyproject.toml index 42cab61..2095611 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,3 +54,6 @@ plugins = "plux.build.setuptools:plugins" # this is actually not a writer, it's a reader :-) "plux.json" = "plux.build.setuptools:load_plux_entrypoints" +[project.entry-points.hatch] +plux = "plux.build.hatchling" + diff --git a/tests/build/__init__.py b/tests/build/__init__.py index e69de29..3b0f85c 100644 --- a/tests/build/__init__.py +++ b/tests/build/__init__.py @@ -0,0 +1 @@ +# tests for plux.build module diff --git a/tests/build/test_hatchling.py b/tests/build/test_hatchling.py new file mode 100644 index 0000000..e282ffb --- /dev/null +++ b/tests/build/test_hatchling.py @@ -0,0 +1,377 @@ +""" +Unit tests for hatchling metadata hook plugin. +""" + +import configparser +from pathlib import Path + +import pytest + + +class TestParsePluginIni: + """Tests for _parse_plux_ini helper function.""" + + def test_parse_simple_ini(self, tmp_path): + """Test parsing a simple plux.ini file.""" + from plux.build.hatchling import _parse_plux_ini + + plux_ini = tmp_path / "plux.ini" + plux_ini.write_text( + """[plux.test.plugins] + myplugin = mysrc.plugins:MyPlugin + """ + ) + + result = _parse_plux_ini(str(plux_ini)) + + assert result == {"plux.test.plugins": {"myplugin": "mysrc.plugins:MyPlugin"}} + + def test_parse_multiple_groups(self, tmp_path): + """Test parsing plux.ini with multiple entry point groups.""" + from plux.build.hatchling import _parse_plux_ini + + plux_ini = tmp_path / "plux.ini" + plux_ini.write_text( + """[plux.test.plugins] + plugin1 = pkg.module:Plugin1 + plugin2 = pkg.module:Plugin2 + + [console_scripts] + mycli = pkg.cli:main + """ + ) + + result = _parse_plux_ini(str(plux_ini)) + + assert result == { + "plux.test.plugins": { + "plugin1": "pkg.module:Plugin1", + "plugin2": "pkg.module:Plugin2", + }, + "console_scripts": {"mycli": "pkg.cli:main"}, + } + + def test_parse_plugin_name_with_colon(self, tmp_path): + """Test that plugin names containing colons are parsed correctly.""" + from plux.build.hatchling import _parse_plux_ini + + plux_ini = tmp_path / "plux.ini" + plux_ini.write_text( + """[plux.test.plugins] + aws:s3 = pkg.aws.s3:S3Plugin + db:postgres = pkg.db.postgres:PostgresPlugin + """ + ) + + result = _parse_plux_ini(str(plux_ini)) + + assert result == { + "plux.test.plugins": { + "aws:s3": "pkg.aws.s3:S3Plugin", + "db:postgres": "pkg.db.postgres:PostgresPlugin", + } + } + + def test_parse_missing_file(self, tmp_path): + """Test that FileNotFoundError is raised for missing file.""" + from plux.build.hatchling import _parse_plux_ini + + nonexistent = tmp_path / "nonexistent.ini" + + with pytest.raises(FileNotFoundError): + _parse_plux_ini(str(nonexistent)) + + def test_parse_invalid_ini_syntax(self, tmp_path): + """Test that configparser.Error is raised for invalid INI syntax.""" + from plux.build.hatchling import _parse_plux_ini + + plux_ini = tmp_path / "plux.ini" + plux_ini.write_text( + """[plux.test.plugins] + invalid syntax here without equals sign + """ + ) + + with pytest.raises(configparser.Error): + _parse_plux_ini(str(plux_ini)) + + def test_parse_empty_file(self, tmp_path): + """Test parsing an empty plux.ini file.""" + from plux.build.hatchling import _parse_plux_ini + + plux_ini = tmp_path / "plux.ini" + plux_ini.write_text("") + + result = _parse_plux_ini(str(plux_ini)) + + assert result == {} + + +class TestMergeEntryPoints: + """Tests for _merge_entry_points helper function.""" + + def test_merge_into_empty_target(self): + """Test merging into an empty target dictionary.""" + from plux.build.hatchling import _merge_entry_points + + target = {} + source = { + "plux.plugins": {"p1": "pkg:P1", "p2": "pkg:P2"}, + "console_scripts": {"cli": "pkg:main"}, + } + + _merge_entry_points(target, source) + + assert target == source + + def test_merge_new_groups(self): + """Test merging adds new groups that don't exist in target.""" + from plux.build.hatchling import _merge_entry_points + + target = {"console_scripts": {"app": "module:main"}} + source = {"plux.plugins": {"p1": "pkg:P1"}} + + _merge_entry_points(target, source) + + assert target == { + "console_scripts": {"app": "module:main"}, + "plux.plugins": {"p1": "pkg:P1"}, + } + + def test_merge_existing_groups(self): + """Test merging into existing groups combines entries.""" + from plux.build.hatchling import _merge_entry_points + + target = {"console_scripts": {"app": "module:main"}} + source = {"console_scripts": {"tool": "module:cli"}} + + _merge_entry_points(target, source) + + assert target == {"console_scripts": {"app": "module:main", "tool": "module:cli"}} + + def test_merge_overwrites_duplicate_names(self): + """Test that source entries overwrite target entries with same name.""" + from plux.build.hatchling import _merge_entry_points + + target = {"plux.plugins": {"p1": "old.module:OldPlugin"}} + source = {"plux.plugins": {"p1": "new.module:NewPlugin"}} + + _merge_entry_points(target, source) + + # Source should overwrite target + assert target == {"plux.plugins": {"p1": "new.module:NewPlugin"}} + + def test_merge_preserves_other_entries(self): + """Test that merging preserves entries not in source.""" + from plux.build.hatchling import _merge_entry_points + + target = {"plux.plugins": {"p1": "pkg:P1", "p2": "pkg:P2"}} + source = {"plux.plugins": {"p1": "pkg:NewP1", "p3": "pkg:P3"}} + + _merge_entry_points(target, source) + + assert target == {"plux.plugins": {"p1": "pkg:NewP1", "p2": "pkg:P2", "p3": "pkg:P3"}} + + +class TestPluxMetadataHook: + """Tests for PluxMetadataHook class.""" + + def test_hook_raises_in_build_hook_mode(self, tmp_path): + """Test that hook is no-op when entrypoint_build_mode is not manual.""" + pytest.importorskip("hatchling") + from plux.build.hatchling import PluxMetadataHook + + # Create a temporary pyproject.toml with build-hook mode + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[tool.plux] + entrypoint_build_mode = "build-hook" + """ + ) + + # Create hook instance + hook = PluxMetadataHook(str(tmp_path), {}) + metadata = {"name": "test-project"} + + # expect to fail for non-manual build hook for now + with pytest.raises(RuntimeError, match="only supported for `entrypoint_build_mode=manual`"): + hook.update(metadata) + + def test_hook_activates_in_manual_mode(self, tmp_path): + """Test that hook processes plux.ini in manual mode.""" + pytest.importorskip("hatchling") + from plux.build.hatchling import PluxMetadataHook + + # Create pyproject.toml with manual mode + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[tool.plux] + entrypoint_build_mode = "manual" + """ + ) + + # Create plux.ini + plux_ini = tmp_path / "plux.ini" + plux_ini.write_text( + """[plux.test.plugins] + myplugin = mysrc.plugins:MyPlugin + """ + ) + + # Create hook instance + hook = PluxMetadataHook(str(tmp_path), {}) + + # Create metadata + metadata = {"name": "test-project"} + + # Call update + hook.update(metadata) + + # Entry points should be added + assert "entry-points" in metadata + assert metadata["entry-points"] == { + "plux.test.plugins": {"myplugin": "mysrc.plugins:MyPlugin"} + } + + def test_hook_uses_custom_static_file_name(self, tmp_path): + """Test that hook uses custom entrypoint_static_file setting.""" + pytest.importorskip("hatchling") + from plux.build.hatchling import PluxMetadataHook + + # Create pyproject.toml with custom file name + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[tool.plux] + entrypoint_build_mode = "manual" + entrypoint_static_file = "custom.ini" + """ + ) + + # Create custom.ini + custom_ini = tmp_path / "custom.ini" + custom_ini.write_text( + """[plux.plugins] + p1 = pkg:P1 + """ + ) + + # Create hook instance + hook = PluxMetadataHook(str(tmp_path), {}) + + # Create metadata + metadata = {} + + # Call update + hook.update(metadata) + + # Entry points should be loaded from custom.ini + assert metadata["entry-points"] == {"plux.plugins": {"p1": "pkg:P1"}} + + def test_hook_merges_with_existing_entry_points(self, tmp_path): + """Test that hook merges plux.ini entries with existing entry-points.""" + pytest.importorskip("hatchling") + from plux.build.hatchling import PluxMetadataHook + + # Create config + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[tool.plux] + entrypoint_build_mode = "manual" + """ + ) + + # Create plux.ini + plux_ini = tmp_path / "plux.ini" + plux_ini.write_text( + """[plux.plugins] + p1 = pkg:P1 + """ + ) + + # Create hook instance + hook = PluxMetadataHook(str(tmp_path), {}) + + # Create metadata with existing entry points + metadata = {"entry-points": {"console_scripts": {"app": "module:main"}}} + + # Call update + hook.update(metadata) + + # Both entry point groups should be present + assert metadata["entry-points"] == { + "console_scripts": {"app": "module:main"}, + "plux.plugins": {"p1": "pkg:P1"}, + } + + def test_hook_handles_missing_plux_ini_gracefully(self, tmp_path): + """Test that hook doesn't fail build when plux.ini is missing.""" + pytest.importorskip("hatchling") + from plux.build.hatchling import PluxMetadataHook + + # Create pyproject.toml but no plux.ini + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[tool.plux] + entrypoint_build_mode = "manual" + """ + ) + + # Create hook instance + hook = PluxMetadataHook(str(tmp_path), {}) + + # Create metadata + metadata = {} + + # Call update - should not raise exception + hook.update(metadata) + + # No entry points should be added + assert "entry-points" not in metadata + + def test_hook_fails_on_invalid_ini_syntax(self, tmp_path): + """Test that hook raises ValueError for invalid INI syntax.""" + pytest.importorskip("hatchling") + from plux.build.hatchling import PluxMetadataHook + + # Create pyproject.toml + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """[tool.plux] + entrypoint_build_mode = "manual" + """ + ) + + # Create invalid plux.ini + plux_ini = tmp_path / "plux.ini" + plux_ini.write_text( + """[plux.plugins] + invalid line without equals + """ + ) + + # Create hook instance + hook = PluxMetadataHook(str(tmp_path), {}) + + # Create metadata + metadata = {} + + # Call update - should raise ValueError + with pytest.raises(ValueError, match="Failed to parse plux.ini"): + hook.update(metadata) + + def test_plugin_name_attribute(self): + """Test that PLUGIN_NAME attribute is set correctly.""" + pytest.importorskip("hatchling") + from plux.build.hatchling import PluxMetadataHook + + assert PluxMetadataHook.PLUGIN_NAME == "plux" + + +def test_hatch_register_metadata_hook(): + """Test that the hook registration function returns the correct class.""" + pytest.importorskip("hatchling") + from plux.build.hatchling import PluxMetadataHook, hatch_register_metadata_hook + + hook_class = hatch_register_metadata_hook() + + assert hook_class is PluxMetadataHook diff --git a/tests/cli/projects/hatchling_manual_mode/.gitignore b/tests/cli/projects/hatchling_manual_mode/.gitignore new file mode 100644 index 0000000..be2e398 --- /dev/null +++ b/tests/cli/projects/hatchling_manual_mode/.gitignore @@ -0,0 +1 @@ +plux.ini \ No newline at end of file diff --git a/tests/cli/projects/hatchling_manual_mode/mysrc/__init__.py b/tests/cli/projects/hatchling_manual_mode/mysrc/__init__.py new file mode 100644 index 0000000..eb15291 --- /dev/null +++ b/tests/cli/projects/hatchling_manual_mode/mysrc/__init__.py @@ -0,0 +1 @@ +# test package diff --git a/tests/cli/projects/hatchling_manual_mode/mysrc/plugins.py b/tests/cli/projects/hatchling_manual_mode/mysrc/plugins.py new file mode 100644 index 0000000..3a0b1b8 --- /dev/null +++ b/tests/cli/projects/hatchling_manual_mode/mysrc/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_mode/mysrc/subpkg/__init__.py b/tests/cli/projects/hatchling_manual_mode/mysrc/subpkg/__init__.py new file mode 100644 index 0000000..6df1500 --- /dev/null +++ b/tests/cli/projects/hatchling_manual_mode/mysrc/subpkg/__init__.py @@ -0,0 +1 @@ +# test subpackage diff --git a/tests/cli/projects/hatchling_manual_mode/mysrc/subpkg/plugins.py b/tests/cli/projects/hatchling_manual_mode/mysrc/subpkg/plugins.py new file mode 100644 index 0000000..163edf8 --- /dev/null +++ b/tests/cli/projects/hatchling_manual_mode/mysrc/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_manual_mode/pyproject.toml b/tests/cli/projects/hatchling_manual_mode/pyproject.toml new file mode 100644 index 0000000..87eef07 --- /dev/null +++ b/tests/cli/projects/hatchling_manual_mode/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling", "plux"] +build-backend = "hatchling.build" + +[project] +name = "test-hatchling-project" +authors = [ + { name = "LocalStack Contributors", email = "info@localstack.cloud" } +] +version = "0.1.0" +description = "A test project to test plux with hatchling and manual build mode" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", +] +dynamic = ["entry-points"] + +[tool.hatch.build.targets.wheel] +packages = ["mysrc"] + +[tool.plux] +entrypoint_build_mode = "manual" + +[tool.hatch.metadata.hooks.plux] + +[tool.hatch.build.targets.sdist] +ignore-vcs = true \ No newline at end of file diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py index b363d25..a042f71 100644 --- a/tests/cli/test_entrypoints.py +++ b/tests/cli/test_entrypoints.py @@ -200,3 +200,62 @@ def test_entrypoints_with_manual_build_mode(): with zip.open(entry_points_file) as f: lines = [line.decode().strip() for line in f.readlines() if line.strip()] assert lines == expected_entry_points + + +def test_hatchling_metadata_hook_with_manual_build_mode(): + """Test that hatchling metadata hook enriches entry points from plux.ini.""" + pytest.importorskip("hatchling") + from build.__main__ import main as build_main + + from plux.__main__ import main + + project = os.path.join(os.path.dirname(__file__), "projects", "hatchling_manual_mode") + os.chdir(project) + + # remove dist dir from previous runs + shutil.rmtree(os.path.join(project, "dist"), ignore_errors=True) + + expected_entry_points = [ + "[plux.test.plugins]", + "mynestedplugin = mysrc.subpkg.plugins:MyNestedPlugin", + "myplugin = mysrc.plugins:MyPlugin", + ] + + sys.path.append(project) + try: + try: + main(["--workdir", project, "entrypoints"]) + except SystemExit: + pass + finally: + sys.path.remove(project) + + # plux.ini should already exist in the test project + ini_file = Path(project, "plux.ini") + assert ini_file.exists(), "plux.ini should exist in test project" + + lines = ini_file.read_text().strip().splitlines() + assert lines == expected_entry_points + + # build the project with hatchling - the metadata hook should enrich entry points + # use --no-isolation to access the local development version of plux with the metadata hook + build_main(["--no-isolation"]) # python -m build --no-isolation + + # inspect the wheel + wheel_file = glob.glob(os.path.join(project, "dist", "test_hatchling_project-*.whl"))[0] + with zipfile.ZipFile(wheel_file, "r") as zip: + members = zip.namelist() + # find the entry_points.txt in the .dist-info directory + entry_points_file = next( + (m for m in members if m.endswith(".dist-info/entry_points.txt")), None + ) + + # Verify that entry_points.txt exists and contains the expected entry points + assert entry_points_file is not None, "entry_points.txt should exist in wheel" + + with zip.open(entry_points_file) as f: + lines = [line.decode().strip() for line in f.readlines() if line.strip()] + assert lines == expected_entry_points, ( + f"Entry points in wheel don't match expected. " + f"Got: {lines}, Expected: {expected_entry_points}" + )