ps-plugin-core 0.2.8__tar.gz

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.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: ps-plugin-core
3
+ Version: 0.2.8
4
+ Summary:
5
+ Requires-Python: >=3.10,<3.14
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.10
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "ps-plugin-core"
3
+ description = ""
4
+ requires-python = ">=3.10,<3.14"
5
+ version = "0.2.8"
6
+
7
+ [project.entry-points."poetry.application.plugin"]
8
+ entry = "ps.plugin.core._plugin:Plugin"
9
+
10
+ [tool.poetry.group.ps.dependencies]
11
+ ps-di = ">=0.2.8,<0.3.0"
12
+ ps-plugin-sdk = ">=0.2.8,<0.3.0"
13
+
14
+ [tool.poetry]
15
+ packages = [ { include = "ps/plugin/core", from = "src" } ]
16
+
17
+ [tool.ps-plugin]
18
+ host-project = ".."
19
+
20
+ [build-system]
21
+ requires = ["poetry-core>=1.0.0"]
22
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,5 @@
1
+ from ._plugin import Plugin
2
+
3
+ __all__ = [
4
+ "Plugin",
5
+ ]
@@ -0,0 +1,293 @@
1
+ import inspect
2
+ import re
3
+ import traceback
4
+ from dataclasses import dataclass, field
5
+ from importlib import metadata
6
+ from typing import Any, Callable, Optional
7
+
8
+ from cleo.io.io import IO
9
+
10
+ from ps.di import DI
11
+ from ps.plugin.sdk.logging import log_debug, log_verbose
12
+ from ps.plugin.sdk.settings import PluginSettings
13
+
14
+ _HANDLER_PATTERN = re.compile(r"^poetry_(activate|command|error|terminate|signal)(_\w+)?$")
15
+ _EVENT_TYPES = ("activate", "command", "error", "terminate", "signal")
16
+
17
+
18
+ @dataclass
19
+ class _ModuleInfo:
20
+ name: str
21
+ handlers: dict[str, Callable] = field(default_factory=dict)
22
+ distribution: Optional[str] = None
23
+ instance: Optional[object] = None
24
+ path: Optional[str] = None
25
+
26
+
27
+ def _get_module_path(obj: Any) -> Optional[str]:
28
+ try:
29
+ return inspect.getfile(obj)
30
+ except (TypeError, OSError):
31
+ return None
32
+
33
+
34
+ def _get_distribution(entry_point: metadata.EntryPoint) -> Optional[str]:
35
+ try:
36
+ return entry_point.dist.name if entry_point.dist else None
37
+ except Exception:
38
+ return None
39
+
40
+
41
+ def _get_class_name(cls: type) -> str:
42
+ name_attr = getattr(cls, "name", None)
43
+ return name_attr if isinstance(name_attr, str) else cls.__name__
44
+
45
+
46
+ def _is_static_or_classmethod(cls: type, name: str) -> bool:
47
+ for klass in cls.__mro__:
48
+ if name in klass.__dict__:
49
+ val = klass.__dict__[name]
50
+ return isinstance(val, (staticmethod, classmethod))
51
+ return False
52
+
53
+
54
+ def _scan_class(cls: type, distribution: Optional[str]) -> list[_ModuleInfo]:
55
+ instance_handlers: dict[str, Callable] = {}
56
+ static_handlers: dict[str, tuple[str, Callable]] = {}
57
+
58
+ for name in dir(cls):
59
+ match = _HANDLER_PATTERN.match(name)
60
+ if not match:
61
+ continue
62
+ event_type = match.group(1)
63
+ suffix = (match.group(2) or "")[1:] # strip leading _
64
+
65
+ if _is_static_or_classmethod(cls, name):
66
+ if not suffix:
67
+ continue # static/class methods MUST have suffix
68
+ fn = getattr(cls, name)
69
+ static_handlers[name] = (suffix, fn)
70
+ else:
71
+ instance_handlers[event_type] = getattr(cls, name)
72
+
73
+ modules: list[_ModuleInfo] = []
74
+
75
+ cls_path = _get_module_path(cls)
76
+
77
+ if instance_handlers:
78
+ modules.append(_ModuleInfo(
79
+ name=_get_class_name(cls),
80
+ handlers=instance_handlers,
81
+ distribution=distribution,
82
+ path=cls_path,
83
+ ))
84
+
85
+ # Group static methods by suffix
86
+ suffix_groups: dict[str, dict[str, Callable]] = {}
87
+ for name, (suffix, fn) in static_handlers.items():
88
+ match = _HANDLER_PATTERN.match(name)
89
+ if match:
90
+ event_type = match.group(1)
91
+ suffix_groups.setdefault(suffix, {})[event_type] = fn
92
+
93
+ for suffix, handlers in suffix_groups.items():
94
+ modules.append(_ModuleInfo(
95
+ name=suffix,
96
+ handlers=handlers,
97
+ distribution=distribution,
98
+ path=cls_path,
99
+ ))
100
+
101
+ return modules
102
+
103
+
104
+ def _scan_function(fn: Callable, distribution: Optional[str]) -> Optional[_ModuleInfo]:
105
+ name = getattr(fn, "__name__", "")
106
+ match = _HANDLER_PATTERN.match(name)
107
+ if not match:
108
+ return None
109
+ event_type = match.group(1)
110
+ suffix = (match.group(2) or "")[1:]
111
+ if not suffix:
112
+ return None # global functions MUST have suffix
113
+ return _ModuleInfo(name=suffix, handlers={event_type: fn}, distribution=distribution, path=_get_module_path(fn))
114
+
115
+
116
+ def _load_module_infos(io: IO) -> list[_ModuleInfo]:
117
+ all_modules: list[_ModuleInfo] = []
118
+
119
+ for entry_point in metadata.entry_points(group="ps.module"):
120
+ ep_name = f"{entry_point.group}:{entry_point.name}"
121
+ try:
122
+ loaded = entry_point.load()
123
+ dist = _get_distribution(entry_point)
124
+ except Exception as e:
125
+ log_verbose(io, f" <fg=yellow>Warning: failed to load entry point '{ep_name}': {e}</>")
126
+ log_debug(io, f" <fg=dark_gray>{traceback.format_exc().strip()}</>")
127
+ continue
128
+
129
+ if inspect.isclass(loaded):
130
+ all_modules.extend(_scan_class(loaded, dist))
131
+ elif inspect.ismodule(loaded):
132
+ for _, obj in inspect.getmembers(loaded):
133
+ if inspect.isclass(obj) and obj.__module__.startswith(loaded.__name__):
134
+ all_modules.extend(_scan_class(obj, dist))
135
+ elif inspect.isfunction(obj) and obj.__module__ == loaded.__name__:
136
+ info = _scan_function(obj, dist)
137
+ if info:
138
+ all_modules.append(info)
139
+ elif inspect.isfunction(loaded):
140
+ info = _scan_function(loaded, dist)
141
+ if info:
142
+ all_modules.append(info)
143
+ else:
144
+ log_verbose(io, f" <fg=yellow>Warning: entry point '{ep_name}' loaded unsupported type {type(loaded).__name__}, skipping.</>")
145
+
146
+ return all_modules
147
+
148
+
149
+ def _detect_collisions(modules: list[_ModuleInfo], io: IO) -> list[_ModuleInfo]:
150
+ name_groups: dict[str, list[_ModuleInfo]] = {}
151
+ for mod in modules:
152
+ name_groups.setdefault(mod.name, []).append(mod)
153
+
154
+ result: list[_ModuleInfo] = []
155
+ for name, group in name_groups.items():
156
+ if len(group) == 1:
157
+ result.append(group[0])
158
+ continue
159
+
160
+ paths = {m.path for m in group}
161
+ if len(paths) == 1 and None not in paths:
162
+ log_debug(io, f"<fg=dark_gray>Module '<fg=cyan>{name}</>' discovered via multiple entry points, using single instance</>")
163
+ result.append(group[0])
164
+ else:
165
+ dist_list = ", ".join(
166
+ f"<fg=yellow>{m.distribution or 'unknown'}</>" for m in group
167
+ )
168
+ log_verbose(
169
+ io,
170
+ f" <fg=yellow>Warning: module name collision: '<fg=cyan>{name}</>' found in [{dist_list}]. None will be loaded.</>",
171
+ )
172
+ if io.is_debug():
173
+ for m in group:
174
+ path_hint = f" — {m.path}" if m.path else ""
175
+ io.write_line(f" <fg=dark_gray> {m.distribution or 'unknown'}{path_hint}</>")
176
+ return result
177
+
178
+
179
+ class _ModulesHandler:
180
+ def __init__(self, di: DI, io: IO, plugin_settings: PluginSettings) -> None:
181
+ self._di = di
182
+ self._io = io
183
+ self._plugin_settings = plugin_settings
184
+ self._modules: list[_ModuleInfo] = []
185
+ self._disabled: set[str] = set()
186
+
187
+ def discover_and_instantiate(self) -> None:
188
+ io = self._io
189
+ all_modules = _load_module_infos(io)
190
+ modules = _detect_collisions(all_modules, io)
191
+
192
+ specified = self._plugin_settings.modules
193
+ if specified is not None:
194
+ name_map = {m.name: m for m in modules}
195
+ modules = [name_map[n] for n in specified if n in name_map]
196
+ selected_names = {m.name for m in modules}
197
+ else:
198
+ modules = []
199
+ selected_names = set()
200
+
201
+ if io.is_verbose():
202
+ available_not_selected = [m for m in all_modules if m.name not in selected_names]
203
+
204
+ io.write_line("<fg=magenta>Selected modules:</>")
205
+ for idx, mod in enumerate(modules, start=1):
206
+ dist_hint = f" <fg=dark_gray>[{mod.distribution}]</>" if mod.distribution else ""
207
+ io.write_line(f" {idx}. <fg=cyan>{mod.name}</>{dist_hint}")
208
+ if io.is_debug() and mod.path:
209
+ io.write_line(f" <fg=dark_gray>{mod.path}</>")
210
+
211
+ if available_not_selected:
212
+ io.write_line("<fg=magenta>Discovered but not selected:</>")
213
+ for mod in available_not_selected:
214
+ dist_hint = f" <fg=dark_gray>[{mod.distribution}]</>" if mod.distribution else ""
215
+ io.write_line(f" - <fg=dark_gray>{mod.name}</>{dist_hint}")
216
+ if io.is_debug() and mod.path:
217
+ io.write_line(f" <fg=dark_gray>{mod.path}</>")
218
+
219
+ # Instantiate class-based modules
220
+ for mod in modules:
221
+ handlers = mod.handlers
222
+ # Check if any handler is an unbound method (needs instance)
223
+ needs_instance = any(
224
+ _is_unbound_method(fn) for fn in handlers.values()
225
+ )
226
+ if needs_instance:
227
+ # Find the class from first unbound method
228
+ cls = _get_defining_class(next(iter(handlers.values())))
229
+ if cls:
230
+ instance = self._di.spawn(cls)
231
+ mod.instance = instance
232
+ # Bind methods to instance
233
+ mod.handlers = {
234
+ event_type: getattr(instance, _event_to_method_name(event_type, mod.name, cls))
235
+ for event_type, fn in handlers.items()
236
+ }
237
+ log_debug(io, f"<fg=dark_gray>Instantiated module <comment>{mod.name}</comment> ({cls.__module__}.{cls.__name__})</>")
238
+
239
+ event_types = ", ".join(sorted(mod.handlers.keys()))
240
+ log_debug(io, f"<fg=dark_gray>Module <comment>{mod.name}</comment> handles: {event_types}</>")
241
+
242
+ self._modules = modules
243
+
244
+ def activate(self) -> None:
245
+ io = self._io
246
+ activate_modules = [m for m in self._modules if "activate" in m.handlers]
247
+ log_verbose(io, f"<info>Activating {len(activate_modules)} module(s)</info>")
248
+
249
+ for mod in activate_modules:
250
+ fn = mod.handlers["activate"]
251
+ log_debug(io, f"<fg=dark_gray>Executing activate for module <comment>{mod.name}</comment></>")
252
+ try:
253
+ result = self._di.satisfy(fn)()
254
+ if result is False:
255
+ self._disabled.add(mod.name)
256
+ log_debug(io, f"<fg=dark_gray>Module <comment>{mod.name}</comment> disabled itself during activation</>")
257
+ except Exception as e:
258
+ io.write_error_line(f"<error>Error during activation of module {mod.name}: {e}</error>")
259
+ raise
260
+
261
+ def get_event_handlers(self, event_type: str) -> list[Callable[..., Any]]:
262
+ return [
263
+ mod.handlers[event_type]
264
+ for mod in self._modules
265
+ if event_type in mod.handlers and mod.name not in self._disabled
266
+ ]
267
+
268
+ def get_module_names(self) -> list[str]:
269
+ return [m.name for m in self._modules if m.name not in self._disabled]
270
+
271
+
272
+ def _is_unbound_method(fn: Callable) -> bool:
273
+ return inspect.isfunction(fn) and "." in getattr(fn, "__qualname__", "")
274
+
275
+
276
+ def _get_defining_class(fn: Callable) -> Optional[type]:
277
+ qualname = getattr(fn, "__qualname__", "")
278
+ parts = qualname.rsplit(".", 1)
279
+ if len(parts) < 2:
280
+ return None
281
+ module = inspect.getmodule(fn)
282
+ if module is None:
283
+ return None
284
+ return getattr(module, parts[0], None)
285
+
286
+
287
+ def _event_to_method_name(event_type: str, module_name: str, cls: type) -> str:
288
+ # Try without suffix first (instance methods can omit suffix)
289
+ base = f"poetry_{event_type}"
290
+ if hasattr(cls, base):
291
+ return base
292
+ # Try with module name as suffix
293
+ return f"poetry_{event_type}_{module_name}"
@@ -0,0 +1,151 @@
1
+ import os
2
+ import site
3
+ import sys
4
+
5
+ import cleo.events.console_events
6
+ from cleo.events.console_command_event import ConsoleCommandEvent
7
+ from cleo.events.event import Event
8
+ from cleo.events.event_dispatcher import EventDispatcher
9
+ from cleo.io.inputs.argv_input import ArgvInput
10
+ from cleo.io.io import IO
11
+ from cleo.io.outputs.output import Verbosity
12
+ from cleo.io.outputs.stream_output import StreamOutput
13
+ from poetry.console.application import Application
14
+ from poetry.plugins.application_plugin import ApplicationPlugin
15
+ from poetry.utils.env import EnvManager
16
+
17
+ from ps.di import DI
18
+ from ps.plugin.sdk.logging import log_debug, log_verbose
19
+ from ps.plugin.sdk.project import Environment
20
+ from ps.plugin.sdk.settings import PluginSettings, parse_plugin_settings_from_document
21
+
22
+ from ._modules_handler import _ModulesHandler
23
+
24
+ _EVENT_LISTENERS = {
25
+ "command": cleo.events.console_events.COMMAND,
26
+ "terminate": cleo.events.console_events.TERMINATE,
27
+ "error": cleo.events.console_events.ERROR,
28
+ "signal": cleo.events.console_events.SIGNAL,
29
+ }
30
+
31
+
32
+ def _create_standard_io() -> IO:
33
+ output = StreamOutput(sys.stdout, verbosity=Verbosity.DEBUG) # type: ignore
34
+ error_output = StreamOutput(sys.stderr, verbosity=Verbosity.DEBUG) # type: ignore
35
+ return IO(ArgvInput(), output, error_output)
36
+
37
+
38
+ def _activate_project_venv(application: Application, io: IO) -> None:
39
+ env_prefix = os.environ.get("VIRTUAL_ENV", os.environ.get("CONDA_PREFIX"))
40
+ conda_env_name = os.environ.get("CONDA_DEFAULT_ENV")
41
+ if env_prefix is not None and conda_env_name != "base":
42
+ log_debug(io, f"Already in virtual environment <comment>{env_prefix}</comment>, skipping venv activation")
43
+ return
44
+
45
+ try:
46
+ env = EnvManager(application.poetry).get()
47
+ venv_path = env.path
48
+ except Exception as e:
49
+ log_debug(io, f"Could not resolve project environment: <comment>{e}</comment>, skipping venv activation")
50
+ return
51
+
52
+ if not venv_path.exists():
53
+ log_debug(io, "Project environment does not exist, skipping venv activation")
54
+ return
55
+
56
+ if sys.platform == "win32":
57
+ site_pkgs = venv_path / "Lib" / "site-packages"
58
+ else:
59
+ py_ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
60
+ site_pkgs = venv_path / "lib" / py_ver / "site-packages"
61
+
62
+ if not site_pkgs.exists():
63
+ log_debug(io, f"Site-packages not found at <comment>{site_pkgs}</comment>, skipping venv activation")
64
+ return
65
+
66
+ site.addsitedir(str(site_pkgs))
67
+
68
+ bin_dir = venv_path / ("Scripts" if sys.platform == "win32" else "bin")
69
+ if bin_dir.exists():
70
+ os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
71
+
72
+ log_verbose(io, f"Activated project venv from <comment>{venv_path}</comment>")
73
+
74
+
75
+ def _resolve_settings(environment: Environment) -> PluginSettings:
76
+ return environment.host_project.plugin_settings
77
+
78
+
79
+ class Plugin(ApplicationPlugin):
80
+ def __init__(self) -> None:
81
+ super().__init__()
82
+ self._di = DI()
83
+
84
+ def activate(self, application: Application) -> None:
85
+ assert application.event_dispatcher is not None
86
+ event_dispatcher = application.event_dispatcher
87
+
88
+ io = self._ensure_io(application)
89
+ project_toml = application.poetry.pyproject.data
90
+ try:
91
+ settings: PluginSettings = parse_plugin_settings_from_document(project_toml)
92
+ if not settings.enabled:
93
+ log_verbose(io, f"<fg=yellow>ps-plugin not enabled or disabled in configuration in {application.poetry.pyproject.file}</>")
94
+ return
95
+ except Exception as e:
96
+ log_debug(io, f"<error>Not in a valid poetry project or configuration error: {e}</error>")
97
+ return
98
+
99
+ log_verbose(io, "<info>Starting activation</info>")
100
+
101
+ _activate_project_venv(application, io)
102
+
103
+ di = self._di
104
+ di.register(IO).factory(lambda: io)
105
+ di.register(Application).factory(lambda: application)
106
+ di.register(Environment).factory(lambda path: Environment(path), application.poetry.pyproject_path)
107
+ di.register(PluginSettings).factory(_resolve_settings)
108
+ di.register(EventDispatcher).factory(lambda: event_dispatcher)
109
+
110
+ handler = di.spawn(_ModulesHandler)
111
+ handler.discover_and_instantiate()
112
+ handler.activate()
113
+
114
+ for event_type, event_constant in _EVENT_LISTENERS.items():
115
+ fns = handler.get_event_handlers(event_type)
116
+ if not fns:
117
+ log_debug(io, f"No handlers for <comment>{event_type}</comment>; skipping listener")
118
+ continue
119
+ log_verbose(io, f"Registering <fg=yellow>{len(fns)}</> handler(s) for <comment>{event_type}</comment>")
120
+ self._register_listener(event_dispatcher, di, io, event_type, event_constant, fns)
121
+
122
+ self.poetry = application.poetry
123
+ log_verbose(io, "<info>Activation complete</info>")
124
+
125
+ def _register_listener(
126
+ self,
127
+ event_dispatcher: EventDispatcher,
128
+ di: DI,
129
+ io: IO,
130
+ event_type: str,
131
+ event_constant: str,
132
+ fns: list,
133
+ ) -> None:
134
+ def _listener(event: Event, event_name: str, dispatcher: EventDispatcher) -> None: # noqa: ARG001
135
+ with di.scope() as scope:
136
+ scope.register(type(event)).factory(lambda: event)
137
+ log_debug(io, f"Processing <comment>{event_name}</comment> event")
138
+ for fn in fns:
139
+ scope.satisfy(fn)()
140
+ if isinstance(event, ConsoleCommandEvent) and not event.command_should_run():
141
+ log_debug(io, f"Command execution stopped after <comment>{event_type}</comment> handler")
142
+ break
143
+
144
+ event_dispatcher.add_listener(event_constant, _listener)
145
+
146
+ def _ensure_io(self, application: Application) -> IO:
147
+ io = getattr(application, "_io", None)
148
+ if io is None:
149
+ io = _create_standard_io()
150
+ application._io = io
151
+ return io