pyglove 0.4.5.dev202411030808__py3-none-any.whl → 0.4.5.dev202411060809__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.

Potentially problematic release.


This version of pyglove might be problematic. Click here for more details.

Files changed (28) hide show
  1. pyglove/core/__init__.py +8 -0
  2. pyglove/core/symbolic/base.py +2 -38
  3. pyglove/core/symbolic/diff.py +14 -13
  4. pyglove/core/symbolic/object_test.py +1 -0
  5. pyglove/core/symbolic/ref.py +7 -7
  6. pyglove/core/views/__init__.py +1 -0
  7. pyglove/core/views/base.py +14 -8
  8. pyglove/core/views/base_test.py +1 -1
  9. pyglove/core/views/html/__init__.py +1 -1
  10. pyglove/core/views/html/base.py +53 -79
  11. pyglove/core/views/html/base_test.py +17 -4
  12. pyglove/core/views/html/controls/__init__.py +35 -0
  13. pyglove/core/views/html/controls/base.py +238 -0
  14. pyglove/core/views/html/controls/label.py +190 -0
  15. pyglove/core/views/html/controls/label_test.py +138 -0
  16. pyglove/core/views/html/controls/progress_bar.py +185 -0
  17. pyglove/core/views/html/controls/progress_bar_test.py +97 -0
  18. pyglove/core/views/html/controls/tab.py +169 -0
  19. pyglove/core/views/html/controls/tab_test.py +54 -0
  20. pyglove/core/views/html/controls/tooltip.py +99 -0
  21. pyglove/core/views/html/controls/tooltip_test.py +99 -0
  22. pyglove/core/views/html/tree_view.py +24 -6
  23. pyglove/core/views/html/tree_view_test.py +7 -2
  24. {pyglove-0.4.5.dev202411030808.dist-info → pyglove-0.4.5.dev202411060809.dist-info}/METADATA +1 -1
  25. {pyglove-0.4.5.dev202411030808.dist-info → pyglove-0.4.5.dev202411060809.dist-info}/RECORD +28 -18
  26. {pyglove-0.4.5.dev202411030808.dist-info → pyglove-0.4.5.dev202411060809.dist-info}/LICENSE +0 -0
  27. {pyglove-0.4.5.dev202411030808.dist-info → pyglove-0.4.5.dev202411060809.dist-info}/WHEEL +0 -0
  28. {pyglove-0.4.5.dev202411030808.dist-info → pyglove-0.4.5.dev202411060809.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,238 @@
1
+ # Copyright 2024 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
+ """Base for HTML controls."""
15
+
16
+ import abc
17
+ import contextlib
18
+ import inspect
19
+ import sys
20
+ from typing import Annotated, Any, Dict, Iterator, List, Optional, Union
21
+
22
+ from pyglove.core import object_utils
23
+ from pyglove.core.symbolic import object as pg_object
24
+ from pyglove.core.views.html import base
25
+
26
+ Html = base.Html
27
+
28
+
29
+ try:
30
+ _notebook = sys.modules['IPython'].display
31
+ except Exception: # pylint: disable=broad-except
32
+ _notebook = None
33
+
34
+
35
+ class HtmlControl(pg_object.Object):
36
+ """Base class for HTML controls."""
37
+
38
+ id: Annotated[
39
+ Optional[str],
40
+ 'Optional ID of the root element.'
41
+ ] = None
42
+
43
+ css_classes: Annotated[
44
+ List[str],
45
+ 'Optional CSS classes of the root element.'
46
+ ] = []
47
+
48
+ styles: Annotated[
49
+ Dict[str, Any],
50
+ 'Ad-hoc styles (vs. class-based) of the root element.'
51
+ ] = {}
52
+
53
+ interactive: Annotated[
54
+ bool,
55
+ (
56
+ 'Whether the control is interactive. If False, optimizations will be '
57
+ 'applied to reduce the size of the HTML.'
58
+ )
59
+ ] = False
60
+
61
+ def _on_bound(self):
62
+ super()._on_bound()
63
+ self._rendered = False
64
+ self._css_styles = []
65
+ self._scripts = []
66
+
67
+ def add_style(self, *css: str) -> 'HtmlControl':
68
+ """Adds CSS styles to the HTML."""
69
+ self._css_styles.extend(css)
70
+ return self
71
+
72
+ def add_script(self, *scripts: str) -> 'HtmlControl':
73
+ self._scripts.extend(scripts)
74
+ return self
75
+
76
+ def to_html(self, **kwargs) -> Html:
77
+ """Returns the HTML representation of the control."""
78
+ self._rendered = True
79
+ html = self._to_html(**kwargs)
80
+ return html.add_style(*self._css_styles).add_script(*self._scripts)
81
+
82
+ @abc.abstractmethod
83
+ def _to_html(self, **kwargs) -> Html:
84
+ """Returns the HTML representation of the control."""
85
+
86
+ @classmethod
87
+ @contextlib.contextmanager
88
+ def track_scripts(cls) -> Iterator[List[str]]:
89
+ del cls
90
+ all_tracked = object_utils.thread_local_get(_TLS_TRACKED_SCRIPTS, [])
91
+ current = []
92
+ all_tracked.append(current)
93
+ object_utils.thread_local_set(_TLS_TRACKED_SCRIPTS, all_tracked)
94
+ try:
95
+ yield current
96
+ finally:
97
+ all_tracked.pop(-1)
98
+ if not all_tracked:
99
+ object_utils.thread_local_del(_TLS_TRACKED_SCRIPTS)
100
+
101
+ def _sync_members(self, **fields) -> None:
102
+ """Synchronizes displayed values to members."""
103
+ self.rebind(fields, skip_notification=True, raise_on_no_change=False)
104
+
105
+ def _run_javascript(self, code: str) -> None:
106
+ """Runs the given JavaScript code."""
107
+ if not self.interactive:
108
+ raise ValueError(
109
+ f'Non-interactive control {self} cannot be updated. '
110
+ 'Please set `interactive=True` in the constructor.'
111
+ )
112
+ if not self._rendered:
113
+ return
114
+
115
+ code = inspect.cleandoc(code)
116
+ if _notebook is not None:
117
+ _notebook.display(_notebook.Javascript(code))
118
+
119
+ # Track script execution.
120
+ all_tracked = object_utils.thread_local_get(_TLS_TRACKED_SCRIPTS, [])
121
+ for tracked in all_tracked:
122
+ tracked.append(code)
123
+
124
+ def element_id(self, child: Optional[str] = None) -> Optional[str]:
125
+ """Returns the element id of this control or a child."""
126
+ if self.id is not None:
127
+ return self.id
128
+ elif not self.interactive:
129
+ return None
130
+ elif child is None:
131
+ return f'control-{id(self)}'
132
+ else:
133
+ return f'control-{id(self)}-{child}'
134
+
135
+ def _update_content(
136
+ self,
137
+ content: Union[str, Html],
138
+ child: Optional[str] = None,
139
+ ) -> None:
140
+ """Updates the content of the control."""
141
+ if isinstance(content, str):
142
+ return self._update_text(content, child)
143
+ else:
144
+ return self._update_inner_html(content, child)
145
+
146
+ def _update_text(
147
+ self,
148
+ content: str,
149
+ child: Optional[str] = None
150
+ ) -> str:
151
+ """Updates the content of the control."""
152
+ self._run_javascript(
153
+ f"""
154
+ elem = document.getElementById("{self.element_id(child)}");
155
+ elem.textContent = "{Html.escape(content, javascript_str=True)}";
156
+ """
157
+ )
158
+ return content
159
+
160
+ def _update_inner_html(
161
+ self,
162
+ html: base.Html,
163
+ child: Optional[str] = None,
164
+ ) -> base.Html:
165
+ """Updates the inner HTML of the control."""
166
+ js_html = Html.escape(html, javascript_str=True)
167
+ assert isinstance(js_html, Html), js_html
168
+ self._run_javascript(
169
+ f"""
170
+ elem = document.getElementById("{self.element_id(child)}");
171
+ elem.innerHTML = "{js_html.to_str(content_only=True)}";
172
+ """
173
+ )
174
+ return html
175
+
176
+ def _update_style(
177
+ self,
178
+ styles: Dict[str, Any],
179
+ *,
180
+ child: Optional[str] = None,
181
+ updates_only: bool = False,
182
+ ) -> Dict[str, Any]:
183
+ """Updates the style of the control."""
184
+ updated_styles = {} if updates_only else dict(self.styles or {})
185
+ updated_styles.update(styles)
186
+ self._run_javascript(
187
+ f"""
188
+ elem = document.getElementById("{self.element_id(child)}");
189
+ elem.style = "{Html.style_str(updated_styles)}";
190
+ """
191
+ )
192
+ return updated_styles
193
+
194
+ def _update_property(
195
+ self,
196
+ name: str,
197
+ value: str,
198
+ child: Optional[str] = None,
199
+ ) -> str:
200
+ """Updates a property of the control."""
201
+ self._run_javascript(
202
+ f"""
203
+ elem = document.getElementById("{self.element_id(child)}");
204
+ elem.{name} = "{value}";
205
+ """
206
+ )
207
+ return value
208
+
209
+ def _add_css_class(
210
+ self,
211
+ css_class: str,
212
+ child: Optional[str] = None,
213
+ ) -> str:
214
+ """Adds a CSS class to the control."""
215
+ self._run_javascript(
216
+ f"""
217
+ elem = document.getElementById("{self.element_id(child)}");
218
+ elem.classList.add("{css_class}");
219
+ """
220
+ )
221
+ return css_class
222
+
223
+ def _remove_css_class(
224
+ self,
225
+ css_class: str,
226
+ child: Optional[str] = None,
227
+ ) -> str:
228
+ """Removes a CSS class from the control."""
229
+ self._run_javascript(
230
+ f"""
231
+ elem = document.getElementById("{self.element_id(child)}");
232
+ elem.classList.remove("{css_class}");
233
+ """
234
+ )
235
+ return css_class
236
+
237
+
238
+ _TLS_TRACKED_SCRIPTS = '__tracked_scripts__'
@@ -0,0 +1,190 @@
1
+ # Copyright 2024 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
+ """Html label control."""
15
+
16
+ from typing import Annotated, Any, Dict, List, Optional, Union
17
+
18
+ from pyglove.core import typing as pg_typing
19
+ from pyglove.core.symbolic import flags as pg_flags
20
+ from pyglove.core.symbolic import object as pg_object
21
+ # pylint: disable=g-importing-member
22
+ from pyglove.core.views.html.base import Html
23
+ from pyglove.core.views.html.controls.base import HtmlControl
24
+ from pyglove.core.views.html.controls.tooltip import Tooltip
25
+ # pylint: enable=g-importing-member
26
+
27
+
28
+ @pg_object.use_init_args(
29
+ ['text', 'tooltip', 'link', 'id', 'css_classes', 'styles']
30
+ )
31
+ class Label(HtmlControl):
32
+ """Html label."""
33
+
34
+ text: Annotated[
35
+ Union[str, Html],
36
+ 'The text or HTML content of the label.'
37
+ ]
38
+
39
+ tooltip: Annotated[
40
+ Optional[Tooltip],
41
+ '(Optional) The tooltip for the label.'
42
+ ] = None
43
+
44
+ link: Annotated[
45
+ Optional[str],
46
+ '(Optional) The URL for the link of the label.'
47
+ ] = None
48
+
49
+ def _on_bound(self):
50
+ super()._on_bound()
51
+ if self.tooltip is not None:
52
+ self.tooltip.rebind(
53
+ for_element='.label',
54
+ interactive=self.tooltip.interactive or self.interactive,
55
+ notify_parents=False
56
+ )
57
+ self.add_style(
58
+ """
59
+ .label {
60
+ display: inline-block;
61
+ color: inherit;
62
+ padding: 5px;
63
+ }
64
+ .label-container {
65
+ display: inline-block;
66
+ }
67
+ """
68
+ )
69
+
70
+ def _to_html(self, **kwargs) -> Html:
71
+ text_elem = Html.element(
72
+ 'a',
73
+ [self.text],
74
+ id=self.element_id(),
75
+ href=self.link,
76
+ css_classes=['label'] + self.css_classes,
77
+ styles=self.styles,
78
+ )
79
+ if self.tooltip is None:
80
+ return text_elem
81
+ return Html.element(
82
+ 'div',
83
+ [text_elem, self.tooltip],
84
+ css_classes=['label-container'],
85
+ )
86
+
87
+ def update(
88
+ self,
89
+ text: Union[str, Html, None] = None,
90
+ tooltip: Union[str, Html, None] = None,
91
+ link: Optional[str] = None,
92
+ styles: Optional[Dict[str, Any]] = None,
93
+ ) -> None:
94
+ if text is not None:
95
+ self._sync_members(text=self._update_content(text))
96
+ if styles:
97
+ self._sync_members(styles=self._update_style(styles))
98
+ if link is not None:
99
+ self._sync_members(link=self._update_property('href', link))
100
+ if tooltip is not None:
101
+ self.tooltip.update(content=tooltip)
102
+
103
+
104
+ # Register converter for automatic conversion.
105
+ pg_typing.register_converter(str, Label, Label)
106
+ pg_typing.register_converter(Html, Label, Label)
107
+
108
+
109
+ class Badge(Label):
110
+ """A badge."""
111
+
112
+ def _on_bound(self):
113
+ super()._on_bound()
114
+ with pg_flags.notify_on_change(False):
115
+ self.css_classes.append('badge')
116
+ self.add_style(
117
+ """
118
+ .badge {
119
+ background-color: #EEE;
120
+ border-radius: 5px;
121
+ color: #777;
122
+ }
123
+ """
124
+ )
125
+
126
+
127
+ @pg_object.use_init_args(
128
+ ['labels', 'name', 'id', 'css_classes', 'styles']
129
+ )
130
+ class LabelGroup(HtmlControl):
131
+ """Label group."""
132
+
133
+ labels: Annotated[
134
+ List[Label],
135
+ 'The labels in the group.'
136
+ ]
137
+
138
+ name: Annotated[
139
+ Optional[Label],
140
+ 'The label for the name of the group.'
141
+ ] = None
142
+
143
+ def _on_bound(self):
144
+ super()._on_bound()
145
+ with pg_flags.notify_on_change(False):
146
+ if self.name is not None:
147
+ self.name.rebind(
148
+ interactive=self.interactive or self.name.interactive,
149
+ raise_on_no_change=False
150
+ )
151
+ self.name.css_classes.append('group-name')
152
+ for label in self.labels:
153
+ label.css_classes.append('group-value')
154
+ label.rebind(
155
+ interactive=self.interactive or label.interactive,
156
+ raise_on_no_change=False,
157
+ )
158
+
159
+ def _to_html(self, **kwargs) -> Html:
160
+ return Html.element(
161
+ 'div',
162
+ [self.name] + self.labels,
163
+ id=self.id or None,
164
+ css_classes=['label-group'] + self.css_classes,
165
+ styles=self.styles,
166
+ ).add_style(
167
+ """
168
+ .label-group {
169
+ display: inline-flex;
170
+ overflow: hidden;
171
+ border-radius: 5px;
172
+ border: 1px solid #DDD;
173
+ padding: 0px;
174
+ margin: 5px
175
+ }
176
+ .label-group .group-name {
177
+ padding: 5px;
178
+ color: white;
179
+ background-color: dodgerblue;
180
+ }
181
+ .label-group .group-name > .text {
182
+ color: white;
183
+ }
184
+ .label-group .group-value {
185
+ }
186
+ .label-group .badge {
187
+ border-radius: 0px;
188
+ }
189
+ """
190
+ )
@@ -0,0 +1,138 @@
1
+ # Copyright 2024 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
+ import inspect
15
+ import unittest
16
+
17
+ from pyglove.core import symbolic # pylint: disable=unused-import
18
+ from pyglove.core.views.html.controls import label as label_lib
19
+
20
+
21
+ class TestCase(unittest.TestCase):
22
+
23
+ def assert_html_content(self, control, expected):
24
+ expected = inspect.cleandoc(expected).strip()
25
+ actual = control.to_html().content.strip()
26
+ if actual != expected:
27
+ print(actual)
28
+ self.assertEqual(actual, expected)
29
+
30
+
31
+ class LabelTest(TestCase):
32
+
33
+ def test_text_only(self):
34
+ label = label_lib.Label('foo')
35
+ self.assertIsNone(label.tooltip)
36
+ self.assertIsNone(label.link)
37
+ self.assert_html_content(label, '<a class="label">foo</a>')
38
+ with self.assertRaisesRegex(ValueError, 'Non-interactive .*'):
39
+ label.update('bar')
40
+
41
+ def test_with_link(self):
42
+ label = label_lib.Label('foo', link='http://google.com', id='my-foo')
43
+ self.assertEqual(label.element_id(), 'my-foo')
44
+ self.assertIsNone(label.tooltip)
45
+ self.assertEqual(label.link, 'http://google.com')
46
+ self.assert_html_content(
47
+ label, '<a class="label" id="my-foo" href="http://google.com">foo</a>'
48
+ )
49
+
50
+ def test_with_tooltip(self):
51
+ label = label_lib.Label('foo', tooltip='bar')
52
+ self.assertEqual(label.tooltip.for_element, '.label')
53
+ self.assertIsNone(label.link)
54
+ self.assert_html_content(
55
+ label,
56
+ (
57
+ '<div class="label-container"><a class="label">foo</a>'
58
+ '<span class="tooltip">bar</span></div>'
59
+ )
60
+ )
61
+
62
+ def test_update(self):
63
+ label = label_lib.Label('foo', 'bar', 'http://google.com', interactive=True)
64
+ self.assertIn('id="control-', label.to_html_str(content_only=True))
65
+ with label.track_scripts() as scripts:
66
+ label.update(
67
+ 'bar',
68
+ tooltip='baz',
69
+ link='http://www.yahoo.com',
70
+ styles=dict(color='red')
71
+ )
72
+ self.assertEqual(label.text, 'bar')
73
+ self.assertEqual(label.tooltip.content, 'baz')
74
+ self.assertEqual(label.link, 'http://www.yahoo.com')
75
+ self.assertEqual(label.styles, dict(color='red'))
76
+ self.assertEqual(
77
+ scripts,
78
+ [
79
+ inspect.cleandoc(
80
+ f"""
81
+ elem = document.getElementById("{label.element_id()}");
82
+ elem.textContent = "bar";
83
+ """
84
+ ),
85
+ inspect.cleandoc(
86
+ f"""
87
+ elem = document.getElementById("{label.element_id()}");
88
+ elem.style = "color:red;";
89
+ """
90
+ ),
91
+ inspect.cleandoc(
92
+ f"""
93
+ elem = document.getElementById("{label.element_id()}");
94
+ elem.href = "http://www.yahoo.com";
95
+ """
96
+ ),
97
+ inspect.cleandoc(
98
+ f"""
99
+ elem = document.getElementById("{label.tooltip.element_id()}");
100
+ elem.textContent = "baz";
101
+ """
102
+ ),
103
+ inspect.cleandoc(
104
+ f"""
105
+ elem = document.getElementById("{label.tooltip.element_id()}");
106
+ elem.classList.remove("html-content");
107
+ """
108
+ ),
109
+ ]
110
+ )
111
+
112
+ def test_badge(self):
113
+ badge = label_lib.Badge('foo')
114
+ self.assert_html_content(badge, '<a class="label badge">foo</a>')
115
+
116
+
117
+ class LabelGroupTest(TestCase):
118
+
119
+ def test_basic(self):
120
+ group = label_lib.LabelGroup(['foo', 'bar'], name='baz')
121
+ self.assert_html_content(
122
+ group,
123
+ (
124
+ '<div class="label-group"><a class="label group-name">baz</a>'
125
+ '<a class="label group-value">foo</a>'
126
+ '<a class="label group-value">bar</a></div>'
127
+ )
128
+ )
129
+
130
+ def test_interactive(self):
131
+ group = label_lib.LabelGroup(['foo', 'bar'], name='baz', interactive=True)
132
+ self.assertTrue(group.name.interactive)
133
+ self.assertTrue(group.labels[0].interactive)
134
+ self.assertTrue(group.labels[1].interactive)
135
+
136
+
137
+ if __name__ == '__main__':
138
+ unittest.main()