polyplug 0.1.0__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,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,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()
@@ -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")
@@ -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,16 @@
1
+ pyproject.toml
2
+ polyplug/__init__.py
3
+ polyplug/runtime.py
4
+ polyplug/scanner.py
5
+ polyplug.egg-info/PKG-INFO
6
+ polyplug.egg-info/SOURCES.txt
7
+ polyplug.egg-info/dependency_links.txt
8
+ polyplug.egg-info/requires.txt
9
+ polyplug.egg-info/top_level.txt
10
+ polyplug/_native/__init__.py
11
+ polyplug/_native/linux-x64/libpolyplug.so
12
+ polyplug/_native/macos-arm64/libpolyplug.dylib
13
+ polyplug/_native/windows-x64/polyplug.dll
14
+ tests/test_host_contract_runtime.py
15
+ tests/test_reload_runtime.py
16
+ tests/test_runtime_config_c.py
@@ -0,0 +1,23 @@
1
+ polyplug-abi
2
+
3
+ [all]
4
+ polyplug-loaders-native
5
+ polyplug-loaders-python
6
+ polyplug-loaders-lua
7
+ polyplug-loaders-js
8
+ polyplug-loaders-dotnet
9
+
10
+ [dotnet]
11
+ polyplug-loaders-dotnet
12
+
13
+ [js]
14
+ polyplug-loaders-js
15
+
16
+ [lua]
17
+ polyplug-loaders-lua
18
+
19
+ [native]
20
+ polyplug-loaders-native
21
+
22
+ [python]
23
+ polyplug-loaders-python
@@ -0,0 +1 @@
1
+ polyplug
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "polyplug"
7
+ version = "0.1.0"
8
+ description = "Python host library for the polyplug plugin runtime"
9
+ requires-python = ">=3.10"
10
+ license = { text = "MIT" }
11
+ keywords = ["polyplug", "plugin", "runtime", "ffi"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "polyplug-abi",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ native = ["polyplug-loaders-native"]
29
+ python = ["polyplug-loaders-python"]
30
+ lua = ["polyplug-loaders-lua"]
31
+ js = ["polyplug-loaders-js"]
32
+ dotnet = ["polyplug-loaders-dotnet"]
33
+ all = [
34
+ "polyplug-loaders-native",
35
+ "polyplug-loaders-python",
36
+ "polyplug-loaders-lua",
37
+ "polyplug-loaders-js",
38
+ "polyplug-loaders-dotnet",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/polyplug/polyplug"
43
+ Documentation = "https://github.com/polyplug/polyplug#readme"
44
+ Repository = "https://github.com/polyplug/polyplug.git"
45
+ Issues = "https://github.com/polyplug/polyplug/issues"
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["."]
49
+ include = ["polyplug*"]
50
+
51
+ [tool.setuptools.package-data]
52
+ polyplug = ["_native/**/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,173 @@
1
+ """sdks/python/host/tests/test_host_contract_runtime.py
2
+
3
+ REAL-runtime host-contract test (mirrors sdks/lua/host/tests/test_reload_runtime.lua).
4
+
5
+ `test_runtime_config_c.py` covers SDK-side ctypes types only — it can never
6
+ catch a broken FFI path. This test drives the actual flow: a Python host
7
+ creates a runtime through the SDK, registers the `host.logger` contract via
8
+ the GENERATED interface factory (which routes dispatch through the native
9
+ trampoline `polyplug_python_host_vm_dispatch` exported by the python loader
10
+ cdylib — ctypes cannot create struct-returning callbacks), loads the REAL
11
+ rust `reporter` example plugin through the native loader, and dispatches its
12
+ `report` function. The plugin resolves `host.logger` across the C ABI and
13
+ calls back into this Python implementation — including `log_with_level`,
14
+ whose first parameter is the `LogLevel` ENUM (proving the repr-ctype enum
15
+ marshalling at runtime, not just at the generated-string level).
16
+
17
+ Skip-honestly policy (matches sdks/lua/host/tests/test_reload_runtime.lua):
18
+ when the required environment is absent the test FAILS LOUDLY with
19
+ instructions — a runtime test that silently passes hides exactly the
20
+ never-run breakage class it exists to catch.
21
+
22
+ Run from repo root:
23
+ cargo build --release -p polyplug -p polyplug_native -p polyplug_python
24
+ bash examples/build_all.sh
25
+ POLYPLUG_LIB=$PWD/target/release/libpolyplug.so \
26
+ POLYPLUG_NATIVE_LIB=$PWD/target/release/libpolyplug_native.so \
27
+ POLYPLUG_PYTHON_LIB=$PWD/target/release/libpolyplug_python.so \
28
+ python3 sdks/python/host/tests/test_host_contract_runtime.py
29
+
30
+ Or via the justfile recipe: just test-host-python
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import ctypes
36
+ import os
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ # ─── Path setup ───────────────────────────────────────────────────────────────
41
+ # Resolve everything from this script's own directory (sdks/python/host/tests/),
42
+ # mirroring the PYTHON_HOST_PATH used by examples/verify_hosts.sh.
43
+ _TESTS_DIR: Path = Path(__file__).resolve().parent
44
+ _HOST_DIR: Path = _TESTS_DIR.parent # sdks/python/host
45
+ _PYTHON_SDK_DIR: Path = _HOST_DIR.parent # sdks/python
46
+ _REPO_ROOT: Path = _PYTHON_SDK_DIR.parent.parent # repo root
47
+
48
+ sys.path.insert(0, str(_HOST_DIR))
49
+ sys.path.insert(0, str(_PYTHON_SDK_DIR / "polyplug_abi"))
50
+ sys.path.insert(0, str(_PYTHON_SDK_DIR))
51
+ sys.path.insert(0, str(_PYTHON_SDK_DIR / "loaders" / "native"))
52
+ # Generated host bindings for examples/api.toml (host.logger factory + callers).
53
+ sys.path.insert(0, str(_REPO_ROOT / "examples" / "hosts" / "python" / "generated"))
54
+
55
+ # ─── Skip-honestly: a runtime test must never silently pass ───────────────────
56
+ _MISSING: list[str] = [
57
+ name
58
+ for name in ("POLYPLUG_LIB", "POLYPLUG_NATIVE_LIB", "POLYPLUG_PYTHON_LIB")
59
+ if not os.environ.get(name)
60
+ ]
61
+ if _MISSING:
62
+ sys.stderr.write(
63
+ "FATAL: "
64
+ + ", ".join(_MISSING)
65
+ + " not set — this runtime test must not silently pass.\n"
66
+ "Build the core and point the test at it:\n"
67
+ " cargo build --release -p polyplug -p polyplug_native -p polyplug_python\n"
68
+ " export POLYPLUG_LIB=$PWD/target/release/libpolyplug.so\n"
69
+ " export POLYPLUG_NATIVE_LIB=$PWD/target/release/libpolyplug_native.so\n"
70
+ " export POLYPLUG_PYTHON_LIB=$PWD/target/release/libpolyplug_python.so\n"
71
+ )
72
+ sys.exit(1)
73
+
74
+ _REPORTER_BUNDLE: Path = _REPO_ROOT / "examples" / "plugins" / "rust_reporter"
75
+ if not _REPORTER_BUNDLE.is_dir():
76
+ sys.stderr.write(
77
+ f"FATAL: reporter example bundle missing: {_REPORTER_BUNDLE}\n"
78
+ "This runtime test drives the REAL rust reporter plugin — build it first:\n"
79
+ " bash examples/build_all.sh\n"
80
+ )
81
+ sys.exit(1)
82
+
83
+ from polyplug import Runtime # noqa: E402 (sys.path setup above)
84
+ from polyplug_abi import StringView, to_str # noqa: E402
85
+ from polyplug_loaders_native import register_native_loader # noqa: E402
86
+ from host.contracts import HostLogger # noqa: E402
87
+ from host.interface_factories import create_host_logger_interface # noqa: E402
88
+ from host.types import LogLevel # noqa: E402
89
+ from host.callers import ( # noqa: E402
90
+ DATA_REPORTER_CONTRACT_ID,
91
+ DataReporterContractCaller,
92
+ )
93
+
94
+
95
+ def _str_view(s: str, keepalive: list) -> StringView:
96
+ """Build a StringView over a UTF-8 buffer kept alive via `keepalive`
97
+ (mirrors examples/hosts/python/main.py)."""
98
+ data: bytes = s.encode("utf-8")
99
+ buf: ctypes.Array = ctypes.create_string_buffer(data, len(data))
100
+ keepalive.append(buf)
101
+ return StringView(ptr=ctypes.cast(buf, ctypes.c_void_p), len=len(data))
102
+
103
+
104
+ class CapturingLogger(HostLogger):
105
+ """Records every call the plugin makes across the C ABI."""
106
+
107
+ def __init__(self) -> None:
108
+ self.entries: list[tuple[str, LogLevel | None, str]] = []
109
+
110
+ def log(self, message: str) -> None:
111
+ self.entries.append(("log", None, message))
112
+
113
+ def log_with_level(self, level: LogLevel, message: str) -> None:
114
+ self.entries.append(("log_with_level", level, message))
115
+
116
+
117
+ def main() -> None:
118
+ rt = Runtime()
119
+ register_native_loader(rt)
120
+
121
+ # Register host.logger through the GENERATED factory: dispatch routes
122
+ # through the python loader cdylib's native trampoline into the scalar
123
+ # ctypes dispatcher built by the factory.
124
+ logger = CapturingLogger()
125
+ bridge_lib: ctypes.CDLL = ctypes.CDLL(os.environ["POLYPLUG_PYTHON_LIB"])
126
+ rt.register_host_contract(create_host_logger_interface(logger, bridge_lib))
127
+
128
+ rt.load_bundle(_REPORTER_BUNDLE)
129
+
130
+ handle = rt.find_guest_contract(DATA_REPORTER_CONTRACT_ID, 0)
131
+ assert handle.index != 0xFFFFFFFF, "data.Reporter contract must be registered"
132
+ caller = DataReporterContractCaller.create(handle, rt._ensure_host(), owner=rt)
133
+
134
+ report_input: str = "TRANSFORMED:NAME|value (transformed)|43"
135
+ keepalive: list = []
136
+ result_sv: StringView = caller.report(_str_view(report_input, keepalive))
137
+ result: str = to_str(result_sv)
138
+ assert result == "Report: NAME has value 'value (transformed)' with count 43", (
139
+ f"unexpected report output: {result!r}"
140
+ )
141
+
142
+ # The plugin must have called back into the host across the ABI: one plain
143
+ # log plus four log_with_level calls with REAL LogLevel enum values.
144
+ expected: list[tuple[str, LogLevel | None, str]] = [
145
+ ("log", None, f"[plugin] Starting report for: {report_input}"),
146
+ ("log_with_level", LogLevel.INFO, "[plugin] Step 1: Parsing input"),
147
+ ("log_with_level", LogLevel.DEBUG, f"[plugin] Input length: {len(report_input)}"),
148
+ ("log_with_level", LogLevel.WARN, "[plugin] Step 2: Processing data"),
149
+ ("log_with_level", LogLevel.ERROR, "[plugin] Step 3: Finalizing report"),
150
+ ]
151
+ assert logger.entries == expected, (
152
+ f"host.logger callbacks mismatch:\n got: {logger.entries}\n"
153
+ f" expected: {expected}"
154
+ )
155
+
156
+ # Enum params must arrive as the generated IntEnum, not a bare int —
157
+ # the dispatcher wraps the raw repr u32 back into LogLevel.
158
+ levels: list[LogLevel | None] = [entry[1] for entry in logger.entries[1:]]
159
+ assert all(isinstance(level, LogLevel) for level in levels), (
160
+ f"levels must be LogLevel instances: {[type(level) for level in levels]}"
161
+ )
162
+
163
+ print("PASS: python host registered host.logger via generated factory")
164
+ print("PASS: rust reporter plugin called log() across the ABI")
165
+ print("PASS: log_with_level() delivered REAL LogLevel enum values:")
166
+ for kind, level, message in logger.entries:
167
+ level_str: str = level.name if isinstance(level, LogLevel) else "-"
168
+ print(f" [{kind}][{level_str}] {message}")
169
+ print("test_host_contract_runtime: all assertions passed")
170
+
171
+
172
+ if __name__ == "__main__":
173
+ main()
@@ -0,0 +1,152 @@
1
+ """sdks/python/host/tests/test_reload_runtime.py
2
+
3
+ REAL-runtime hot-reload notification test (mirrors
4
+ sdks/lua/host/tests/test_reload_runtime.lua and
5
+ sdks/js/host/tests/reload_runtime_test.ts).
6
+
7
+ `test_runtime_config_c.py` covers the SDK-side RuntimeConfig/ReloadPhase ctypes
8
+ types only — it builds local structs and asserts on them, which can never catch
9
+ a broken FFI path. This test drives the actual flow: create a runtime through
10
+ the Python host SDK with an `on_reload` callback (a real ctypes CFUNCTYPE for
11
+ the `void(*)(void*, const ReloadPhase*)` ABI signature), load the native reload
12
+ fixture bundle, trigger a reload through the runtime, and assert the callback
13
+ fired with REAL phase data delivered across the C ABI.
14
+
15
+ Skip-honestly policy (matches sdks/lua/host/tests/test_reload_runtime.lua):
16
+ when POLYPLUG_LIB / POLYPLUG_NATIVE_LIB are absent the test FAILS LOUDLY with
17
+ instructions — a runtime test that silently passes hides exactly the never-run
18
+ breakage class it exists to catch.
19
+
20
+ Run from repo root:
21
+ cargo build --release -p polyplug -p polyplug_native
22
+ bash tests/fixtures/build_all.sh
23
+ POLYPLUG_LIB=$PWD/target/release/libpolyplug.so \
24
+ POLYPLUG_NATIVE_LIB=$PWD/target/release/libpolyplug_native.so \
25
+ python3 sdks/python/host/tests/test_reload_runtime.py
26
+
27
+ Or via the justfile recipe: just test-host-python
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ # ─── Path setup ───────────────────────────────────────────────────────────────
37
+ # Resolve everything from this script's own directory (sdks/python/host/tests/),
38
+ # mirroring test_host_contract_runtime.py.
39
+ _TESTS_DIR: Path = Path(__file__).resolve().parent
40
+ _HOST_DIR: Path = _TESTS_DIR.parent # sdks/python/host
41
+ _PYTHON_SDK_DIR: Path = _HOST_DIR.parent # sdks/python
42
+ _REPO_ROOT: Path = _PYTHON_SDK_DIR.parent.parent # repo root
43
+
44
+ sys.path.insert(0, str(_HOST_DIR))
45
+ sys.path.insert(0, str(_PYTHON_SDK_DIR / "polyplug_abi"))
46
+ sys.path.insert(0, str(_PYTHON_SDK_DIR))
47
+ sys.path.insert(0, str(_PYTHON_SDK_DIR / "loaders" / "native"))
48
+
49
+ # ─── Skip-honestly: a runtime test must never silently pass ───────────────────
50
+ _MISSING: list[str] = [
51
+ name
52
+ for name in ("POLYPLUG_LIB", "POLYPLUG_NATIVE_LIB")
53
+ if not os.environ.get(name)
54
+ ]
55
+ if _MISSING:
56
+ sys.stderr.write(
57
+ "FATAL: "
58
+ + ", ".join(_MISSING)
59
+ + " not set — this runtime test must not silently pass.\n"
60
+ "Build the core and point the test at it:\n"
61
+ " cargo build --release -p polyplug -p polyplug_native\n"
62
+ " export POLYPLUG_LIB=$PWD/target/release/libpolyplug.so\n"
63
+ " export POLYPLUG_NATIVE_LIB=$PWD/target/release/libpolyplug_native.so\n"
64
+ )
65
+ sys.exit(1)
66
+
67
+ # Platform-specific cdylib naming (matches tests/fixtures/build_all.sh):
68
+ # `<name>.dll` on Windows (no `lib` prefix), `lib<name>.dylib` on macOS,
69
+ # `lib<name>.so` on Linux.
70
+ if sys.platform == "win32":
71
+ _V1_LIB_NAME: str = "reload_plugin_v1.dll"
72
+ _V2_LIB_NAME: str = "reload_plugin_v2.dll"
73
+ elif sys.platform == "darwin":
74
+ _V1_LIB_NAME = "libreload_plugin_v1.dylib"
75
+ _V2_LIB_NAME = "libreload_plugin_v2.dylib"
76
+ else:
77
+ _V1_LIB_NAME = "libreload_plugin_v1.so"
78
+ _V2_LIB_NAME = "libreload_plugin_v2.so"
79
+
80
+ _FIXTURES_DIR: Path = _REPO_ROOT / "tests" / "fixtures"
81
+ _V1_DIR: Path = _FIXTURES_DIR / "reload_plugin_v1"
82
+ # The reload target is the v2 cdylib INSIDE its bundle dir — the runtime reads
83
+ # the sibling manifest.toml during reload (mirrors integration_reload.rs).
84
+ _V2_LIB: Path = _FIXTURES_DIR / "reload_plugin_v2" / _V2_LIB_NAME
85
+
86
+ for _fixture in (_V1_DIR / "manifest.toml", _V1_DIR / _V1_LIB_NAME, _V2_LIB):
87
+ if not _fixture.is_file():
88
+ sys.stderr.write(
89
+ f"FATAL: reload fixture missing: {_fixture}\n"
90
+ "Run `bash tests/fixtures/build_all.sh` first.\n"
91
+ )
92
+ sys.exit(1)
93
+
94
+ from polyplug import Runtime # noqa: E402 (sys.path setup above)
95
+ from polyplug_abi import ( # noqa: E402
96
+ ReloadPhase,
97
+ RuntimeConfig,
98
+ bundle_id,
99
+ )
100
+ from polyplug_loaders_native import register_native_loader # noqa: E402
101
+
102
+ # Name from tests/fixtures/reload_plugin_v1/manifest.toml; the bundle id is
103
+ # FNV-1a 64 of the name (TRUST_MODEL §2) — computed via the SDK helper, never
104
+ # hand-rolled.
105
+ _V1_BUNDLE_ID: int = bundle_id("reload_plugin_v1")
106
+
107
+
108
+ def main() -> None:
109
+ phases: list[ReloadPhase] = []
110
+
111
+ config = RuntimeConfig()
112
+ config.hot_reload_enabled = True
113
+ rt = Runtime(config=config, on_reload=phases.append)
114
+ register_native_loader(rt)
115
+
116
+ rt.load_bundle(_V1_DIR)
117
+ assert not phases, f"no reload phases expected before the reload, got: {phases}"
118
+
119
+ rt.reload_bundle(_V2_LIB)
120
+
121
+ assert len(phases) >= 2, (
122
+ f"reload must deliver at least Preparing + Reloaded, got {len(phases)}: {phases}"
123
+ )
124
+
125
+ first: ReloadPhase = phases[0]
126
+ assert first.is_preparing(), f"first phase must be Preparing, got: {first!r}"
127
+ assert first.bundle_id == _V1_BUNDLE_ID, (
128
+ "Preparing phase must carry the real bundle id from the manifest "
129
+ f"(got {first.bundle_id}, want {_V1_BUNDLE_ID})"
130
+ )
131
+ assert first.bundle_name == "reload_plugin_v1", (
132
+ f"Preparing phase must carry the real bundle name (got {first.bundle_name!r})"
133
+ )
134
+ # Non-Failed phases carry the null-view reason; the SDK surfaces it as None.
135
+ assert first.reason is None, (
136
+ f"non-Failed phase must carry the null-view reason as None (got {first.reason!r})"
137
+ )
138
+
139
+ assert any(phase.is_reloaded() for phase in phases), (
140
+ f"a Reloaded phase must follow, got: {phases}"
141
+ )
142
+
143
+ print("PASS: no reload phases before the reload")
144
+ print("PASS: reload delivered Preparing with real bundle_id/bundle_name/null reason")
145
+ print("PASS: a Reloaded phase followed")
146
+ for phase in phases:
147
+ print(f" {phase!r}")
148
+ print("test_reload_runtime: all assertions passed")
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
@@ -0,0 +1,76 @@
1
+ """Tests for Python SDK RuntimeConfig matching polyplug_abi RuntimeConfig."""
2
+
3
+ import ctypes
4
+
5
+
6
+ class Compatibility(ctypes.c_uint32):
7
+ """Compatibility enum matching polyplug_abi::Compatibility."""
8
+ Strict = 0
9
+ Relaxed = 1
10
+ Yolo = 2
11
+
12
+
13
+ # Nullable function pointer (Option<fn>). Can be set to None.
14
+ _runtime_config_on_reload_t = ctypes.CFUNCTYPE(None, ctypes.c_void_p)
15
+
16
+
17
+ # Define RuntimeConfig directly to test without native library loading
18
+ class RuntimeConfig(ctypes.Structure):
19
+ """FFI RuntimeConfig matching polyplug_abi::RuntimeConfig (24-byte prefix)."""
20
+
21
+ _fields_ = [
22
+ ("compatibility", Compatibility), # offset 0, 4 bytes
23
+ ("hot_reload_enabled", ctypes.c_bool), # offset 4, 1 byte (+3 pad)
24
+ ("on_reload", _runtime_config_on_reload_t), # offset 8, 8 bytes
25
+ ("on_reload_user_data", ctypes.c_void_p), # offset 16, 8 bytes
26
+ ]
27
+
28
+
29
+ COMPATIBILITY_STRICT = 0
30
+ COMPATIBILITY_RELAXED = 1
31
+ COMPATIBILITY_YOLO = 2
32
+
33
+
34
+ def test_runtime_config_has_compatibility_field():
35
+ """RuntimeConfig must have compatibility field."""
36
+ fields = [f[0] for f in RuntimeConfig._fields_]
37
+ assert "compatibility" in fields, f"Missing compatibility field. Fields: {fields}"
38
+
39
+
40
+ def test_runtime_config_has_correct_field_types():
41
+ """RuntimeConfig field types must match polyplug_abi."""
42
+ # Check compatibility is a c_uint32 subtype
43
+ field_types = {f[0]: f[1] for f in RuntimeConfig._fields_}
44
+ assert issubclass(field_types["compatibility"], ctypes.c_uint32), \
45
+ f"compatibility must be c_uint32, got {field_types['compatibility']}"
46
+ assert field_types["hot_reload_enabled"] == ctypes.c_bool, \
47
+ f"hot_reload_enabled must be c_bool, got {field_types['hot_reload_enabled']}"
48
+ assert field_types["on_reload"] == _runtime_config_on_reload_t, \
49
+ f"on_reload must be a function pointer type, got {field_types['on_reload']}"
50
+
51
+
52
+ def test_runtime_config_prefix_is_24_bytes():
53
+ """RuntimeConfig prefix (through on_reload_user_data) must be 24 bytes."""
54
+ size = ctypes.sizeof(RuntimeConfig)
55
+ assert size == 24, f"RuntimeConfig prefix must be 24 bytes, got {size}"
56
+
57
+
58
+ def test_runtime_config_on_reload_at_offset_8():
59
+ """on_reload must sit at offset 8 (compatibility + bool + padding)."""
60
+ assert RuntimeConfig.on_reload.offset == 8
61
+
62
+
63
+ def test_compatibility_constants_defined():
64
+ """Compatibility enum constants must be defined."""
65
+ assert COMPATIBILITY_STRICT == 0
66
+ assert COMPATIBILITY_RELAXED == 1
67
+ assert COMPATIBILITY_YOLO == 2
68
+
69
+
70
+ if __name__ == "__main__":
71
+ test_runtime_config_has_compatibility_field()
72
+ test_runtime_config_has_correct_field_types()
73
+ test_runtime_config_prefix_is_24_bytes()
74
+ test_runtime_config_on_reload_at_offset_8()
75
+ test_compatibility_constants_defined()
76
+ print("All tests passed!")