Skip to content

Commit 5b63705

Browse files
authored
add entrypoint build modes feature (#31)
1 parent d3ddfa2 commit 5b63705

File tree

14 files changed

+626
-118
lines changed

14 files changed

+626
-118
lines changed

README.md

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ If an error occurs at any state of the lifecycle, the `PluginManager` informs th
3939

4040
### Discovering entrypoints
4141

42+
Plux supports two modes for building entry points: **build-hooks mode** (default) and **manual mode**.
43+
44+
#### Build-hooks mode (default)
45+
4246
To build a source distribution and a wheel of your code with your plugins as entrypoints, simply run `python setup.py plugins sdist bdist_wheel`.
4347
If you don't have a `setup.py`, you can use the plux build frontend and run `python -m plux entrypoints`.
4448

@@ -49,6 +53,42 @@ When a setuptools command is used to create the distribution (e.g., `python setu
4953
The `plux.json` file becomes a part of the distribution, s.t., the plugins do not have to be discovered every time your distribution is installed elsewhere.
5054
Discovering at build time also works when using `python -m build`, since it calls registered setuptools scripts.
5155

56+
#### Manual mode
57+
58+
Manual mode is useful for isolated build environments where dependencies cannot be installed, or when build hooks are not suitable for your build process.
59+
60+
To enable manual mode, add the following to your `pyproject.toml`:
61+
62+
```toml
63+
[tool.plux]
64+
entrypoint_build_mode = "manual"
65+
```
66+
67+
In manual mode, plux does not use build hooks. Instead, you manually generate entry points by running:
68+
69+
```bash
70+
python -m plux entrypoints
71+
```
72+
73+
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`:
74+
75+
```toml
76+
[project]
77+
dynamic = ["entry-points"]
78+
79+
[tool.setuptools.package-data]
80+
"*" = ["plux.ini"]
81+
82+
[tool.setuptools.dynamic]
83+
entry-points = {file = ["plux.ini"]}
84+
```
85+
86+
You can also manually control the output format and location:
87+
88+
```bash
89+
python -m plux discover --format ini --output plux.ini
90+
```
91+
5292

5393
Examples
5494
--------
@@ -201,9 +241,35 @@ build-backend = "setuptools.build_meta"
201241
Additional configuration
202242
------------------------
203243

204-
You can pass additional configuration to Plux, either via the command line or your project `pyproject.toml`.
244+
You can pass additional configuration to Plux, either via the command line or your project `pyproject.toml`.
205245

206-
### Excluding Python packages during discovery
246+
### Configuration options
247+
248+
The following options can be configured in the `[tool.plux]` section of your `pyproject.toml`:
249+
250+
```toml
251+
[tool.plux]
252+
# The build mode for entry points: "build-hooks" (default) or "manual"
253+
entrypoint_build_mode = "manual"
254+
255+
# The file path to scan for plugins (optional)
256+
path = "mysrc"
257+
258+
# Python packages to exclude during discovery (optional)
259+
exclude = ["**/database/alembic*"]
260+
```
261+
262+
#### `entrypoint_build_mode`
263+
264+
Controls how plux generates entry points:
265+
- `build-hooks` (default): Plux automatically hooks into the build process to generate entry points
266+
- `manual`: You manually control when and how entry points are generated (see [Manual mode](#manual-mode))
267+
268+
#### `path`
269+
270+
Specifies the file path to scan for plugins. By default, plux scans the entire project.
271+
272+
#### `exclude`
207273

208274
When [discovering entrypoints](#discovering-entrypoints), Plux will try importing your code to discover Plugins.
209275
Some parts of your codebase might have side effects, or raise errors when imported outside a specific context like some database
@@ -212,14 +278,7 @@ migration scripts.
212278
You can ignore those Python packages by specifying the `--exclude` flag to the entrypoints discovery commands (`python -m plux entrypoints` or `python setup.py plugins`).
213279
The option takes a list of comma-separated values that can be paths or package names.
214280

215-
You can also specify those values in the `tool.plux` section of your `pyproject.toml`:
216-
217-
```toml
218-
# ...
219-
220-
[tool.plux]
221-
exclude = ["**/database/alembic*"]
222-
```
281+
You can also specify those values in the `tool.plux` section of your `pyproject.toml` as shown above.
223282

224283
Install
225284
-------

plux/build/config.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,75 @@
33
future to support ``tox.ini``, ``setup.cfg``, etc.
44
"""
55
import dataclasses
6+
import enum
67
import os
8+
import sys
79
from importlib.util import find_spec
8-
import typing as t
10+
11+
12+
class EntrypointBuildMode(enum.Enum):
13+
"""
14+
The build mode for entrypoints. When ``build-hook`` is used, plux hooks into the build backend's build process using
15+
various extension points. Currently, we only support setuptools, where plux generates a plugin index and adds it to
16+
the .egg-info directory, which is later used to generate entry points.
17+
18+
The alternative is ``manual``, where build hooks are disabled and the user is responsible for generating and
19+
referencing entry points.
20+
"""
21+
MANUAL = "manual"
22+
BUILD_HOOK = "build-hook"
923

1024

1125
@dataclasses.dataclass
1226
class PluxConfiguration:
1327
"""
1428
Configuration object with sane default values.
1529
"""
16-
exclude: t.List[str] = dataclasses.field(default_factory=list)
30+
31+
path: str = "."
32+
"""The path to scan for plugins."""
33+
34+
exclude: list[str] = dataclasses.field(default_factory=list)
35+
"""A list of paths to exclude from scanning."""
36+
37+
entrypoint_build_mode: EntrypointBuildMode = EntrypointBuildMode.BUILD_HOOK
38+
"""The point in the build process path plugins should be discovered and entrypoints generated."""
39+
40+
entrypoint_static_file: str = "plux.ini"
41+
"""The name of the entrypoint ini file if entrypoint_build_mode is set to MANUAL."""
42+
43+
def merge(
44+
self,
45+
path: str = None,
46+
exclude: list[str] = None,
47+
entrypoint_build_mode: EntrypointBuildMode = None,
48+
entrypoint_static_file: str = None,
49+
) -> "PluxConfiguration":
50+
"""
51+
Merges or overwrites the given values into the current configuration and returns a new configuration object.
52+
If the passed values are None, they are not changed.
53+
"""
54+
return PluxConfiguration(
55+
path=path if path is not None else self.path,
56+
exclude=list(set((exclude if exclude is not None else []) + self.exclude)),
57+
entrypoint_build_mode=entrypoint_build_mode if entrypoint_build_mode is not None else self.entrypoint_build_mode,
58+
entrypoint_static_file=entrypoint_static_file if entrypoint_static_file is not None else self.entrypoint_static_file,
59+
)
60+
61+
62+
def read_plux_config_from_workdir(workdir: str = None) -> PluxConfiguration:
63+
"""
64+
Reads the plux configuration from the specified workdir. Currently, it only checks for a ``pyproject.toml`` to
65+
parse. If no pyproject.toml is found, a default configuration is returned.
66+
67+
:param workdir: The workdir which defaults to the current working directory.
68+
:return: A plux configuration object
69+
"""
70+
try:
71+
pyproject_file = os.path.join(workdir or os.getcwd(), "pyproject.toml")
72+
return parse_pyproject_toml(pyproject_file)
73+
except FileNotFoundError:
74+
return PluxConfiguration()
1775

1876

1977
def parse_pyproject_toml(path: str | os.PathLike[str]) -> PluxConfiguration:
@@ -22,7 +80,7 @@ def parse_pyproject_toml(path: str | os.PathLike[str]) -> PluxConfiguration:
2280
object from the found values. Uses tomli or tomllib to parse the file.
2381
2482
:param path: Path to the pyproject.toml file.
25-
:return: A ``Configuration`` object containing the parsed values.
83+
:return: A plux configuration object containing the parsed values.
2684
:raises FileNotFoundError: If the file does not exist.
2785
"""
2886
if find_spec("tomllib"):
@@ -32,13 +90,28 @@ def parse_pyproject_toml(path: str | os.PathLike[str]) -> PluxConfiguration:
3290
else:
3391
raise ImportError("Could not find a TOML parser. Please install either tomllib or tomli.")
3492

93+
# read the file
3594
if not os.path.exists(path):
3695
raise FileNotFoundError(f"No pyproject.toml found at {path}")
37-
3896
with open(path, "rb") as file:
3997
pyproject_config = load_toml(file)
4098

99+
# find the [tool.plux] section
41100
tool_table = pyproject_config.get("tool", {})
42101
tool_config = tool_table.get("plux", {})
43102

44-
return PluxConfiguration(**tool_config)
103+
# filter out all keys that are not available in the config object
104+
kwargs = {}
105+
for key, value in tool_config.items():
106+
if key not in PluxConfiguration.__annotations__:
107+
print(f"Warning: ignoring unknown key {key} in [tool.plux] section of {path}", file=sys.stderr)
108+
continue
109+
110+
kwargs[key] = value
111+
112+
# parse entrypoint_build_mode enum
113+
if mode := kwargs.get("entrypoint_build_mode"):
114+
# will raise a ValueError exception if the mode is invalid
115+
kwargs["entrypoint_build_mode"] = EntrypointBuildMode(mode)
116+
117+
return PluxConfiguration(**kwargs)

plux/build/discovery.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
"""
2-
Buildtool independent utils to discover plugins from the codebase.
2+
Buildtool independent utils to discover plugins from the codebase, and write index files.
33
"""
4+
import configparser
45
import inspect
6+
import json
57
import logging
6-
from types import ModuleType
8+
import sys
79
import typing as t
10+
from types import ModuleType
811

912
from plux import PluginFinder, PluginSpecResolver, PluginSpec
13+
from plux.core.entrypoint import discover_entry_points, EntryPointDict
14+
15+
if t.TYPE_CHECKING:
16+
from _typeshed import SupportsWrite
1017

1118
LOG = logging.getLogger(__name__)
1219

@@ -41,3 +48,74 @@ def find_plugins(self) -> t.List[PluginSpec]:
4148
pass
4249

4350
return plugins
51+
52+
53+
class PluginIndexBuilder:
54+
"""
55+
Builds an index file containing all discovered plugins. The index file can be written to stdout, or to a file.
56+
The writer supports two formats: json and ini.
57+
"""
58+
59+
def __init__(
60+
self,
61+
plugin_finder: PluginFinder,
62+
output_format: t.Literal["json", "ini"] = "json",
63+
):
64+
self.plugin_finder = plugin_finder
65+
self.output_format = output_format
66+
67+
def write(self, fp: "SupportsWrite[str]" = sys.stdout) -> EntryPointDict:
68+
"""
69+
Discover entry points using the configured ``PluginFinder``, and write the entry points into a file.
70+
71+
:param fp: The file-like object to write to.
72+
:return: The discovered entry points that were written into the file.
73+
"""
74+
ep = discover_entry_points(self.plugin_finder)
75+
76+
# sort entrypoints alphabetically in each group first
77+
for group in ep:
78+
ep[group].sort()
79+
80+
if self.output_format == "json":
81+
json.dump(ep, fp, sort_keys=True, indent=2)
82+
elif self.output_format == "ini":
83+
cfg = configparser.ConfigParser()
84+
cfg.read_dict(self.convert_to_nested_entry_point_dict(ep))
85+
cfg.write(fp)
86+
else:
87+
raise ValueError(f"unknown plugin index output format {self.output_format}")
88+
89+
return ep
90+
91+
@staticmethod
92+
def convert_to_nested_entry_point_dict(ep: EntryPointDict) -> dict[str, dict[str, str]]:
93+
"""
94+
Converts and ``EntryPointDict`` to a nested dict, where the keys are the section names and values are
95+
dictionaries. Each dictionary maps entry point names to their values. It also sorts the output alphabetically.
96+
97+
Example:
98+
Input EntryPointDict:
99+
{
100+
'console_scripts': ['app=module:main', 'tool=module:cli'],
101+
'plux.plugins': ['plugin1=pkg.module:Plugin1']
102+
}
103+
104+
Output nested dict:
105+
{
106+
'console_scripts': {
107+
'app': 'module:main',
108+
'tool': 'module:cli'
109+
},
110+
'plux.plugins': {
111+
'plugin1': 'pkg.module:Plugin1'
112+
}
113+
}
114+
"""
115+
result = {}
116+
for section_name in sorted(ep.keys()):
117+
result[section_name] = {}
118+
for entry_point in sorted(ep[section_name]):
119+
name, value = entry_point.split("=")
120+
result[section_name][name] = value
121+
return result

0 commit comments

Comments
 (0)