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 +26 -0
- polyplug/_native/__init__.py +164 -0
- polyplug/_native/linux-x64/libpolyplug.so +0 -0
- polyplug/_native/macos-arm64/libpolyplug.dylib +0 -0
- polyplug/_native/windows-x64/polyplug.dll +0 -0
- polyplug/runtime.py +486 -0
- polyplug/scanner.py +47 -0
- polyplug-0.1.0.dist-info/METADATA +37 -0
- polyplug-0.1.0.dist-info/RECORD +11 -0
- polyplug-0.1.0.dist-info/WHEEL +5 -0
- polyplug-0.1.0.dist-info/top_level.txt +1 -0
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()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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 @@
|
|
|
1
|
+
polyplug
|