|
| 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 |
1 | 9 | import logging |
2 | 10 | import os |
3 | 11 | import sys |
|
6 | 14 |
|
7 | 15 | from hatchling.builders.config import BuilderConfig |
8 | 16 | from hatchling.builders.wheel import WheelBuilder |
| 17 | +from hatchling.metadata.plugin.interface import MetadataHookInterface |
| 18 | +from hatchling.plugin import hookimpl |
9 | 19 |
|
10 | | -from plux.build.config import EntrypointBuildMode |
| 20 | +from plux.build.config import EntrypointBuildMode, read_plux_config_from_workdir |
11 | 21 | from plux.build.discovery import PackageFinder, Filter, MatchAllFilter, SimplePackageFinder |
12 | 22 | from plux.build.project import Project |
13 | 23 |
|
@@ -144,3 +154,156 @@ def path(self) -> str: |
144 | 154 |
|
145 | 155 | def filter_packages(self, packages: t.Iterable[str]) -> t.Iterable[str]: |
146 | 156 | 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 |
0 commit comments