Skip to content

Commit 8fa45ab

Browse files
authored
decouple cli from setuptools to allow new build backend integrations (#34)
1 parent 401297c commit 8fa45ab

File tree

17 files changed

+432
-218
lines changed

17 files changed

+432
-218
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ with the `@plugin` decorator, you can expose functions as plugins. They will be
200200
into `FunctionPlugin` instances, which satisfy both the contract of a Plugin, and that of the function.
201201

202202
```python
203-
from plugin import plugin
203+
from plux import plugin
204204

205205
@plugin(namespace="localstack.configurators")
206206
def configure_logging(runtime):

plux/build/discovery.py

Lines changed: 110 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,110 @@
11
"""
2-
Buildtool independent utils to discover plugins from the codebase, and write index files.
2+
Buildtool independent utils to discover plugins from the project's source code.
33
"""
44

5-
import configparser
5+
import importlib
66
import inspect
7-
import json
87
import logging
9-
import sys
108
import typing as t
9+
from fnmatch import fnmatchcase
1110
from types import ModuleType
11+
import os
12+
import pkgutil
1213

1314
from plux import PluginFinder, PluginSpecResolver, PluginSpec
14-
from plux.core.entrypoint import discover_entry_points, EntryPointDict
15-
16-
if t.TYPE_CHECKING:
17-
from _typeshed import SupportsWrite
1815

1916
LOG = logging.getLogger(__name__)
2017

2118

19+
class PackageFinder:
20+
"""
21+
Generate a list of Python packages. How these are generated depends on the implementation.
22+
23+
Why this abstraction? The naive way to find packages is to list all directories with an ``__init__.py`` file.
24+
However, this approach does not work for distributions that have namespace packages. How do we know whether
25+
something is a namespace package or just a directory? Typically, the build configuration will tell us. For example,
26+
setuptools has the following directives for ``pyproject.toml``::
27+
28+
[tool.setuptools]
29+
packages = ["mypkg", "mypkg.subpkg1", "mypkg.subpkg2"]
30+
31+
Or in hatch::
32+
33+
[tool.hatch.build.targets.wheel]
34+
packages = ["src/foo"]
35+
36+
So this abstraction allows us to use the build tool internals to generate a list of packages that we should be
37+
scanning for plugins.
38+
"""
39+
40+
def find_packages(self) -> t.Iterable[str]:
41+
"""
42+
Returns an Iterable of Python packages. Each item is a string-representation of a Python package (for example,
43+
``plux.core``, ``myproject.mypackage.utils``, ...)
44+
45+
:return: An Iterable of Packages
46+
"""
47+
raise NotImplementedError
48+
49+
@property
50+
def path(self) -> str:
51+
"""
52+
The root file path under which the packages are located.
53+
54+
:return: A file path
55+
"""
56+
raise NotImplementedError
57+
58+
59+
class PluginFromPackageFinder(PluginFinder):
60+
"""
61+
Finds Plugins from packages that are resolved by the given ``PackageFinder``. Under the hood this uses a
62+
``ModuleScanningPluginFinder``, which, for each package returned by the ``PackageFinder``, imports the package using
63+
``importlib``, and scans the module for plugins.
64+
"""
65+
66+
finder: PackageFinder
67+
68+
def __init__(self, finder: PackageFinder):
69+
self.finder = finder
70+
71+
def find_plugins(self) -> list[PluginSpec]:
72+
collector = ModuleScanningPluginFinder(self._load_modules())
73+
return collector.find_plugins()
74+
75+
def _load_modules(self) -> t.Generator[ModuleType, None, None]:
76+
"""
77+
Generator to load all imported modules that are part of the packages returned by the ``PackageFinder``.
78+
79+
:return: A generator of python modules
80+
"""
81+
for module_name in self._list_module_names():
82+
try:
83+
yield importlib.import_module(module_name)
84+
except Exception as e:
85+
LOG.error("error importing module %s: %s", module_name, e)
86+
87+
def _list_module_names(self) -> set[str]:
88+
"""
89+
This method creates a set of module names by iterating over the packages detected by the ``PackageFinder``. It
90+
includes top-level packages, as well as submodules found within those packages.
91+
92+
:return: A set of strings where each string represents a module name.
93+
"""
94+
# adapted from https://stackoverflow.com/a/54323162/804840
95+
96+
modules = set()
97+
98+
for pkg in self.finder.find_packages():
99+
modules.add(pkg)
100+
pkgpath = self.finder.path.rstrip(os.sep) + os.sep + pkg.replace(".", os.sep)
101+
for info in pkgutil.iter_modules([pkgpath]):
102+
if not info.ispkg:
103+
modules.add(pkg + "." + info.name)
104+
105+
return modules
106+
107+
22108
class ModuleScanningPluginFinder(PluginFinder):
23109
"""
24110
A PluginFinder that scans the members of given modules for available PluginSpecs. Each member is evaluated with a
@@ -51,72 +137,27 @@ def find_plugins(self) -> list[PluginSpec]:
51137
return plugins
52138

53139

54-
class PluginIndexBuilder:
140+
class Filter:
55141
"""
56-
Builds an index file containing all discovered plugins. The index file can be written to stdout, or to a file.
57-
The writer supports two formats: json and ini.
142+
Given a list of patterns, create a callable that will be true only if
143+
the input matches at least one of the patterns.
144+
This is from `setuptools.discovery._Filter`
58145
"""
59146

60-
def __init__(
61-
self,
62-
plugin_finder: PluginFinder,
63-
output_format: t.Literal["json", "ini"] = "json",
64-
):
65-
self.plugin_finder = plugin_finder
66-
self.output_format = output_format
147+
def __init__(self, patterns: t.Iterable[str]):
148+
self._patterns = patterns
67149

68-
def write(self, fp: "SupportsWrite[str]" = sys.stdout) -> EntryPointDict:
69-
"""
70-
Discover entry points using the configured ``PluginFinder``, and write the entry points into a file.
150+
def __call__(self, item: str):
151+
return any(fnmatchcase(item, pat) for pat in self._patterns)
71152

72-
:param fp: The file-like object to write to.
73-
:return: The discovered entry points that were written into the file.
74-
"""
75-
ep = discover_entry_points(self.plugin_finder)
76153

77-
# sort entrypoints alphabetically in each group first
78-
for group in ep:
79-
ep[group].sort()
80-
81-
if self.output_format == "json":
82-
json.dump(ep, fp, sort_keys=True, indent=2)
83-
elif self.output_format == "ini":
84-
cfg = configparser.ConfigParser()
85-
cfg.read_dict(self.convert_to_nested_entry_point_dict(ep))
86-
cfg.write(fp)
87-
else:
88-
raise ValueError(f"unknown plugin index output format {self.output_format}")
154+
class MatchAllFilter(Filter):
155+
"""
156+
Filter that is equivalent to ``_Filter(["*"])``.
157+
"""
89158

90-
return ep
159+
def __init__(self):
160+
super().__init__([])
91161

92-
@staticmethod
93-
def convert_to_nested_entry_point_dict(ep: EntryPointDict) -> dict[str, dict[str, str]]:
94-
"""
95-
Converts and ``EntryPointDict`` to a nested dict, where the keys are the section names and values are
96-
dictionaries. Each dictionary maps entry point names to their values. It also sorts the output alphabetically.
97-
98-
Example:
99-
Input EntryPointDict:
100-
{
101-
'console_scripts': ['app=module:main', 'tool=module:cli'],
102-
'plux.plugins': ['plugin1=pkg.module:Plugin1']
103-
}
104-
105-
Output nested dict:
106-
{
107-
'console_scripts': {
108-
'app': 'module:main',
109-
'tool': 'module:cli'
110-
},
111-
'plux.plugins': {
112-
'plugin1': 'pkg.module:Plugin1'
113-
}
114-
}
115-
"""
116-
result = {}
117-
for section_name in sorted(ep.keys()):
118-
result[section_name] = {}
119-
for entry_point in sorted(ep[section_name]):
120-
name, value = entry_point.split("=")
121-
result[section_name][name] = value
122-
return result
162+
def __call__(self, item: str):
163+
return True

plux/build/hatchling.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from plux.build.project import Project
2+
3+
4+
class HatchlingProject(Project):
5+
# TODO: implement me
6+
pass

plux/build/index.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Code to manage the plux plugin index file. The index file contains all discovered plugins, which later is used to
3+
generate entry points.
4+
"""
5+
6+
import configparser
7+
import json
8+
import sys
9+
import typing as t
10+
11+
from plux.core.plugin import PluginFinder
12+
from plux.core.entrypoint import EntryPointDict, discover_entry_points
13+
14+
if t.TYPE_CHECKING:
15+
from _typeshed import SupportsWrite
16+
17+
18+
class PluginIndexBuilder:
19+
"""
20+
Builds an index file containing all discovered plugins. The index file can be written to stdout, or to a file.
21+
The writer supports two formats: json and ini.
22+
"""
23+
24+
def __init__(
25+
self,
26+
plugin_finder: PluginFinder,
27+
):
28+
self.plugin_finder = plugin_finder
29+
30+
def write(
31+
self,
32+
fp: "SupportsWrite[str]" = sys.stdout,
33+
output_format: t.Literal["json", "ini"] = "json",
34+
) -> EntryPointDict:
35+
"""
36+
Discover entry points using the configured ``PluginFinder``, and write the entry points into a file.
37+
38+
:param fp: The file-like object to write to.
39+
:param output_format: The format to write the entry points in. Can be either "json" or "ini".
40+
:return: The discovered entry points that were written into the file.
41+
"""
42+
ep = discover_entry_points(self.plugin_finder)
43+
44+
# sort entrypoints alphabetically in each group first
45+
for group in ep:
46+
ep[group].sort()
47+
48+
if output_format == "json":
49+
json.dump(ep, fp, sort_keys=True, indent=2)
50+
elif output_format == "ini":
51+
cfg = configparser.ConfigParser()
52+
cfg.read_dict(self.convert_to_nested_entry_point_dict(ep))
53+
cfg.write(fp)
54+
else:
55+
raise ValueError(f"unknown plugin index output format {output_format}")
56+
57+
return ep
58+
59+
@staticmethod
60+
def convert_to_nested_entry_point_dict(ep: EntryPointDict) -> dict[str, dict[str, str]]:
61+
"""
62+
Converts and ``EntryPointDict`` to a nested dict, where the keys are the section names and values are
63+
dictionaries. Each dictionary maps entry point names to their values. It also sorts the output alphabetically.
64+
65+
Example:
66+
Input EntryPointDict:
67+
{
68+
'console_scripts': ['app=module:main', 'tool=module:cli'],
69+
'plux.plugins': ['plugin1=pkg.module:Plugin1']
70+
}
71+
72+
Output nested dict:
73+
{
74+
'console_scripts': {
75+
'app': 'module:main',
76+
'tool': 'module:cli'
77+
},
78+
'plux.plugins': {
79+
'plugin1': 'pkg.module:Plugin1'
80+
}
81+
}
82+
"""
83+
result = {}
84+
for section_name in sorted(ep.keys()):
85+
result[section_name] = {}
86+
for entry_point in sorted(ep[section_name]):
87+
name, value = entry_point.split("=")
88+
result[section_name][name] = value
89+
return result

plux/build/project.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import os
2+
from pathlib import Path
3+
4+
from plux.build.config import PluxConfiguration, read_plux_config_from_workdir
5+
from plux.build.discovery import PackageFinder, PluginFromPackageFinder
6+
from plux.build.index import PluginIndexBuilder
7+
8+
9+
class Project:
10+
"""
11+
Abstraction for a python project to hide the details of a build tool from the CLI. A Project provides access to
12+
the project's configuration, package discovery, and the entrypoint build mechanism.
13+
"""
14+
15+
workdir: Path
16+
config: PluxConfiguration
17+
18+
def __init__(self, workdir: str = None):
19+
self.workdir = Path(workdir or os.curdir)
20+
self.config = self.read_static_plux_config()
21+
22+
def find_entry_point_file(self) -> Path:
23+
"""
24+
Finds the entry_point.txt file of the current project. In case of setuptools, this may be in the
25+
``<package>.egg-info`` directory, in case of hatch, where ``pip install -e .`` has become the standard, the
26+
entrypoints file lives in the ``.dist-info`` directory of the venv.
27+
28+
:return: A path pointing to the entrypoints file. The file might not exist.
29+
"""
30+
raise NotImplementedError
31+
32+
def find_plux_index_file(self) -> Path:
33+
"""
34+
Returns the plux index file location. This may depend on the build tool, for similar reasons described in
35+
``find_entry_point_file``. For example, in setuptools, the plux index file by default is in
36+
``.egg-info/plux.json``.
37+
38+
:return: A path pointing to the plux index file.
39+
"""
40+
raise NotImplementedError
41+
42+
def create_package_finder(self) -> PackageFinder:
43+
"""
44+
Returns a build tool-specific PackageFinder instance that can be used to discover packages to scan for plugins.
45+
46+
:return: A PackageFinder instance
47+
"""
48+
raise NotImplementedError
49+
50+
def build_entrypoints(self):
51+
"""
52+
Routine to build the entrypoints file using ``EntryPointBuildMode.BUILD_HOOK``. This is called by the CLI
53+
frontend. It's build tool-specific since we need to hook into the build process that generates the
54+
``entry_points.txt``.
55+
"""
56+
raise NotImplementedError
57+
58+
def create_plugin_index_builder(self) -> PluginIndexBuilder:
59+
"""
60+
Returns a PluginIndexBuilder instance that can be used to build the plugin index.
61+
62+
The default implementation creates a PluginFromPackageFinder instance using ``create_package_finder``.
63+
64+
:return: A PluginIndexBuilder instance.
65+
"""
66+
plugin_finder = PluginFromPackageFinder(self.create_package_finder())
67+
return PluginIndexBuilder(plugin_finder)
68+
69+
def read_static_plux_config(self) -> PluxConfiguration:
70+
"""
71+
Reads the static configuration (``pyproject.toml``) from the Project's working directory using
72+
``read_read_plux_config_from_workdir``.
73+
74+
:return: A PluxConfiguration object
75+
"""
76+
return read_plux_config_from_workdir(str(self.workdir))

0 commit comments

Comments
 (0)