pyplugy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyplugy/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """pyPlugy — plugin framework with hooks (pyHooky) and tasks (pyWorkflowy)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyplugy._context import PluginContext, current_context
6
+ from pyplugy._manager import (
7
+ HOOK_PLUGIN_DISABLE,
8
+ HOOK_PLUGIN_ENABLE,
9
+ HOOK_PLUGIN_ERROR,
10
+ HOOK_PLUGIN_LOAD,
11
+ HOOK_PLUGIN_UNLOAD,
12
+ PluginManager,
13
+ current_manager,
14
+ )
15
+ from pyplugy._plugin import (
16
+ Plugin,
17
+ PluginInfo,
18
+ PluginManifest,
19
+ PluginState,
20
+ plugin,
21
+ )
22
+ from pyplugy.exceptions import (
23
+ PluginAlreadyLoadedError,
24
+ PluginDependencyError,
25
+ PluginError,
26
+ PluginLoadError,
27
+ PluginManifestError,
28
+ PluginNotFoundError,
29
+ PluginUnloadError,
30
+ )
31
+
32
+ __all__ = [
33
+ "HOOK_PLUGIN_DISABLE",
34
+ "HOOK_PLUGIN_ENABLE",
35
+ "HOOK_PLUGIN_ERROR",
36
+ "HOOK_PLUGIN_LOAD",
37
+ "HOOK_PLUGIN_UNLOAD",
38
+ "Plugin",
39
+ "PluginAlreadyLoadedError",
40
+ "PluginContext",
41
+ "PluginDependencyError",
42
+ "PluginError",
43
+ "PluginInfo",
44
+ "PluginLoadError",
45
+ "PluginManager",
46
+ "PluginManifest",
47
+ "PluginManifestError",
48
+ "PluginNotFoundError",
49
+ "PluginState",
50
+ "PluginUnloadError",
51
+ "current_context",
52
+ "current_manager",
53
+ "plugin",
54
+ ]
55
+
56
+ __version__ = "0.1.0"
pyplugy/_context.py ADDED
@@ -0,0 +1,239 @@
1
+ """The :class:`PluginContext` handed to a plugin's setup / lifecycle methods.
2
+
3
+ A context bundles everything a plugin needs to register hooks, schedule tasks,
4
+ read config, and talk to its manager. The manager always runs ``on_load``
5
+ inside a ``pyhooky.tag_scope(plugin_name)`` block, so *every* hook registered
6
+ through the context (or directly via ``pyhooky``'s module helpers) is
7
+ auto-tagged with the plugin's name. That auto-tagging is the central
8
+ mechanism enabling clean unload via ``HookRegistry.clear_tag``.
9
+
10
+ The task helpers on the context use the :mod:`pyplugy._tasky_protocol` so
11
+ plugins don't need pyWorkflowy installed at import time. When the manager has no
12
+ ``tasky`` configured, ``ctx.task`` raises a clear :class:`RuntimeError`
13
+ pointing at the ``pyplugy[tasks]`` extra.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from contextvars import ContextVar
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable, Mapping
24
+
25
+ from pyhooky import HookRegistry
26
+
27
+ from pyplugy._plugin import PluginManifest
28
+ from pyplugy._tasky_protocol import SchedulerProtocol, TaskyProtocol
29
+
30
+
31
+ __all__ = ["PluginContext", "current_context"]
32
+
33
+
34
+ _current_context: ContextVar[PluginContext | None] = ContextVar(
35
+ "pyplugy_current_context", default=None
36
+ )
37
+
38
+
39
+ def current_context() -> PluginContext | None:
40
+ """Return the :class:`PluginContext` for the plugin currently being loaded, if any.
41
+
42
+ Set by :class:`PluginManager` for the duration of an ``on_load`` /
43
+ ``on_unload`` / ``on_enable`` / ``on_disable`` call. Contextvar-based, so
44
+ it propagates across asyncio tasks and threads spawned during setup.
45
+ """
46
+ return _current_context.get()
47
+
48
+
49
+ class PluginContext:
50
+ """Per-plugin handle for hook registration, task wiring, config, and logging.
51
+
52
+ :class:`PluginContext` is constructed by :class:`PluginManager` and passed
53
+ to every lifecycle method. All hook-registration helpers (:meth:`before`,
54
+ :meth:`after`, :meth:`around`, :meth:`on`, :meth:`on_error`) are thin
55
+ passthroughs to the bound :class:`pyhooky.HookRegistry` — they target the
56
+ registry the manager owns by default, but a custom one may be passed at
57
+ construction.
58
+
59
+ Because the manager wraps every call to a plugin's lifecycle methods in
60
+ ``pyhooky.tag_scope(plugin.name)``, the ``tag=`` argument can be omitted —
61
+ every registration in the block picks up the plugin name automatically.
62
+ Helpers exposed here merely make that intent explicit and route through
63
+ the manager-owned registry.
64
+ """
65
+
66
+ __slots__ = (
67
+ "_config",
68
+ "_logger",
69
+ "_manifest",
70
+ "_registry",
71
+ "_scheduler",
72
+ "_tasks",
73
+ "_tasky",
74
+ )
75
+
76
+ def __init__(
77
+ self,
78
+ manifest: PluginManifest,
79
+ *,
80
+ registry: HookRegistry,
81
+ config: Mapping[str, Any] | None = None,
82
+ tasky: TaskyProtocol | None = None,
83
+ scheduler: SchedulerProtocol | None = None,
84
+ ) -> None:
85
+ self._manifest = manifest
86
+ self._registry = registry
87
+ self._config: dict[str, Any] = dict(config or {})
88
+ self._tasky = tasky
89
+ self._scheduler = scheduler
90
+ self._tasks: list[Any] = []
91
+ self._logger = logging.getLogger(f"pyplugy.{manifest.name}")
92
+
93
+ # ---------- exposed surface ----------
94
+
95
+ @property
96
+ def name(self) -> str:
97
+ return self._manifest.name
98
+
99
+ @property
100
+ def manifest(self) -> PluginManifest:
101
+ return self._manifest
102
+
103
+ @property
104
+ def hooks(self) -> HookRegistry:
105
+ """The :class:`pyhooky.HookRegistry` hooks land in by default."""
106
+ return self._registry
107
+
108
+ @property
109
+ def config(self) -> dict[str, Any]:
110
+ """Mutable per-plugin config dict — populated by the manager."""
111
+ return self._config
112
+
113
+ @property
114
+ def logger(self) -> logging.Logger:
115
+ """A :class:`logging.Logger` named ``pyplugy.<plugin-name>``."""
116
+ return self._logger
117
+
118
+ @property
119
+ def scheduler(self) -> SchedulerProtocol | None:
120
+ """The optional shared scheduler. ``None`` when none is configured."""
121
+ return self._scheduler
122
+
123
+ @property
124
+ def tasks(self) -> tuple[Any, ...]:
125
+ """Tasks registered via :meth:`task`, in registration order."""
126
+ return tuple(self._tasks)
127
+
128
+ # ---------- hook passthroughs ----------
129
+
130
+ def before(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
131
+ from pyhooky import before as _before
132
+
133
+ return _before(target, fn, registry=self._registry, **kwargs)
134
+
135
+ def after(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
136
+ from pyhooky import after as _after
137
+
138
+ return _after(target, fn, registry=self._registry, **kwargs)
139
+
140
+ def around(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
141
+ from pyhooky import around as _around
142
+
143
+ return _around(target, fn, registry=self._registry, **kwargs)
144
+
145
+ def on(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
146
+ from pyhooky import on as _on
147
+
148
+ return _on(target, fn, registry=self._registry, **kwargs)
149
+
150
+ def on_error(self, target: Any, fn: Any = None, **kwargs: Any) -> Any:
151
+ from pyhooky import on_error as _on_error
152
+
153
+ return _on_error(target, fn, registry=self._registry, **kwargs)
154
+
155
+ def hook(self, target: Any = None, fn: Any = None, **kwargs: Any) -> Any:
156
+ """Wrap a callable so plugin hooks fire around it — passthrough to :func:`pyhooky.hook`.
157
+
158
+ Supports the same call shapes as :func:`pyhooky.hook`: bare callable,
159
+ bare invocation, explicit string target, or direct ``(name, fn)`` pair.
160
+ """
161
+ from pyhooky import hook as _hook
162
+
163
+ # Bare callable form — `@ctx.hook` directly on a function. The bare
164
+ # overload of `pyhooky.hook` doesn't accept registry=, so wrap it.
165
+ if callable(target) and not isinstance(target, str) and fn is None and not kwargs:
166
+ wrapped = self._registry.wrap(getattr(target, "__name__", None) or repr(target), target)
167
+ return wrapped
168
+ if target is None and fn is None:
169
+ return _hook(registry=self._registry, **kwargs)
170
+ if isinstance(target, str) and fn is None:
171
+ return _hook(target, registry=self._registry, **kwargs)
172
+ return _hook(target, fn, registry=self._registry, **kwargs)
173
+
174
+ # ---------- task passthroughs ----------
175
+
176
+ def task(self, fn: Callable[..., Any] | None = None, **kwargs: Any) -> Any:
177
+ """Register a task with the plugin's task scope.
178
+
179
+ Requires a :mod:`pyplugy._tasky_protocol`-compatible object on the
180
+ manager (real pyWorkflowy or a test stub). Without one, raises a
181
+ :class:`RuntimeError` pointing at the ``pyplugy[tasks]`` extra.
182
+
183
+ Both bare (``@ctx.task``) and parameterised (``@ctx.task(retries=3)``)
184
+ decorator forms work — same call shape as ``@pyworkflowy.task``.
185
+ """
186
+ if self._tasky is None:
187
+ raise RuntimeError(
188
+ f"plugin {self._manifest.name!r} called ctx.task but no task backend is "
189
+ "configured on the manager. Install pyWorkflowy (`pip install pyplugy[tasks]`) "
190
+ "or pass tasky= to PluginManager(...)."
191
+ )
192
+
193
+ decorator = self._tasky.task
194
+
195
+ def _record(task_obj: Any) -> Any:
196
+ self._tasks.append(task_obj)
197
+ return task_obj
198
+
199
+ if fn is None:
200
+ # Parameterised — `@ctx.task(retries=3)`
201
+ inner = decorator(**kwargs)
202
+
203
+ def wrap(real_fn: Callable[..., Any]) -> Any:
204
+ return _record(inner(real_fn))
205
+
206
+ return wrap
207
+
208
+ if callable(fn) and not kwargs:
209
+ # Bare — `@ctx.task` directly on the function.
210
+ return _record(decorator(fn))
211
+
212
+ # Mixed form: `ctx.task(fn, retries=3)` — uncommon but legal.
213
+ return _record(decorator(fn, **kwargs))
214
+
215
+ # ---------- introspection ----------
216
+
217
+ def __repr__(self) -> str:
218
+ return (
219
+ f"PluginContext(name={self._manifest.name!r}, "
220
+ f"registry={self._registry.name!r}, tasks={len(self._tasks)})"
221
+ )
222
+
223
+
224
+ def _activate(ctx: PluginContext) -> Any:
225
+ """Internal helper used by :class:`PluginManager` to publish ``ctx`` via contextvar.
226
+
227
+ Returns a contextmanager that restores the previous context on exit.
228
+ """
229
+ from contextlib import contextmanager
230
+
231
+ @contextmanager
232
+ def _scope() -> Any:
233
+ token = _current_context.set(ctx)
234
+ try:
235
+ yield ctx
236
+ finally:
237
+ _current_context.reset(token)
238
+
239
+ return _scope()
pyplugy/_depgraph.py ADDED
@@ -0,0 +1,135 @@
1
+ """Plugin dependency resolution.
2
+
3
+ Each plugin's :attr:`PluginManifest.requires` entry is a string like
4
+ ``"other>=2.0"`` — a PEP 440 specifier prefixed with the dependency's name.
5
+ The leading identifier is the dependency name; anything that follows the name
6
+ is a :class:`packaging.specifiers.SpecifierSet` constraint. A bare name
7
+ (``"other"``) means "any version".
8
+
9
+ :func:`resolve_load_order` returns a list of plugins ordered so that every
10
+ plugin appears after the ones it depends on. Missing dependencies, version
11
+ mismatches, and cycles all raise :class:`PluginDependencyError`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import TYPE_CHECKING
18
+
19
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
20
+ from packaging.version import InvalidVersion, Version
21
+
22
+ from pyplugy.exceptions import PluginDependencyError
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Iterable
26
+
27
+ from pyplugy._plugin import Plugin
28
+
29
+
30
+ __all__ = ["parse_requirement", "resolve_load_order"]
31
+
32
+
33
+ # Plugin names are loose — we just split on the first specifier operator.
34
+ _REQ_SPLIT_RE = re.compile(r"(===|==|!=|<=|>=|~=|<|>)")
35
+
36
+
37
+ def parse_requirement(req: str) -> tuple[str, SpecifierSet]:
38
+ """Split ``"other>=2.0"`` into ``("other", SpecifierSet(">=2.0"))``.
39
+
40
+ A bare name returns an empty :class:`SpecifierSet` (matches any version).
41
+ Raises :class:`PluginDependencyError` if the specifier portion is malformed.
42
+ """
43
+ if not req or not isinstance(req, str):
44
+ raise PluginDependencyError(f"requirement must be a non-empty string, got {req!r}")
45
+ match = _REQ_SPLIT_RE.search(req)
46
+ if match is None:
47
+ return req.strip(), SpecifierSet()
48
+ name = req[: match.start()].strip()
49
+ spec_str = req[match.start() :].strip()
50
+ if not name:
51
+ raise PluginDependencyError(f"requirement {req!r} has no plugin name before the specifier")
52
+ try:
53
+ spec = SpecifierSet(spec_str)
54
+ except InvalidSpecifier as exc:
55
+ raise PluginDependencyError(
56
+ f"requirement {req!r} has an invalid version specifier {spec_str!r}: {exc}"
57
+ ) from exc
58
+ return name, spec
59
+
60
+
61
+ def _parse_version(name: str, version_str: str) -> Version | None:
62
+ if not version_str:
63
+ return None
64
+ try:
65
+ return Version(version_str)
66
+ except InvalidVersion:
67
+ # Non-PEP440 plugin versions silently disable specifier checks for that
68
+ # plugin — better than refusing to load. We still warn-via-error if a
69
+ # *consumer* requires a specifier on a non-PEP440 dep.
70
+ return None
71
+
72
+
73
+ def resolve_load_order(plugins: Iterable[Plugin]) -> list[Plugin]:
74
+ """Topologically sort ``plugins`` honoring ``manifest.requires``.
75
+
76
+ - Raises :class:`PluginDependencyError` when a required plugin is missing.
77
+ - Raises :class:`PluginDependencyError` when the available version doesn't
78
+ satisfy the specifier.
79
+ - Raises :class:`PluginDependencyError` on cycles, naming all plugins in
80
+ the cycle for easy debugging.
81
+ """
82
+ plugins_list = list(plugins)
83
+ by_name: dict[str, Plugin] = {}
84
+ for p in plugins_list:
85
+ name = p.manifest.name
86
+ if name in by_name:
87
+ raise PluginDependencyError(f"duplicate plugin name {name!r} in resolve_load_order")
88
+ by_name[name] = p
89
+
90
+ # Build adjacency: dep -> dependents (so we can Kahn-sort).
91
+ incoming: dict[str, set[str]] = {n: set() for n in by_name}
92
+ outgoing: dict[str, set[str]] = {n: set() for n in by_name}
93
+
94
+ for plugin_obj in plugins_list:
95
+ consumer = plugin_obj.manifest.name
96
+ for req in plugin_obj.manifest.requires:
97
+ dep_name, spec = parse_requirement(req)
98
+ if dep_name not in by_name:
99
+ raise PluginDependencyError(
100
+ f"plugin {consumer!r} requires {dep_name!r} which is not loaded "
101
+ f"(requirement {req!r})"
102
+ )
103
+ dep_plugin = by_name[dep_name]
104
+ if spec:
105
+ parsed = _parse_version(dep_name, dep_plugin.manifest.version)
106
+ if parsed is None:
107
+ raise PluginDependencyError(
108
+ f"plugin {consumer!r} requires {req!r}, but {dep_name!r} reports "
109
+ f"non-PEP440 version {dep_plugin.manifest.version!r}; cannot check"
110
+ )
111
+ if parsed not in spec:
112
+ raise PluginDependencyError(
113
+ f"plugin {consumer!r} requires {req!r}, but {dep_name!r} version "
114
+ f"{dep_plugin.manifest.version!r} does not satisfy the specifier"
115
+ )
116
+ incoming[consumer].add(dep_name)
117
+ outgoing[dep_name].add(consumer)
118
+
119
+ # Kahn's algorithm — process plugins whose deps are already emitted.
120
+ ready = sorted(n for n, deps in incoming.items() if not deps)
121
+ ordered: list[Plugin] = []
122
+ while ready:
123
+ name = ready.pop(0)
124
+ ordered.append(by_name[name])
125
+ for dependent in sorted(outgoing[name]):
126
+ incoming[dependent].discard(name)
127
+ if not incoming[dependent]:
128
+ ready.append(dependent)
129
+ # Keep `ready` sorted for deterministic output.
130
+ ready.sort()
131
+
132
+ if len(ordered) != len(plugins_list):
133
+ cycle = sorted(n for n, deps in incoming.items() if deps)
134
+ raise PluginDependencyError(f"cyclic plugin dependency involving: {', '.join(cycle)}")
135
+ return ordered
pyplugy/_discovery.py ADDED
@@ -0,0 +1,178 @@
1
+ """Plugin discovery — entry points, directory scan, JIT loading.
2
+
3
+ Three discovery modes feed plugins into the manager:
4
+
5
+ - **Entry points**: ``manager.load_entry_points(group="myapp.plugins")``
6
+ resolves each entry point and scans the loaded module for plugins.
7
+ - **Directory scanning**: ``manager.load_directory(path, pattern="*.py")``
8
+ imports every match through :func:`importlib.util.spec_from_file_location`
9
+ under a sandboxed module name (``pyplugy._loaded.<stem>``) so we don't
10
+ pollute the global :data:`sys.modules` namespace.
11
+ - **JIT (just-in-time)**: ``manager.load(target)`` accepts a class, instance,
12
+ module, dotted path, or filesystem path and figures out what to do.
13
+
14
+ All three modes converge on :func:`scan_module` — given a module object, it
15
+ returns every :class:`Plugin` instance or class found in it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib
21
+ import importlib.metadata
22
+ import importlib.util
23
+ import inspect
24
+ import sys
25
+ from pathlib import Path
26
+ from types import ModuleType
27
+ from typing import TYPE_CHECKING
28
+
29
+ from pyplugy._plugin import Plugin
30
+ from pyplugy.exceptions import PluginLoadError
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Iterable, Iterator
34
+
35
+
36
+ __all__ = [
37
+ "import_file",
38
+ "iter_directory_modules",
39
+ "iter_entry_points",
40
+ "scan_module",
41
+ ]
42
+
43
+
44
+ _SANDBOX_PREFIX = "pyplugy._loaded"
45
+
46
+
47
+ def scan_module(module: ModuleType) -> list[Plugin]:
48
+ """Return every plugin defined in ``module``.
49
+
50
+ Recognises three shapes:
51
+
52
+ - Module-level :class:`Plugin` instances (e.g. produced by the
53
+ :func:`plugin` decorator).
54
+ - :class:`Plugin` subclasses defined in the module (instantiated here with
55
+ no arguments).
56
+ - A ``setup`` callable wrapped by the :func:`plugin` decorator (already a
57
+ :class:`Plugin` instance — covered by the first case).
58
+
59
+ Items imported *from* other modules are skipped: only definitions whose
60
+ ``__module__`` matches ``module.__name__`` are considered. That keeps
61
+ re-imports from accidentally double-registering a plugin discovered via
62
+ another path.
63
+ """
64
+ found: list[Plugin] = []
65
+ seen_ids: set[int] = set()
66
+ seen_names: set[str] = set()
67
+
68
+ for attr_name in dir(module):
69
+ if attr_name.startswith("_"):
70
+ continue
71
+ obj = getattr(module, attr_name, None)
72
+
73
+ # 1) Plugin instances (decorator form)
74
+ if isinstance(obj, Plugin):
75
+ if id(obj) in seen_ids:
76
+ continue
77
+ seen_ids.add(id(obj))
78
+ name = obj.manifest.name or attr_name
79
+ if name in seen_names:
80
+ continue
81
+ seen_names.add(name)
82
+ found.append(obj)
83
+ continue
84
+
85
+ # 2) Plugin subclasses (class form) — only those defined in this module
86
+ if (
87
+ inspect.isclass(obj)
88
+ and issubclass(obj, Plugin)
89
+ and obj is not Plugin
90
+ and getattr(obj, "__module__", None) == module.__name__
91
+ ):
92
+ try:
93
+ instance = obj()
94
+ except Exception as exc: # pragma: no cover - defensive
95
+ raise PluginLoadError(
96
+ f"failed to instantiate plugin class {obj.__name__!r} from "
97
+ f"module {module.__name__!r}: {exc}"
98
+ ) from exc
99
+ if instance.manifest.name in seen_names:
100
+ continue
101
+ seen_names.add(instance.manifest.name)
102
+ seen_ids.add(id(instance))
103
+ found.append(instance)
104
+
105
+ return found
106
+
107
+
108
+ def iter_entry_points(group: str) -> Iterator[tuple[str, ModuleType]]:
109
+ """Yield ``(entry_point_name, module)`` for every entry point in ``group``.
110
+
111
+ Each entry point is resolved with :func:`importlib.metadata.entry_points`.
112
+ Targets that don't resolve to a module are imported via dotted-path
113
+ semantics so plugins published as ``"my.module:setup"`` still work
114
+ (the manager scans the parent module).
115
+ """
116
+ eps = importlib.metadata.entry_points(group=group)
117
+ for ep in eps:
118
+ loaded = ep.load()
119
+ if isinstance(loaded, ModuleType):
120
+ yield ep.name, loaded
121
+ else:
122
+ # ``ep.load()`` may return a class/function — use its parent module.
123
+ module_name = getattr(loaded, "__module__", None) or ep.value.split(":")[0]
124
+ module = importlib.import_module(module_name)
125
+ yield ep.name, module
126
+
127
+
128
+ def _sandbox_name(stem: str, *, counter: dict[str, int]) -> str:
129
+ """Pick a unique sandboxed name like ``pyplugy._loaded.foo`` (or ``foo_1`` on collision)."""
130
+ base = f"{_SANDBOX_PREFIX}.{stem}"
131
+ n = counter.get(base, 0)
132
+ counter[base] = n + 1
133
+ return base if n == 0 else f"{base}_{n}"
134
+
135
+
136
+ def import_file(path: Path, *, sandbox_counter: dict[str, int] | None = None) -> ModuleType:
137
+ """Import a ``.py`` file under a sandboxed module name.
138
+
139
+ Avoids inserting the loaded module under its bare stem in
140
+ :data:`sys.modules` — that would shadow real top-level packages.
141
+ """
142
+ if sandbox_counter is None:
143
+ sandbox_counter = {}
144
+ module_name = _sandbox_name(path.stem, counter=sandbox_counter)
145
+ spec = importlib.util.spec_from_file_location(module_name, path)
146
+ if spec is None or spec.loader is None:
147
+ raise PluginLoadError(f"cannot build module spec for {path!r}")
148
+ module = importlib.util.module_from_spec(spec)
149
+ # Register before exec so intra-module relative imports resolve.
150
+ sys.modules[module_name] = module
151
+ try:
152
+ spec.loader.exec_module(module)
153
+ except Exception:
154
+ sys.modules.pop(module_name, None)
155
+ raise
156
+ return module
157
+
158
+
159
+ def iter_directory_modules(
160
+ path: Path,
161
+ *,
162
+ recursive: bool = False,
163
+ pattern: str = "*.py",
164
+ ) -> Iterable[ModuleType]:
165
+ """Yield modules imported from every file matching ``pattern`` under ``path``."""
166
+ if not path.exists():
167
+ raise PluginLoadError(f"plugin directory {path!r} does not exist")
168
+ if not path.is_dir():
169
+ raise PluginLoadError(f"plugin path {path!r} is not a directory")
170
+
171
+ sandbox_counter: dict[str, int] = {}
172
+ iterator = path.rglob(pattern) if recursive else path.glob(pattern)
173
+ for file in sorted(iterator):
174
+ if not file.is_file():
175
+ continue
176
+ if file.name == "__init__.py":
177
+ continue
178
+ yield import_file(file, sandbox_counter=sandbox_counter)