tywrap 0.5.1 → 0.6.0
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.
- package/dist/cli.js +8 -6
- package/dist/cli.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +236 -147
- package/dist/config/index.js.map +1 -1
- package/dist/core/annotation-parser.d.ts.map +1 -1
- package/dist/core/annotation-parser.js +93 -83
- package/dist/core/annotation-parser.js.map +1 -1
- package/dist/core/discovery.d.ts.map +1 -1
- package/dist/core/discovery.js +26 -0
- package/dist/core/discovery.js.map +1 -1
- package/dist/core/emit-call.d.ts +58 -0
- package/dist/core/emit-call.d.ts.map +1 -0
- package/dist/core/emit-call.js +103 -0
- package/dist/core/emit-call.js.map +1 -0
- package/dist/core/generator.d.ts +2 -0
- package/dist/core/generator.d.ts.map +1 -1
- package/dist/core/generator.js +54 -211
- package/dist/core/generator.js.map +1 -1
- package/dist/core/mapper.d.ts.map +1 -1
- package/dist/core/mapper.js +118 -104
- package/dist/core/mapper.js.map +1 -1
- package/dist/index.d.ts +6 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -31
- package/dist/index.js.map +1 -1
- package/dist/runtime/bounded-context.d.ts +2 -2
- package/dist/runtime/bounded-context.js +2 -2
- package/dist/runtime/{safe-codec.d.ts → bridge-codec.d.ts} +11 -11
- package/dist/runtime/bridge-codec.d.ts.map +1 -0
- package/dist/runtime/{safe-codec.js → bridge-codec.js} +26 -26
- package/dist/runtime/bridge-codec.js.map +1 -0
- package/dist/runtime/{http-io.d.ts → http-transport.d.ts} +6 -6
- package/dist/runtime/http-transport.d.ts.map +1 -0
- package/dist/runtime/{http-io.js → http-transport.js} +3 -3
- package/dist/runtime/http-transport.js.map +1 -0
- package/dist/runtime/http.d.ts +3 -3
- package/dist/runtime/http.d.ts.map +1 -1
- package/dist/runtime/http.js +4 -4
- package/dist/runtime/http.js.map +1 -1
- package/dist/runtime/index.d.ts +9 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +9 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +6 -6
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +7 -7
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +11 -11
- package/dist/runtime/pooled-transport.d.ts.map +1 -1
- package/dist/runtime/pooled-transport.js +10 -10
- package/dist/runtime/pooled-transport.js.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
- package/dist/runtime/{pyodide-io.d.ts → pyodide-transport.d.ts} +8 -8
- package/dist/runtime/pyodide-transport.d.ts.map +1 -0
- package/dist/runtime/{pyodide-io.js → pyodide-transport.js} +5 -5
- package/dist/runtime/pyodide-transport.js.map +1 -0
- package/dist/runtime/pyodide.d.ts +3 -3
- package/dist/runtime/pyodide.d.ts.map +1 -1
- package/dist/runtime/pyodide.js +5 -5
- package/dist/runtime/pyodide.js.map +1 -1
- package/dist/runtime/rpc-client.d.ts +5 -5
- package/dist/runtime/rpc-client.d.ts.map +1 -1
- package/dist/runtime/rpc-client.js +6 -7
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/{process-io.d.ts → subprocess-transport.d.ts} +9 -9
- package/dist/runtime/subprocess-transport.d.ts.map +1 -0
- package/dist/runtime/{process-io.js → subprocess-transport.js} +6 -6
- package/dist/runtime/subprocess-transport.js.map +1 -0
- package/dist/runtime/{worker-pool.d.ts → transport-pool.d.ts} +15 -15
- package/dist/runtime/transport-pool.d.ts.map +1 -0
- package/dist/runtime/{worker-pool.js → transport-pool.js} +6 -6
- package/dist/runtime/transport-pool.js.map +1 -0
- package/dist/runtime/transport.d.ts +14 -8
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +10 -4
- package/dist/runtime/transport.js.map +1 -1
- package/dist/types/index.d.ts +10 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/cache.d.ts +2 -2
- package/dist/utils/cache.d.ts.map +1 -1
- package/dist/utils/cache.js +2 -2
- package/dist/utils/cache.js.map +1 -1
- package/dist/utils/codec.d.ts +1 -1
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +181 -182
- package/dist/utils/codec.js.map +1 -1
- package/dist/version.d.ts +12 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +12 -0
- package/dist/version.js.map +1 -0
- package/package.json +3 -2
- package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
- package/runtime/python_bridge.py +54 -6
- package/runtime/safe_codec.py +8 -8
- package/runtime/tywrap_bridge_core.py +159 -23
- package/src/cli.ts +8 -12
- package/src/config/index.ts +271 -165
- package/src/core/annotation-parser.ts +112 -105
- package/src/core/discovery.ts +32 -0
- package/src/core/emit-call.ts +171 -0
- package/src/core/generator.ts +67 -263
- package/src/core/mapper.ts +125 -110
- package/src/index.ts +11 -68
- package/src/runtime/bounded-context.ts +2 -2
- package/src/runtime/{safe-codec.ts → bridge-codec.ts} +27 -27
- package/src/runtime/{http-io.ts → http-transport.ts} +5 -5
- package/src/runtime/http.ts +5 -5
- package/src/runtime/index.ts +23 -1
- package/src/runtime/node.ts +15 -15
- package/src/runtime/pooled-transport.ts +15 -15
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/{pyodide-io.ts → pyodide-transport.ts} +7 -7
- package/src/runtime/pyodide.ts +6 -6
- package/src/runtime/rpc-client.ts +12 -8
- package/src/runtime/{process-io.ts → subprocess-transport.ts} +8 -8
- package/src/runtime/{worker-pool.ts → transport-pool.ts} +26 -26
- package/src/runtime/transport.ts +15 -8
- package/src/types/index.ts +10 -1
- package/src/utils/cache.ts +2 -2
- package/src/utils/codec.ts +217 -196
- package/src/version.ts +12 -0
- package/dist/runtime/http-io.d.ts.map +0 -1
- package/dist/runtime/http-io.js.map +0 -1
- package/dist/runtime/optimized-node.d.ts +0 -23
- package/dist/runtime/optimized-node.d.ts.map +0 -1
- package/dist/runtime/optimized-node.js +0 -23
- package/dist/runtime/optimized-node.js.map +0 -1
- package/dist/runtime/process-io.d.ts.map +0 -1
- package/dist/runtime/process-io.js.map +0 -1
- package/dist/runtime/protocol.d.ts +0 -3
- package/dist/runtime/protocol.d.ts.map +0 -1
- package/dist/runtime/protocol.js +0 -3
- package/dist/runtime/protocol.js.map +0 -1
- package/dist/runtime/pyodide-io.d.ts.map +0 -1
- package/dist/runtime/pyodide-io.js.map +0 -1
- package/dist/runtime/safe-codec.d.ts.map +0 -1
- package/dist/runtime/safe-codec.js.map +0 -1
- package/dist/runtime/worker-pool.d.ts.map +0 -1
- package/dist/runtime/worker-pool.js.map +0 -1
- package/dist/utils/bundle-optimizer.d.ts +0 -182
- package/dist/utils/bundle-optimizer.d.ts.map +0 -1
- package/dist/utils/bundle-optimizer.js +0 -680
- package/dist/utils/bundle-optimizer.js.map +0 -1
- package/dist/utils/memory-profiler.d.ts +0 -123
- package/dist/utils/memory-profiler.d.ts.map +0 -1
- package/dist/utils/memory-profiler.js +0 -426
- package/dist/utils/memory-profiler.js.map +0 -1
- package/src/runtime/optimized-node.ts +0 -25
- package/src/runtime/protocol.ts +0 -2
- package/src/utils/bundle-optimizer.ts +0 -876
- package/src/utils/memory-profiler.ts +0 -582
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tywrap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Generate type-safe TypeScript wrappers for any Python library — Node.js, Deno, Bun, and browsers via Pyodide.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"README.md"
|
|
39
39
|
],
|
|
40
40
|
"scripts": {
|
|
41
|
-
"build": "npm run build:pyodide-bootstrap && tsc && npm run build:tools",
|
|
41
|
+
"build": "npm run build:version && npm run build:pyodide-bootstrap && tsc && npm run build:tools",
|
|
42
|
+
"build:version": "node scripts/generate-version.mjs",
|
|
42
43
|
"build:pyodide-bootstrap": "node scripts/generate-pyodide-bootstrap.mjs",
|
|
43
44
|
"build:tools": "tsc -p tsconfig.tools.json",
|
|
44
45
|
"dev": "tsc --watch",
|
|
Binary file
|
|
Binary file
|
package/runtime/python_bridge.py
CHANGED
|
@@ -7,10 +7,25 @@ subprocess transport and the HTTP transport:
|
|
|
7
7
|
- the stdin/stdout JSONL request/response loop (main())
|
|
8
8
|
- env-var size guards (TYWRAP_CODEC_MAX_BYTES / TYWRAP_REQUEST_MAX_BYTES)
|
|
9
9
|
- TYWRAP_CODEC_FALLBACK=json marker mode and TYWRAP_TORCH_ALLOW_COPY
|
|
10
|
+
- the import/attribute allowlist policy (TYWRAP_ALLOWED_MODULES /
|
|
11
|
+
TYWRAP_ALLOW_PRIVATE_ATTRS), threaded into core.dispatch_request
|
|
10
12
|
- the real OS pid and bridge='python-subprocess' identity
|
|
11
|
-
- the final
|
|
13
|
+
- the final BridgeCodec.encode wrapper (NaN rejection + numpy scalar coercion +
|
|
12
14
|
the explicit byte-size limit error message)
|
|
13
15
|
|
|
16
|
+
SECURITY / TRUST MODEL: the bridge imports the requested module and getattrs the
|
|
17
|
+
requested function/class/method, so call/instantiate/call_method are an arbitrary
|
|
18
|
+
import+getattr+call surface. Two guards (implemented in tywrap_bridge_core) bound
|
|
19
|
+
that surface:
|
|
20
|
+
* TYWRAP_ALLOWED_MODULES (comma/space separated): when set, only those modules
|
|
21
|
+
(plus the stdlib the bridge itself needs) may be imported; anything else fails
|
|
22
|
+
with ImportNotAllowedError. UNSET = no restriction (preserves prior behavior).
|
|
23
|
+
* TYWRAP_ALLOW_PRIVATE_ATTRS=1: opt out of the default block on underscore-prefixed
|
|
24
|
+
(private/dunder) attribute access, which otherwise prevents the classic
|
|
25
|
+
__globals__/__subclasses__/__builtins__ escape chain.
|
|
26
|
+
The bridge does NOT sandbox the called code itself; only trusted Python should be
|
|
27
|
+
exposed. See SECURITY.md for the full threat model.
|
|
28
|
+
|
|
14
29
|
The protocol dispatch, request deserialization, and the 6 __tywrap__ marker
|
|
15
30
|
serializers live in the shared, pure module tywrap_bridge_core (so the in-WASM
|
|
16
31
|
Pyodide server can run the SAME code). Those names are re-exported below for
|
|
@@ -22,7 +37,7 @@ import json
|
|
|
22
37
|
import os
|
|
23
38
|
import importlib # noqa: F401 (re-exported for compat / used by handlers via core)
|
|
24
39
|
|
|
25
|
-
from safe_codec import
|
|
40
|
+
from safe_codec import BridgeCodec, CodecError
|
|
26
41
|
|
|
27
42
|
import tywrap_bridge_core as core
|
|
28
43
|
|
|
@@ -34,6 +49,10 @@ from tywrap_bridge_core import ( # noqa: F401
|
|
|
34
49
|
CODEC_VERSION,
|
|
35
50
|
ProtocolError,
|
|
36
51
|
InstanceHandleError,
|
|
52
|
+
ImportNotAllowedError,
|
|
53
|
+
AttributeNotAllowedError,
|
|
54
|
+
import_allowed_module,
|
|
55
|
+
get_allowed_attr,
|
|
37
56
|
deserialize,
|
|
38
57
|
arrow_available,
|
|
39
58
|
module_available,
|
|
@@ -77,6 +96,33 @@ TORCH_ALLOW_COPY = os.environ.get('TYWRAP_TORCH_ALLOW_COPY', '').lower() in ('1'
|
|
|
77
96
|
BRIDGE_NAME = 'python-subprocess'
|
|
78
97
|
|
|
79
98
|
|
|
99
|
+
def parse_allowed_modules():
|
|
100
|
+
"""
|
|
101
|
+
Parse TYWRAP_ALLOWED_MODULES into an allowlist set, or None when unset.
|
|
102
|
+
|
|
103
|
+
Why: returning None preserves the historical "import anything" behavior so
|
|
104
|
+
existing configurations keep working; supplying the env var (comma- and/or
|
|
105
|
+
whitespace-separated module names) switches the bridge into allowlist mode,
|
|
106
|
+
where only the listed modules plus the stdlib the bridge itself needs may be
|
|
107
|
+
imported. An empty/blank value is treated as unset (no restriction).
|
|
108
|
+
"""
|
|
109
|
+
raw = os.environ.get('TYWRAP_ALLOWED_MODULES')
|
|
110
|
+
if raw is None:
|
|
111
|
+
return None
|
|
112
|
+
names = {name.strip() for chunk in raw.split(',') for name in chunk.split()}
|
|
113
|
+
names.discard('')
|
|
114
|
+
if not names:
|
|
115
|
+
return None
|
|
116
|
+
return frozenset(names)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Why: parse once at startup. None => no allowlist (prior behavior preserved).
|
|
120
|
+
ALLOWED_MODULES = parse_allowed_modules()
|
|
121
|
+
# Why: underscore-prefixed (private/dunder) attribute access is blocked by default
|
|
122
|
+
# to prevent sandbox-escape via __globals__/__subclasses__/__builtins__; this opts out.
|
|
123
|
+
ALLOW_PRIVATE_ATTRS = os.environ.get('TYWRAP_ALLOW_PRIVATE_ATTRS', '').lower() in ('1', 'true', 'yes')
|
|
124
|
+
|
|
125
|
+
|
|
80
126
|
class CodecConfigError(ValueError):
|
|
81
127
|
"""Codec configuration error."""
|
|
82
128
|
|
|
@@ -138,12 +184,12 @@ def get_codec_max_bytes():
|
|
|
138
184
|
# Why: parse once at startup to avoid per-response env lookups.
|
|
139
185
|
CODEC_MAX_BYTES = get_codec_max_bytes()
|
|
140
186
|
|
|
141
|
-
# Why: use
|
|
142
|
-
# edge cases like numpy scalars. We use sys.maxsize for
|
|
187
|
+
# Why: use BridgeCodec for final JSON encoding to reject NaN/Infinity and handle
|
|
188
|
+
# edge cases like numpy scalars. We use sys.maxsize for BridgeCodec's internal limit
|
|
143
189
|
# to preserve the original "no limit unless TYWRAP_CODEC_MAX_BYTES is set" behavior.
|
|
144
190
|
# The explicit size check in encode_response() provides the specific error message
|
|
145
191
|
# mentioning the env var name, which is important for debugging.
|
|
146
|
-
_response_codec =
|
|
192
|
+
_response_codec = BridgeCodec(
|
|
147
193
|
allow_nan=False,
|
|
148
194
|
max_payload_bytes=sys.maxsize,
|
|
149
195
|
)
|
|
@@ -241,6 +287,8 @@ def dispatch_request(msg):
|
|
|
241
287
|
allow_nan=False,
|
|
242
288
|
python_version=sys.version.split()[0],
|
|
243
289
|
torch_allow_copy=TORCH_ALLOW_COPY,
|
|
290
|
+
allowed_modules=ALLOWED_MODULES,
|
|
291
|
+
allow_private_attrs=ALLOW_PRIVATE_ATTRS,
|
|
244
292
|
)
|
|
245
293
|
return out['id'], out['result']
|
|
246
294
|
|
|
@@ -250,7 +298,7 @@ def encode_response(out):
|
|
|
250
298
|
Serialize the response and enforce size limits.
|
|
251
299
|
|
|
252
300
|
Why: keep payload size checks outside the main loop for clarity and lint compliance.
|
|
253
|
-
Uses
|
|
301
|
+
Uses BridgeCodec to reject NaN/Infinity and handle edge cases like numpy scalars.
|
|
254
302
|
"""
|
|
255
303
|
try:
|
|
256
304
|
payload = _response_codec.encode(out)
|
package/runtime/safe_codec.py
CHANGED
|
@@ -87,7 +87,7 @@ def _has_pydantic_model_dump(obj: Any) -> bool:
|
|
|
87
87
|
return callable(model_dump)
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
class
|
|
90
|
+
class BridgeCodec:
|
|
91
91
|
"""
|
|
92
92
|
Safe JSON codec with explicit edge case handling.
|
|
93
93
|
|
|
@@ -103,7 +103,7 @@ class SafeCodec:
|
|
|
103
103
|
max_payload_bytes: Maximum payload size in bytes (default 10MB).
|
|
104
104
|
|
|
105
105
|
Example:
|
|
106
|
-
>>> codec =
|
|
106
|
+
>>> codec = BridgeCodec()
|
|
107
107
|
>>> codec.encode({"key": "value"})
|
|
108
108
|
'{"key": "value"}'
|
|
109
109
|
>>> codec.decode('{"key": "value"}')
|
|
@@ -300,19 +300,19 @@ class SafeCodec:
|
|
|
300
300
|
|
|
301
301
|
|
|
302
302
|
# Module-level convenience instance with default settings
|
|
303
|
-
_default_codec: Optional[
|
|
303
|
+
_default_codec: Optional[BridgeCodec] = None
|
|
304
304
|
|
|
305
305
|
|
|
306
|
-
def get_default_codec() ->
|
|
306
|
+
def get_default_codec() -> BridgeCodec:
|
|
307
307
|
"""
|
|
308
|
-
Get or create the default
|
|
308
|
+
Get or create the default BridgeCodec instance.
|
|
309
309
|
|
|
310
310
|
Returns:
|
|
311
|
-
The default
|
|
311
|
+
The default BridgeCodec instance with standard settings.
|
|
312
312
|
"""
|
|
313
313
|
global _default_codec
|
|
314
314
|
if _default_codec is None:
|
|
315
|
-
_default_codec =
|
|
315
|
+
_default_codec = BridgeCodec()
|
|
316
316
|
return _default_codec
|
|
317
317
|
|
|
318
318
|
|
|
@@ -331,7 +331,7 @@ def encode(value: Any, *, allow_nan: bool = False) -> str:
|
|
|
331
331
|
CodecError: If encoding fails.
|
|
332
332
|
"""
|
|
333
333
|
if allow_nan:
|
|
334
|
-
codec =
|
|
334
|
+
codec = BridgeCodec(allow_nan=True)
|
|
335
335
|
return codec.encode(value)
|
|
336
336
|
return get_default_codec().encode(value)
|
|
337
337
|
|
|
@@ -6,10 +6,10 @@ protocol. It is imported by:
|
|
|
6
6
|
|
|
7
7
|
- runtime/python_bridge.py (the Node/Bun/Deno subprocess server and the HTTP
|
|
8
8
|
server), which owns I/O concerns: the stdin/stdout JSONL loop, env-var size
|
|
9
|
-
guards, the real OS pid, bridge='python-subprocess', and the final
|
|
9
|
+
guards, the real OS pid, bridge='python-subprocess', and the final BridgeCodec
|
|
10
10
|
encode wrapper.
|
|
11
11
|
|
|
12
|
-
- the in-WASM Pyodide server (src/runtime/pyodide-
|
|
12
|
+
- the in-WASM Pyodide server (src/runtime/pyodide-transport.ts). Pyodide cannot read
|
|
13
13
|
this file from disk, so it is shipped as a build-time-generated TypeScript
|
|
14
14
|
string constant (src/runtime/pyodide-bootstrap-core.generated.ts) produced by
|
|
15
15
|
scripts/generate-pyodide-bootstrap.mjs and exec'd into a module registered in
|
|
@@ -17,7 +17,7 @@ protocol. It is imported by:
|
|
|
17
17
|
asserts the generated constant stays byte-identical to this file.
|
|
18
18
|
|
|
19
19
|
CROSS-LANGUAGE CONTRACT (Python <-> the TypeScript decoder in src/utils/codec.ts
|
|
20
|
-
and the request encoder in src/runtime/
|
|
20
|
+
and the request encoder in src/runtime/bridge-codec.ts):
|
|
21
21
|
|
|
22
22
|
* Every value-type "marker" envelope carries {'__tywrap__': <type>,
|
|
23
23
|
'codecVersion': 1, 'encoding': ...}. The 6 markers are: ndarray, dataframe,
|
|
@@ -66,6 +66,123 @@ class InstanceHandleError(ValueError):
|
|
|
66
66
|
"""Raised when an instance handle is unknown or no longer valid."""
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
class ImportNotAllowedError(PermissionError):
|
|
70
|
+
"""Raised when a requested module import is not on the active allowlist."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, module_name):
|
|
73
|
+
super().__init__(
|
|
74
|
+
f'Import of module {module_name!r} is not permitted by the tywrap bridge '
|
|
75
|
+
'allowlist; add it to TYWRAP_ALLOWED_MODULES (subprocess) or the '
|
|
76
|
+
'allowed_modules parameter to enable it'
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AttributeNotAllowedError(PermissionError):
|
|
81
|
+
"""Raised when access to a private/dunder attribute is denied by policy."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, attr_name):
|
|
84
|
+
super().__init__(
|
|
85
|
+
f'Access to attribute {attr_name!r} is not permitted by the tywrap bridge: '
|
|
86
|
+
'underscore-prefixed (private/dunder) attributes are blocked to prevent '
|
|
87
|
+
'sandbox-escape via attributes like __globals__/__subclasses__/__builtins__; '
|
|
88
|
+
'set TYWRAP_ALLOW_PRIVATE_ATTRS=1 (subprocess) or pass allow_private_attrs=True '
|
|
89
|
+
'to override'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# =============================================================================
|
|
94
|
+
# IMPORT / ATTRIBUTE ALLOWLIST (trust boundary enforcement)
|
|
95
|
+
# =============================================================================
|
|
96
|
+
#
|
|
97
|
+
# The bridge dispatches call/instantiate/call_method by importing the requested
|
|
98
|
+
# module and getattr-ing the requested function/class/method. That is an
|
|
99
|
+
# arbitrary import+getattr+call surface, so two complementary guards live here.
|
|
100
|
+
# Both are PURE (no env reads) so the rules behave identically under the
|
|
101
|
+
# subprocess server and the in-WASM Pyodide server; the subprocess server derives
|
|
102
|
+
# the parameters from env vars (TYWRAP_ALLOWED_MODULES / TYWRAP_ALLOW_PRIVATE_ATTRS)
|
|
103
|
+
# and threads them in, exactly like force_json_markers / torch_allow_copy.
|
|
104
|
+
#
|
|
105
|
+
# 1. MODULE ALLOWLIST (opt-in, default = allow all):
|
|
106
|
+
# allowed_modules=None means "no restriction" so existing configurations keep
|
|
107
|
+
# working unchanged. When a caller supplies a set, only those modules (plus the
|
|
108
|
+
# stdlib the bridge itself needs to serialize results, see _BRIDGE_REQUIRED_MODULES)
|
|
109
|
+
# may be imported; submodules of an allowed module are permitted (e.g. allowing
|
|
110
|
+
# 'scipy' also allows 'scipy.sparse'). A non-allowlisted import fails LOUDLY with
|
|
111
|
+
# ImportNotAllowedError rather than silently importing.
|
|
112
|
+
#
|
|
113
|
+
# 2. PRIVATE-ATTRIBUTE BLOCK (default ON):
|
|
114
|
+
# getattr of any name starting with '_' (single-underscore private OR dunder) is
|
|
115
|
+
# rejected. This blocks the classic escape chain (obj.__class__.__subclasses__()
|
|
116
|
+
# /__globals__/__builtins__/__import__) without depending on the module allowlist.
|
|
117
|
+
# tywrap-generated wrappers never reference underscore-prefixed names (the IR
|
|
118
|
+
# analyzer skips them), so this does not regress generated code. Set
|
|
119
|
+
# allow_private_attrs=True to restore unrestricted getattr for trusted callers.
|
|
120
|
+
|
|
121
|
+
# Stdlib modules the bridge's own serialization/handlers may need to import even
|
|
122
|
+
# when a caller-supplied allowlist is active. Optional codec deps (numpy, pandas,
|
|
123
|
+
# scipy, torch, sklearn, pyarrow) are intentionally NOT here: if a caller restricts
|
|
124
|
+
# modules, they must opt those in explicitly. These names cover only what the
|
|
125
|
+
# bridge core itself imports.
|
|
126
|
+
_BRIDGE_REQUIRED_MODULES = frozenset(
|
|
127
|
+
{
|
|
128
|
+
'base64',
|
|
129
|
+
'datetime',
|
|
130
|
+
'decimal',
|
|
131
|
+
'importlib',
|
|
132
|
+
'json',
|
|
133
|
+
'math',
|
|
134
|
+
'sys',
|
|
135
|
+
'traceback',
|
|
136
|
+
'uuid',
|
|
137
|
+
'pathlib',
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _top_level_package(module_name):
|
|
143
|
+
"""Return the top-level package of a dotted module name ('a.b.c' -> 'a')."""
|
|
144
|
+
return module_name.split('.', 1)[0]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_module_allowed(module_name, allowed_modules):
|
|
148
|
+
"""
|
|
149
|
+
Return True when module_name may be imported under the active policy.
|
|
150
|
+
|
|
151
|
+
allowed_modules=None disables enforcement (allow all). Otherwise a module is
|
|
152
|
+
allowed when it (or its top-level package) is explicitly listed, or it is one
|
|
153
|
+
of the stdlib modules the bridge itself requires.
|
|
154
|
+
"""
|
|
155
|
+
if allowed_modules is None:
|
|
156
|
+
return True
|
|
157
|
+
if module_name in allowed_modules or module_name in _BRIDGE_REQUIRED_MODULES:
|
|
158
|
+
return True
|
|
159
|
+
top = _top_level_package(module_name)
|
|
160
|
+
return top in allowed_modules or top in _BRIDGE_REQUIRED_MODULES
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def import_allowed_module(module_name, allowed_modules):
|
|
164
|
+
"""
|
|
165
|
+
Import module_name only if permitted by the allowlist, else raise loudly.
|
|
166
|
+
|
|
167
|
+
This is the single chokepoint every handler routes module imports through.
|
|
168
|
+
"""
|
|
169
|
+
if not _is_module_allowed(module_name, allowed_modules):
|
|
170
|
+
raise ImportNotAllowedError(module_name)
|
|
171
|
+
return importlib.import_module(module_name)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_allowed_attr(obj, attr_name, *, allow_private_attrs):
|
|
175
|
+
"""
|
|
176
|
+
getattr(obj, attr_name) with the private/dunder block applied.
|
|
177
|
+
|
|
178
|
+
Rejects any underscore-prefixed name unless allow_private_attrs is True. This
|
|
179
|
+
is the single chokepoint every handler routes attribute access through.
|
|
180
|
+
"""
|
|
181
|
+
if not allow_private_attrs and attr_name.startswith('_'):
|
|
182
|
+
raise AttributeNotAllowedError(attr_name)
|
|
183
|
+
return getattr(obj, attr_name)
|
|
184
|
+
|
|
185
|
+
|
|
69
186
|
class CodecError(Exception):
|
|
70
187
|
"""Raised when value encoding fails (e.g. NaN/Infinity not allowed)."""
|
|
71
188
|
|
|
@@ -85,10 +202,10 @@ def _deserialize_bytes_envelope(value):
|
|
|
85
202
|
Decode base64-encoded bytes envelopes from JS into Python bytes.
|
|
86
203
|
|
|
87
204
|
Supported shapes:
|
|
88
|
-
- { "__tywrap_bytes__": true, "b64": "..." } (JS
|
|
205
|
+
- { "__tywrap_bytes__": true, "b64": "..." } (JS BridgeCodec.encodeRequest)
|
|
89
206
|
- { "__type__": "bytes", "encoding": "base64", "data": "..." } (legacy/compat)
|
|
90
207
|
|
|
91
|
-
Why: TS
|
|
208
|
+
Why: TS BridgeCodec encodes Uint8Array/ArrayBuffer as base64 objects, but
|
|
92
209
|
Python handlers expect real bytes/bytearray to preserve behavior (e.g., len()).
|
|
93
210
|
"""
|
|
94
211
|
if not isinstance(value, dict):
|
|
@@ -530,7 +647,7 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
|
|
|
530
647
|
"""
|
|
531
648
|
Top-level result serializer. Dispatch order is significant: numpy ndarray ->
|
|
532
649
|
dataframe -> series -> scipy.sparse -> torch -> sklearn -> Pydantic -> stdlib
|
|
533
|
-
-> passthrough. The remaining
|
|
650
|
+
-> passthrough. The remaining BridgeCodec value behaviors (numpy/pandas scalars,
|
|
534
651
|
bytes, sets, complex rejection, NaN/Infinity) are applied later during JSON
|
|
535
652
|
encoding by default_encoder.
|
|
536
653
|
"""
|
|
@@ -558,12 +675,12 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
|
|
|
558
675
|
|
|
559
676
|
|
|
560
677
|
# =============================================================================
|
|
561
|
-
# JSON ENCODE:
|
|
678
|
+
# JSON ENCODE: BridgeCodec-equivalent value handling (NaN reject, scalars, bytes)
|
|
562
679
|
# =============================================================================
|
|
563
680
|
#
|
|
564
|
-
# This mirrors
|
|
681
|
+
# This mirrors BridgeCodec._default_encoder (runtime/safe_codec.py) for the VALUE
|
|
565
682
|
# behaviors that are part of the wire contract. The subprocess server still uses
|
|
566
|
-
# the real
|
|
683
|
+
# the real BridgeCodec for its final encode (it also enforces size limits); this
|
|
567
684
|
# core encoder exists so the Pyodide server gets identical value handling without
|
|
568
685
|
# depending on safe_codec.py. The conformance suite asserts these behaviors match.
|
|
569
686
|
|
|
@@ -594,7 +711,7 @@ def _is_pandas_scalar(obj):
|
|
|
594
711
|
|
|
595
712
|
def make_default_encoder(*, allow_nan):
|
|
596
713
|
"""
|
|
597
|
-
Build a json.dumps default= encoder matching
|
|
714
|
+
Build a json.dumps default= encoder matching BridgeCodec's value handling.
|
|
598
715
|
|
|
599
716
|
Raises CodecError for NaN/Infinity extracted from numpy scalars (json.dumps
|
|
600
717
|
itself rejects top-level/nested NaN/Infinity floats when allow_nan=False).
|
|
@@ -663,11 +780,11 @@ def make_default_encoder(*, allow_nan):
|
|
|
663
780
|
|
|
664
781
|
def encode_value(value, *, allow_nan):
|
|
665
782
|
"""
|
|
666
|
-
JSON-encode a fully-serialized response value, applying the
|
|
783
|
+
JSON-encode a fully-serialized response value, applying the BridgeCodec-equivalent
|
|
667
784
|
default encoder and rejecting NaN/Infinity when allow_nan is False.
|
|
668
785
|
|
|
669
786
|
Raises CodecError (wrapping the json.dumps ValueError) on NaN/Infinity, matching
|
|
670
|
-
|
|
787
|
+
BridgeCodec's "Cannot serialize NaN..." wording so error parity holds.
|
|
671
788
|
"""
|
|
672
789
|
try:
|
|
673
790
|
return json.dumps(value, default=make_default_encoder(allow_nan=allow_nan), allow_nan=allow_nan)
|
|
@@ -729,31 +846,31 @@ def coerce_dict(value, key):
|
|
|
729
846
|
return value
|
|
730
847
|
|
|
731
848
|
|
|
732
|
-
def handle_call(params, *, force_json_markers, torch_allow_copy):
|
|
849
|
+
def handle_call(params, *, force_json_markers, torch_allow_copy, allowed_modules, allow_private_attrs):
|
|
733
850
|
module_name = require_str(params, 'module')
|
|
734
851
|
function_name = require_str(params, 'functionName')
|
|
735
852
|
args = deserialize(coerce_list(params.get('args'), 'args'))
|
|
736
853
|
kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
|
|
737
|
-
mod =
|
|
738
|
-
func =
|
|
854
|
+
mod = import_allowed_module(module_name, allowed_modules)
|
|
855
|
+
func = get_allowed_attr(mod, function_name, allow_private_attrs=allow_private_attrs)
|
|
739
856
|
res = func(*args, **kwargs)
|
|
740
857
|
return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
|
|
741
858
|
|
|
742
859
|
|
|
743
|
-
def handle_instantiate(params, instances):
|
|
860
|
+
def handle_instantiate(params, instances, *, allowed_modules, allow_private_attrs):
|
|
744
861
|
module_name = require_str(params, 'module')
|
|
745
862
|
class_name = require_str(params, 'className')
|
|
746
863
|
args = deserialize(coerce_list(params.get('args'), 'args'))
|
|
747
864
|
kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
|
|
748
|
-
mod =
|
|
749
|
-
cls =
|
|
865
|
+
mod = import_allowed_module(module_name, allowed_modules)
|
|
866
|
+
cls = get_allowed_attr(mod, class_name, allow_private_attrs=allow_private_attrs)
|
|
750
867
|
obj = cls(*args, **kwargs)
|
|
751
868
|
handle_id = str(id(obj))
|
|
752
869
|
instances[handle_id] = obj
|
|
753
870
|
return handle_id
|
|
754
871
|
|
|
755
872
|
|
|
756
|
-
def handle_call_method(params, instances, *, force_json_markers, torch_allow_copy):
|
|
873
|
+
def handle_call_method(params, instances, *, force_json_markers, torch_allow_copy, allow_private_attrs):
|
|
757
874
|
handle_id = require_str(params, 'handle')
|
|
758
875
|
method_name = require_str(params, 'methodName')
|
|
759
876
|
args = deserialize(coerce_list(params.get('args'), 'args'))
|
|
@@ -761,7 +878,7 @@ def handle_call_method(params, instances, *, force_json_markers, torch_allow_cop
|
|
|
761
878
|
if handle_id not in instances:
|
|
762
879
|
raise InstanceHandleError(f'Unknown instance handle: {handle_id}')
|
|
763
880
|
obj = instances[handle_id]
|
|
764
|
-
func =
|
|
881
|
+
func = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)
|
|
765
882
|
res = func(*args, **kwargs)
|
|
766
883
|
return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
|
|
767
884
|
|
|
@@ -815,6 +932,8 @@ def dispatch_request(
|
|
|
815
932
|
python_version=None,
|
|
816
933
|
torch_allow_copy=False,
|
|
817
934
|
arrow_available_override=None,
|
|
935
|
+
allowed_modules=None,
|
|
936
|
+
allow_private_attrs=False,
|
|
818
937
|
):
|
|
819
938
|
"""
|
|
820
939
|
Validate and route a request, returning the fully-serialized response dict
|
|
@@ -824,6 +943,13 @@ def dispatch_request(
|
|
|
824
943
|
|
|
825
944
|
allow_nan is accepted for signature symmetry; NaN rejection happens during
|
|
826
945
|
the final encode_value() call, which the caller performs.
|
|
946
|
+
|
|
947
|
+
allowed_modules: None (default) disables the import allowlist so existing
|
|
948
|
+
behavior is preserved. Supplying a set restricts call/instantiate imports to
|
|
949
|
+
those modules (plus the stdlib the bridge itself needs) and raises
|
|
950
|
+
ImportNotAllowedError otherwise. allow_private_attrs=False (default) blocks
|
|
951
|
+
getattr of underscore-prefixed names; True restores unrestricted access. See
|
|
952
|
+
the IMPORT / ATTRIBUTE ALLOWLIST section above for the full trust model.
|
|
827
953
|
"""
|
|
828
954
|
mid = require_protocol(msg)
|
|
829
955
|
method = msg.get('method')
|
|
@@ -832,13 +958,23 @@ def dispatch_request(
|
|
|
832
958
|
params = coerce_dict(msg.get('params'), 'params')
|
|
833
959
|
if method == 'call':
|
|
834
960
|
result = handle_call(
|
|
835
|
-
params,
|
|
961
|
+
params,
|
|
962
|
+
force_json_markers=force_json_markers,
|
|
963
|
+
torch_allow_copy=torch_allow_copy,
|
|
964
|
+
allowed_modules=allowed_modules,
|
|
965
|
+
allow_private_attrs=allow_private_attrs,
|
|
836
966
|
)
|
|
837
967
|
elif method == 'instantiate':
|
|
838
|
-
result = handle_instantiate(
|
|
968
|
+
result = handle_instantiate(
|
|
969
|
+
params, instances, allowed_modules=allowed_modules, allow_private_attrs=allow_private_attrs
|
|
970
|
+
)
|
|
839
971
|
elif method == 'call_method':
|
|
840
972
|
result = handle_call_method(
|
|
841
|
-
params,
|
|
973
|
+
params,
|
|
974
|
+
instances,
|
|
975
|
+
force_json_markers=force_json_markers,
|
|
976
|
+
torch_allow_copy=torch_allow_copy,
|
|
977
|
+
allow_private_attrs=allow_private_attrs,
|
|
842
978
|
)
|
|
843
979
|
elif method == 'dispose_instance':
|
|
844
980
|
result = handle_dispose_instance(params, instances)
|
package/src/cli.ts
CHANGED
|
@@ -46,23 +46,22 @@ function parseModules(modulesList?: string): string[] {
|
|
|
46
46
|
.filter(Boolean);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function buildModulesConfig(
|
|
50
|
-
|
|
51
|
-
runtime
|
|
52
|
-
|
|
53
|
-
return Object.fromEntries(modules.map(name => [name, {
|
|
49
|
+
function buildModulesConfig(modules: string[]): TywrapOptions['pythonModules'] {
|
|
50
|
+
// The per-module `runtime` field is dead input (never read during generation);
|
|
51
|
+
// the active runtime is resolved from the top-level `runtime` config. Seed only
|
|
52
|
+
// the fields that actually affect generation.
|
|
53
|
+
return Object.fromEntries(modules.map(name => [name, { typeHints: 'strict' }]));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function renderConfigTemplate(options: {
|
|
57
57
|
format: 'ts' | 'json';
|
|
58
58
|
modules: string[];
|
|
59
|
-
runtime: RuntimeStrategy;
|
|
60
59
|
outputDir: string;
|
|
61
60
|
}): string {
|
|
62
61
|
const modules = options.modules.length > 0 ? options.modules : ['math'];
|
|
63
62
|
if (options.format === 'json') {
|
|
64
63
|
const config = {
|
|
65
|
-
pythonModules: buildModulesConfig(modules
|
|
64
|
+
pythonModules: buildModulesConfig(modules),
|
|
66
65
|
output: { dir: options.outputDir, format: 'esm', declaration: false, sourceMap: false },
|
|
67
66
|
runtime: { node: { pythonPath: 'python3' } },
|
|
68
67
|
types: { presets: ['stdlib'] },
|
|
@@ -71,9 +70,7 @@ function renderConfigTemplate(options: {
|
|
|
71
70
|
}
|
|
72
71
|
|
|
73
72
|
const moduleLines = modules
|
|
74
|
-
.map(
|
|
75
|
-
name => ` ${JSON.stringify(name)}: { runtime: '${options.runtime}', typeHints: 'strict' },`
|
|
76
|
-
)
|
|
73
|
+
.map(name => ` ${JSON.stringify(name)}: { typeHints: 'strict' },`)
|
|
77
74
|
.join('\n');
|
|
78
75
|
return `import { defineConfig } from 'tywrap';
|
|
79
76
|
|
|
@@ -230,7 +227,7 @@ async function main(): Promise<void> {
|
|
|
230
227
|
|
|
231
228
|
const overrides: NonNullable<ResolveConfigOptions['overrides']> = {};
|
|
232
229
|
if (modules.length > 0) {
|
|
233
|
-
overrides.pythonModules = buildModulesConfig(modules
|
|
230
|
+
overrides.pythonModules = buildModulesConfig(modules);
|
|
234
231
|
}
|
|
235
232
|
|
|
236
233
|
if (
|
|
@@ -456,7 +453,6 @@ async function main(): Promise<void> {
|
|
|
456
453
|
const content = renderConfigTemplate({
|
|
457
454
|
format: argv.format,
|
|
458
455
|
modules,
|
|
459
|
-
runtime: argv.runtime,
|
|
460
456
|
outputDir: argv.outputDir,
|
|
461
457
|
});
|
|
462
458
|
|