pyglove 0.4.5.dev202410290809__py3-none-any.whl → 0.4.5.dev202411010809__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.
@@ -11,12 +11,15 @@
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
- """Utilities for profiling."""
14
+ """Utilities for timing."""
15
15
 
16
+ import collections
16
17
  import dataclasses
17
18
  import time
18
- from typing import Dict, List, Optional
19
+ from typing import Any, Dict, List, Optional
19
20
 
21
+ from pyglove.core.object_utils import error_utils
22
+ from pyglove.core.object_utils import json_conversion
20
23
  from pyglove.core.object_utils import thread_local
21
24
 
22
25
 
@@ -24,12 +27,12 @@ class TimeIt:
24
27
  """Context manager for timing the execution of a code block."""
25
28
 
26
29
  @dataclasses.dataclass(frozen=True)
27
- class Status:
30
+ class Status(json_conversion.JSONConvertible):
28
31
  """Status of a single `pg.timeit`."""
29
32
  name: str
30
- elapse: float
31
- has_ended: bool
32
- error: Optional[Exception]
33
+ elapse: float = 0.0
34
+ has_ended: bool = True
35
+ error: Optional[error_utils.ErrorInfo] = None
33
36
 
34
37
  @property
35
38
  def has_started(self) -> bool:
@@ -41,18 +44,33 @@ class TimeIt:
41
44
  """Returns whether the context has error."""
42
45
  return self.error is not None
43
46
 
47
+ def to_json(self, **kwargs) -> Dict[str, Any]:
48
+ return self.to_json_dict(
49
+ fields=dict(
50
+ name=(self.name, None),
51
+ elapse=(self.elapse, 0.0),
52
+ has_ended=(self.has_ended, True),
53
+ error=(self.error, None),
54
+ ),
55
+ exclude_default=True,
56
+ **kwargs,
57
+ )
58
+
44
59
  @dataclasses.dataclass
45
- class StatusSummary:
60
+ class StatusSummary(json_conversion.JSONConvertible):
46
61
  """Aggregated summary for repeated calls for `pg.timeit`."""
47
62
 
48
63
  @dataclasses.dataclass
49
- class Entry:
64
+ class Entry(json_conversion.JSONConvertible):
50
65
  """Aggregated status from the `pg.timeit` calls of the same name."""
51
66
 
52
67
  num_started: int = 0
53
68
  num_ended: int = 0
54
69
  num_failed: int = 0
55
70
  avg_duration: float = 0.0
71
+ error_tags: Dict[str, int] = dataclasses.field(
72
+ default_factory=lambda: collections.defaultdict(int)
73
+ )
56
74
 
57
75
  def update(self, status: 'TimeIt.Status'):
58
76
  self.avg_duration = (
@@ -64,24 +82,52 @@ class TimeIt:
64
82
  self.num_ended += 1
65
83
  if status.has_error:
66
84
  self.num_failed += 1
85
+ assert status.error is not None
86
+ self.error_tags[status.error.tag] += 1
87
+
88
+ def to_json(self, **kwargs) -> Dict[str, Any]:
89
+ return self.to_json_dict(
90
+ fields=dict(
91
+ num_started=(self.num_started, 0),
92
+ num_ended=(self.num_ended, 0),
93
+ num_failed=(self.num_failed, 0),
94
+ avg_duration=(self.avg_duration, 0.0),
95
+ error_tags=(self.error_tags, {}),
96
+ ),
97
+ exclude_default=True,
98
+ **kwargs,
99
+ )
67
100
 
68
101
  breakdown: dict[str, 'TimeIt.StatusSummary.Entry'] = (
69
102
  dataclasses.field(default_factory=dict)
70
103
  )
71
104
 
72
- def aggregate(self, timeit_obj: 'TimeIt'):
73
- for k, v in timeit_obj.status().items():
105
+ def __bool__(self) -> bool:
106
+ """Returns True if the summary is non-empty."""
107
+ return bool(self.breakdown)
108
+
109
+ def aggregate(self, timeit_status: Dict[str, 'TimeIt.Status']):
110
+ for k, v in timeit_status.items():
74
111
  if k not in self.breakdown:
75
112
  self.breakdown[k] = TimeIt.StatusSummary.Entry()
76
113
  self.breakdown[k].update(v)
77
114
 
78
- def __init__(self, name: str):
79
- self._name = name
80
- self._start_time = None
81
- self._end_time = None
82
- self._child_contexts = {}
83
- self._error = None
84
- self._parent = None
115
+ def to_json(self, **kwargs) -> Dict[str, Any]:
116
+ return self.to_json_dict(
117
+ fields=dict(
118
+ breakdown=(self.breakdown, {}),
119
+ ),
120
+ exclude_default=True,
121
+ **kwargs,
122
+ )
123
+
124
+ def __init__(self, name: str = ''):
125
+ self._name: str = name
126
+ self._start_time: Optional[float] = None
127
+ self._end_time: Optional[float] = None
128
+ self._child_contexts: Dict[str, TimeIt] = {}
129
+ self._error: Optional[error_utils.ErrorInfo] = None
130
+ self._parent: Optional[TimeIt] = None
85
131
 
86
132
  @property
87
133
  def name(self) -> str:
@@ -107,7 +153,9 @@ class TimeIt:
107
153
  """Ends timing."""
108
154
  if not self.has_ended:
109
155
  self._end_time = time.time()
110
- self._error = error
156
+ self._error = (
157
+ None if error is None else error_utils.ErrorInfo.from_exception(error)
158
+ )
111
159
  return True
112
160
  return False
113
161
 
@@ -132,7 +180,7 @@ class TimeIt:
132
180
  return self._end_time
133
181
 
134
182
  @property
135
- def error(self) -> Optional[BaseException]:
183
+ def error(self) -> Optional[error_utils.ErrorInfo]:
136
184
  """Returns error."""
137
185
  return self._error
138
186
 
@@ -161,7 +209,8 @@ class TimeIt:
161
209
  for child in self._child_contexts.values():
162
210
  child_result = child.status()
163
211
  for k, v in child_result.items():
164
- result[f'{self.name}.{k}'] = v
212
+ key = f'{self.name}.{k}' if self.name else k
213
+ result[key] = v
165
214
  return result
166
215
 
167
216
  def __enter__(self):
@@ -182,6 +231,6 @@ class TimeIt:
182
231
  thread_local.thread_local_set('__timing_context__', self._parent)
183
232
 
184
233
 
185
- def timeit(name: str) -> TimeIt:
234
+ def timeit(name: str = '') -> TimeIt:
186
235
  """Context manager to time a block of code."""
187
236
  return TimeIt(name)
@@ -15,14 +15,15 @@
15
15
  import time
16
16
  import unittest
17
17
 
18
- from pyglove.core.object_utils import profiling
18
+ from pyglove.core.object_utils import json_conversion
19
+ from pyglove.core.object_utils import timing
19
20
 
20
21
 
21
22
  class TimeItTest(unittest.TestCase):
22
23
  """Tests for `pg.symbolic.thread_local`."""
23
24
 
24
25
  def test_basics(self):
25
- tc = profiling.TimeIt('node')
26
+ tc = timing.TimeIt('node')
26
27
  self.assertFalse(tc.has_started)
27
28
  self.assertEqual(tc.elapse, 0)
28
29
 
@@ -37,7 +38,7 @@ class TimeItTest(unittest.TestCase):
37
38
  self.assertEqual(tc.elapse, elapse1)
38
39
 
39
40
  def test_timeit(self):
40
- with profiling.timeit('node') as t:
41
+ with timing.timeit('node') as t:
41
42
  self.assertEqual(t.name, 'node')
42
43
  self.assertIsNotNone(t.start_time)
43
44
  self.assertTrue(t.has_started)
@@ -46,9 +47,9 @@ class TimeItTest(unittest.TestCase):
46
47
  time.sleep(0.5)
47
48
  elapse1 = t.elapse
48
49
  self.assertEqual(t.children, [])
49
- with profiling.timeit('child') as t1:
50
+ with timing.timeit('child') as t1:
50
51
  time.sleep(0.5)
51
- with profiling.timeit('grandchild') as t2:
52
+ with timing.timeit('grandchild') as t2:
52
53
  time.sleep(0.5)
53
54
 
54
55
  self.assertEqual(t.children, [t1])
@@ -63,7 +64,7 @@ class TimeItTest(unittest.TestCase):
63
64
  self.assertTrue(r['node.child.grandchild'].has_ended)
64
65
 
65
66
  with self.assertRaisesRegex(ValueError, '.* already exists'):
66
- with profiling.timeit('grandchild'):
67
+ with timing.timeit('grandchild'):
67
68
  pass
68
69
 
69
70
  elapse2 = t.elapse
@@ -83,42 +84,49 @@ class TimeItTest(unittest.TestCase):
83
84
  )
84
85
  self.assertTrue(all(v.has_ended for v in statuss.values()))
85
86
  self.assertFalse(any(v.has_error for v in statuss.values()))
87
+ status = t.status()
88
+ json_dict = json_conversion.to_json(status)
89
+ status2 = json_conversion.from_json(json_dict)
90
+ self.assertIsNot(status2, status)
91
+ self.assertEqual(status2, status)
86
92
 
87
93
  def test_timeit_with_error(self):
88
94
  with self.assertRaises(ValueError):
89
- with profiling.timeit('node') as t:
90
- with profiling.timeit('child') as t1:
91
- with profiling.timeit('grandchild') as t2:
95
+ with timing.timeit('node') as t:
96
+ with timing.timeit('child') as t1:
97
+ with timing.timeit('grandchild') as t2:
92
98
  raise ValueError('error')
93
99
 
94
100
  r = t.status()
95
101
  self.assertTrue(r['node'].has_error)
96
102
  self.assertTrue(t.has_error)
97
- self.assertIsInstance(t.error, ValueError)
98
- self.assertIsInstance(r['node'].error, ValueError)
103
+ self.assertTrue(t.error.tag.startswith('ValueError'))
104
+ self.assertTrue(r['node'].error.tag.startswith('ValueError'))
99
105
  self.assertTrue(r['node.child'].has_error)
100
106
  self.assertTrue(t1.has_error)
101
107
  self.assertTrue(r['node.child.grandchild'].has_error)
102
108
  self.assertTrue(t2.has_error)
103
109
 
104
110
  def test_timeit_summary(self):
105
- summary = profiling.TimeIt.StatusSummary()
111
+ summary = timing.TimeIt.StatusSummary()
112
+ self.assertFalse(summary)
106
113
  for i in range(10):
107
- with profiling.timeit('node') as t:
114
+ with timing.timeit() as t:
108
115
  time.sleep(0.1)
109
- with profiling.timeit('child'):
116
+ with timing.timeit('child'):
110
117
  time.sleep(0.1)
111
118
  try:
112
- with profiling.timeit('grandchild'):
119
+ with timing.timeit('grandchild'):
113
120
  time.sleep(0.1)
114
121
  if i < 2:
115
122
  raise ValueError('error')
116
123
  except ValueError:
117
124
  pass
118
- summary.aggregate(t)
125
+ summary.aggregate(t.status())
126
+ self.assertTrue(summary)
119
127
  self.assertEqual(
120
128
  list(summary.breakdown.keys()),
121
- ['node', 'node.child', 'node.child.grandchild']
129
+ ['', 'child', 'child.grandchild']
122
130
  )
123
131
  self.assertEqual(
124
132
  [x.num_started for x in summary.breakdown.values()],
@@ -132,6 +140,15 @@ class TimeItTest(unittest.TestCase):
132
140
  [x.num_failed for x in summary.breakdown.values()],
133
141
  [0, 0, 2]
134
142
  )
143
+ self.assertEqual(
144
+ summary.breakdown['child.grandchild'].error_tags,
145
+ {'ValueError': 2},
146
+ )
147
+ # Test serialization.
148
+ json_dict = summary.to_json()
149
+ summary2 = timing.TimeIt.StatusSummary.from_json(json_dict)
150
+ self.assertIsNot(summary2, summary)
151
+ self.assertEqual(summary2.breakdown, summary.breakdown)
135
152
 
136
153
 
137
154
  if __name__ == '__main__':
@@ -120,6 +120,7 @@ from pyglove.core.symbolic.base import to_json
120
120
  from pyglove.core.symbolic.base import to_json_str
121
121
  from pyglove.core.symbolic.base import load
122
122
  from pyglove.core.symbolic.base import save
123
+ from pyglove.core.symbolic.base import open_jsonl
123
124
 
124
125
  # Interfaces for pure symbolic objects.
125
126
  from pyglove.core.symbolic.pure_symbolic import PureSymbolic
@@ -2041,13 +2041,16 @@ def contains(
2041
2041
  return not traverse(x, _contains)
2042
2042
 
2043
2043
 
2044
- def from_json(json_value: Any,
2045
- *,
2046
- allow_partial: bool = False,
2047
- root_path: Optional[object_utils.KeyPath] = None,
2048
- auto_import: bool = True,
2049
- auto_dict: bool = False,
2050
- **kwargs) -> Any:
2044
+ def from_json(
2045
+ json_value: Any,
2046
+ *,
2047
+ allow_partial: bool = False,
2048
+ root_path: Optional[object_utils.KeyPath] = None,
2049
+ auto_import: bool = True,
2050
+ auto_dict: bool = False,
2051
+ value_spec: Optional[pg_typing.ValueSpec] = None,
2052
+ **kwargs
2053
+ ) -> Any:
2051
2054
  """Deserializes a (maybe) symbolic value from JSON value.
2052
2055
 
2053
2056
  Example::
@@ -2073,6 +2076,7 @@ def from_json(json_value: Any,
2073
2076
  find the class 'A' within the imported module.
2074
2077
  auto_dict: If True, dict with '_type' that cannot be loaded will remain
2075
2078
  as dict, with '_type' renamed to 'type_name'.
2079
+ value_spec: The value spec for the symbolic list or dict.
2076
2080
  **kwargs: Allow passing through keyword arguments to from_json of specific
2077
2081
  types.
2078
2082
 
@@ -2093,10 +2097,15 @@ def from_json(json_value: Any,
2093
2097
  json_value, auto_import=auto_import, auto_dict=auto_dict
2094
2098
  )
2095
2099
 
2096
- kwargs.update({
2097
- 'allow_partial': allow_partial,
2098
- 'root_path': root_path,
2099
- })
2100
+ def _load_child(k, v):
2101
+ return from_json(
2102
+ v,
2103
+ root_path=object_utils.KeyPath(k, root_path),
2104
+ _typename_resolved=True,
2105
+ allow_partial=allow_partial,
2106
+ **kwargs
2107
+ )
2108
+
2100
2109
  if isinstance(json_value, list):
2101
2110
  if (json_value
2102
2111
  and json_value[0] == object_utils.JSONConvertible.TUPLE_MARKER):
@@ -2106,21 +2115,27 @@ def from_json(json_value: Any,
2106
2115
  f'Tuple should have at least one element '
2107
2116
  f'besides \'{object_utils.JSONConvertible.TUPLE_MARKER}\'. '
2108
2117
  f'Encountered: {json_value}', root_path))
2109
- kwargs.pop('root_path')
2110
- return tuple([
2111
- from_json(
2112
- v,
2113
- root_path=object_utils.KeyPath(i, root_path),
2114
- _typename_resolved=True,
2115
- **kwargs
2116
- )
2117
- for i, v in enumerate(json_value[1:])
2118
- ])
2119
- return Symbolic.ListType(json_value, **kwargs) # pytype: disable=not-callable # pylint: disable=not-callable
2118
+ return tuple(_load_child(i, v) for i, v in enumerate(json_value[1:]))
2119
+ return Symbolic.ListType.from_json( # pytype: disable=attribute-error
2120
+ json_value,
2121
+ value_spec=value_spec,
2122
+ root_path=root_path,
2123
+ allow_partial=allow_partial,
2124
+ **kwargs,
2125
+ )
2120
2126
  elif isinstance(json_value, dict):
2121
2127
  if object_utils.JSONConvertible.TYPE_NAME_KEY not in json_value:
2122
- return Symbolic.DictType.from_json(json_value, **kwargs)
2123
- return object_utils.from_json(json_value, _typename_resolved=True, **kwargs)
2128
+ return Symbolic.DictType.from_json( # pytype: disable=attribute-error
2129
+ json_value,
2130
+ value_spec=value_spec,
2131
+ root_path=root_path,
2132
+ allow_partial=allow_partial,
2133
+ **kwargs,
2134
+ )
2135
+ return object_utils.from_json(
2136
+ json_value, _typename_resolved=True,
2137
+ root_path=root_path, allow_partial=allow_partial, **kwargs
2138
+ )
2124
2139
  return json_value
2125
2140
 
2126
2141
 
@@ -2310,6 +2325,42 @@ def save(value: Any, path: str, *args, **kwargs) -> Any:
2310
2325
  return save_handler(value, path, *args, **kwargs)
2311
2326
 
2312
2327
 
2328
+ def open_jsonl(
2329
+ path: str,
2330
+ mode: str = 'r',
2331
+ **kwargs
2332
+ ) -> pg_io.Sequence:
2333
+ """Open a JSONL file for reading or writing.
2334
+
2335
+ Example::
2336
+
2337
+ with pg.open_jsonl('my_file.jsonl', 'w') as f:
2338
+ f.add(1)
2339
+ f.add('foo')
2340
+ f.add(dict(x=1))
2341
+
2342
+ with pg.open_jsonl('my_file.jsonl', 'r') as f:
2343
+ for value in f:
2344
+ print(value)
2345
+
2346
+ Args:
2347
+ path: The path to the file.
2348
+ mode: The mode of the file.
2349
+ **kwargs: Additional keyword arguments that will be passed to
2350
+ ``pg_io.open_sequence``.
2351
+
2352
+ Returns:
2353
+ A sequence for PyGlove objects.
2354
+ """
2355
+ return pg_io.open_sequence(
2356
+ path,
2357
+ mode,
2358
+ serializer=to_json_str,
2359
+ deserializer=from_json_str,
2360
+ **kwargs
2361
+ )
2362
+
2363
+
2313
2364
  def default_load_handler(
2314
2365
  path: str,
2315
2366
  file_format: Literal['json', 'txt'] = 'json',
@@ -152,10 +152,19 @@ class Dict(dict, base.Symbolic, pg_typing.CustomTyping):
152
152
  # Not okay:
153
153
  d.a.f2.abc = 1
154
154
  """
155
- return cls(json_value,
156
- value_spec=value_spec,
157
- allow_partial=allow_partial,
158
- root_path=root_path)
155
+ return cls(
156
+ {
157
+ k: base.from_json(
158
+ v,
159
+ root_path=object_utils.KeyPath(k, root_path),
160
+ allow_partial=allow_partial,
161
+ **kwargs
162
+ ) for k, v in json_value.items()
163
+ },
164
+ value_spec=value_spec,
165
+ root_path=root_path,
166
+ allow_partial=allow_partial,
167
+ )
159
168
 
160
169
  def __init__(self,
161
170
  dict_obj: Union[
@@ -132,10 +132,19 @@ class List(list, base.Symbolic, pg_typing.CustomTyping):
132
132
  Returns:
133
133
  A schema-less symbolic list, but its items maybe symbolic.
134
134
  """
135
- return cls(json_value,
136
- value_spec=value_spec,
137
- allow_partial=allow_partial,
138
- root_path=root_path)
135
+ return cls(
136
+ [
137
+ base.from_json(
138
+ v,
139
+ root_path=object_utils.KeyPath(i, root_path),
140
+ allow_partial=allow_partial,
141
+ **kwargs
142
+ ) for i, v in enumerate(json_value)
143
+ ],
144
+ value_spec=value_spec,
145
+ root_path=root_path,
146
+ allow_partial=allow_partial,
147
+ )
139
148
 
140
149
  def __init__(
141
150
  self,
@@ -543,7 +543,9 @@ class Object(base.Symbolic, metaclass=ObjectMeta):
543
543
  json_value: Any,
544
544
  *,
545
545
  allow_partial: bool = False,
546
- root_path: Optional[object_utils.KeyPath] = None) -> 'Object':
546
+ root_path: Optional[object_utils.KeyPath] = None,
547
+ **kwargs
548
+ ) -> 'Object':
547
549
  """Class method that load an symbolic Object from a JSON value.
548
550
 
549
551
  Example::
@@ -580,12 +582,13 @@ class Object(base.Symbolic, metaclass=ObjectMeta):
580
582
  json_value: Input JSON value, only JSON dict is acceptable.
581
583
  allow_partial: Whether to allow elements of the list to be partial.
582
584
  root_path: KeyPath of loaded object in its object tree.
585
+ **kwargs: Additional keyword arguments to pass through.
583
586
 
584
587
  Returns:
585
588
  A symbolic Object instance.
586
589
  """
587
590
  return cls(allow_partial=allow_partial, root_path=root_path, **{
588
- k: base.from_json(v, allow_partial=allow_partial)
591
+ k: base.from_json(v, allow_partial=allow_partial, **kwargs)
589
592
  for k, v in json_value.items()
590
593
  })
591
594
 
@@ -333,7 +333,11 @@ class Html(base.Content):
333
333
  return s
334
334
 
335
335
  @classmethod
336
- def escape(cls, s: WritableTypes) -> WritableTypes:
336
+ def escape(
337
+ cls,
338
+ s: WritableTypes,
339
+ javascript_str: bool = False
340
+ ) -> WritableTypes:
337
341
  """Escapes an HTML writable object."""
338
342
  if s is None:
339
343
  return None
@@ -341,11 +345,22 @@ class Html(base.Content):
341
345
  if callable(s):
342
346
  s = s()
343
347
 
344
- if isinstance(s, str):
348
+ def _escape(s: str) -> str:
349
+ if javascript_str:
350
+ return (
351
+ s.replace('\\', '\\\\')
352
+ .replace('"', '\\"')
353
+ .replace('\r', '\\r')
354
+ .replace('\n', '\\n')
355
+ .replace('\t', '\\t')
356
+ )
345
357
  return html_lib.escape(s)
358
+
359
+ if isinstance(s, str):
360
+ return _escape(s)
346
361
  else:
347
362
  assert isinstance(s, Html), s
348
- return Html(html_lib.escape(s.content)).write(
363
+ return Html(_escape(s.content)).write(
349
364
  s, shared_parts_only=True
350
365
  )
351
366
 
@@ -695,6 +695,8 @@ class HtmlTest(TestCase):
695
695
  self.assertEqual(Html.escape('foo"bar'), 'foo&quot;bar')
696
696
  self.assertEqual(Html.escape(Html('foo"bar')), Html('foo&quot;bar'))
697
697
  self.assertEqual(Html.escape(lambda: 'foo"bar'), 'foo&quot;bar')
698
+ self.assertEqual(Html.escape('"x=y"', javascript_str=True), '\\"x=y\\"')
699
+ self.assertEqual(Html.escape('x\n"', javascript_str=True), 'x\\n\\"')
698
700
 
699
701
  def test_concate(self):
700
702
  self.assertIsNone(Html.concate(None))
@@ -1215,6 +1215,7 @@ class HtmlTreeView(HtmlView):
1215
1215
  parent: Any = None,
1216
1216
  root_path: Optional[KeyPath] = None,
1217
1217
  css_classes: Optional[Sequence[str]] = None,
1218
+ id: Optional[str] = None, # pylint: disable=redefined-builtin
1218
1219
  content: Union[str, Html, None] = None,
1219
1220
  **kwargs,
1220
1221
  ) -> Html:
@@ -1225,8 +1226,9 @@ class HtmlTreeView(HtmlView):
1225
1226
  parent: The parent of the value.
1226
1227
  root_path: The root path of the value.
1227
1228
  css_classes: CSS classes to add to the HTML element.
1229
+ id: The ID of the tooltip span element. If None, no ID will be added.
1228
1230
  content: The content to render. If None, the value will be rendered.
1229
- **kwargs: Additional keyword arguments passed from the user that
1231
+ **kwargs: Additional keyword arguments passed from the user that
1230
1232
  will be ignored.
1231
1233
 
1232
1234
  Returns:
@@ -1248,6 +1250,7 @@ class HtmlTreeView(HtmlView):
1248
1250
  return Html.element(
1249
1251
  'span',
1250
1252
  [content],
1253
+ id=id,
1251
1254
  css_classes=[
1252
1255
  'tooltip',
1253
1256
  css_classes,
@@ -1266,6 +1269,9 @@ class HtmlTreeView(HtmlView):
1266
1269
  position: absolute;
1267
1270
  z-index: 1;
1268
1271
  }
1272
+ span.tooltip:hover {
1273
+ visibility: visible;
1274
+ }
1269
1275
  """
1270
1276
  )
1271
1277
 
@@ -74,6 +74,9 @@ class TooltipTest(TestCase):
74
74
  position: absolute;
75
75
  z-index: 1;
76
76
  }
77
+ span.tooltip:hover {
78
+ visibility: visible;
79
+ }
77
80
  </style>
78
81
  """
79
82
  )
@@ -122,6 +125,9 @@ class SummaryTest(TestCase):
122
125
  position: absolute;
123
126
  z-index: 1;
124
127
  }
128
+ span.tooltip:hover {
129
+ visibility: visible;
130
+ }
125
131
  /* Summary styles. */
126
132
  details.pyglove summary {
127
133
  font-weight: bold;
@@ -314,6 +320,9 @@ class ContentTest(TestCase):
314
320
  position: absolute;
315
321
  z-index: 1;
316
322
  }
323
+ span.tooltip:hover {
324
+ visibility: visible;
325
+ }
317
326
  /* Summary styles. */
318
327
  details.pyglove summary {
319
328
  font-weight: bold;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyglove
3
- Version: 0.4.5.dev202410290809
3
+ Version: 0.4.5.dev202411010809
4
4
  Summary: PyGlove: A library for manipulating Python objects.
5
5
  Home-page: https://github.com/google/pyglove
6
6
  Author: PyGlove Authors