pyglove 0.4.5.dev20240318__py3-none-any.whl → 0.4.5.dev202501132210__py3-none-any.whl

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.
Files changed (145) hide show
  1. pyglove/core/__init__.py +54 -20
  2. pyglove/core/coding/__init__.py +42 -0
  3. pyglove/core/coding/errors.py +111 -0
  4. pyglove/core/coding/errors_test.py +98 -0
  5. pyglove/core/coding/execution.py +309 -0
  6. pyglove/core/coding/execution_test.py +333 -0
  7. pyglove/core/{object_utils/codegen.py → coding/function_generation.py} +10 -4
  8. pyglove/core/{object_utils/codegen_test.py → coding/function_generation_test.py} +5 -7
  9. pyglove/core/coding/parsing.py +153 -0
  10. pyglove/core/coding/parsing_test.py +150 -0
  11. pyglove/core/coding/permissions.py +100 -0
  12. pyglove/core/coding/permissions_test.py +93 -0
  13. pyglove/core/geno/base.py +54 -41
  14. pyglove/core/geno/base_test.py +2 -4
  15. pyglove/core/geno/categorical.py +37 -28
  16. pyglove/core/geno/custom.py +19 -16
  17. pyglove/core/geno/numerical.py +20 -17
  18. pyglove/core/geno/space.py +4 -5
  19. pyglove/core/hyper/base.py +6 -6
  20. pyglove/core/hyper/categorical.py +94 -55
  21. pyglove/core/hyper/custom.py +7 -7
  22. pyglove/core/hyper/custom_test.py +9 -10
  23. pyglove/core/hyper/derived.py +30 -22
  24. pyglove/core/hyper/derived_test.py +2 -4
  25. pyglove/core/hyper/dynamic_evaluation.py +5 -6
  26. pyglove/core/hyper/evolvable.py +57 -46
  27. pyglove/core/hyper/numerical.py +48 -24
  28. pyglove/core/hyper/numerical_test.py +9 -9
  29. pyglove/core/hyper/object_template.py +58 -46
  30. pyglove/core/io/__init__.py +1 -0
  31. pyglove/core/io/file_system.py +17 -7
  32. pyglove/core/io/file_system_test.py +2 -0
  33. pyglove/core/io/sequence.py +299 -0
  34. pyglove/core/io/sequence_test.py +124 -0
  35. pyglove/core/logging_test.py +0 -2
  36. pyglove/core/patching/object_factory.py +4 -4
  37. pyglove/core/patching/pattern_based.py +4 -4
  38. pyglove/core/patching/rule_based.py +17 -5
  39. pyglove/core/patching/rule_based_test.py +27 -4
  40. pyglove/core/symbolic/__init__.py +2 -7
  41. pyglove/core/symbolic/base.py +320 -183
  42. pyglove/core/symbolic/base_test.py +123 -19
  43. pyglove/core/symbolic/boilerplate.py +7 -13
  44. pyglove/core/symbolic/boilerplate_test.py +25 -23
  45. pyglove/core/symbolic/class_wrapper.py +48 -45
  46. pyglove/core/symbolic/class_wrapper_test.py +2 -2
  47. pyglove/core/symbolic/compounding.py +9 -15
  48. pyglove/core/symbolic/compounding_test.py +2 -4
  49. pyglove/core/symbolic/dict.py +154 -110
  50. pyglove/core/symbolic/dict_test.py +238 -130
  51. pyglove/core/symbolic/diff.py +199 -10
  52. pyglove/core/symbolic/diff_test.py +226 -0
  53. pyglove/core/symbolic/flags.py +1 -1
  54. pyglove/core/symbolic/functor.py +29 -26
  55. pyglove/core/symbolic/functor_test.py +102 -50
  56. pyglove/core/symbolic/inferred.py +2 -2
  57. pyglove/core/symbolic/list.py +81 -50
  58. pyglove/core/symbolic/list_test.py +119 -97
  59. pyglove/core/symbolic/object.py +225 -113
  60. pyglove/core/symbolic/object_test.py +320 -108
  61. pyglove/core/symbolic/origin.py +17 -14
  62. pyglove/core/symbolic/origin_test.py +4 -2
  63. pyglove/core/symbolic/pure_symbolic.py +4 -3
  64. pyglove/core/symbolic/ref.py +108 -21
  65. pyglove/core/symbolic/ref_test.py +93 -0
  66. pyglove/core/symbolic/symbolize_test.py +10 -2
  67. pyglove/core/tuning/local_backend.py +2 -2
  68. pyglove/core/tuning/protocols.py +3 -3
  69. pyglove/core/tuning/sample_test.py +3 -3
  70. pyglove/core/typing/__init__.py +14 -5
  71. pyglove/core/typing/annotation_conversion.py +43 -27
  72. pyglove/core/typing/annotation_conversion_test.py +23 -0
  73. pyglove/core/typing/callable_ext.py +241 -3
  74. pyglove/core/typing/callable_ext_test.py +255 -0
  75. pyglove/core/typing/callable_signature.py +510 -66
  76. pyglove/core/typing/callable_signature_test.py +619 -99
  77. pyglove/core/typing/class_schema.py +229 -154
  78. pyglove/core/typing/class_schema_test.py +149 -95
  79. pyglove/core/typing/custom_typing.py +5 -4
  80. pyglove/core/typing/inspect.py +63 -0
  81. pyglove/core/typing/inspect_test.py +39 -0
  82. pyglove/core/typing/key_specs.py +10 -11
  83. pyglove/core/typing/key_specs_test.py +7 -4
  84. pyglove/core/typing/type_conversion.py +4 -5
  85. pyglove/core/typing/type_conversion_test.py +12 -12
  86. pyglove/core/typing/typed_missing.py +6 -7
  87. pyglove/core/typing/typed_missing_test.py +7 -8
  88. pyglove/core/typing/value_specs.py +604 -362
  89. pyglove/core/typing/value_specs_test.py +328 -90
  90. pyglove/core/utils/__init__.py +164 -0
  91. pyglove/core/{object_utils → utils}/common_traits.py +3 -67
  92. pyglove/core/utils/common_traits_test.py +36 -0
  93. pyglove/core/{object_utils → utils}/docstr_utils.py +23 -0
  94. pyglove/core/{object_utils → utils}/docstr_utils_test.py +36 -4
  95. pyglove/core/{object_utils → utils}/error_utils.py +78 -9
  96. pyglove/core/{object_utils → utils}/error_utils_test.py +61 -5
  97. pyglove/core/utils/formatting.py +464 -0
  98. pyglove/core/utils/formatting_test.py +453 -0
  99. pyglove/core/{object_utils → utils}/hierarchical.py +23 -25
  100. pyglove/core/{object_utils → utils}/hierarchical_test.py +3 -5
  101. pyglove/core/{object_utils → utils}/json_conversion.py +177 -52
  102. pyglove/core/{object_utils → utils}/json_conversion_test.py +97 -16
  103. pyglove/core/{object_utils → utils}/missing.py +3 -3
  104. pyglove/core/{object_utils → utils}/missing_test.py +2 -4
  105. pyglove/core/utils/text_color.py +128 -0
  106. pyglove/core/utils/text_color_test.py +94 -0
  107. pyglove/core/{object_utils → utils}/thread_local_test.py +1 -3
  108. pyglove/core/utils/timing.py +236 -0
  109. pyglove/core/utils/timing_test.py +154 -0
  110. pyglove/core/{object_utils → utils}/value_location.py +275 -6
  111. pyglove/core/utils/value_location_test.py +707 -0
  112. pyglove/core/views/__init__.py +32 -0
  113. pyglove/core/views/base.py +804 -0
  114. pyglove/core/views/base_test.py +580 -0
  115. pyglove/core/views/html/__init__.py +27 -0
  116. pyglove/core/views/html/base.py +547 -0
  117. pyglove/core/views/html/base_test.py +830 -0
  118. pyglove/core/views/html/controls/__init__.py +35 -0
  119. pyglove/core/views/html/controls/base.py +275 -0
  120. pyglove/core/views/html/controls/label.py +207 -0
  121. pyglove/core/views/html/controls/label_test.py +157 -0
  122. pyglove/core/views/html/controls/progress_bar.py +183 -0
  123. pyglove/core/views/html/controls/progress_bar_test.py +97 -0
  124. pyglove/core/views/html/controls/tab.py +320 -0
  125. pyglove/core/views/html/controls/tab_test.py +87 -0
  126. pyglove/core/views/html/controls/tooltip.py +99 -0
  127. pyglove/core/views/html/controls/tooltip_test.py +99 -0
  128. pyglove/core/views/html/tree_view.py +1517 -0
  129. pyglove/core/views/html/tree_view_test.py +1461 -0
  130. {pyglove-0.4.5.dev20240318.dist-info → pyglove-0.4.5.dev202501132210.dist-info}/METADATA +18 -4
  131. pyglove-0.4.5.dev202501132210.dist-info/RECORD +214 -0
  132. {pyglove-0.4.5.dev20240318.dist-info → pyglove-0.4.5.dev202501132210.dist-info}/WHEEL +1 -1
  133. pyglove/core/object_utils/__init__.py +0 -154
  134. pyglove/core/object_utils/common_traits_test.py +0 -82
  135. pyglove/core/object_utils/formatting.py +0 -234
  136. pyglove/core/object_utils/formatting_test.py +0 -223
  137. pyglove/core/object_utils/value_location_test.py +0 -385
  138. pyglove/core/symbolic/schema_utils.py +0 -327
  139. pyglove/core/symbolic/schema_utils_test.py +0 -57
  140. pyglove/core/typing/class_schema_utils.py +0 -202
  141. pyglove/core/typing/class_schema_utils_test.py +0 -194
  142. pyglove-0.4.5.dev20240318.dist-info/RECORD +0 -185
  143. /pyglove/core/{object_utils → utils}/thread_local.py +0 -0
  144. {pyglove-0.4.5.dev20240318.dist-info → pyglove-0.4.5.dev202501132210.dist-info}/LICENSE +0 -0
  145. {pyglove-0.4.5.dev20240318.dist-info → pyglove-0.4.5.dev202501132210.dist-info}/top_level.txt +0 -0
@@ -16,13 +16,14 @@
16
16
  import abc
17
17
  import base64
18
18
  import collections
19
+ import contextlib
19
20
  import importlib
20
21
  import inspect
21
22
  import marshal
22
23
  import pickle
23
24
  import types
24
25
  import typing
25
- from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Type, TypeVar, Union
26
+ from typing import Any, Callable, ContextManager, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Type, TypeVar, Union
26
27
 
27
28
  # Nestable[T] is a (maybe) nested structure of T, which could be T, a Dict
28
29
  # a List or a Tuple of Nestable[T]. We use a Union to fool PyType checker to
@@ -36,7 +37,7 @@ JSONPrimitiveType = Union[int, float, bool, str]
36
37
  # pytype doesn't support recursion. Use Any instead of 'JSONValueType'
37
38
  # in List and Dict.
38
39
  JSONListType = List[Any]
39
- JSONDictType = Dict[str, Any]
40
+ JSONDictType = Dict[Union[str, int], Any]
40
41
  JSONValueType = Union[JSONPrimitiveType, JSONListType, JSONDictType]
41
42
 
42
43
  # pylint: enable=invalid-name
@@ -57,6 +58,7 @@ class _TypeRegistry:
57
58
  # registered for a user class.
58
59
  self._type_to_cls_map = dict()
59
60
  self._prefix_mapping = dict()
61
+ self._ondemand_registry_stack = []
60
62
 
61
63
  def register(
62
64
  self, type_name: str, cls: Type[Any], override_existing: bool = False
@@ -89,9 +91,32 @@ class _TypeRegistry:
89
91
  """Returns whether a type name is registered."""
90
92
  return type_name in self._type_to_cls_map
91
93
 
94
+ @contextlib.contextmanager
95
+ def load_types_for_deserialization(
96
+ self,
97
+ *types_to_deserialize: Type[Any],
98
+ ) -> Iterator[Dict[str, Type[Any]]]:
99
+ """Context manager for loading unregistered types for deserialization."""
100
+ if self._ondemand_registry_stack:
101
+ stack_top = dict(self._ondemand_registry_stack[-1])
102
+ else:
103
+ stack_top = {}
104
+ stack_top.update({t.__name__: t for t in types_to_deserialize})
105
+ try:
106
+ self._ondemand_registry_stack.append(stack_top)
107
+ yield stack_top
108
+ finally:
109
+ self._ondemand_registry_stack.pop()
110
+
92
111
  def class_from_typename(
93
112
  self, type_name: str) -> Optional[Type[Any]]:
94
113
  """Get class from type name."""
114
+ if self._ondemand_registry_stack:
115
+ top_registry = self._ondemand_registry_stack[-1]
116
+ class_name = type_name.split('.')[-1]
117
+ if class_name in top_registry:
118
+ return top_registry[class_name]
119
+
95
120
  cls = self._type_to_cls_map.get(type_name, None)
96
121
  if cls is None:
97
122
  # Modules could be renamed, to load legacy serialized objects, we
@@ -177,9 +202,8 @@ class JSONConvertible(metaclass=abc.ABCMeta):
177
202
  Returns:
178
203
  An instance of cls.
179
204
  """
180
- del kwargs
181
205
  assert isinstance(json_value, dict)
182
- init_args = {k: from_json(v) for k, v in json_value.items()
206
+ init_args = {k: from_json(v, **kwargs) for k, v in json_value.items()
183
207
  if k != JSONConvertible.TYPE_NAME_KEY}
184
208
  return cls(**init_args)
185
209
 
@@ -249,6 +273,36 @@ class JSONConvertible(metaclass=abc.ABCMeta):
249
273
  """Returns an iterator of registered (serialization key, class) tuples."""
250
274
  return cls._TYPE_REGISTRY.iteritems()
251
275
 
276
+ @classmethod
277
+ def load_types_for_deserialization(
278
+ cls,
279
+ *types_to_deserialize: Type[Any]
280
+ ) -> ContextManager[Dict[str, Type[Any]]]:
281
+ """Context manager for loading unregistered types for deserialization.
282
+
283
+ Example::
284
+
285
+ class A(pg.Object):
286
+ auto_register = False
287
+ x: int
288
+
289
+ class B(A):
290
+ y: str
291
+ with pg.JSONConvertile.load_types_for_deserialization(A, B):
292
+ pg.from_json_str(A(1).to_json_str())
293
+ pg.from_json_str(B(1, 'hi').to_json_str())
294
+
295
+ Args:
296
+ *types_to_deserialize: A list of types to be loaded for deserialization.
297
+
298
+ Returns:
299
+ A context manager within which the objects of the requested types
300
+ could be deserialized.
301
+ """
302
+ return cls._TYPE_REGISTRY.load_types_for_deserialization(
303
+ *types_to_deserialize
304
+ )
305
+
252
306
  @classmethod
253
307
  def to_json_dict(
254
308
  cls,
@@ -355,54 +409,68 @@ def to_json(value: Any, **kwargs) -> Any:
355
409
  JSON value.
356
410
  """
357
411
  if isinstance(value, (type(None), bool, int, float, str)):
358
- return value
412
+ v = value
359
413
  elif isinstance(value, JSONConvertible):
360
- return value.to_json(**kwargs)
414
+ v = value.to_json(**kwargs)
361
415
  elif isinstance(value, tuple):
362
- return [JSONConvertible.TUPLE_MARKER] + to_json(list(value), **kwargs)
416
+ v = [JSONConvertible.TUPLE_MARKER] + to_json(list(value), **kwargs)
363
417
  elif isinstance(value, list):
364
- return [to_json(item, **kwargs) for item in value]
418
+ v = [to_json(item, **kwargs) for item in value]
365
419
  elif isinstance(value, dict):
366
- return {k: to_json(v, **kwargs) for k, v in value.items()}
420
+ v = {k: to_json(v, **kwargs) for k, v in value.items()}
367
421
  elif isinstance(value, (type, typing.GenericAlias)): # pytype: disable=module-attr
368
- return _type_to_json(value)
422
+ v = _type_to_json(value)
369
423
  elif inspect.isbuiltin(value):
370
- return _builtin_function_to_json(value)
424
+ v = _builtin_function_to_json(value)
371
425
  elif inspect.isfunction(value):
372
- return _function_to_json(value)
426
+ v = _function_to_json(value)
373
427
  elif inspect.ismethod(value):
374
- return _method_to_json(value)
428
+ v = _method_to_json(value)
375
429
  # pytype: disable=module-attr
376
430
  elif isinstance(value, typing._Final): # pylint: disable=protected-access
377
431
  # pytype: enable=module-attr
378
- return _annotation_to_json(value)
432
+ v = _annotation_to_json(value)
379
433
  elif value is ...:
380
- return {JSONConvertible.TYPE_NAME_KEY: 'type', 'name': 'builtins.Ellipsis'}
434
+ v = {JSONConvertible.TYPE_NAME_KEY: 'type', 'name': 'builtins.Ellipsis'}
381
435
  else:
436
+ v, converted = None, False
382
437
  if JSONConvertible.TYPE_CONVERTER is not None:
383
438
  converter = JSONConvertible.TYPE_CONVERTER(type(value)) # pylint: disable=not-callable
384
439
  if converter:
385
- return to_json(converter(value))
386
- return _OpaqueObject(value).to_json(**kwargs)
387
-
388
-
389
- def from_json(json_value: JSONValueType,
390
- *,
391
- force_dict: bool = False,
392
- **kwargs) -> Any:
440
+ v = to_json(converter(value))
441
+ converted = True
442
+ if not converted:
443
+ v = _OpaqueObject(value).to_json(**kwargs)
444
+ return v
445
+
446
+
447
+ def from_json(
448
+ json_value: JSONValueType,
449
+ *,
450
+ auto_import: bool = True,
451
+ auto_dict: bool = False,
452
+ **kwargs) -> Any:
393
453
  """Deserializes a (maybe) JSONConvertible value from JSON value.
394
454
 
395
455
  Args:
396
456
  json_value: Input JSON value.
397
- force_dict: If True, "_type" keys will be stripped before loading. As a
398
- result, JSONConvertible objects will be returned as dict.
457
+ auto_import: If True, when a '_type' is not registered, PyGlove will
458
+ identify its parent module and automatically import it. For example,
459
+ if the type is 'foo.bar.A', PyGlove will try to import 'foo.bar' and
460
+ find the class 'A' within the imported module.
461
+ auto_dict: If True, dict with '_type' that cannot be loaded will remain
462
+ as dict, with '_type' renamed to 'type_name'.
399
463
  **kwargs: Keyword arguments that will be passed to JSONConvertible.__init__.
400
464
 
401
465
  Returns:
402
466
  Deserialized value.
403
467
  """
404
- if force_dict:
405
- json_value = strip_types(json_value)
468
+ typename_resolved = kwargs.pop('_typename_resolved', False)
469
+ if not typename_resolved:
470
+ json_value = resolve_typenames(json_value, auto_import, auto_dict)
471
+
472
+ def child_from(v):
473
+ return from_json(v, _typename_resolved=True, **kwargs)
406
474
 
407
475
  if isinstance(json_value, list):
408
476
  if json_value and json_value[0] == JSONConvertible.TUPLE_MARKER:
@@ -411,41 +479,89 @@ def from_json(json_value: JSONValueType,
411
479
  f'Tuple should have at least one element '
412
480
  f'besides \'{JSONConvertible.TUPLE_MARKER}\'. '
413
481
  f'Encountered: {json_value}.')
414
- return tuple([from_json(v, **kwargs) for v in json_value[1:]])
415
- return [from_json(v, **kwargs) for v in json_value]
482
+ return tuple([child_from(v) for v in json_value[1:]])
483
+ return [child_from(v) for v in json_value]
416
484
  elif isinstance(json_value, dict):
417
485
  if JSONConvertible.TYPE_NAME_KEY not in json_value:
418
- return {k: from_json(v, **kwargs) for k, v in json_value.items()}
419
- type_name = json_value.pop(JSONConvertible.TYPE_NAME_KEY)
486
+ return {k: child_from(v) for k, v in json_value.items()}
487
+ factory_fn = json_value.pop(JSONConvertible.TYPE_NAME_KEY)
488
+ assert factory_fn is not None
489
+ return factory_fn(json_value, **kwargs)
490
+ return json_value
491
+
492
+
493
+ def resolve_typenames(
494
+ json_value: JSONValueType,
495
+ auto_import: bool = True,
496
+ auto_dict: bool = False
497
+ ) -> JSONValueType:
498
+ """Inplace resolves the "_type" keys with their factories in a JSON tree."""
499
+
500
+ def _resolve_typename(v: Dict[str, Any]) -> bool:
501
+ """Returns True if the subtree is resolved for the first time."""
502
+ if JSONConvertible.TYPE_NAME_KEY not in v:
503
+ return True
504
+ if not isinstance(v[JSONConvertible.TYPE_NAME_KEY], str):
505
+ return False
506
+ type_name = v[JSONConvertible.TYPE_NAME_KEY]
420
507
  if type_name == 'type':
421
- return _type_from_json(json_value)
508
+ factory_fn = _type_from_json
422
509
  elif type_name == 'function':
423
- return _function_from_json(json_value)
510
+ factory_fn = _function_from_json
424
511
  elif type_name == 'method':
425
- return _method_from_json(json_value)
512
+ factory_fn = _method_from_json
426
513
  else:
427
514
  cls = JSONConvertible.class_from_typename(type_name)
428
515
  if cls is None:
516
+ if auto_import:
517
+ try:
518
+ cls = _load_symbol(type_name)
519
+ assert inspect.isclass(cls), cls
520
+ except (ModuleNotFoundError, AttributeError) as e:
521
+ if not auto_dict:
522
+ raise TypeError(
523
+ f'Cannot load class {type_name!r}.\n'
524
+ 'Try pass `auto_dict=True` to load the object into a dict '
525
+ 'without depending on the type.'
526
+ ) from e
527
+ elif not auto_dict:
528
+ raise TypeError(
529
+ f'Type name \'{type_name}\' is not registered '
530
+ 'with a `pg.JSONConvertible` subclass.\n'
531
+ 'Try pass `auto_import=True` to load the type from its module, '
532
+ 'or pass `auto_dict=True` to load the object into a dict '
533
+ 'without depending on the type.'
534
+ )
535
+
536
+ factory_fn = getattr(cls, 'from_json', None)
537
+ if cls is not None and factory_fn is None and not auto_dict:
429
538
  raise TypeError(
430
- f'Type name \'{type_name}\' is not registered '
431
- f'with a `pg.JSONConvertible` subclass.')
432
- return cls.from_json(json_value, **kwargs)
433
- return json_value
539
+ f'{cls} is not a `pg.JSONConvertible` subclass.'
540
+ 'Try pass `auto_dict=True` to load the object into a dict '
541
+ 'without depending on the type.'
542
+ )
543
+
544
+ if factory_fn is None and auto_dict:
545
+ v['type_name'] = type_name
546
+ v.pop(JSONConvertible.TYPE_NAME_KEY)
547
+ return True
548
+ assert factory_fn is not None
434
549
 
550
+ v[JSONConvertible.TYPE_NAME_KEY] = factory_fn
551
+ return True
435
552
 
436
- def strip_types(json_value: JSONValueType) -> JSONValueType:
437
- """Inplace strips the "_type" key from a JSON tree."""
438
- def _strip_type(v) -> None:
553
+ def _visit(v) -> None:
439
554
  if isinstance(v, (tuple, list)):
440
555
  for x in v:
441
- _strip_type(x)
556
+ _visit(x)
442
557
  elif isinstance(v, dict):
443
- if JSONConvertible.TYPE_NAME_KEY in v:
444
- v.pop(JSONConvertible.TYPE_NAME_KEY)
445
- for x in v.values():
446
- _strip_type(x)
558
+ if _resolve_typename(v):
559
+ # Only resolve children when _types in this tree is not resolved
560
+ # previously
561
+ for x in v.values():
562
+ _visit(x)
447
563
 
448
- _strip_type(json_value)
564
+ _visit(json_value)
449
565
  return json_value
450
566
 
451
567
 
@@ -610,6 +726,9 @@ def _load_symbol(type_name: str) -> Any:
610
726
  module_name = '.'.join(maybe_modules[:module_end_pos])
611
727
  parent_symbols = maybe_modules[module_end_pos:]
612
728
 
729
+ if not module_name:
730
+ raise ModuleNotFoundError(f'Cannot load symbol {type_name!r}.')
731
+
613
732
  # Import module and lookup parent symbols.
614
733
  module = importlib.import_module(module_name)
615
734
  parent = module
@@ -622,21 +741,26 @@ def _load_symbol(type_name: str) -> Any:
622
741
  return symbol
623
742
 
624
743
 
625
- def _type_from_json(json_value: Dict[str, str]) -> Type[Any]:
744
+ def _type_from_json(json_value: Dict[str, str], **kwargs) -> Type[Any]:
626
745
  """Loads a type from a JSON dict."""
746
+ del kwargs
627
747
  t = _load_symbol(json_value['name'])
628
748
  if 'args' in json_value:
629
- return _bind_type_args(t, from_json(json_value['args']))
749
+ return _bind_type_args(
750
+ t, from_json(json_value['args'], _typename_resolved=True)
751
+ )
630
752
  return t
631
753
 
632
754
 
633
- def _function_from_json(json_value: Dict[str, str]) -> types.FunctionType:
755
+ def _function_from_json(
756
+ json_value: Dict[str, str], **kwargs) -> types.FunctionType:
634
757
  """Loads a function from a JSON dict."""
758
+ del kwargs
635
759
  function_name = json_value['name']
636
760
  if 'code' in json_value:
637
761
  code = marshal.loads(
638
762
  base64.decodebytes(json_value['code'].encode('utf-8')))
639
- defaults = from_json(json_value['defaults'])
763
+ defaults = from_json(json_value['defaults'], _typename_resolved=True)
640
764
  return types.FunctionType(
641
765
  code=code,
642
766
  globals=globals(),
@@ -646,8 +770,9 @@ def _function_from_json(json_value: Dict[str, str]) -> types.FunctionType:
646
770
  return _load_symbol(function_name)
647
771
 
648
772
 
649
- def _method_from_json(json_value: Dict[str, str]) -> types.MethodType:
773
+ def _method_from_json(json_value: Dict[str, str], **kwargs) -> types.MethodType:
650
774
  """Loads a class method from a JSON dict."""
775
+ del kwargs
651
776
  return _load_symbol(json_value['name'])
652
777
 
653
778
 
@@ -11,13 +11,11 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
- """Tests for pyglove.object_utils.json_conversion."""
15
-
16
14
  import abc
17
15
  import typing
18
16
  import unittest
19
- from pyglove.core.object_utils import json_conversion
20
17
  from pyglove.core.typing import inspect as pg_inspect
18
+ from pyglove.core.utils import json_conversion
21
19
 
22
20
 
23
21
  class X:
@@ -96,7 +94,7 @@ class JSONConvertibleTest(unittest.TestCase):
96
94
  self.x = x
97
95
 
98
96
  def to_json(self):
99
- return A.to_json_dict({
97
+ return self.__class__.to_json_dict({
100
98
  'x': self.x
101
99
  })
102
100
 
@@ -130,6 +128,27 @@ class JSONConvertibleTest(unittest.TestCase):
130
128
  (typename(B), C),
131
129
  list(json_conversion.JSONConvertible.registered_types()))
132
130
 
131
+ # Test load_types_for_deserialization.
132
+ class D(C):
133
+ auto_register = False
134
+
135
+ with self.assertRaisesRegex(TypeError, 'Cannot load class .*'):
136
+ json_conversion.from_json(D(1).to_json())
137
+
138
+ self.assertEqual(
139
+ json_conversion.JSONConvertible._TYPE_REGISTRY._ondemand_registry_stack,
140
+ []
141
+ )
142
+ with json_conversion.JSONConvertible.load_types_for_deserialization(A):
143
+ with json_conversion.JSONConvertible.load_types_for_deserialization(
144
+ D) as ondemand_registry:
145
+ self.assertEqual(ondemand_registry, {'A': A, 'D': D})
146
+ self.assertIsNotNone(json_conversion.from_json(D(1).to_json()))
147
+ self.assertEqual(
148
+ json_conversion.JSONConvertible._TYPE_REGISTRY._ondemand_registry_stack,
149
+ []
150
+ )
151
+
133
152
  def test_json_conversion(self):
134
153
 
135
154
  class T(json_conversion.JSONConvertible):
@@ -171,21 +190,41 @@ class JSONConvertibleTest(unittest.TestCase):
171
190
  T()
172
191
  )
173
192
 
174
- # Test bad cases.
175
- with self.assertRaisesRegex(
176
- ValueError, 'Tuple should have at least one element besides .*'):
177
- json_conversion.from_json(['__tuple__'])
178
-
179
- with self.assertRaisesRegex(
180
- TypeError, 'Type name .* is not registered'):
181
- json_conversion.from_json({'_type': '__main__.ABC'})
182
-
183
193
  def assert_conversion_is(self, v):
184
194
  self.assertIs(json_conversion.from_json(json_conversion.to_json(v)), v)
185
195
 
186
196
  def assert_conversion_equal(self, v):
187
197
  self.assertEqual(json_conversion.from_json(json_conversion.to_json(v)), v)
188
198
 
199
+ class CustomJsonConvertible(json_conversion.JSONConvertible):
200
+ auto_register = False
201
+
202
+ def __init__(self, x=None):
203
+ self.x = x
204
+
205
+ def to_json(self):
206
+ return self.to_json_dict(
207
+ dict(x=(self.x, None)), exclude_default=True
208
+ )
209
+
210
+ def __eq__(self, other):
211
+ return isinstance(other, self.__class__) and self.x == other.x
212
+
213
+ def __ne__(self, other):
214
+ return not self.__eq__(other)
215
+
216
+ def test_json_conversion_with_auto_import(self):
217
+ json_dict = json_conversion.to_json(self.CustomJsonConvertible(1))
218
+
219
+ with self.assertRaisesRegex(
220
+ TypeError, 'Type name .* is not registered'):
221
+ json_conversion.from_json(json_dict, auto_import=False)
222
+
223
+ self.assertEqual(
224
+ json_conversion.from_json(json_dict),
225
+ self.CustomJsonConvertible(1)
226
+ )
227
+
189
228
  def test_json_conversion_for_types(self):
190
229
  # Built-in types.
191
230
  self.assert_conversion_is(int)
@@ -297,7 +336,8 @@ class JSONConvertibleTest(unittest.TestCase):
297
336
  ValueError, 'Cannot decode opaque object with pickle.'):
298
337
  json_conversion.from_json(json_dict)
299
338
 
300
- def test_json_conversion_force_dict(self):
339
+ def test_json_conversion_auto_dict(self):
340
+ # Does not exist.
301
341
  self.assertEqual(
302
342
  json_conversion.from_json([
303
343
  '__tuple__',
@@ -306,12 +346,53 @@ class JSONConvertibleTest(unittest.TestCase):
306
346
  '_type': 'Unknown type',
307
347
  'x': [{
308
348
  '_type': 'Unknown type',
349
+ }, {
350
+ '_type': 'function',
351
+ 'name': 'builtins.print'
309
352
  }]
310
353
  }
311
- ], force_dict=True),
312
- (1, {'x': [{}]})
354
+ ], auto_dict=True),
355
+ (1, {
356
+ 'type_name': 'Unknown type',
357
+ 'x': [{
358
+ 'type_name': 'Unknown type',
359
+ }, print]
360
+ })
313
361
  )
314
362
 
363
+ def test_json_conversion_with_bad_types(self):
364
+ # Bad tuple.
365
+ with self.assertRaisesRegex(
366
+ ValueError, 'Tuple should have at least one element besides .*'):
367
+ json_conversion.from_json(['__tuple__'])
368
+
369
+ # Unregistered type without auto_import.
370
+ with self.assertRaisesRegex(
371
+ TypeError, 'Type name .* is not registered with'
372
+ ):
373
+ json_conversion.from_json(
374
+ {
375
+ '_type': 'Unknown type',
376
+ 'x': [{
377
+ '_type': 'Unknown type',
378
+ }]
379
+ }, auto_import=False
380
+ )
381
+
382
+ # Type does not exist.
383
+ with self.assertRaisesRegex(
384
+ TypeError, 'Cannot load class .*'):
385
+ json_conversion.from_json({'_type': '__main__.ABC'})
386
+
387
+ # Type exist but not a JSONConvertible subclass.
388
+ class A:
389
+ pass
390
+
391
+ json_conversion.JSONConvertible.register('__main__.A', A)
392
+ with self.assertRaisesRegex(
393
+ TypeError, '.* is not a `pg.JSONConvertible` subclass'):
394
+ json_conversion.from_json({'_type': '__main__.A'})
395
+
315
396
 
316
397
  if __name__ == '__main__':
317
398
  unittest.main()
@@ -14,11 +14,11 @@
14
14
  """Representing missing value for a field."""
15
15
 
16
16
  from typing import Any, Dict
17
- from pyglove.core.object_utils import common_traits
18
- from pyglove.core.object_utils import json_conversion
17
+ from pyglove.core.utils import formatting
18
+ from pyglove.core.utils import json_conversion
19
19
 
20
20
 
21
- class MissingValue(common_traits.Formattable, json_conversion.JSONConvertible):
21
+ class MissingValue(formatting.Formattable, json_conversion.JSONConvertible):
22
22
  """Value placeholder for an unassigned attribute."""
23
23
 
24
24
  def format(self, *args, **kwargs): # pytype: disable=signature-mismatch
@@ -11,11 +11,9 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
- """Tests for pyglove.object_utils.missing."""
15
-
16
14
  import unittest
17
- from pyglove.core.object_utils import json_conversion
18
- from pyglove.core.object_utils import missing
15
+ from pyglove.core.utils import json_conversion
16
+ from pyglove.core.utils import missing
19
17
 
20
18
 
21
19
  class MissingValueTest(unittest.TestCase):
@@ -0,0 +1,128 @@
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
+ """Utility library for text coloring."""
15
+
16
+ import re
17
+ from typing import List, Optional
18
+
19
+ try:
20
+ import termcolor # pylint: disable=g-import-not-at-top
21
+ except ImportError:
22
+ termcolor = None
23
+
24
+
25
+ # Regular expression for ANSI color characters.
26
+ _ANSI_COLOR_REGEX = re.compile(r'\x1b\[[0-9;]*m')
27
+
28
+
29
+ def decolor(text: str) -> str:
30
+ """De-colors a string that may contains ANSI color characters."""
31
+ return re.sub(_ANSI_COLOR_REGEX, '', text)
32
+
33
+
34
+ def colored(
35
+ text: str,
36
+ color: Optional[str] = None,
37
+ background: Optional[str] = None,
38
+ styles: Optional[List[str]] = None,
39
+ ) -> str:
40
+ """Returns the colored text with ANSI color characters.
41
+
42
+ Args:
43
+ text: A string that may or may not already has ANSI color characters.
44
+ color: A string for text colors. Applicable values are:
45
+ 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'.
46
+ background: A string for background colors. Applicable values are:
47
+ 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'.
48
+ styles: A list of strings for applying styles on the text.
49
+ Applicable values are:
50
+ 'bold', 'dark', 'underline', 'blink', 'reverse', 'concealed'.
51
+
52
+ Returns:
53
+ A string with ANSI color characters embracing the entire text.
54
+ """
55
+ if not termcolor:
56
+ return text
57
+ return termcolor.colored(
58
+ text,
59
+ color=color,
60
+ on_color=('on_' + background) if background else None,
61
+ attrs=styles
62
+ )
63
+
64
+
65
+ def colored_block(
66
+ text: str,
67
+ block_start: str,
68
+ block_end: str,
69
+ color: Optional[str] = None,
70
+ background: Optional[str] = None,
71
+ styles: Optional[List[str]] = None,
72
+ ) -> str:
73
+ """Apply colors to text blocks.
74
+
75
+ Args:
76
+ text: A string that may or may not already has ANSI color characters.
77
+ block_start: A string that signals the start of a block. E.g. '{{'
78
+ block_end: A string that signals the end of a block. E.g. '}}'.
79
+ color: A string for text colors. Applicable values are:
80
+ 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'.
81
+ background: A string for background colors. Applicable values are:
82
+ 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'.
83
+ styles: A list of strings for applying styles on the text.
84
+ Applicable values are:
85
+ 'bold', 'dark', 'underline', 'blink', 'reverse', 'concealed'.
86
+
87
+ Returns:
88
+ A string with ANSI color characters embracing the matched text blocks.
89
+ """
90
+ if not color and not background and not styles:
91
+ return text
92
+
93
+ s = []
94
+ start_index = 0
95
+ end_index = 0
96
+ previous_color = None
97
+
98
+ def write_nonblock_text(text: str, previous_color: Optional[str]):
99
+ if previous_color:
100
+ s.append(previous_color)
101
+ s.append(text)
102
+
103
+ while start_index < len(text):
104
+ start_index = text.find(block_start, end_index)
105
+ if start_index == -1:
106
+ write_nonblock_text(text[end_index:], previous_color)
107
+ break
108
+
109
+ # Deal with text since last block.
110
+ since_last_block = text[end_index:start_index]
111
+ write_nonblock_text(since_last_block, previous_color)
112
+ colors = re.findall(_ANSI_COLOR_REGEX, since_last_block)
113
+ if colors:
114
+ previous_color = colors[-1]
115
+
116
+ # Match block.
117
+ end_index = text.find(block_end, start_index + len(block_start))
118
+ if end_index == -1:
119
+ write_nonblock_text(text[start_index:], previous_color)
120
+ break
121
+ end_index += len(block_end)
122
+
123
+ # Write block text.
124
+ block = text[start_index:end_index]
125
+ block = colored(
126
+ block, color=color, background=background, styles=styles)
127
+ s.append(block)
128
+ return ''.join(s)