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.
- polyplug-0.1.0/PKG-INFO +37 -0
- polyplug-0.1.0/polyplug/__init__.py +26 -0
- polyplug-0.1.0/polyplug/_native/__init__.py +164 -0
- polyplug-0.1.0/polyplug/_native/linux-x64/libpolyplug.so +0 -0
- polyplug-0.1.0/polyplug/_native/macos-arm64/libpolyplug.dylib +0 -0
- polyplug-0.1.0/polyplug/_native/windows-x64/polyplug.dll +0 -0
- polyplug-0.1.0/polyplug/runtime.py +486 -0
- polyplug-0.1.0/polyplug/scanner.py +47 -0
- polyplug-0.1.0/polyplug.egg-info/PKG-INFO +37 -0
- polyplug-0.1.0/polyplug.egg-info/SOURCES.txt +16 -0
- polyplug-0.1.0/polyplug.egg-info/dependency_links.txt +1 -0
- polyplug-0.1.0/polyplug.egg-info/requires.txt +23 -0
- polyplug-0.1.0/polyplug.egg-info/top_level.txt +1 -0
- polyplug-0.1.0/pyproject.toml +52 -0
- polyplug-0.1.0/setup.cfg +4 -0
- polyplug-0.1.0/tests/test_host_contract_runtime.py +173 -0
- polyplug-0.1.0/tests/test_reload_runtime.py +152 -0
- polyplug-0.1.0/tests/test_runtime_config_c.py +76 -0
polyplug-0.1.0/PKG-INFO
ADDED
|
@@ -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()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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/**/*"]
|
polyplug-0.1.0/setup.cfg
ADDED
|
@@ -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!")
|