|
1 | 1 | """ |
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. |
3 | 3 | """ |
4 | 4 |
|
5 | | -import configparser |
| 5 | +import importlib |
6 | 6 | import inspect |
7 | | -import json |
8 | 7 | import logging |
9 | | -import sys |
10 | 8 | import typing as t |
| 9 | +from fnmatch import fnmatchcase |
11 | 10 | from types import ModuleType |
| 11 | +import os |
| 12 | +import pkgutil |
12 | 13 |
|
13 | 14 | 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 |
18 | 15 |
|
19 | 16 | LOG = logging.getLogger(__name__) |
20 | 17 |
|
21 | 18 |
|
| 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 | + |
22 | 108 | class ModuleScanningPluginFinder(PluginFinder): |
23 | 109 | """ |
24 | 110 | 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]: |
51 | 137 | return plugins |
52 | 138 |
|
53 | 139 |
|
54 | | -class PluginIndexBuilder: |
| 140 | +class Filter: |
55 | 141 | """ |
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` |
58 | 145 | """ |
59 | 146 |
|
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 |
67 | 149 |
|
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) |
71 | 152 |
|
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) |
76 | 153 |
|
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 | + """ |
89 | 158 |
|
90 | | - return ep |
| 159 | + def __init__(self): |
| 160 | + super().__init__([]) |
91 | 161 |
|
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 |
0 commit comments