Skip to content

Commit 80c79e1

Browse files
thraudfangl
andauthored
add hatchling support for manual build mode (#35)
* hatchling support wip, package scanner, first steps with testing * implement rest of HatchlingProject * reorganize code * add isolated testing mode for hatch * create more explicit build backend selection * move away from bild_backend to more stable build_backend and add tests * Fix nitpicks --------- Co-authored-by: Daniel Fangl <daniel.fangl@localstack.cloud>
1 parent f075707 commit 80c79e1

19 files changed

Lines changed: 792 additions & 44 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,5 +136,8 @@ venv.bak/
136136
# don't ignore build package
137137
!plux/build
138138

139+
!tests/build
140+
plux.ini
141+
139142
# Ignore dynamically generated version.py
140-
plux/version.py
143+
plux/version.py

plux/build/config.py

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55

66
import dataclasses
77
import enum
8+
import logging
89
import os
910
import sys
1011
from importlib.util import find_spec
12+
from typing import Any
13+
14+
LOG = logging.getLogger(__name__)
1115

1216

1317
class EntrypointBuildMode(enum.Enum):
@@ -24,6 +28,17 @@ class EntrypointBuildMode(enum.Enum):
2428
BUILD_HOOK = "build-hook"
2529

2630

31+
class BuildBackend(enum.Enum):
32+
"""
33+
The build backend integration to use. Currently, we support setuptools and hatchling. If set to ``auto``, there
34+
is an algorithm to detect the build backend automatically from the config.
35+
"""
36+
37+
AUTO = "auto"
38+
SETUPTOOLS = "setuptools"
39+
HATCHLING = "hatchling"
40+
41+
2742
@dataclasses.dataclass
2843
class PluxConfiguration:
2944
"""
@@ -47,13 +62,17 @@ class PluxConfiguration:
4762
entrypoint_static_file: str = "plux.ini"
4863
"""The name of the entrypoint ini file if entrypoint_build_mode is set to MANUAL."""
4964

65+
build_backend: BuildBackend = BuildBackend.AUTO
66+
"""The build backend to use. If set to ``auto``, the build backend will be detected automatically from the config."""
67+
5068
def merge(
5169
self,
5270
path: str = None,
5371
exclude: list[str] = None,
5472
include: list[str] = None,
5573
entrypoint_build_mode: EntrypointBuildMode = None,
5674
entrypoint_static_file: str = None,
75+
build_backend: BuildBackend = None,
5776
) -> "PluxConfiguration":
5877
"""
5978
Merges or overwrites the given values into the current configuration and returns a new configuration object.
@@ -69,6 +88,7 @@ def merge(
6988
entrypoint_static_file=entrypoint_static_file
7089
if entrypoint_static_file is not None
7190
else self.entrypoint_static_file,
91+
build_backend=build_backend if build_backend is not None else self.build_backend,
7292
)
7393

7494

@@ -81,8 +101,7 @@ def read_plux_config_from_workdir(workdir: str = None) -> PluxConfiguration:
81101
:return: A plux configuration object
82102
"""
83103
try:
84-
pyproject_file = os.path.join(workdir or os.getcwd(), "pyproject.toml")
85-
return parse_pyproject_toml(pyproject_file)
104+
return parse_pyproject_toml(workdir or os.getcwd())
86105
except FileNotFoundError:
87106
return PluxConfiguration()
88107

@@ -96,18 +115,7 @@ def parse_pyproject_toml(path: str | os.PathLike[str]) -> PluxConfiguration:
96115
:return: A plux configuration object containing the parsed values.
97116
:raises FileNotFoundError: If the file does not exist.
98117
"""
99-
if find_spec("tomllib"):
100-
from tomllib import load as load_toml
101-
elif find_spec("tomli"):
102-
from tomli import load as load_toml
103-
else:
104-
raise ImportError("Could not find a TOML parser. Please install either tomllib or tomli.")
105-
106-
# read the file
107-
if not os.path.exists(path):
108-
raise FileNotFoundError(f"No pyproject.toml found at {path}")
109-
with open(path, "rb") as file:
110-
pyproject_config = load_toml(file)
118+
pyproject_config = load_pyproject_toml(path)
111119

112120
# find the [tool.plux] section
113121
tool_table = pyproject_config.get("tool", {})
@@ -127,4 +135,102 @@ def parse_pyproject_toml(path: str | os.PathLike[str]) -> PluxConfiguration:
127135
# will raise a ValueError exception if the mode is invalid
128136
kwargs["entrypoint_build_mode"] = EntrypointBuildMode(mode)
129137

138+
# parse build_backend
139+
if build_backend := kwargs.get("build_backend"):
140+
# will raise a ValueError exception if the build backend is invalid
141+
kwargs["build_backend"] = BuildBackend(build_backend)
142+
130143
return PluxConfiguration(**kwargs)
144+
145+
146+
def determine_build_backend_from_pyproject_config(pyproject_config: dict[str, Any]) -> BuildBackend | None:
147+
"""
148+
Determine the build backend to use based on the pyproject.toml configuration.
149+
"""
150+
build_backend = pyproject_config.get("build-system", {}).get("build-backend", "")
151+
if build_backend.startswith("setuptools."):
152+
return BuildBackend.SETUPTOOLS
153+
if build_backend.startswith("hatchling."):
154+
return BuildBackend.HATCHLING
155+
else:
156+
return None
157+
158+
159+
def load_pyproject_toml(pyproject_file_or_workdir: str | os.PathLike[str] = None) -> dict[str, Any]:
160+
"""
161+
Loads a pyproject.toml file from the given path or the current working directory. Uses tomli or tomllib to parse.
162+
163+
:param pyproject_file_or_workdir: Path to the pyproject.toml file or the directory containing it. Defaults to the current working directory.
164+
:return: The parsed pyproject.toml file as a dictionary.
165+
"""
166+
if pyproject_file_or_workdir is None:
167+
pyproject_file_or_workdir = os.getcwd()
168+
if os.path.isfile(pyproject_file_or_workdir):
169+
pyproject_file = pyproject_file_or_workdir
170+
else:
171+
pyproject_file = os.path.join(pyproject_file_or_workdir, "pyproject.toml")
172+
173+
if find_spec("tomllib"):
174+
from tomllib import load as load_toml
175+
elif find_spec("tomli"):
176+
from tomli import load as load_toml
177+
else:
178+
raise ImportError("Could not find a TOML parser. Please install either tomllib or tomli.")
179+
180+
# read the file
181+
if not os.path.exists(pyproject_file):
182+
raise FileNotFoundError(f"No .toml file found at {pyproject_file}")
183+
with open(pyproject_file, "rb") as file:
184+
pyproject_config = load_toml(file)
185+
186+
return pyproject_config
187+
188+
189+
def determine_build_backend_from_config(workdir: str) -> BuildBackend:
190+
"""
191+
Algorithm to determine the build backend to use based on the given workdir. First, it checks the pyproject.toml to
192+
see whether there's a [tool.plux] build_backend =... is configured directly. If not found, it checks the
193+
``build-backend`` attribute in the pyproject.toml. Then, as a fallback, it tries to import both setuptools and
194+
hatchling, and uses the first one that works
195+
"""
196+
# parse config to get build backend
197+
plux_config = read_plux_config_from_workdir(workdir)
198+
199+
if plux_config.build_backend != BuildBackend.AUTO:
200+
# first, check if the user configured one
201+
return plux_config.build_backend
202+
203+
# otherwise, try to determine it from the build-backend attribute in the pyproject.toml
204+
try:
205+
backend = determine_build_backend_from_pyproject_config(load_pyproject_toml(workdir))
206+
if backend is not None:
207+
return backend
208+
except FileNotFoundError:
209+
pass
210+
211+
# if that also fails, just try to import both build backends and return the first one that works
212+
try:
213+
import setuptools # noqa
214+
215+
try:
216+
# Try import here again to log proper warning if both are present in the environment
217+
import hatchling
218+
219+
LOG.warning(
220+
"Both setuptools and hatchling build backends available. Please manually choose a build-backend in the plux config. Defaulting to setuptools."
221+
)
222+
except ImportError:
223+
pass
224+
225+
return BuildBackend.SETUPTOOLS
226+
except ImportError:
227+
pass
228+
229+
try:
230+
import hatchling # noqa
231+
232+
return BuildBackend.HATCHLING
233+
except ImportError:
234+
pass
235+
236+
raise ValueError("No supported build backend found. Plux needs either setuptools or hatchling to work.")

plux/build/discovery.py

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
import importlib
66
import inspect
77
import logging
8+
import os
9+
import pkgutil
810
import typing as t
911
from fnmatch import fnmatchcase
12+
from pathlib import Path
1013
from types import ModuleType
11-
import os
12-
import pkgutil
1314

1415
from plux import PluginFinder, PluginSpecResolver, PluginSpec
1516

@@ -56,6 +57,107 @@ def path(self) -> str:
5657
raise NotImplementedError
5758

5859

60+
class SimplePackageFinder(PackageFinder):
61+
"""
62+
A package finder that uses a heuristic to find python packages within a given path. It iterates over all
63+
subdirectories in the path and returns every directory that contains a ``__init__.py`` file. It will include the
64+
root package in the list of results, so if your tree looks like this::
65+
66+
mypkg
67+
├── __init__.py
68+
├── subpkg1
69+
│ ├── __init__.py
70+
│ └── nested_subpkg1
71+
│ └── __init__.py
72+
└── subpkg2
73+
└── __init__.py
74+
75+
and you instantiate SimplePackageFinder("mypkg"), it will return::
76+
77+
[
78+
"mypkg",
79+
"mypkg.subpkg1",
80+
"mypkg.subpkg2",
81+
"mypkg.subpkg1.nested_subpkg1,
82+
]
83+
84+
If the root is not a package, say if you have a ``src/`` layout, and you pass "src/mypkg" as ``path`` it will omit
85+
everything in the preceding path that's not a package.
86+
"""
87+
88+
DEFAULT_EXCLUDES = {"__pycache__"}
89+
90+
def __init__(self, path: str):
91+
self._path = path
92+
93+
@property
94+
def path(self) -> str:
95+
return self._path
96+
97+
def find_packages(self) -> t.Iterable[str]:
98+
"""
99+
Find all Python packages in the given path.
100+
101+
Returns a list of package names in the format "pkg", "pkg.subpkg", etc.
102+
"""
103+
path = self.path
104+
if not os.path.isdir(path):
105+
return []
106+
107+
result = []
108+
109+
# Get the absolute path to handle relative paths correctly
110+
abs_path = os.path.abspath(path)
111+
112+
# Check if the root directory is a package
113+
root_is_package = self._looks_like_package(abs_path)
114+
115+
# Walk through the directory tree
116+
for root, dirs, files in os.walk(abs_path):
117+
# Skip directories that don't look like packages
118+
if not self._looks_like_package(root):
119+
continue
120+
121+
# Determine the base directory for relative path calculation
122+
# If the root is not a package, we use the root directory itself as the base
123+
# This ensures we don't include the root directory name in the package names
124+
if root_is_package:
125+
base_dir = os.path.dirname(abs_path)
126+
else:
127+
base_dir = abs_path
128+
129+
# Convert the path to a module name
130+
rel_path = os.path.relpath(root, base_dir)
131+
if rel_path == ".":
132+
# If we're at the root and it's a package, use the directory name
133+
rel_path = os.path.basename(abs_path)
134+
135+
# skip excludes TODO: should re-use Filter API
136+
if os.path.basename(rel_path).strip(os.pathsep) in self.DEFAULT_EXCLUDES:
137+
continue
138+
139+
# Skip invalid package names (those containing dots in the path)
140+
if "." in os.path.basename(rel_path):
141+
continue
142+
143+
module_name = self._path_to_module(rel_path)
144+
result.append(module_name)
145+
146+
# Sort the results for consistent output
147+
return sorted(result)
148+
149+
def _looks_like_package(self, path: str) -> bool:
150+
return os.path.exists(os.path.join(path, "__init__.py"))
151+
152+
@staticmethod
153+
def _path_to_module(path: str):
154+
"""
155+
Convert a path to a Python module to its module representation
156+
Example: plux/core/test -> plux.core.test
157+
"""
158+
return ".".join(Path(path).with_suffix("").parts)
159+
160+
59161
class PluginFromPackageFinder(PluginFinder):
60162
"""
61163
Finds Plugins from packages that are resolved by the given ``PackageFinder``. Under the hood this uses a

0 commit comments

Comments
 (0)