polyplug 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.
polyplug/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """polyplug — host-side Python library for polyplug app developers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from polyplug._native import load_native_lib as _load_native_lib
6
+
7
+ _native_lib = _load_native_lib()
8
+
9
+ from polyplug.runtime import Runtime
10
+ from polyplug_abi import ReloadPhase, ReloadPhaseType
11
+
12
+ __all__ = [
13
+ "Runtime",
14
+ "ReloadPhase",
15
+ "ReloadPhaseType",
16
+ "load_native_lib",
17
+ ]
18
+
19
+
20
+ def load_native_lib():
21
+ """Return the loaded native library instance.
22
+
23
+ Returns:
24
+ ctypes.CDLL: The loaded libpolyplug instance.
25
+ """
26
+ return _native_lib
@@ -0,0 +1,164 @@
1
+ """Native library loader for polyplug.
2
+
3
+ This module handles loading the native libpolyplug shared library.
4
+
5
+ Resolution order (an explicit POLYPLUG_LIB always wins — this is the same
6
+ order the FFI backend in ``polyplug.runtime`` uses, so the preloaded library
7
+ and the backend library are always the SAME file):
8
+ 1. The POLYPLUG_LIB environment variable (absolute path to the .so/.dylib/.dll).
9
+ 2. Locally staged platform subdirectory (e.g. _native/linux-x64/).
10
+ 3. System library paths (LD_LIBRARY_PATH / DYLD_LIBRARY_PATH / PATH).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ import ctypes
18
+ import platform
19
+ from typing import Optional
20
+
21
+ __all__ = ["load_native_lib", "get_native_lib_name", "NativeLibLoader"]
22
+
23
+
24
+ def get_native_lib_name() -> str:
25
+ """Get the native library filename for the current platform.
26
+
27
+ Returns:
28
+ The platform-specific library filename.
29
+
30
+ Raises:
31
+ RuntimeError: If the current platform is not supported.
32
+ """
33
+ if sys.platform == "linux":
34
+ return "libpolyplug.so"
35
+ elif sys.platform == "darwin":
36
+ return "libpolyplug.dylib"
37
+ elif sys.platform == "win32":
38
+ return "polyplug.dll"
39
+ else:
40
+ raise RuntimeError(f"Unsupported platform: {sys.platform}")
41
+
42
+
43
+ def get_platform_identifier() -> str:
44
+ """Get the platform identifier for GitHub Releases downloads.
45
+
46
+ Returns:
47
+ Platform identifier string (e.g., 'linux-x64', 'macos-arm64').
48
+
49
+ Raises:
50
+ RuntimeError: If the current platform/architecture is not supported.
51
+ """
52
+ machine = platform.machine().lower()
53
+
54
+ if sys.platform == "linux":
55
+ if machine in ("x86_64", "amd64"):
56
+ return "linux-x64"
57
+ elif machine == "aarch64":
58
+ return "linux-arm64"
59
+ else:
60
+ raise RuntimeError(f"Unsupported Linux architecture: {machine}")
61
+ elif sys.platform == "darwin":
62
+ if machine == "arm64":
63
+ return "macos-arm64"
64
+ elif machine in ("x86_64", "amd64"):
65
+ return "macos-x64"
66
+ else:
67
+ raise RuntimeError(f"Unsupported macOS architecture: {machine}")
68
+ elif sys.platform == "win32":
69
+ if machine in ("x86_64", "amd64"):
70
+ return "windows-x64"
71
+ else:
72
+ raise RuntimeError(f"Unsupported Windows architecture: {machine}")
73
+ else:
74
+ raise RuntimeError(f"Unsupported platform: {sys.platform}")
75
+
76
+
77
+ class NativeLibLoader:
78
+ """Manages loading of the native polyplug library.
79
+
80
+ This class attempts to load the native library from multiple locations
81
+ (an explicit POLYPLUG_LIB always wins):
82
+ 1. POLYPLUG_LIB environment variable
83
+ 2. Embedded _native/ directory
84
+ 3. System library paths
85
+
86
+ Attributes:
87
+ lib: The loaded ctypes.CDLL instance, or None if not loaded.
88
+ load_path: The path from which the library was loaded, or None.
89
+ """
90
+
91
+ def __init__(self) -> None:
92
+ self.lib: Optional[ctypes.CDLL] = None
93
+ self.load_path: Optional[str] = None
94
+
95
+ def load(self) -> ctypes.CDLL:
96
+ """Load the native library.
97
+
98
+ Returns:
99
+ The loaded ctypes.CDLL instance.
100
+
101
+ Raises:
102
+ RuntimeError: If the library cannot be loaded from any location.
103
+ """
104
+ if self.lib is not None:
105
+ return self.lib
106
+
107
+ lib_name = get_native_lib_name()
108
+ platform_id = get_platform_identifier()
109
+
110
+ # POLYPLUG_LIB wins over the embedded copy: the FFI backend
111
+ # (polyplug.runtime._resolve_lib_path) honors the env var first, so
112
+ # preferring the embedded copy here would dlopen TWO different
113
+ # libpolyplug builds into one process.
114
+ env_path = os.environ.get("POLYPLUG_LIB")
115
+ if env_path and os.path.exists(env_path):
116
+ self.lib = ctypes.CDLL(env_path)
117
+ self.load_path = env_path
118
+ return self.lib
119
+
120
+ embedded_path = os.path.join(os.path.dirname(__file__), platform_id, lib_name)
121
+
122
+ if os.path.exists(embedded_path):
123
+ self.lib = ctypes.CDLL(embedded_path)
124
+ self.load_path = embedded_path
125
+ return self.lib
126
+
127
+ try:
128
+ self.lib = ctypes.CDLL(lib_name)
129
+ self.load_path = lib_name
130
+ return self.lib
131
+ except OSError:
132
+ pass
133
+
134
+ raise RuntimeError(
135
+ f"Failed to load native library '{lib_name}'. "
136
+ f"Tried: {embedded_path}"
137
+ f"{', POLYPLUG_LIB=' + env_path if env_path else ''}"
138
+ f", system paths. "
139
+ f"Ensure the library is installed or set POLYPLUG_LIB."
140
+ )
141
+
142
+
143
+ _loader: Optional[NativeLibLoader] = None
144
+
145
+
146
+ def load_native_lib() -> ctypes.CDLL:
147
+ """Load the native polyplug library.
148
+
149
+ This function attempts to load the native library from
150
+ (an explicit POLYPLUG_LIB always wins):
151
+ 1. The POLYPLUG_LIB environment variable
152
+ 2. The embedded _native/ directory
153
+ 3. System library paths
154
+
155
+ Returns:
156
+ The loaded ctypes.CDLL instance.
157
+
158
+ Raises:
159
+ RuntimeError: If the library cannot be loaded from any location.
160
+ """
161
+ global _loader
162
+ if _loader is None:
163
+ _loader = NativeLibLoader()
164
+ return _loader.load()
polyplug/runtime.py ADDED
@@ -0,0 +1,486 @@
1
+ """
2
+ polyplug Python Host Library
3
+
4
+ After 18-02/18-03: All operations go through HostApi struct fields.
5
+ Only two FFI exports remain: polyplug_runtime_create, polyplug_runtime_destroy.
6
+
7
+ The Runtime class holds a HostApi pointer and calls methods through struct fields.
8
+ All FFI struct types are imported from the auto-generated polyplug_abi module.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ctypes
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Callable, Optional, Protocol, runtime_checkable
18
+
19
+ # Import all FFI struct types from the auto-generated abi module.
20
+ # The polyplug_abi package re-exports from sdks/python/abi/abi.py (per D-28).
21
+ from polyplug_abi import (
22
+ AbiError,
23
+ AbiErrorCode,
24
+ Array,
25
+ Compatibility,
26
+ DispatchMechanisms,
27
+ GuestContractHandle,
28
+ GuestContractInstance,
29
+ GuestContractInterface,
30
+ HostContractInterface,
31
+ HostContractInstance,
32
+ HostApi,
33
+ ReloadPhase,
34
+ ReloadPhaseType,
35
+ RuntimeConfig,
36
+ )
37
+
38
+ # The ABI-level ReloadPhase ctypes Structure (the on_reload callback receives a
39
+ # const pointer to this 48-byte struct). The `polyplug_abi` package re-exports a
40
+ # higher-level Python `ReloadPhase` wrapper class under the same name, so the
41
+ # raw ctypes Structure is imported from its defining module to disambiguate.
42
+ from polyplug_abi.abi import ReloadPhase as AbiReloadPhase
43
+
44
+ _LIB_NAME: str = "polyplug"
45
+
46
+ # ─── Compatibility Constants ─────────────────────────────────────────────────────
47
+ # These match polyplug_abi::Compatibility #[repr(u32)] enum
48
+
49
+ COMPATIBILITY_STRICT: int = Compatibility.Strict
50
+ COMPATIBILITY_RELAXED: int = Compatibility.Relaxed
51
+ COMPATIBILITY_YOLO: int = Compatibility.Yolo
52
+
53
+ # GuestContractHandle is `#[repr(C)] { index: u32, generation: u32 }` (8 bytes, align 4).
54
+ # The null handle sentinel has index == u32::MAX (0xFFFFFFFF) and generation == 0.
55
+ _NULL_HANDLE_INDEX: int = (1 << 32) - 1
56
+
57
+ _BACKEND: str = "ctypes"
58
+ _cffi_available: bool = False
59
+
60
+ try:
61
+ import cffi
62
+ _cffi_available = True
63
+ _BACKEND = "cffi"
64
+ except ImportError:
65
+ pass
66
+
67
+
68
+ # ─── Backend Protocol (18-03: HostApi-based) ─────────────────────────────────
69
+
70
+ @runtime_checkable
71
+ class Backend(Protocol):
72
+ """Protocol for HostApi-based FFI backend.
73
+
74
+ Only two FFI bindings needed: create and destroy.
75
+ All operations go through HostApi struct fields.
76
+ """
77
+
78
+ def create_host_interface(self, config_ptr: int = 0) -> int: ...
79
+ def destroy_host_interface(self, host: int) -> None: ...
80
+ def load_host_interface(self, host: int) -> HostApi: ...
81
+
82
+
83
+ class CTypesBackend:
84
+ """ctypes-based FFI backend for HostApi operations."""
85
+
86
+ def __init__(self, lib_path: str) -> None:
87
+ self.ctypes = ctypes
88
+ self.lib: ctypes.CDLL = ctypes.CDLL(lib_path)
89
+ self._setup_bindings()
90
+
91
+ def _setup_bindings(self) -> None:
92
+ # Only two FFI exports: create (takes optional *const RuntimeConfig,
93
+ # null for defaults) and destroy.
94
+ self.lib.polyplug_runtime_create.argtypes = [self.ctypes.c_void_p]
95
+ self.lib.polyplug_runtime_create.restype = self.ctypes.c_void_p
96
+
97
+ self.lib.polyplug_runtime_destroy.argtypes = [self.ctypes.c_void_p]
98
+ self.lib.polyplug_runtime_destroy.restype = None
99
+
100
+ def create_host_interface(self, config_ptr: int = 0) -> int:
101
+ """Create runtime and return HostApi pointer.
102
+
103
+ Args:
104
+ config_ptr: Address of a RuntimeConfig struct, or 0 for defaults.
105
+ """
106
+ return self.lib.polyplug_runtime_create(config_ptr or None) or 0
107
+
108
+ def destroy_host_interface(self, host: int) -> None:
109
+ """Destroy HostApi and runtime."""
110
+ self.lib.polyplug_runtime_destroy(host)
111
+
112
+ def load_host_interface(self, host: int) -> HostApi:
113
+ """Load HostApi struct from pointer."""
114
+ return HostApi.from_address(host)
115
+
116
+
117
+ class CFFIBackend:
118
+ """cffi ABI mode backend for HostApi operations."""
119
+
120
+ CDEF = """
121
+ void* polyplug_runtime_create(const void* config);
122
+ void polyplug_runtime_destroy(void* host);
123
+ """
124
+
125
+ def __init__(self, lib_path: str) -> None:
126
+ import cffi
127
+ self.ffi = cffi.FFI()
128
+ self.ffi.cdef(self.CDEF)
129
+ self.lib = self.ffi.dlopen(lib_path)
130
+
131
+ def create_host_interface(self, config_ptr: int = 0) -> int:
132
+ """Create runtime and return HostApi pointer.
133
+
134
+ Args:
135
+ config_ptr: Address of a RuntimeConfig struct, or 0 for defaults.
136
+ """
137
+ return int(
138
+ self.ffi.cast(
139
+ "uintptr_t",
140
+ self.lib.polyplug_runtime_create(self.ffi.cast("void*", config_ptr)),
141
+ )
142
+ )
143
+
144
+ def destroy_host_interface(self, host: int) -> None:
145
+ """Destroy HostApi and runtime."""
146
+ self.lib.polyplug_runtime_destroy(self.ffi.cast("void*", host))
147
+
148
+ def load_host_interface(self, host: int) -> HostApi:
149
+ """Load HostApi struct from pointer (via ctypes)."""
150
+ return HostApi.from_address(host)
151
+
152
+
153
+ def get_backend() -> str:
154
+ """Return the current FFI backend name ('cffi' or 'ctypes')."""
155
+ return _BACKEND
156
+
157
+
158
+ def _resolve_lib_path() -> str:
159
+ env_path: str | None = os.getenv("POLYPLUG_LIB")
160
+ if env_path:
161
+ return env_path
162
+
163
+ import ctypes.util
164
+ found: str | None = ctypes.util.find_library(_LIB_NAME)
165
+ if found is None:
166
+ return "libpolyplug.so"
167
+ return found
168
+
169
+
170
+ def _create_backend(lib_path: str) -> Backend:
171
+ """Create the appropriate backend based on availability."""
172
+ if _cffi_available:
173
+ return CFFIBackend(lib_path)
174
+ return CTypesBackend(lib_path)
175
+
176
+
177
+ def _read_c_string(ptr: int, length: int) -> str:
178
+ """Read a C string from a pointer and length."""
179
+ if ptr == 0 or length == 0:
180
+ return ""
181
+ return ctypes.string_at(ptr, length).decode("utf-8", errors="replace")
182
+
183
+
184
+ class Runtime:
185
+ """polyplug runtime for loading and managing plugins.
186
+
187
+ Holds HostApi pointer and calls methods through struct fields.
188
+
189
+ All configuration is per-instance (Rule 12: no class-level statics shared
190
+ across runtimes): pass ``config`` and ``on_reload`` to the constructor.
191
+ The ctypes callback wrapper and host-contract keepalives live on the
192
+ instance and die with it.
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ config: Optional["RuntimeConfig"] = None,
198
+ on_reload: Optional[Callable[[ReloadPhase], None]] = None,
199
+ ) -> None:
200
+ lib_path: str = _resolve_lib_path()
201
+ self._backend: Backend = _create_backend(lib_path)
202
+ self.ctypes = ctypes
203
+
204
+ # Per-instance reload-callback and config state (set before create so
205
+ # the C callback wrapper outlives the create call).
206
+ self._on_reload_cb: Optional[Callable[[ReloadPhase], None]] = on_reload
207
+ self._config: Optional["RuntimeConfig"] = config
208
+ self._c_callback: Optional[ctypes.CFUNCTYPE] = None
209
+ self._runtime_config: Optional[RuntimeConfig] = None
210
+
211
+ # Per-instance host-contract keepalives: registered interface structs
212
+ # (with their thunks/stubs anchored on `_keepalive`), keyed by
213
+ # contract_id. The runtime holds raw pointers into these for its whole
214
+ # lifetime, so they must stay alive on THIS instance (Rule 12).
215
+ self._host_contract_interfaces: dict[int, HostContractInterface] = {}
216
+
217
+ # Create HostApi (options or default)
218
+ if self._on_reload_cb is not None or self._config is not None:
219
+ host_ptr: int = self._create_runtime_with_options()
220
+ else:
221
+ host_ptr = self._backend.create_host_interface()
222
+
223
+ if host_ptr == 0:
224
+ raise RuntimeError("polyplug_runtime_create returned null")
225
+
226
+ # Store HostApi pointer and load struct
227
+ self._host: int = host_ptr
228
+ self._host_struct: HostApi = self._backend.load_host_interface(host_ptr)
229
+
230
+ # The HostApi struct fields are already fully-typed C function
231
+ # pointers (CFUNCTYPE with the canonical ABI signatures from abi.py:
232
+ # out-param ABI — these return None and write their AbiError through a
233
+ # trailing pointer passed as ctypes.byref(err)). Cache them directly —
234
+ # re-wrapping in a hand-rolled CFUNCTYPE would both duplicate the
235
+ # signature and risk drift from the canonical type.
236
+ self._load_bundle_fn = self._host_struct.load_bundle
237
+ self._reload_bundle_fn = self._host_struct.reload_bundle
238
+ self._unload_bundle_fn = self._host_struct.unload_bundle
239
+ self._find_guest_contract_fn = self._host_struct.find_guest_contract
240
+ self._find_all_fn = self._host_struct.find_all_guest_contracts
241
+ self._resolve_fn = self._host_struct.resolve_guest_contract
242
+ self._get_last_error_fn = self._host_struct.get_last_error
243
+ self._get_error_len_fn = self._host_struct.get_error_len
244
+ self._register_host_contract_fn = self._host_struct.register_host_contract
245
+ self._register_loader_fn = self._host_struct.register_loader
246
+ self._free_fn = self._host_struct.free
247
+
248
+ def _create_runtime_with_options(self) -> int:
249
+ """Create runtime via polyplug_runtime_create with a RuntimeConfig.
250
+
251
+ The RuntimeConfig (48 bytes) has:
252
+ - compatibility (u32)
253
+ - hot_reload_enabled (bool/u8)
254
+ - on_reload (fn pointer or null)
255
+ - on_reload_user_data (pointer or null)
256
+ - log (fn pointer or null)
257
+ - log_user_data (pointer or null)
258
+ - log_max_level (u32)
259
+
260
+ The runtime only borrows the config for the duration of the build,
261
+ but the config is retained on the instance so the C callback wrapper
262
+ (referenced by `on_reload`) is not garbage-collected while in use.
263
+ """
264
+ config = RuntimeConfig()
265
+ config.hot_reload_enabled = False
266
+ config.compatibility = COMPATIBILITY_STRICT
267
+
268
+ if self._config is not None:
269
+ config.hot_reload_enabled = bool(self._config.hot_reload_enabled)
270
+ if hasattr(self._config, "compatibility"):
271
+ config.compatibility = self._config.compatibility
272
+
273
+ if self._on_reload_cb is not None:
274
+ self._c_callback = self._make_c_callback()
275
+ # The generated field type erases the pointee (c_void_p); the typed
276
+ # callback (POINTER(AbiReloadPhase) param) is cast to it. The
277
+ # original wrapper stays referenced via self._c_callback so the
278
+ # ctypes thunk is not garbage-collected while the runtime lives.
279
+ config.on_reload = ctypes.cast(self._c_callback, type(config.on_reload))
280
+ else:
281
+ config.on_reload = ctypes.cast(None, type(config.on_reload))
282
+
283
+ self._runtime_config = config
284
+ return self._backend.create_host_interface(ctypes.addressof(config))
285
+
286
+ def __del__(self) -> None:
287
+ host_ptr: int = getattr(self, "_host", 0)
288
+ backend: Backend = getattr(self, "_backend", None)
289
+ if host_ptr != 0 and backend is not None:
290
+ backend.destroy_host_interface(host_ptr)
291
+ self._host = 0
292
+
293
+ def _make_c_callback(self) -> ctypes.CFUNCTYPE:
294
+ """Internal: Create a C-compatible callback wrapper bound to THIS instance.
295
+
296
+ The wrapper never raises: a Python exception inside a ctypes callback
297
+ is swallowed by ctypes (the callback silently dies), so the phase-type
298
+ conversion is total (unknown discriminants pass through as the raw
299
+ ``int``) and the user callback is wrapped in a catch-all that logs to
300
+ stderr.
301
+ """
302
+ user_callback: Callable[[ReloadPhase], None] = self._on_reload_cb # type: ignore[assignment]
303
+
304
+ @ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(AbiReloadPhase))
305
+ def c_callback(_user_data: int, abi_phase_ptr: "ctypes._Pointer[AbiReloadPhase]") -> None:
306
+ try:
307
+ if not abi_phase_ptr:
308
+ # The runtime contract guarantees a non-null pointer; this
309
+ # guard is pure defence-in-depth.
310
+ return
311
+ # The pointee is valid only for the duration of this call; all
312
+ # fields (and the strings they reference) are copied into the
313
+ # Python-level ReloadPhase below before the callback returns.
314
+ abi_phase: AbiReloadPhase = abi_phase_ptr.contents
315
+ raw_type: int = abi_phase.phase_type
316
+ # Total conversion: ReloadPhaseType(raw) raises ValueError on an
317
+ # unknown discriminant, which ctypes would swallow — fall back to
318
+ # the raw int and warn instead.
319
+ if raw_type in ReloadPhaseType._value2member_map_:
320
+ phase_type: ReloadPhaseType | int = ReloadPhaseType(raw_type)
321
+ else:
322
+ phase_type = raw_type
323
+ print(
324
+ f"polyplug: unknown reload phase type {raw_type}; "
325
+ "passing raw value through",
326
+ file=sys.stderr,
327
+ )
328
+ reason: str = _read_c_string(
329
+ abi_phase.reason.ptr, abi_phase.reason.len
330
+ )
331
+ phase = ReloadPhase(
332
+ type=phase_type,
333
+ bundle_id=abi_phase.bundle_id,
334
+ bundle_name=_read_c_string(
335
+ abi_phase.bundle_name.ptr, abi_phase.bundle_name.len
336
+ ),
337
+ reason=reason if reason else None,
338
+ )
339
+ user_callback(phase)
340
+ except Exception as e: # noqa: BLE001 — must not unwind across the C ABI
341
+ print(f"polyplug: reload callback error: {e}", file=sys.stderr)
342
+
343
+ return c_callback
344
+
345
+ def _ensure_host(self) -> int:
346
+ if self._host == 0:
347
+ raise RuntimeError("Runtime is closed")
348
+ return self._host
349
+
350
+ def _get_error(self) -> str:
351
+ """Get last error message from HostApi."""
352
+ host: int = self._ensure_host()
353
+ error_len: int = self._get_error_len_fn(host)
354
+ if error_len == 0:
355
+ return ""
356
+
357
+ buf = (ctypes.c_uint8 * error_len)()
358
+ written: int = self._get_last_error_fn(host, buf, error_len)
359
+ if written <= 0:
360
+ return ""
361
+ return bytes(buf[:written]).decode("utf-8", errors="replace")
362
+
363
+ def _check_error(self, code: int, context: str) -> None:
364
+ """Check error code and raise if non-zero."""
365
+ if code == 0:
366
+ return
367
+ msg: str = self._get_error()
368
+ if msg:
369
+ raise RuntimeError(msg)
370
+ raise RuntimeError(f"{context} failed with code {code}")
371
+
372
+ def load_bundle(self, path: str | Path) -> None:
373
+ """Load a plugin bundle from path."""
374
+ host: int = self._ensure_host()
375
+ path_bytes: bytes = str(Path(path)).encode("utf-8")
376
+ buf = (ctypes.c_uint8 * len(path_bytes))(*path_bytes)
377
+ err: AbiError = AbiError()
378
+ self._load_bundle_fn(host, buf, len(path_bytes), ctypes.byref(err))
379
+ self._check_error(err.code, "load_bundle")
380
+
381
+ def reload_bundle(self, path: str | Path) -> None:
382
+ """Hot-reload a plugin bundle."""
383
+ host: int = self._ensure_host()
384
+ path_bytes: bytes = str(Path(path)).encode("utf-8")
385
+ buf = (ctypes.c_uint8 * len(path_bytes))(*path_bytes)
386
+ err: AbiError = AbiError()
387
+ self._reload_bundle_fn(host, buf, len(path_bytes), ctypes.byref(err))
388
+ self._check_error(err.code, "reload_bundle")
389
+
390
+ def unload_bundle(self, bundle_id: int) -> None:
391
+ """Unload a plugin bundle by bundle ID."""
392
+ host: int = self._ensure_host()
393
+ err: AbiError = AbiError()
394
+ self._unload_bundle_fn(host, bundle_id, ctypes.byref(err))
395
+ self._check_error(err.code, "unload_bundle")
396
+
397
+ def find_guest_contract(self, contract_id: int, min_version: int) -> GuestContractHandle:
398
+ """Find a guest contract by contract_id and minimum version.
399
+
400
+ Returns a GuestContractHandle struct (index: u32, generation: u32).
401
+ The null/not-found sentinel has index == 0xFFFFFFFF.
402
+ """
403
+ host: int = self._ensure_host()
404
+ return self._find_guest_contract_fn(host, contract_id, min_version)
405
+
406
+ def find_all_by_contract(self, contract_id: int, min_version: int) -> list[GuestContractHandle]:
407
+ """Find all guest contracts matching contract_id."""
408
+ host: int = self._ensure_host()
409
+ # `find_all_guest_contracts` returns an `Array` struct BY VALUE
410
+ # (#[repr(C)] { items: *mut T, len: usize, align: usize } = 24 bytes).
411
+ # The CFUNCTYPE restype is the `Array` Structure, so ctypes performs the
412
+ # sret struct-return ABI and `array` is a populated `Array` instance —
413
+ # NOT a pointer. Reading its fields directly is correct; treating the
414
+ # result as a pointer (the old behavior) misread the sret register.
415
+ array: Array = self._find_all_fn(host, contract_id, min_version)
416
+
417
+ array_data: int = array.items or 0
418
+ array_len: int = array.len
419
+ if array_len == 0 or array_data == 0:
420
+ return []
421
+
422
+ # GuestContractHandle is `#[repr(C)] { index: u32, generation: u32 }` = 8 bytes,
423
+ # so the array has an 8-byte element stride and each element is read as a full struct.
424
+ element_size: int = ctypes.sizeof(GuestContractHandle)
425
+ handles: list[GuestContractHandle] = []
426
+ for i in range(array_len):
427
+ handle: GuestContractHandle = GuestContractHandle.from_address(array_data + i * element_size)
428
+ handles.append(handle)
429
+
430
+ # Free the array via host->free using the same size/align the runtime
431
+ # allocated with: size = len * sizeof(element), align = array.align.
432
+ self._free_fn(host, array_data, array_len * element_size, array.align)
433
+
434
+ return handles
435
+
436
+ def resolve_guest_contract(self, handle: GuestContractHandle) -> int:
437
+ """Resolve a guest contract handle to a GuestContractInterface pointer."""
438
+ # Null handle sentinel has index == u32::MAX (0xFFFFFFFF).
439
+ if handle.index == _NULL_HANDLE_INDEX:
440
+ raise RuntimeError("null plugin handle")
441
+ host: int = self._ensure_host()
442
+ return self._resolve_fn(host, handle)
443
+
444
+ def release_plugin(self, resolve_handle: int) -> None:
445
+ """Release a resolve handle (no-op in HostApi model, managed by registry)."""
446
+ # In HostApi model, resolve handles are borrowed references
447
+ # No explicit release needed - the registry manages lifetimes
448
+ pass
449
+
450
+ def register_host_contract(self, interface: HostContractInterface) -> None:
451
+ """Register a fully populated host contract interface with the runtime.
452
+
453
+ The interface comes from a GENERATED factory
454
+ (``generated/host/interface_factories.py``: ``create_*_interface``),
455
+ which builds the thunks, instance stubs, and dispatch table with the
456
+ correct ABI signatures and anchors them on ``interface._keepalive``.
457
+
458
+ The interface struct is kept alive on THIS instance (per-runtime,
459
+ keyed by contract_id) — never on the class, where a second runtime
460
+ registering the same contract_id would clobber the first (Rule 12).
461
+ The runtime holds the raw pointer for its whole lifetime.
462
+ """
463
+ host: int = self._ensure_host()
464
+
465
+ contract_id: int = interface.contract_id
466
+ # Instance-owned keepalive: the struct address must stay stable and
467
+ # alive for the runtime lifetime.
468
+ self._host_contract_interfaces[contract_id] = interface
469
+
470
+ interface_ptr = ctypes.addressof(interface)
471
+ err: AbiError = AbiError()
472
+ self._register_host_contract_fn(host, interface_ptr, ctypes.byref(err))
473
+ if err.code == AbiErrorCode.DuplicateProvider:
474
+ raise RuntimeError(f"duplicate host contract: contract_id={contract_id}")
475
+ self._check_error(err.code, "register_host_contract")
476
+
477
+ def register_loader(self, loader_ptr: int) -> None:
478
+ """Register a language loader with the runtime via HostApi.
479
+
480
+ Args:
481
+ loader_ptr: Opaque loader pointer from the loader cdylib's create function.
482
+ """
483
+ host: int = self._ensure_host()
484
+ err: AbiError = AbiError()
485
+ self._register_loader_fn(host, loader_ptr, ctypes.byref(err))
486
+ self._check_error(err.code, "register_loader")
polyplug/scanner.py ADDED
@@ -0,0 +1,47 @@
1
+ """Scanner module for discovering polyplug plugin bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import List, Tuple
10
+
11
+
12
+ @dataclass
13
+ class Manifest:
14
+ """Plugin bundle manifest."""
15
+
16
+ name: str
17
+ version: str = "0.0.0"
18
+ provides: List[str] | None = None
19
+
20
+
21
+ def scan_dir(dir_path: str) -> List[Tuple[str, Manifest]]:
22
+ """Scan a directory for polyplug plugin bundles.
23
+
24
+ Args:
25
+ dir_path: Path to directory containing plugin bundles
26
+
27
+ Returns:
28
+ List of (bundle_path, Manifest) tuples
29
+ """
30
+ bundles: List[Tuple[str, Manifest]] = []
31
+
32
+ for entry in os.scandir(dir_path):
33
+ if not entry.is_dir():
34
+ continue
35
+
36
+ manifest_path = os.path.join(entry.path, "manifest.toml")
37
+ if os.path.exists(manifest_path):
38
+ with open(manifest_path, "rb") as f:
39
+ data = tomllib.load(f)
40
+ manifest = Manifest(
41
+ name=data.get("name", ""),
42
+ version=data.get("version", "0.0.0"),
43
+ provides=data.get("provides"),
44
+ )
45
+ bundles.append((entry.path, manifest))
46
+
47
+ return bundles
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: polyplug
3
+ Version: 0.1.0
4
+ Summary: Python host library for the polyplug plugin runtime
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/polyplug/polyplug
7
+ Project-URL: Documentation, https://github.com/polyplug/polyplug#readme
8
+ Project-URL: Repository, https://github.com/polyplug/polyplug.git
9
+ Project-URL: Issues, https://github.com/polyplug/polyplug/issues
10
+ Keywords: polyplug,plugin,runtime,ffi
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: polyplug-abi
22
+ Provides-Extra: native
23
+ Requires-Dist: polyplug-loaders-native; extra == "native"
24
+ Provides-Extra: python
25
+ Requires-Dist: polyplug-loaders-python; extra == "python"
26
+ Provides-Extra: lua
27
+ Requires-Dist: polyplug-loaders-lua; extra == "lua"
28
+ Provides-Extra: js
29
+ Requires-Dist: polyplug-loaders-js; extra == "js"
30
+ Provides-Extra: dotnet
31
+ Requires-Dist: polyplug-loaders-dotnet; extra == "dotnet"
32
+ Provides-Extra: all
33
+ Requires-Dist: polyplug-loaders-native; extra == "all"
34
+ Requires-Dist: polyplug-loaders-python; extra == "all"
35
+ Requires-Dist: polyplug-loaders-lua; extra == "all"
36
+ Requires-Dist: polyplug-loaders-js; extra == "all"
37
+ Requires-Dist: polyplug-loaders-dotnet; extra == "all"
@@ -0,0 +1,11 @@
1
+ polyplug/__init__.py,sha256=C1YfHHzqLZvnzcM8rBQrRGouVOCPhY0kJRlFrIkA-Ks,570
2
+ polyplug/runtime.py,sha256=BI6a8X8dqK5c_GgKffziQKhQeloE6DZXRC18YWDU5NA,20483
3
+ polyplug/scanner.py,sha256=ojl9t2WJUiIvzuOpmWZvMx7EcTDelMH0xnfVTuaBGfo,1209
4
+ polyplug/_native/__init__.py,sha256=CxOAZm_6PvF5NYQHa8Ldopvs0bXteTRSMjG14V_djyk,5173
5
+ polyplug/_native/linux-x64/libpolyplug.so,sha256=qY1KEW6al0h3bajE3mugN27nN3Y97i0xUycshfqvmwY,828312
6
+ polyplug/_native/macos-arm64/libpolyplug.dylib,sha256=Hssg95UcnOKlaMZMgBUi0oH9pYDY5-O0dAZmL2kN2E8,699984
7
+ polyplug/_native/windows-x64/polyplug.dll,sha256=Jlm_Cnx1Dwwg9Z_Gw5eHKveohmWOWw01K8-RtnkVgqI,689152
8
+ polyplug-0.1.0.dist-info/METADATA,sha256=EsymxIi2-O5JwB2zFHS_jCTWUmVSLEbZWrjoml1_uD4,1570
9
+ polyplug-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ polyplug-0.1.0.dist-info/top_level.txt,sha256=7Nxql0CQJEwkGuFioT7iInbt8N5jQdv4UnBnuP7ojaE,9
11
+ polyplug-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ polyplug