Skip to content

Commit 37bfd3d

Browse files
authored
hatchling metadata hook support (#42)
* initial hatchling metdata hook support * update readme * fix linting
1 parent 80c79e1 commit 37bfd3d

File tree

12 files changed

+660
-9
lines changed

12 files changed

+660
-9
lines changed

README.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,28 @@ python -m plux entrypoints
7272

7373
This creates a `plux.ini` file in your working directory with the discovered plugins. You can then include this file in your distribution by configuring your `pyproject.toml`:
7474

75-
```toml
76-
[project]
77-
dynamic = ["entry-points"]
75+
- **setuptools:**
76+
```toml
77+
[project]
78+
dynamic = ["entry-points"]
7879

79-
[tool.setuptools.package-data]
80-
"*" = ["plux.ini"]
80+
[tool.setuptools.package-data]
81+
"*" = ["plux.ini"]
8182

82-
[tool.setuptools.dynamic]
83-
entry-points = {file = ["plux.ini"]}
84-
```
83+
[tool.setuptools.dynamic]
84+
entry-points = {file = ["plux.ini"]}
85+
```
86+
87+
- **hatchling:**
88+
```toml
89+
[project]
90+
dynamic = ["entry-points"]
91+
92+
[tool.setuptools.package-data]
93+
"*" = ["plux.ini"]
94+
95+
[tool.hatch.metadata.hooks.plux]
96+
```
8597

8698
You can also manually control the output format and location:
8799

plux/build/hatchling.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
"""
2+
Hatchling build backend integration for plux.
3+
4+
This module provides integration with hatchling's build system, including a metadata hook plugin
5+
that enriches project entry-points with data from plux.ini in manual build mode.
6+
"""
7+
8+
import configparser
19
import logging
210
import os
311
import sys
@@ -6,8 +14,10 @@
614

715
from hatchling.builders.config import BuilderConfig
816
from hatchling.builders.wheel import WheelBuilder
17+
from hatchling.metadata.plugin.interface import MetadataHookInterface
18+
from hatchling.plugin import hookimpl
919

10-
from plux.build.config import EntrypointBuildMode
20+
from plux.build.config import EntrypointBuildMode, read_plux_config_from_workdir
1121
from plux.build.discovery import PackageFinder, Filter, MatchAllFilter, SimplePackageFinder
1222
from plux.build.project import Project
1323

@@ -144,3 +154,156 @@ def path(self) -> str:
144154

145155
def filter_packages(self, packages: t.Iterable[str]) -> t.Iterable[str]:
146156
return [item for item in packages if not self.exclude(item) and self.include(item)]
157+
158+
159+
def _parse_plux_ini(path: str) -> dict[str, dict[str, str]]:
160+
"""Parse a plux.ini file and return entry points as a nested dictionary.
161+
162+
The parser uses ``delimiters=('=',)`` to ensure that only the equals sign is treated as a delimiter.
163+
This is critical because plugin names may contain colons, which are the default delimiter in
164+
configparser along with equals.
165+
"""
166+
if not os.path.exists(path):
167+
raise FileNotFoundError(f"plux.ini file not found at {path}")
168+
169+
# Use delimiters=('=',) to prevent colons in plugin names from being treated as delimiters
170+
parser = configparser.ConfigParser(delimiters=("=",))
171+
parser.read(path)
172+
173+
# Convert ConfigParser to nested dict format
174+
result = {}
175+
for section in parser.sections():
176+
result[section] = dict(parser.items(section))
177+
178+
return result
179+
180+
181+
def _merge_entry_points(target: dict, source: dict) -> None:
182+
"""Merge entry points from source into target dictionary.
183+
184+
For each group in source:
185+
- If the group doesn't exist in target, it's added
186+
- If the group exists, entries are merged (source entries overwrite target entries with same name)
187+
"""
188+
for group, entries in source.items():
189+
if group not in target:
190+
target[group] = {}
191+
target[group].update(entries)
192+
193+
194+
class PluxMetadataHook(MetadataHookInterface):
195+
"""Hatchling metadata hook that enriches entry-points with data from plux.ini.
196+
197+
This hook only activates when ``entrypoint_build_mode = "manual"`` is set in the ``[tool.plux]``
198+
section of pyproject.toml. When active, it reads the plux.ini file (default location or as
199+
specified by ``entrypoint_static_file``) and merges the discovered entry points into the
200+
project metadata.
201+
202+
Configuration in consumer projects::
203+
204+
[tool.plux]
205+
entrypoint_build_mode = "manual"
206+
entrypoint_static_file = "plux.ini" # optional, defaults to "plux.ini"
207+
208+
[tool.hatch.metadata.hooks.plux]
209+
# Empty section is sufficient to activate the hook
210+
211+
The plux.ini file format::
212+
213+
[entry.point.group]
214+
entry_name = module.path:object
215+
another_entry = module.path:AnotherObject
216+
217+
When parsing plux.ini, the hook uses ``ConfigParser(delimiters=('=',))`` to ensure that only
218+
the equals sign is treated as a delimiter. This is critical because plugin names may contain
219+
colons.
220+
"""
221+
222+
PLUGIN_NAME = "plux"
223+
224+
def update(self, metadata: dict) -> None:
225+
"""Update project metadata by enriching entry-points with data from plux.ini.
226+
227+
This method performs the following steps:
228+
229+
1. Reads the plux configuration from ``[tool.plux]`` in pyproject.toml
230+
2. Checks if ``entrypoint_build_mode`` is ``"manual"``
231+
3. If not manual mode, raises an exception
232+
4. Reads and parses the plux.ini file
233+
5. Merges the parsed entry points into ``metadata["entry-points"]``
234+
235+
:param metadata: The project metadata dictionary to update in-place. Entry points are
236+
stored in ``metadata["entry-points"]`` as a nested dict where keys are
237+
entry point groups and values are dicts of entry name -> value.
238+
:type metadata: dict
239+
:raises RuntimeError: If the build mode is not ``"manual"``
240+
:raises ValueError: If plux.ini has invalid syntax
241+
"""
242+
# Read plux configuration from pyproject.toml
243+
try:
244+
cfg = read_plux_config_from_workdir(self.root)
245+
except Exception as e:
246+
# If we can't read config, use defaults and log warning
247+
LOG.warning(f"Failed to read plux configuration, using defaults: {e}")
248+
from plux.build.config import PluxConfiguration
249+
250+
cfg = PluxConfiguration()
251+
252+
# Only activate hook in manual mode
253+
if cfg.entrypoint_build_mode != EntrypointBuildMode.MANUAL:
254+
raise RuntimeError(
255+
"The Hatchling metadata build hook is currently only supported for "
256+
"`entrypoint_build_mode=manual`"
257+
)
258+
259+
# Construct path to plux.ini
260+
plux_ini_path = os.path.join(self.root, cfg.entrypoint_static_file)
261+
262+
# Parse plux.ini
263+
try:
264+
entry_points = _parse_plux_ini(plux_ini_path)
265+
except FileNotFoundError:
266+
# Log warning but don't fail build - allows incremental adoption
267+
LOG.warning(
268+
f"plux.ini not found at {plux_ini_path}. "
269+
f"In manual mode, you should generate it with: python -m plux entrypoints"
270+
)
271+
return
272+
except configparser.Error as e:
273+
# Invalid format is a user error - fail the build with clear message
274+
raise ValueError(
275+
f"Failed to parse plux.ini at {plux_ini_path}. Please check the file format. Error: {e}"
276+
) from e
277+
278+
if not entry_points:
279+
LOG.info(f"No entry points found in {plux_ini_path}")
280+
return
281+
282+
# Initialize entry-points in metadata if not present
283+
if "entry-points" not in metadata:
284+
metadata["entry-points"] = {}
285+
286+
# Merge entry points from plux.ini
287+
_merge_entry_points(metadata["entry-points"], entry_points)
288+
289+
LOG.info(
290+
f"Enriched entry-points from {plux_ini_path}: "
291+
f"added {sum(len(v) for v in entry_points.values())} entry points "
292+
f"across {len(entry_points)} groups"
293+
)
294+
295+
296+
@hookimpl
297+
def hatch_register_metadata_hook():
298+
"""Register the PluxMetadataHook with hatchling.
299+
300+
This function is called by hatchling's plugin system to discover and register
301+
the metadata hook. The hook is registered via the entry point::
302+
303+
[project.entry-points.hatch]
304+
plux = "plux.build.hatchling"
305+
306+
:return: The PluxMetadataHook class
307+
:rtype: type[PluxMetadataHook]
308+
"""
309+
return PluxMetadataHook

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,6 @@ plugins = "plux.build.setuptools:plugins"
5555
# this is actually not a writer, it's a reader :-)
5656
"plux.json" = "plux.build.setuptools:load_plux_entrypoints"
5757

58+
[project.entry-points.hatch]
59+
plux = "plux.build.hatchling"
60+

tests/build/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# tests for plux.build module

0 commit comments

Comments
 (0)