godcode-engine 4.0.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.
godcode/plugins.py ADDED
@@ -0,0 +1,255 @@
1
+ """Pillar 3 — plugins and the SUMMON foreign-function interface.
2
+
3
+ A plugin is a plain Python module living in a plugins directory. On load it
4
+ exposes::
5
+
6
+ PLUGIN_API_VERSION = 1
7
+
8
+ def register(interpreter):
9
+ interpreter.register_plugin_verb("myns.myverb", myverb, plugin="myns")
10
+
11
+ The interpreter calls ``register(interpreter)`` at startup, so the plugin can
12
+ add new built-in verbs through the same ``_builtins`` mechanism the v2 core
13
+ verbs use. God Code calls those verbs through the ``SUMMON`` FFI verb::
14
+
15
+ SUMMON("myns.myverb", arg1, arg2)
16
+
17
+ Plugins are **trusted host code**: they run with the full power of Python,
18
+ and SUMMON calls into them bypass any sandbox policy *by design*. Every
19
+ plugin verb is recorded with ``trusted=True`` on the interpreter
20
+ (``interpreter.plugin_verb_info``) so a future sandbox pillar can consult the
21
+ marker when deciding what a scroll may touch.
22
+
23
+ Set ``GODCODE_NO_PLUGINS=1`` in the environment to skip plugin auto-loading
24
+ entirely.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import importlib.metadata
30
+ import importlib.util
31
+ import os
32
+ import re
33
+ import sys
34
+ import types
35
+ from pathlib import Path
36
+ from typing import Any, Callable
37
+
38
+ from godcode.values import Contract, RiteFunction, Symbol # noqa: F401 (re-exported for plugin authors)
39
+
40
+ PLUGIN_API_VERSION = 1
41
+ """Version of the plugin contract this engine speaks."""
42
+
43
+ ENTRY_POINT_GROUP = "godcode_plugins"
44
+ """importlib.metadata entry-point group for installed plugins (best-effort)."""
45
+
46
+ DISABLE_ENV_VAR = "GODCODE_NO_PLUGINS"
47
+ """Set to "1" to skip plugin auto-loading entirely."""
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # discovery
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def default_plugin_dirs() -> list[Path]:
55
+ """Where plugins are looked for: ``./plugins`` then ``~/.godcode/plugins``."""
56
+ dirs = [Path.cwd() / "plugins"]
57
+ try:
58
+ dirs.append(Path.home() / ".godcode" / "plugins")
59
+ except Exception: # pragma: no cover - exotic platforms without a home
60
+ pass
61
+ return dirs
62
+
63
+
64
+ def _warn(message: str) -> None:
65
+ print(f"godcode: plugin warning: {message}", file=sys.stderr)
66
+
67
+
68
+ def _iter_plugin_files(dirs: list[Path]):
69
+ """Yield ``(stem, path)`` for each candidate plugin file in *dirs*."""
70
+ for directory in dirs:
71
+ try:
72
+ if not directory.is_dir():
73
+ continue
74
+ entries = sorted(directory.iterdir())
75
+ except OSError as exc:
76
+ _warn(f"cannot scan {directory}: {exc}")
77
+ continue
78
+ for path in entries:
79
+ if not path.is_file() or path.suffix != ".py":
80
+ continue
81
+ stem = path.stem
82
+ if stem == "__init__" or stem.startswith(("_", ".")):
83
+ continue
84
+ yield stem, path
85
+
86
+
87
+ def _module_name_for(stem: str) -> str:
88
+ safe = re.sub(r"[^0-9a-zA-Z_]", "_", stem)
89
+ if not safe or safe[0].isdigit():
90
+ safe = "_" + safe
91
+ return f"godcode_plugin_{safe}"
92
+
93
+
94
+ def _import_from_path(stem: str, path: Path) -> types.ModuleType:
95
+ """Import the module at *path* without touching ``sys.path``."""
96
+ name = _module_name_for(stem)
97
+ spec = importlib.util.spec_from_file_location(name, path)
98
+ if spec is None or spec.loader is None: # pragma: no cover - defensive
99
+ raise ImportError(f"cannot build a module spec for {path}")
100
+ module = importlib.util.module_from_spec(spec)
101
+ # NOTE: deliberately *not* cached in sys.modules — each interpreter gets a
102
+ # freshly executed plugin module, so plugin state never leaks between runs.
103
+ spec.loader.exec_module(module)
104
+ return module
105
+
106
+
107
+ def _entry_point_plugins() -> dict[str, Any]:
108
+ """Best-effort load of the ``godcode_plugins`` entry-point group."""
109
+ found: dict[str, Any] = {}
110
+ try:
111
+ entry_points = importlib.metadata.entry_points()
112
+ except Exception as exc: # pragma: no cover - metadata backend missing
113
+ _warn(f"could not read entry points: {exc}")
114
+ return found
115
+ try:
116
+ if hasattr(entry_points, "select"): # Python 3.10+ EntryPoints
117
+ selected = entry_points.select(group=ENTRY_POINT_GROUP)
118
+ else: # pragma: no cover - legacy dict-style interface
119
+ selected = entry_points.get(ENTRY_POINT_GROUP, ())
120
+ except Exception as exc:
121
+ _warn(f"could not select {ENTRY_POINT_GROUP!r} entry points: {exc}")
122
+ return found
123
+ for ep in selected:
124
+ try:
125
+ found[ep.name] = ep.load()
126
+ except Exception as exc:
127
+ _warn(f"entry-point plugin {ep.name!r} failed to load: {exc}")
128
+ return found
129
+
130
+
131
+ def discover_plugins(dirs: list[Path] | None = None) -> dict[str, Any]:
132
+ """Import every plugin module found and return ``{name: module}``.
133
+
134
+ *dirs* defaults to :func:`default_plugin_dirs`, with the
135
+ ``godcode_plugins`` entry-point group consulted afterwards (later sources
136
+ shadow earlier ones on name clashes).
137
+
138
+ A module that fails to import is skipped with a warning on stderr — a
139
+ broken plugin never crashes the host run.
140
+ """
141
+ if dirs is None:
142
+ dirs = default_plugin_dirs()
143
+ discovered: dict[str, Any] = {}
144
+ for stem, path in _iter_plugin_files(dirs):
145
+ try:
146
+ discovered[stem] = _import_from_path(stem, path)
147
+ except Exception as exc:
148
+ _warn(f"plugin {path} failed to import ({exc}) — skipped")
149
+ for name, module in _entry_point_plugins().items():
150
+ discovered[name] = module
151
+ return discovered
152
+
153
+
154
+ def load_plugins(interpreter, dirs: list[Path] | None = None) -> list[str]:
155
+ """Discover plugins and call ``register(interpreter)`` on each.
156
+
157
+ Returns the names of the plugins whose ``register()`` ran cleanly. A
158
+ missing ``register()``, a ``PLUGIN_API_VERSION`` mismatch, or an
159
+ exception inside ``register()`` only produces a stderr warning — never
160
+ an exception, so startup always survives a bad plugin.
161
+ """
162
+ loaded: list[str] = []
163
+ for name, module in discover_plugins(dirs).items():
164
+ api = getattr(module, "PLUGIN_API_VERSION", 1)
165
+ if api != PLUGIN_API_VERSION:
166
+ _warn(
167
+ f"plugin {name!r} speaks API version {api}, "
168
+ f"this engine speaks {PLUGIN_API_VERSION} — skipped"
169
+ )
170
+ continue
171
+ register = getattr(module, "register", None)
172
+ if not callable(register):
173
+ _warn(f"plugin {name!r} exposes no register(interpreter) — skipped")
174
+ continue
175
+ try:
176
+ register(interpreter)
177
+ except Exception as exc:
178
+ _warn(f"plugin {name!r} register() failed ({exc}) — skipped")
179
+ continue
180
+ loaded.append(name)
181
+ return loaded
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # value conversion: God Code <-> Python
186
+ # ---------------------------------------------------------------------------
187
+ #
188
+ # God Code values already *are* Python objects inside the interpreter, so most
189
+ # conversions are the identity. The table below is the contract plugin
190
+ # authors can rely on:
191
+ #
192
+ # God Code number <-> Python int / float (identity)
193
+ # God Code string <-> Python str (identity)
194
+ # God Code boolean <-> Python bool (identity)
195
+ # God Code void <-> Python None (identity)
196
+ # God Code list <-> Python list (recursive)
197
+ # God Code symbol <-> Symbol, a str subclass (identity)
198
+ # God Code contract <-> Contract (opaque, identity)
199
+ # God Code rite <-> RiteFunction (opaque, identity)
200
+ #
201
+ # Anything else a plugin returns (dict, set, tuple, custom objects, ...)
202
+ # passes through opaquely, except tuples which become lists recursively.
203
+ # REVEAL renders opaque values with str().
204
+
205
+ def to_python(value: Any) -> Any:
206
+ """Convert a God Code value to the Python value a plugin receives.
207
+
208
+ Identity for every documented God Code value; see the table above.
209
+ """
210
+ return value
211
+
212
+
213
+ def to_godcode(value: Any) -> Any:
214
+ """Convert a plugin's Python return value back into a God Code value.
215
+
216
+ Tuples become lists (recursively); every other value passes through
217
+ as-is. Values with no God Code counterpart (dicts, sets, custom
218
+ objects) travel opaquely and are rendered with ``str()`` by REVEAL.
219
+ """
220
+ if isinstance(value, tuple):
221
+ return [to_godcode(item) for item in value]
222
+ if isinstance(value, list):
223
+ return [to_godcode(item) for item in value]
224
+ return value
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # interpreter mixin helpers (kept here so interpreter.py stays small)
229
+ # ---------------------------------------------------------------------------
230
+
231
+ def make_verb_adapter(name: str, func: Callable[..., Any]):
232
+ """Wrap a plain ``func(*args)`` into the ``(args, line)`` builtin shape.
233
+
234
+ Arguments are converted God Code -> Python, the return value Python ->
235
+ God Code. A plugin exception becomes a line-numbered GodRuntimeError;
236
+ divine errors pass through untouched.
237
+ """
238
+ from godcode.errors import GodCodeError, GodRuntimeError
239
+
240
+ def _adapter(args: list, line):
241
+ py_args = [to_python(arg) for arg in args]
242
+ try:
243
+ result = func(*py_args)
244
+ except GodCodeError:
245
+ raise
246
+ except Exception as exc:
247
+ raise GodRuntimeError(
248
+ f"The '{name}' verb faltered in the outer world: {exc}",
249
+ line,
250
+ ) from exc
251
+ return to_godcode(result)
252
+
253
+ _adapter.__name__ = f"plugin:{name}"
254
+ _adapter.__doc__ = getattr(func, "__doc__", None)
255
+ return _adapter