Skip to content

Commit b344f54

Browse files
authored
Merge pull request #311 from bbopen/feat/0.10-nested-produce
feat(runtime): serialize nested scientific values
2 parents 7d2ca33 + bf7ba7e commit b344f54

7 files changed

Lines changed: 555 additions & 33 deletions

File tree

runtime/tywrap_bridge_core.py

Lines changed: 188 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import importlib.util
4848
import json
4949
import math
50+
import re
5051
import sys
5152
import traceback
5253
import uuid
@@ -57,6 +58,9 @@
5758
PROTOCOL = 'tywrap/1'
5859
PROTOCOL_VERSION = 1
5960
CODEC_VERSION = 1
61+
MAX_SERIALIZE_DEPTH = 900
62+
MAX_SERIALIZE_NODES = 1_000_000
63+
_SERIALIZE_PATH_IDENTIFIER = re.compile(r'^[A-Za-z_$][\w$]*$', re.ASCII)
6064

6165

6266
class ProtocolError(Exception):
@@ -813,34 +817,48 @@ def serialize_stdlib(obj):
813817
return None
814818

815819

816-
def serialize(obj, *, force_json_markers, torch_allow_copy=False):
817-
"""
818-
Top-level result serializer.
820+
_NO_SCIENTIFIC = object()
819821

820-
Scientific codecs are type-first and only inspect packages that the value can
821-
belong to. A value from an optional package implies that package is already in
822-
sys.modules, so these checks never cold-import the scientific stack. The
823-
package dispatch deliberately precedes the JSON-native fast path: e.g. a
824-
package-defined subclass of dict still receives its relevant codec check.
825-
The remaining BridgeCodec value behaviors (numpy/pandas scalars, bytes, sets,
826-
complex rejection, NaN/Infinity) are applied later during JSON encoding by
827-
default_encoder.
828-
"""
822+
823+
def _check_serialize_depth(depth, path):
824+
if depth > MAX_SERIALIZE_DEPTH:
825+
raise RuntimeError(
826+
f'Scientific envelope serialization maximum depth '
827+
f'{MAX_SERIALIZE_DEPTH} exceeded at {path}'
828+
)
829+
830+
831+
def _check_serialize_nodes(nodes, path):
832+
if nodes > MAX_SERIALIZE_NODES:
833+
raise RuntimeError(
834+
f'Scientific envelope serialization maximum visited nodes '
835+
f'{MAX_SERIALIZE_NODES} exceeded at {path}'
836+
)
837+
838+
839+
def _serialize_scientific(obj, *, force_json_markers, torch_allow_copy, depth, path):
840+
"""Serialize a supported scientific value, or return _NO_SCIENTIFIC."""
829841
package = type(obj).__module__.split('.', 1)[0]
830842

831843
if package == 'numpy' and 'numpy' in sys.modules:
832844
if is_numpy_array(obj):
845+
_check_serialize_depth(depth, path)
833846
return serialize_ndarray(obj, force_json_markers=force_json_markers)
834847
elif package == 'pandas' and 'pandas' in sys.modules:
835848
if is_pandas_dataframe(obj):
849+
_check_serialize_depth(depth, path)
836850
return serialize_dataframe(obj, force_json_markers=force_json_markers)
837851
if is_pandas_series(obj):
852+
_check_serialize_depth(depth, path)
838853
return serialize_series(obj, force_json_markers=force_json_markers)
839854
elif package == 'scipy' and 'scipy.sparse' in sys.modules:
840855
if is_scipy_sparse(obj):
856+
_check_serialize_depth(depth, path)
841857
return serialize_sparse_matrix(obj)
842858
elif package == 'torch' and 'torch' in sys.modules:
843859
if is_torch_tensor(obj):
860+
_check_serialize_depth(depth, path)
861+
_check_serialize_depth(depth + 1, _serialize_path(path, 'value'))
844862
return serialize_torch_tensor(
845863
obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
846864
)
@@ -849,18 +867,168 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
849867
# BaseEstimator is sklearn's documented extension point, so user-defined
850868
# estimators live outside the 'sklearn' package and must still get the
851869
# estimator serializer (and its param-naming errors).
870+
_check_serialize_depth(depth, path)
852871
return serialize_sklearn_estimator(obj)
853872

854-
if isinstance(obj, (type(None), bool, int, float, str, dict, list, tuple)):
855-
return obj
873+
return _NO_SCIENTIFIC
874+
875+
876+
def _serialize_path(base, key):
877+
"""Build a decoder-compatible JSONPath-like result path."""
878+
if isinstance(key, int):
879+
return f'{base}[{key}]'
880+
if _SERIALIZE_PATH_IDENTIFIER.fullmatch(key):
881+
return f'{base}.{key}'
882+
return f'{base}[{json.dumps(key, ensure_ascii=False)}]'
856883

857-
pydantic_value = serialize_pydantic(obj)
884+
885+
def _invalid_key_path(base, key):
886+
"""Name a dict key that cannot be represented by JSON."""
887+
return f'{base}[{key!r}]'
888+
889+
890+
def _needs_serialize_visit(value):
891+
"""Return whether value needs container or scientific traversal work."""
892+
if type(value) in (type(None), bool, int, float, str):
893+
return False
894+
if type(value) in (dict, list, tuple):
895+
return True
896+
package = type(value).__module__.split('.', 1)[0]
897+
if package in ('numpy', 'pandas', 'scipy', 'torch'):
898+
return True
899+
return 'sklearn.base' in sys.modules and is_sklearn_estimator(value)
900+
901+
902+
def _serialize_leaf(value):
903+
"""Apply non-container conversions without allocating a traversal frame."""
904+
if type(value) in (type(None), bool, int, float, str):
905+
return value
906+
pydantic_value = serialize_pydantic(value)
858907
if pydantic_value is not _NO_PYDANTIC:
859908
return pydantic_value
860-
stdlib_value = serialize_stdlib(obj)
909+
stdlib_value = serialize_stdlib(value)
861910
if stdlib_value is not None:
862911
return stdlib_value
863-
return obj
912+
return value
913+
914+
915+
def serialize(obj, *, force_json_markers, torch_allow_copy=False):
916+
"""
917+
Top-level result serializer.
918+
919+
Scientific codecs are type-first and only inspect packages that the value can
920+
belong to. A value from an optional package implies that package is already in
921+
sys.modules, so these checks never cold-import the scientific stack. The
922+
package dispatch deliberately precedes the JSON-native fast path: e.g. a
923+
package-defined subclass of dict still receives its relevant codec check.
924+
Every other value is left untouched so the shared JSON encoder applies the
925+
exact same default conversion at the root and at any nested depth.
926+
"""
927+
root = [None]
928+
active_ids = set()
929+
stack = [('visit', obj, 0, 'result', root, 0)]
930+
visited_nodes = 0
931+
932+
# Repeated aliases have value semantics and are intentionally serialized twice.
933+
while stack:
934+
frame = stack.pop()
935+
action = frame[0]
936+
if action == 'dict':
937+
_, current, depth, path, parent, key, output, iterator = frame
938+
try:
939+
item_key, item = next(iterator)
940+
except StopIteration:
941+
active_ids.remove(id(current))
942+
parent[key] = output
943+
continue
944+
stack.append(frame)
945+
if not (isinstance(item_key, (str, int, float, bool)) or item_key is None):
946+
invalid_path = _invalid_key_path(path, item_key)
947+
raise TypeError(
948+
f'keys must be str, int, float, bool or None, not '
949+
f'{type(item_key).__name__} at {invalid_path}'
950+
)
951+
child_key = next(iter(json.loads(json.dumps({item_key: None}))))
952+
child_path = _serialize_path(path, child_key)
953+
if _needs_serialize_visit(item):
954+
stack.append(('visit', item, depth + 1, child_path, output, item_key))
955+
else:
956+
output[item_key] = _serialize_leaf(item)
957+
continue
958+
if action == 'sequence':
959+
_, current, depth, path, parent, key, output, index = frame
960+
if index == len(output):
961+
active_ids.remove(id(current))
962+
parent[key] = output if type(current) is list else tuple(output)
963+
continue
964+
stack.append(('sequence', current, depth, path, parent, key, output, index + 1))
965+
item = current[index]
966+
if _needs_serialize_visit(item):
967+
stack.append(
968+
('visit', item, depth + 1, _serialize_path(path, index), output, index)
969+
)
970+
else:
971+
output[index] = _serialize_leaf(item)
972+
continue
973+
974+
_, current, depth, path, parent, key = frame
975+
try:
976+
scientific = _serialize_scientific(
977+
current,
978+
force_json_markers=force_json_markers,
979+
torch_allow_copy=torch_allow_copy,
980+
depth=depth,
981+
path=path,
982+
)
983+
except Exception as exc:
984+
if path == 'result':
985+
raise
986+
raise RuntimeError(f'Scientific value serialization failed at {path}: {exc}') from exc
987+
if scientific is not _NO_SCIENTIFIC:
988+
# Recognized envelopes are terminal containers to the JS decoder.
989+
visited_nodes += 1
990+
_check_serialize_nodes(visited_nodes, path)
991+
if scientific.get('__tywrap__') == 'torch.tensor':
992+
nested_path = _serialize_path(path, 'value')
993+
try:
994+
visited_nodes += 1
995+
_check_serialize_nodes(visited_nodes, nested_path)
996+
except Exception as exc:
997+
if path == 'result':
998+
raise
999+
raise RuntimeError(
1000+
f'Scientific value serialization failed at {path}: {exc}'
1001+
) from exc
1002+
parent[key] = scientific
1003+
continue
1004+
1005+
container_type = type(current)
1006+
if container_type in (dict, list, tuple):
1007+
_check_serialize_depth(depth, path)
1008+
visited_nodes += 1
1009+
_check_serialize_nodes(visited_nodes, path)
1010+
current_id = id(current)
1011+
if current_id in active_ids:
1012+
raise RuntimeError(f'Circular reference detected at {path}')
1013+
active_ids.add(current_id)
1014+
1015+
if container_type is dict:
1016+
output = {}
1017+
parent[key] = output
1018+
stack.append(
1019+
('dict', current, depth, path, parent, key, output, iter(current.items()))
1020+
)
1021+
continue
1022+
1023+
output = [None] * len(current)
1024+
if container_type is list:
1025+
parent[key] = output
1026+
stack.append(('sequence', current, depth, path, parent, key, output, 0))
1027+
continue
1028+
1029+
parent[key] = _serialize_leaf(current)
1030+
1031+
return root[0]
8641032

8651033

8661034
# =============================================================================
@@ -982,12 +1150,10 @@ def encode_value(value, *, allow_nan):
9821150
# ("...not JSON compliant: nan"), but 3.10/3.11 emit only the canonical
9831151
# "Out of range float values are not JSON compliant". Match that phrase too
9841152
# so the typed error message is stable across versions.
1153+
nonfinite_token = re.search(r'(^|[^a-z])(nan|inf|infinity)([^a-z]|$)', error_msg)
9851154
if (
986-
'nan' in error_msg
987-
or 'infinity' in error_msg
988-
or 'inf' in error_msg
989-
or 'out of range float' in error_msg
990-
):
1155+
not allow_nan and 'out of range float values are not json compliant' in error_msg
1156+
) or nonfinite_token:
9911157
raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON') from exc
9921158
raise CodecError(f'JSON encoding failed: {exc}') from exc
9931159
except TypeError as exc:

src/runtime/pyodide-bootstrap-core.generated.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

test/menagerie/fixtures/library_torture.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def numpy_adversarial() -> object:
1919
return {
2020
"array": np.array([2**53 + 1, 2**63 - 1], dtype=np.int64),
2121
"scalar": np.int64(2**53 + 1),
22-
"nan_column": np.array([1.0, np.nan]),
22+
"float_column": np.array([1.0, 2.5]),
2323
}
2424

2525

@@ -40,6 +40,22 @@ def pandas_adversarial() -> object:
4040
}
4141

4242

43+
def pandas_nested_list() -> object:
44+
import pandas as pd
45+
46+
return [pd.DataFrame({"value": [1]}), pd.DataFrame({"value": [2]})]
47+
48+
49+
def sklearn_projection_result() -> object:
50+
import numpy as np
51+
import pandas as pd
52+
53+
return {
54+
"samples": pd.DataFrame({"sample": ["alpha", "beta"]}),
55+
"projection": np.array([[1.5, -2.0], [3.25, 4.5]], dtype=np.float64),
56+
}
57+
58+
4359
def networkx_tuple_key_shape() -> object:
4460
import networkx as nx
4561

test/menagerie/libs.test.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ function makeBridge(): NodeBridge {
3333
});
3434
}
3535

36+
interface ArrowTableLike {
37+
toArray(): unknown[];
38+
}
39+
40+
function tableRows(value: ArrowTableLike): unknown[] {
41+
return Array.from(value.toArray(), row =>
42+
typeof row === 'object' && row !== null ? Object.fromEntries(Object.entries(row)) : row
43+
);
44+
}
45+
3646
describe.skipIf(!bridgeAvailable)('menagerie optional-library gate', () => {
3747
it.skipIf(!hasPydanticV2())(
3848
'round-trips pydantic v2 through model_dump',
@@ -52,29 +62,56 @@ describe.skipIf(!bridgeAvailable)('menagerie optional-library gate', () => {
5262
15_000
5363
);
5464

55-
it.skipIf(!hasPythonModule('numpy'))(
56-
'records numpy scalar, int64, and NaN behavior',
65+
it.skipIf(!hasPythonModule('numpy') || !hasPythonModule('pyarrow'))(
66+
'round-trips ndarrays nested in a mapping while retaining scalar behavior',
5767
async () => {
5868
const bridge = makeBridge();
5969
try {
6070
await expect(
6171
bridge.call('fixtures.library_torture', 'numpy_adversarial', [])
62-
).rejects.toThrow(/NaN|Infinity|serialize|ndarray/i);
72+
).resolves.toEqual({
73+
array: [9007199254740993n, 9223372036854775807n],
74+
scalar: 9007199254740992,
75+
float_column: [1, 2.5],
76+
});
6377
} finally {
6478
await bridge.dispose();
6579
}
6680
},
6781
15_000
6882
);
6983

70-
it.skipIf(!hasPythonModule('pandas'))(
71-
'fails loudly for timezone, categorical, MultiIndex, and empty frames nested in a mapping',
84+
it.skipIf(!hasPythonModule('pandas') || !hasPythonModule('pyarrow'))(
85+
'round-trips timezone, categorical, MultiIndex, and empty frames nested in a mapping',
7286
async () => {
7387
const bridge = makeBridge();
7488
try {
75-
await expect(
76-
bridge.call('fixtures.library_torture', 'pandas_adversarial', [])
77-
).rejects.toThrow(/DataFrame|serialize/i);
89+
const result = await bridge.call<Record<string, ArrowTableLike>>(
90+
'fixtures.library_torture',
91+
'pandas_adversarial',
92+
[]
93+
);
94+
expect(tableRows(result.frame)).toEqual([{ when: 1704067200000, category: 'a' }]);
95+
expect(tableRows(result.multi)).toEqual([{ value: 1n, side: 'left', n: 1n }]);
96+
expect(tableRows(result.empty)).toEqual([]);
97+
} finally {
98+
await bridge.dispose();
99+
}
100+
},
101+
15_000
102+
);
103+
104+
it.skipIf(!hasPythonModule('pandas') || !hasPythonModule('pyarrow'))(
105+
'round-trips DataFrames nested in a list',
106+
async () => {
107+
const bridge = makeBridge();
108+
try {
109+
const result = await bridge.call<ArrowTableLike[]>(
110+
'fixtures.library_torture',
111+
'pandas_nested_list',
112+
[]
113+
);
114+
expect(result.map(tableRows)).toEqual([[{ value: 1n }], [{ value: 2n }]]);
78115
} finally {
79116
await bridge.dispose();
80117
}

0 commit comments

Comments
 (0)