Skip to content

Commit 921336a

Browse files
daiyippyglove authors
authored andcommitted
# Enabling Flexible Deserialization with Unknown Types in PyGlove
We're introducing the option to use `pg.from_json(..., convert_unknown=True)` to allow deserialization to proceed even when the original Python class, function, or method definitions are not available at runtime. ## Motivation: the problem of missing type definitions When serializing and deserializing complex objects with PyGlove, an issue often arises when the code defining parts of the object (like a specific class `A` or a function `foo` from the example code below) is inaccessible in the deserializing environment (e.g., in a different process or a service that only handles data). By default, PyGlove would raise an error. ## Solution: `UnknownSymbol` objects With `convert_unknown=True`, PyGlove no longer fails. Instead, it converts these missing types into specialized, dictionary-like `UnknownSymbol` objects, which bypass PyGlove type checking: For unknown typed objects: An instance of a missing class (e.g., `A(x=1)`) is deserialized as a `pg.symbolic.UnknownTypedObject`. This object behaves like a dictionary, allowing access to the serialized attributes (e.g., `x` and `y`). Crucially, its original class name is preserved and accessible via its `type_name` property. For unknown types, functions, and methods: Similarly, PyGlove converts these missing definitions into their respective symbolic representations: * `pg.symbolic.UnknownType` * `pg.symbolic.UnknownFunction` * `pg.symbolic.UnknownMethod` This mechanism allows you to load configuration files or serialized objects without requiring a full definition of every single dependency, treating the missing parts as plain data while retaining essential metadata about their original type. ## Example Process 1: Serialization (Definitions are present) ```python import pyglove as pg class A(pg.Object): x: int y: str def foo(t): return t + 1 # Save the complex object and function to a file. pg.save(dict(a=A(x=1, y='hello'), b=foo), '/path/to/data.json') ``` Process 2: Deserialization (Definitions for A and foo are missing) ```python import pyglove as pg # Class A and function foo are NOT defined in this environment. # Load the data, allowing conversion of unknown types. v = pg.load('/path/to/data.json', convert_unknown=True) # The instance of A is loaded as an UnknownTypedObject. print(v.a.type_name) # Output: '__main__.A' print(v.a.x) # Output: 1 # The function foo is loaded as an UnknownFunction. print(v.b.name) # Output: '__main__.foo' # Verification of types: assert isinstance(v.a, pg.symbolic.UnknownTypedObject) assert isinstance(v.b, pg.symbolic.UnknownFunction) ``` PiperOrigin-RevId: 833908094
1 parent 272e4a7 commit 921336a

File tree

10 files changed

+493
-79
lines changed

10 files changed

+493
-79
lines changed

pyglove/core/symbolic/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,11 @@
147147
from pyglove.core.symbolic.base import WritePermissionError
148148
from pyglove.core.symbolic.error_info import ErrorInfo
149149

150+
# Unknown symbols.
151+
from pyglove.core.symbolic.unknown_symbols import UnknownSymbol
152+
from pyglove.core.symbolic.unknown_symbols import UnknownType
153+
from pyglove.core.symbolic.unknown_symbols import UnknownFunction
154+
from pyglove.core.symbolic.unknown_symbols import UnknownMethod
155+
from pyglove.core.symbolic.unknown_symbols import UnknownTypedObject
156+
150157
# pylint: enable=g-bad-import-order

pyglove/core/symbolic/base.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2042,7 +2042,7 @@ def from_json(
20422042
context: Optional[utils.JSONConversionContext] = None,
20432043
auto_symbolic: bool = True,
20442044
auto_import: bool = True,
2045-
auto_dict: bool = False,
2045+
convert_unknown: bool = False,
20462046
allow_partial: bool = False,
20472047
root_path: Optional[utils.KeyPath] = None,
20482048
value_spec: Optional[pg_typing.ValueSpec] = None,
@@ -2073,8 +2073,13 @@ class A(pg.Object):
20732073
identify its parent module and automatically import it. For example,
20742074
if the type is 'foo.bar.A', PyGlove will try to import 'foo.bar' and
20752075
find the class 'A' within the imported module.
2076-
auto_dict: If True, dict with '_type' that cannot be loaded will remain
2077-
as dict, with '_type' renamed to 'type_name'.
2076+
convert_unknown: If True, when a '_type' is not registered and cannot
2077+
be imported, PyGlove will create objects of:
2078+
- `pg.symbolic.UnknownType` for unknown types;
2079+
- `pg.symbolic.UnknownTypedObject` for objects of unknown types;
2080+
- `pg.symbolic.UnknownFunction` for unknown functions;
2081+
- `pg.symbolic.UnknownMethod` for unknown methods.
2082+
If False, TypeError will be raised.
20782083
allow_partial: Whether to allow elements of the list to be partial.
20792084
root_path: KeyPath of loaded object in its object tree.
20802085
value_spec: The value spec for the symbolic list or dict.
@@ -2095,15 +2100,20 @@ class A(pg.Object):
20952100
if context is None:
20962101
if (isinstance(json_value, dict) and (
20972102
context_node := json_value.get(utils.JSONConvertible.CONTEXT_KEY))):
2098-
context = utils.JSONConversionContext.from_json(context_node, **kwargs)
2103+
context = utils.JSONConversionContext.from_json(
2104+
context_node,
2105+
auto_import=auto_import,
2106+
convert_unknown=convert_unknown,
2107+
**kwargs
2108+
)
20992109
json_value = json_value[utils.JSONConvertible.ROOT_VALUE_KEY]
21002110
else:
21012111
context = utils.JSONConversionContext()
21022112

21032113
typename_resolved = kwargs.pop('_typename_resolved', False)
21042114
if not typename_resolved:
21052115
json_value = utils.json_conversion.resolve_typenames(
2106-
json_value, auto_import, auto_dict
2116+
json_value, auto_import, convert_unknown
21072117
)
21082118

21092119
def _load_child(k, v):
@@ -2177,7 +2187,7 @@ def from_json_str(
21772187
*,
21782188
context: Optional[utils.JSONConversionContext] = None,
21792189
auto_import: bool = True,
2180-
auto_dict: bool = False,
2190+
convert_unknown: bool = False,
21812191
allow_partial: bool = False,
21822192
root_path: Optional[utils.KeyPath] = None,
21832193
value_spec: Optional[pg_typing.ValueSpec] = None,
@@ -2205,8 +2215,13 @@ class A(pg.Object):
22052215
identify its parent module and automatically import it. For example,
22062216
if the type is 'foo.bar.A', PyGlove will try to import 'foo.bar' and
22072217
find the class 'A' within the imported module.
2208-
auto_dict: If True, dict with '_type' that cannot be loaded will remain
2209-
as dict, with '_type' renamed to 'type_name'.
2218+
convert_unknown: If True, when a '_type' is not registered and cannot
2219+
be imported, PyGlove will create objects of:
2220+
- `pg.symbolic.UnknownType` for unknown types;
2221+
- `pg.symbolic.UnknownTypedObject` for objects of unknown types;
2222+
- `pg.symbolic.UnknownFunction` for unknown functions;
2223+
- `pg.symbolic.UnknownMethod` for unknown methods.
2224+
If False, TypeError will be raised.
22102225
allow_partial: If True, allow a partial symbolic object to be created.
22112226
Otherwise error will be raised on partial value.
22122227
root_path: The symbolic path used for the deserialized root object.
@@ -2236,7 +2251,7 @@ def _decode_int_keys(v):
22362251
_decode_int_keys(json.loads(json_str)),
22372252
context=context,
22382253
auto_import=auto_import,
2239-
auto_dict=auto_dict,
2254+
convert_unknown=convert_unknown,
22402255
allow_partial=allow_partial,
22412256
root_path=root_path,
22422257
value_spec=value_spec,

pyglove/core/symbolic/object.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,8 @@ def __init_subclass__(cls):
339339

340340
# Set `__serialization_key__` before JSONConvertible.__init_subclass__
341341
# is called.
342-
setattr(cls, '__serialization_key__', cls.__type_name__)
342+
if '__serialization_key__' not in cls.__dict__:
343+
setattr(cls, '__serialization_key__', cls.__type_name__)
343344

344345
super().__init_subclass__()
345346

pyglove/core/symbolic/object_test.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from pyglove.core.symbolic.origin import Origin
3939
from pyglove.core.symbolic.pure_symbolic import NonDeterministic
4040
from pyglove.core.symbolic.pure_symbolic import PureSymbolic
41+
from pyglove.core.symbolic.unknown_symbols import UnknownTypedObject
4142
from pyglove.core.views.html import tree_view # pylint: disable=unused-import
4243

4344

@@ -3158,7 +3159,7 @@ class Q(Object):
31583159
Q.partial(P.partial()).to_json_str(), allow_partial=True),
31593160
Q.partial(P.partial()))
31603161

3161-
def test_serialization_with_auto_dict(self):
3162+
def test_serialization_with_convert_unknown(self):
31623163

31633164
class P(Object):
31643165
auto_register = False
@@ -3181,15 +3182,17 @@ class Q(Object):
31813182
}
31823183
)
31833184
self.assertEqual(
3184-
base.from_json_str(Q(P(1), y='foo').to_json_str(), auto_dict=True),
3185-
{
3186-
'p': {
3187-
'type_name': P.__type_name__,
3188-
'x': 1
3189-
},
3190-
'y': 'foo',
3191-
'type_name': Q.__type_name__,
3192-
}
3185+
base.from_json_str(
3186+
Q(P(1), y='foo').to_json_str(), convert_unknown=True
3187+
),
3188+
UnknownTypedObject(
3189+
type_name=Q.__type_name__,
3190+
p=UnknownTypedObject(
3191+
type_name=P.__type_name__,
3192+
x=1
3193+
),
3194+
y='foo'
3195+
)
31933196
)
31943197

31953198
def test_serialization_with_converter(self):
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Copyright 2021 The PyGlove Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Symbolic types for reprenting unknown types and objects."""
15+
16+
from typing import Annotated, Any, ClassVar, Literal
17+
from pyglove.core import typing as pg_typing
18+
from pyglove.core import utils
19+
from pyglove.core.symbolic import list as pg_list # pylint: disable=unused-import
20+
from pyglove.core.symbolic import object as pg_object
21+
22+
23+
class UnknownSymbol(pg_object.Object, pg_typing.CustomTyping):
24+
"""Interface for symbolic representation of unknown symbols."""
25+
auto_register = False
26+
27+
def custom_apply(self, *args, **kwargs) -> tuple[bool, Any]:
28+
"""Bypass PyGlove type check."""
29+
return (False, self)
30+
31+
32+
class UnknownType(UnknownSymbol):
33+
"""Symbolic object for representing unknown types."""
34+
35+
auto_register = True
36+
__serialization_key__ = 'unknown_type'
37+
38+
# TODO(daiyip): Revisit the design on how `pg.typing.Object()` handles
39+
# UnknownType. This hacky solution should be removed in the future.
40+
__no_type_check__ = True
41+
42+
name: str
43+
args: list[Any] = []
44+
45+
def sym_jsonify(self, **kwargs) -> utils.JSONValueType:
46+
json_dict = {'_type': 'type', 'name': self.name}
47+
if self.args:
48+
json_dict['args'] = utils.to_json(self.args, **kwargs)
49+
return json_dict
50+
51+
def format(
52+
self,
53+
compact: bool = False,
54+
verbose: bool = True,
55+
root_indent: int = 0,
56+
**kwargs
57+
) -> str:
58+
s = f'<unknown-type {self.name}>'
59+
if self.args:
60+
s += f'[{", ".join(repr(x) for x in self.args)}]'
61+
return s
62+
63+
def __call__(self, **kwargs):
64+
return UnknownTypedObject(
65+
type_name=self.name, **kwargs
66+
)
67+
68+
69+
class UnknownCallable(UnknownSymbol):
70+
"""Symbolic object for representing unknown callables."""
71+
72+
auto_register = False
73+
name: str
74+
CALLABLE_TYPE: ClassVar[Literal['function', 'method']]
75+
76+
def sym_jsonify(self, **kwargs) -> utils.JSONValueType:
77+
return {'_type': self.CALLABLE_TYPE, 'name': self.name}
78+
79+
def format(
80+
self,
81+
compact: bool = False,
82+
verbose: bool = True,
83+
root_indent: int = 0,
84+
**kwargs
85+
) -> str:
86+
return f'<unknown-{self.CALLABLE_TYPE} {self.name}>'
87+
88+
89+
class UnknownFunction(UnknownCallable):
90+
"""Symbolic objject for representing unknown functions."""
91+
92+
auto_register = True
93+
__serialization_key__ = 'unknown_function'
94+
CALLABLE_TYPE = 'function'
95+
96+
97+
class UnknownMethod(UnknownCallable):
98+
"""Symbolic object for representing unknown methods."""
99+
100+
auto_register = True
101+
__serialization_key__ = 'unknown_method'
102+
CALLABLE_TYPE = 'method'
103+
104+
105+
class UnknownTypedObject(UnknownSymbol):
106+
"""Symbolic object for representing objects of unknown-type."""
107+
108+
auto_register = True
109+
__serialization_key__ = 'unknown_object'
110+
111+
type_name: str
112+
__kwargs__: Annotated[
113+
Any,
114+
(
115+
'Fields of the original object will be kept as symbolic attributes '
116+
'of this object so they can be accessed through `__getattr__`.'
117+
)
118+
]
119+
120+
def sym_jsonify(self, **kwargs) -> utils.JSONValueType:
121+
"""Converts current object to a dict of plain Python objects."""
122+
json_dict = self._sym_attributes.to_json(
123+
exclude_keys=set(['type_name']), **kwargs
124+
)
125+
assert isinstance(json_dict, dict)
126+
json_dict[utils.JSONConvertible.TYPE_NAME_KEY] = self.type_name
127+
return json_dict
128+
129+
def format(
130+
self,
131+
compact: bool = False,
132+
verbose: bool = True,
133+
root_indent: int = 0,
134+
**kwargs
135+
) -> str:
136+
exclude_keys = kwargs.pop('exclude_keys', set())
137+
exclude_keys.add('type_name')
138+
kwargs['exclude_keys'] = exclude_keys
139+
return self._sym_attributes.format(
140+
compact,
141+
verbose,
142+
root_indent,
143+
cls_name=f'<unknown-type {self.type_name}>',
144+
key_as_attribute=True,
145+
bracket_type=utils.BracketType.ROUND,
146+
**kwargs,
147+
)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Copyright 2025 The PyGlove Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import unittest
16+
from pyglove.core import utils
17+
from pyglove.core.symbolic import unknown_symbols
18+
19+
20+
class UnknownTypeTest(unittest.TestCase):
21+
22+
def test_basics(self):
23+
t = unknown_symbols.UnknownType(name='__main__.ABC', args=[int, str])
24+
self.assertEqual(t.name, '__main__.ABC')
25+
self.assertEqual(t.args, [int, str])
26+
self.assertEqual(
27+
repr(t),
28+
'<unknown-type __main__.ABC>[<class \'int\'>, <class \'str\'>]'
29+
)
30+
self.assertEqual(
31+
t.to_json(),
32+
{
33+
'_type': 'type',
34+
'name': '__main__.ABC',
35+
'args': [
36+
{'_type': 'type', 'name': 'builtins.int'},
37+
{'_type': 'type', 'name': 'builtins.str'},
38+
]
39+
}
40+
)
41+
self.assertEqual(utils.from_json(t.to_json(), convert_unknown=True), t)
42+
self.assertEqual(
43+
t(x=1, y=2),
44+
unknown_symbols.UnknownTypedObject(type_name='__main__.ABC', x=1, y=2)
45+
)
46+
47+
48+
class UnknownFunctionTest(unittest.TestCase):
49+
50+
def test_basics(self):
51+
t = unknown_symbols.UnknownFunction(name='__main__.foo')
52+
self.assertEqual(t.name, '__main__.foo')
53+
self.assertEqual(repr(t), '<unknown-function __main__.foo>')
54+
self.assertEqual(
55+
t.to_json(),
56+
{
57+
'_type': 'function',
58+
'name': '__main__.foo',
59+
}
60+
)
61+
self.assertEqual(utils.from_json(t.to_json(), convert_unknown=True), t)
62+
63+
64+
class UnknownMethodTest(unittest.TestCase):
65+
66+
def test_basics(self):
67+
t = unknown_symbols.UnknownMethod(name='__main__.ABC.bar')
68+
self.assertEqual(t.name, '__main__.ABC.bar')
69+
self.assertEqual(repr(t), '<unknown-method __main__.ABC.bar>')
70+
self.assertEqual(
71+
t.to_json(),
72+
{
73+
'_type': 'method',
74+
'name': '__main__.ABC.bar',
75+
}
76+
)
77+
self.assertEqual(utils.from_json(t.to_json(), convert_unknown=True), t)
78+
79+
80+
class UnknownObjectTest(unittest.TestCase):
81+
82+
def test_basics(self):
83+
v = unknown_symbols.UnknownTypedObject(type_name='__main__.ABC', x=1)
84+
self.assertEqual(v.type_name, '__main__.ABC')
85+
self.assertEqual(v.x, 1)
86+
self.assertEqual(repr(v), '<unknown-type __main__.ABC>(x=1)')
87+
self.assertEqual(
88+
str(v), '<unknown-type __main__.ABC>(\n x = 1\n)')
89+
self.assertEqual(
90+
v.to_json(),
91+
{
92+
'_type': '__main__.ABC',
93+
'x': 1,
94+
}
95+
)
96+
self.assertEqual(utils.from_json(v.to_json(), convert_unknown=True), v)
97+
98+
99+
if __name__ == '__main__':
100+
unittest.main()

0 commit comments

Comments
 (0)