pyglove 0.4.5.dev202410020809__py3-none-any.whl → 0.4.5.dev202410100808__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 (29) hide show
  1. pyglove/core/__init__.py +9 -0
  2. pyglove/core/object_utils/__init__.py +1 -0
  3. pyglove/core/object_utils/value_location.py +10 -0
  4. pyglove/core/object_utils/value_location_test.py +22 -0
  5. pyglove/core/symbolic/base.py +40 -2
  6. pyglove/core/symbolic/base_test.py +67 -0
  7. pyglove/core/symbolic/diff.py +190 -1
  8. pyglove/core/symbolic/diff_test.py +290 -0
  9. pyglove/core/symbolic/object_test.py +3 -8
  10. pyglove/core/symbolic/ref.py +29 -0
  11. pyglove/core/symbolic/ref_test.py +143 -0
  12. pyglove/core/typing/__init__.py +4 -0
  13. pyglove/core/typing/callable_ext.py +240 -1
  14. pyglove/core/typing/callable_ext_test.py +255 -0
  15. pyglove/core/typing/inspect.py +63 -0
  16. pyglove/core/typing/inspect_test.py +39 -0
  17. pyglove/core/views/__init__.py +30 -0
  18. pyglove/core/views/base.py +906 -0
  19. pyglove/core/views/base_test.py +615 -0
  20. pyglove/core/views/html/__init__.py +27 -0
  21. pyglove/core/views/html/base.py +529 -0
  22. pyglove/core/views/html/base_test.py +804 -0
  23. pyglove/core/views/html/tree_view.py +1052 -0
  24. pyglove/core/views/html/tree_view_test.py +748 -0
  25. {pyglove-0.4.5.dev202410020809.dist-info → pyglove-0.4.5.dev202410100808.dist-info}/METADATA +1 -1
  26. {pyglove-0.4.5.dev202410020809.dist-info → pyglove-0.4.5.dev202410100808.dist-info}/RECORD +29 -21
  27. {pyglove-0.4.5.dev202410020809.dist-info → pyglove-0.4.5.dev202410100808.dist-info}/LICENSE +0 -0
  28. {pyglove-0.4.5.dev202410020809.dist-info → pyglove-0.4.5.dev202410100808.dist-info}/WHEEL +0 -0
  29. {pyglove-0.4.5.dev202410020809.dist-info → pyglove-0.4.5.dev202410100808.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,906 @@
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
+ """Interface of PyGlove's view system: Content, View, and Extension.
15
+
16
+ PyGlove introduces a flexible and extensible view system for rendering objects
17
+ in different formats, such as text or HTML. This system allows users to plug in
18
+ various predefined views or create their own custom views with minimal effort.
19
+ The two core concepts of this system are ``View`` and ``Extension``.
20
+
21
+ A ``View`` defines how objects are displayed, with specific classes like
22
+ ``HtmlView`` and more specialized subclasses such as ``HtmlTreeView``. Each
23
+ concrete ``View`` class has a ``VIEW_ID`` that can be used to select the view
24
+ when calling functions like ``pg.view`` or ``pg.to_html``. Views provide default
25
+ rendering behavior for built-in Python types such as ``int``, ``str``, ``bool``,
26
+ ``list``, and ``dict``. However, users can extend this behavior by subclassing
27
+ the ``View.Extension`` class to define custom rendering logic for user-defined
28
+ classes.
29
+
30
+ .. code-block::
31
+
32
+ pg.to_html
33
+ |
34
+ | (calls)
35
+ v
36
+ pg.view
37
+ |
38
+ | (refers)
39
+ v
40
+ pg.View --------------------------------> pg.View.Extension
41
+ ^ (calls) ^
42
+ | (extends) |
43
+ +-----------+ |
44
+ | | |
45
+ ... pg.views.HtmlView ---------------------> pg.views.HtmlView.Extension
46
+ ^ (calls) ^
47
+ | (extends) | (extends)
48
+ pg.views.HtmlTreeView ------------------> pg.views.HtmlTreeView.Extension
49
+ (calls) ^
50
+ |
51
+ pg.Symbolic
52
+ ^
53
+ |
54
+ pg.Object
55
+
56
+ Each ``View`` class also defines an inner ``Extension`` class that manages
57
+ interactions with user-defined types. The ``Extension`` class typically
58
+ implements several methods chosen by the ``View`` developer, such as
59
+ ``_html_tree_view_render``, ``_html_tree_view__summary``, etc., depending on
60
+ the specific renderin needs.
61
+ The ``View.extension_method(<extension_method_name>)`` decorator enables
62
+ flexible mapping between a ``View`` method and its corresponding ``Extension``
63
+ method. This allows users to define custom rendering logic within their
64
+ ``Extension`` class and bind it to the view.
65
+
66
+ An example of introducing a new view is described below:
67
+
68
+ .. code-block:: python
69
+
70
+ class MyView(pg.View):
71
+ VIEW_ID = 'my_view'
72
+
73
+ class Extension(pg.View.Extension):
74
+ def _myview_render(self, **kwargs):
75
+ return ...
76
+
77
+ def _myview_part1(self, **kwargs):
78
+ return ...
79
+
80
+ def _myview_part2(self, **kwargs):
81
+ return ...
82
+
83
+ @pg.View.extension_method('_myview_render')
84
+ def render(self, value: Any, **kwargs):
85
+ part1 = self.render_part1(value.part1, **kwargs)
86
+ part2 = self.render_part2(value.part2, **kwargs)
87
+ return ...
88
+
89
+ @pg.View.extension_method('_myview_part1')
90
+ def render_part1(self, value: Any, **kwargs):
91
+ return ...
92
+
93
+ @pg.View.extension_method('_myview_part2')
94
+ def render_part2(self, value: Any, **kwargs):
95
+ return ...
96
+
97
+ The ``View.extension_method`` decorator also supports multiple views for a
98
+ single user class by allowing multi-inheritance of ``Extension`` classes.
99
+ For example, a class can support both ``View1`` and ``View2`` by inheriting
100
+ their respective ``Extension`` classes and implementing methods like
101
+ ``_render_view1`` and ``_render_view2`` to handle different view-specific
102
+ rendering:
103
+
104
+ .. code-block:: python
105
+
106
+ class MyObject(MyView1.Extension, MyView2.Extension):
107
+ def _myview1_render(self, value, **kwargs):
108
+ return ...
109
+
110
+ def _myview2_render(self, value, **kwargs):
111
+ return ...
112
+
113
+ For ``extension_method``-decorated ``View`` methods, they must include a
114
+ ``value`` argument, which is the target object to be rendered. The view will
115
+ delegate rendering to the user logic only when the ``value`` is an instance of
116
+ the ``Extension`` class.
117
+
118
+ `pg.view` is the function to display an object with a specific view. It takes
119
+ a ``view_id`` argument to specify which view to use, and returns a ``Content``
120
+ object, which acts as the media to be displayed. For example::
121
+
122
+ .. code-block:: python
123
+
124
+ pg.view([1, 2, MyObject(...)], view_id='my_view1')
125
+
126
+ """
127
+ import abc
128
+ import collections
129
+ import contextlib
130
+ import copy as copy_lib
131
+ import functools
132
+ import inspect
133
+ import io
134
+ import types
135
+ from typing import Any, Callable, ContextManager, Dict, Iterator, Optional, Sequence, Set, Type, Union
136
+
137
+ from pyglove.core import io as pg_io
138
+ from pyglove.core import object_utils
139
+ from pyglove.core import typing as pg_typing
140
+
141
+
142
+ # Type definition for the value filter function.
143
+ NodeFilter = Callable[
144
+ [
145
+ object_utils.KeyPath, # The path to the value.
146
+ Any, # Current value.
147
+ Any, # Parent value
148
+ ],
149
+ bool # Whether to include the value.
150
+ ]
151
+
152
+
153
+ class Content(object_utils.Formattable, metaclass=abc.ABCMeta):
154
+ """Content: A type of media to be displayed in a view.
155
+
156
+ For example, `pg.Html` is a `Content` type that represents HTML to be
157
+ displayed in an HTML view.
158
+ """
159
+
160
+ WritableTypes = Union[ # pylint: disable=invalid-name
161
+ str,
162
+ 'Content',
163
+ Callable[[], Union[str, 'Content', None]],
164
+ None
165
+ ]
166
+
167
+ class SharedParts(object_utils.Formattable):
168
+ """A part of the content that should appear just once.
169
+
170
+ For example, `pg.Html.Styles` is a `SharedParts` type that represents
171
+ a group of CSS styles that should only be included once in the HEAD section.
172
+ """
173
+
174
+ __slots__ = ('_parts',)
175
+
176
+ def __init__(
177
+ self,
178
+ *parts: Union[str, 'Content.SharedParts', None]
179
+ ) -> None:
180
+ self._parts: Dict[str, int] = collections.defaultdict(int)
181
+ self.add(*parts)
182
+
183
+ def add(self, *parts: Union[str, 'Content.SharedParts', None]) -> bool:
184
+ """Adds one or multiple parts."""
185
+ updated = False
186
+ for part in parts:
187
+ if part is None:
188
+ continue
189
+ if isinstance(part, Content.SharedParts):
190
+ assert isinstance(part, self.__class__), (part, self.__class__)
191
+ for p, count in part.parts.items():
192
+ self._parts[p] += count
193
+ updated = True
194
+ else:
195
+ assert isinstance(part, str), part
196
+ updated |= (part not in self._parts)
197
+ self._parts[part] += 1
198
+
199
+ if updated:
200
+ self.__dict__.pop('content', None)
201
+ return updated
202
+
203
+ @property
204
+ def parts(self) -> Dict[str, int]:
205
+ """Returns all parts and their reference counts."""
206
+ return self._parts
207
+
208
+ def __bool__(self) -> bool:
209
+ """Returns True if there is any part."""
210
+ return bool(self._parts)
211
+
212
+ def __contains__(self, part: str) -> bool:
213
+ """Returns True if the part is in the shared parts."""
214
+ return part in self._parts
215
+
216
+ def __iter__(self) -> Iterator[str]:
217
+ """Iterates all parts."""
218
+ return iter(self._parts.keys())
219
+
220
+ def __copy__(self) -> 'Content.SharedParts':
221
+ """Returns a copy of the shared parts."""
222
+ return self.__class__(self) # pytype: disable=not-instantiable
223
+
224
+ def __eq__(self, other: Any):
225
+ if not isinstance(other, self.__class__):
226
+ return False
227
+ return set(self._parts.keys()) == set(other.parts.keys())
228
+
229
+ def __ne__(self, other: Any):
230
+ return not self.__eq__(other)
231
+
232
+ def format(
233
+ self,
234
+ compact: bool = False,
235
+ verbose: bool = True,
236
+ root_indent: int = 0,
237
+ **kwargs
238
+ ) -> str:
239
+ if compact:
240
+ return object_utils.kvlist_str(
241
+ [
242
+ ('parts', self._parts, {}),
243
+ ],
244
+ label=self.__class__.__name__,
245
+ compact=compact,
246
+ verbose=verbose,
247
+ root_indent=root_indent,
248
+ bracket_type=object_utils.BracketType.ROUND,
249
+ )
250
+ return self.content
251
+
252
+ @property
253
+ @abc.abstractmethod
254
+ def content(self) -> str:
255
+ """Returns the content string representing the the shared parts."""
256
+
257
+ __slots__ = ('_content_stream', '_shared_parts',)
258
+
259
+ def __init__(
260
+ self,
261
+ *content: WritableTypes,
262
+ **shared_parts: 'Content.SharedParts'
263
+ ):
264
+ self._content_stream = io.StringIO()
265
+ self._shared_parts = shared_parts
266
+
267
+ for c in content:
268
+ if c is None:
269
+ continue
270
+ if callable(c):
271
+ c = c()
272
+ if isinstance(c, str):
273
+ self._content_stream.write(c)
274
+ else:
275
+ self.write(c)
276
+
277
+ @functools.cached_property
278
+ def content(self) -> str:
279
+ """Returns the content."""
280
+ return self._content_stream.getvalue()
281
+
282
+ @property
283
+ def shared_parts(self) -> Dict[str, 'Content.SharedParts']:
284
+ """Returns the shared parts."""
285
+ return self._shared_parts
286
+
287
+ def write(
288
+ self, *parts: WritableTypes, shared_parts_only: bool = False
289
+ ) -> 'Content':
290
+ """Writes one or more parts to current Content.
291
+
292
+ Args:
293
+ *parts: The parts to be written. Each part can be a string, a Content
294
+ object, a callable that returns one of the above, or None.
295
+ shared_parts_only: If True, only write the shared parts.
296
+
297
+ Returns:
298
+ The current Content object for chaining.
299
+ """
300
+ content_updated = False
301
+ for p in parts:
302
+ if callable(p):
303
+ p = p()
304
+
305
+ if p is None:
306
+ continue
307
+
308
+ if not isinstance(p, (str, self.__class__)):
309
+ raise TypeError(
310
+ f'{p!r} ({type(p)}) cannot be writable. '
311
+ f'Only str, None, {self.__class__.__name__} and callable object '
312
+ 'that returns one of them are supported.'
313
+ )
314
+
315
+ if isinstance(p, Content):
316
+ current = self._shared_parts
317
+ for k, v in p.shared_parts.items():
318
+ # Since `p` is the same type of `self`, we expect they have the
319
+ # same set of shared parts, which is determined at the __init__ time.
320
+ current[k].add(v)
321
+ p = p.content
322
+
323
+ if not shared_parts_only:
324
+ self._content_stream.write(p)
325
+ content_updated = True
326
+
327
+ if content_updated:
328
+ self.__dict__.pop('content', None)
329
+ return self
330
+
331
+ def save(self, file: str, **kwargs):
332
+ """Save content to a file."""
333
+ pg_io.writefile(file, self.to_str(**kwargs))
334
+
335
+ def __add__(self, other: WritableTypes) -> 'Content':
336
+ """Operator +: Concatenates two Content objects."""
337
+ if callable(other):
338
+ other = other()
339
+ if not other:
340
+ return self
341
+ s = copy_lib.deepcopy(self)
342
+ s.write(other)
343
+ return s
344
+
345
+ def __radd__(self, other: WritableTypes) -> 'Content':
346
+ """Right-hand operator +: concatenates two Content objects."""
347
+ s = self.from_value(other, copy=True)
348
+ if s is None:
349
+ return self
350
+ s.write(self)
351
+ return s
352
+
353
+ def format(self,
354
+ compact: bool = False,
355
+ verbose: bool = True,
356
+ root_indent: int = 0,
357
+ content_only: bool = False,
358
+ **kwargs) -> str:
359
+ """Formats the Content object."""
360
+ del kwargs
361
+ if compact:
362
+ return object_utils.kvlist_str(
363
+ [
364
+ ('content', self.content, ''),
365
+ ] + [
366
+ (k, v, None) for k, v in self._shared_parts.items()
367
+ ],
368
+ label=self.__class__.__name__,
369
+ compact=compact,
370
+ verbose=verbose,
371
+ root_indent=root_indent,
372
+ bracket_type=object_utils.BracketType.ROUND,
373
+ )
374
+ return self.to_str(content_only=content_only)
375
+
376
+ @abc.abstractmethod
377
+ def to_str(self, *, content_only: bool = False, **kwargs) -> str:
378
+ """Returns the string representation of the content."""
379
+
380
+ def __eq__(self, other: Any) -> bool:
381
+ if not isinstance(other, self.__class__):
382
+ return False
383
+ return (
384
+ self.content == other.content
385
+ and self._shared_parts == other._shared_parts # pylint: disable=protected-access
386
+ )
387
+
388
+ def __ne__(self, other: Any) -> bool:
389
+ return not self.__eq__(other)
390
+
391
+ def __hash__(self):
392
+ return hash(self.to_str())
393
+
394
+ @classmethod
395
+ def from_value(
396
+ cls,
397
+ value: WritableTypes,
398
+ copy: bool = False
399
+ ) -> Union['Content', None]:
400
+ """Returns a Content object or None from a writable type."""
401
+ if value is None:
402
+ return None
403
+
404
+ if isinstance(value, Content):
405
+ assert isinstance(value, cls), (value, cls)
406
+ if copy:
407
+ return copy_lib.deepcopy(value)
408
+ return value
409
+ return cls(value) # pytype: disable=not-instantiable
410
+
411
+
412
+ def view(
413
+ value: Any,
414
+ *,
415
+ name: Optional[str] = None,
416
+ root_path: Optional[object_utils.KeyPath] = None,
417
+ view_id: str = 'html-tree-view',
418
+ **kwargs
419
+ ) -> Content:
420
+ """Views an object through generating content based on a specific view.
421
+
422
+ Args:
423
+ value: The value to view.
424
+ name: The name of the value.
425
+ root_path: The root path of the value.
426
+ view_id: The ID of the view to use. See `pg.View.dir()` for all available
427
+ view IDs.
428
+ **kwargs: Additional keyword arguments passed to the view, wich
429
+ will be used as the preset arguments for the View and Extension methods.
430
+
431
+ Returns:
432
+ The rendered `Content` object.
433
+ """
434
+ if isinstance(value, Content):
435
+ return value
436
+
437
+ view_object = View.create(view_id)
438
+ with View.preset_args(**kwargs):
439
+ return view_object.render(
440
+ value, name=name, root_path=root_path or object_utils.KeyPath(),
441
+ **kwargs
442
+ )
443
+
444
+
445
+ class View(metaclass=abc.ABCMeta):
446
+ """Base class for views.
447
+
448
+ A view defines an unique way/format of rendering an object (e.g. int, str,
449
+ list, dict, user-defined object). For example, `pg.HtmlView` is a view that
450
+ renders an object into HTML. `pg.HtmlTreeViews` is a concrete `pg.HtmlView`
451
+ that renders an object into a HTML tree view.
452
+ """
453
+
454
+ # The ID of the view, which will be used as the value for the `view`
455
+ # argument of `pg.view()`. Must be set for non-abstract subclasses.
456
+ VIEW_ID = None
457
+
458
+ class PresetArgValue(pg_typing.PresetArgValue):
459
+ """Argument marker for View and Extension methods to use preset values.
460
+
461
+ Methods defined in a ``View`` or ``Extension`` class can be parameterized by
462
+ keyword arguments from the ``pg.view()`` function. These arguments, referred
463
+ to as preset arguments, allow the View or Extension method to consume
464
+ parameters passed during object rendering. For instance, when rendering a
465
+ user-defined object ``MyObject()`` with a keyword argument
466
+ ``hide_default=True``, the View or Extension method may use this argument to
467
+ customize the rendering behavior.
468
+
469
+ Since there can be multiple layers of calls between ``View`` and
470
+ ``Extension`` methods, it is easy to lose track of arguments passed from
471
+ ``pg.view()``. To simplify this process, users can employ the
472
+ ``PresetArgValue`` marker to annotate specific arguments. This ensures that
473
+ View or Extension methods can access preset arguments from the parent call
474
+ to ``pg.view()``, even when those arguments are not explicitly passed around
475
+ in subsequent method calls.
476
+
477
+ Example usage:
478
+
479
+ .. code-block:: python
480
+
481
+ class MyView(pg.View):
482
+ VIEW_TYPE = 'my_view'
483
+
484
+ class Extension(pg.View.Extension):
485
+ def _myview_render(
486
+ self,
487
+ *,
488
+ view,
489
+ hide_default_value=pg.View.PresetArgValue(default=False),
490
+ **kwargs
491
+ ):
492
+ return view.render(value, **kwargs)
493
+
494
+ @pg.View.extension_method('_myview_render')
495
+ def render(self, value, *, **kwargs):
496
+ return ...
497
+
498
+ In the above example, the ``hide_default_value`` argument is annotated with
499
+ ``pg.View.PresetArgValue``. This allows it to consume the
500
+ ``hide_default=True`` keyword argument from the parent call to
501
+ ``pg.view()``, or fallback to the default value ``False`` if the argument is
502
+ not provided.
503
+ """
504
+
505
+ @classmethod
506
+ def preset_args(cls, **kwargs) -> ContextManager[Dict[str, Any]]:
507
+ """Context manager to provide preset argument values for a specific view."""
508
+ return pg_typing.preset_args(
509
+ kwargs, preset_name=_VIEW_ARGS_PRESET_NAME, inherit_preset=True
510
+ )
511
+
512
+ class Extension:
513
+ """Extension for the View class.
514
+
515
+ View developers should always create a corresponding ``Extension`` class as
516
+ an inner class of the ``View`` class. The ``Extension`` class defines custom
517
+ rendering logic for user-defined types and provides methods that can be
518
+ bound to the ``View`` methods via the ``@pg.View.extension_method``
519
+ decorator.
520
+
521
+ Example:
522
+
523
+ .. code-block:: python
524
+
525
+ class MyView(pg.View):
526
+ VIEW_TYPE = 'my_view'
527
+
528
+ class Extension(pg.View.Extension):
529
+
530
+ def _my_view_render(self, value, *, view, **kwargs):
531
+ return view.render(value, **kwargs)
532
+
533
+ def _my_view_title(self, value, *, view, **kwargs):
534
+ return view.render_title(value, **kwargs)
535
+
536
+ @pg.View.extension_method('_my_view_title')
537
+ def title(self, value: Any, **kwargs):
538
+ return pg.Html('<h1>' + str(value) + '</h1>')
539
+
540
+ @pg.View.extension_method('_my_view_render')
541
+ def render(self, value: Any, **kwargs):
542
+ return pg.Html(str(value))
543
+
544
+ To use the ``Extension`` class, users can subclass it and override the
545
+ extension methods in their own classes.
546
+
547
+ For example:
548
+
549
+ .. code-block:: python
550
+
551
+ class MyObject(MyView.Extension):
552
+
553
+ def _my_view_title(self, value, *, view, **kwargs):
554
+ return pg.Html('Custom title for ' + str(value) + '</h1>')
555
+
556
+ def _my_view_render(self, value, *, view, **kwargs):
557
+ return self._my_view_title(value, **kwargs) + pg.Html(str(value))
558
+
559
+ In this example, ``MyObject`` subclasses the ``Extension`` class of
560
+ ``MyView`` and overrides the ``_my_view_title`` and ``_my_view_render``
561
+ methods to provide custom view rendering for the object.
562
+ """
563
+
564
+ def __init_subclass__(cls):
565
+ # Enable preset args for all methods. As a result, function args
566
+ # with `View.PresetArgValue` will be able to consume the keyword args
567
+ # from the call to `pg.view`.
568
+ supported_preset_args = {}
569
+ for name, method in cls.__dict__.items():
570
+ if inspect.isfunction(method):
571
+ used_preset_args = View.PresetArgValue.inspect(method)
572
+ if used_preset_args:
573
+ supported_preset_args.update(used_preset_args)
574
+ setattr(
575
+ cls, name,
576
+ pg_typing.enable_preset_args(
577
+ include_all_preset_kwargs=True,
578
+ preset_name=_VIEW_ARGS_PRESET_NAME,
579
+ )(method)
580
+ )
581
+ setattr(cls, '_SUPPORTED_PRESET_ARGS', supported_preset_args)
582
+ super().__init_subclass__()
583
+
584
+ @classmethod
585
+ def supported_preset_args(cls) -> Dict[str, Any]:
586
+ """Returns supported preset args for this view."""
587
+ return getattr(cls, '_SUPPORTED_PRESET_ARGS', {})
588
+
589
+ @classmethod
590
+ @functools.cache
591
+ def supported_view_classes(cls) -> Set[Type['View']]:
592
+ """Returns all non-abstract View classes that the current class supports.
593
+
594
+ A class can inherit from multiple ``View.Extension`` classes. For example:
595
+
596
+ .. code-block:: python
597
+
598
+ class MyObject(View1.Extension, View2.Extension):
599
+ ...
600
+
601
+ In this case, ``MyObject`` supports both ``View1`` and ``View2``.
602
+
603
+ Returns:
604
+ All non-abstract View classes that the current class supports.
605
+ """
606
+ supported_view_classes = set()
607
+ view_class = pg_typing.get_outer_class(
608
+ cls, base_cls=View, immediate=True
609
+ )
610
+ if view_class is not None and not inspect.isabstract(view_class):
611
+ supported_view_classes.add(view_class)
612
+
613
+ for base_cls in cls.__bases__:
614
+ if issubclass(base_cls, View.Extension):
615
+ supported_view_classes.update(base_cls.supported_view_classes())
616
+ return supported_view_classes
617
+
618
+ @classmethod
619
+ def extension_method(cls, method_name: str) -> Any:
620
+ """Decorator that dispatches a View method to a View.Extension method.
621
+
622
+ A few things to note:
623
+ 1) The View method being decorated must have a `value` argument, based on
624
+ which the Extension method will be dispatched.
625
+ 2) The View method's `value` argument will map to the Extension method's
626
+ `self` argument.
627
+ 3) The Extension method can optionally have a `view` argument, which will
628
+ be set to the current View class.
629
+
630
+ Args:
631
+ method_name: The name of the method in the Extension class to dispatch
632
+ from current View method.
633
+
634
+ Returns:
635
+ A decorator that dispatches a View method to a View.Extension method.
636
+ """
637
+
638
+ def decorator(func):
639
+ sig = pg_typing.signature(
640
+ func, auto_typing=False, auto_doc=False
641
+ )
642
+ # We substract 1 to offset the `self` argument.
643
+ try:
644
+ extension_arg_index = sig.arg_names.index('value') - 1
645
+ except ValueError as e:
646
+ raise TypeError(
647
+ f'View method {func.__name__!r} must have a `value` argument, '
648
+ 'which represents the target object to render.'
649
+ ) from e
650
+ if sig.varargs is not None:
651
+ raise TypeError(
652
+ f'View method must not have variable positional argument. '
653
+ f'Found `*{sig.varargs.name}` in {func.__name__!r}'
654
+ )
655
+
656
+ def get_extension(args: Sequence[Any], kwargs: Dict[str, Any]) -> Any:
657
+ if 'value' in kwargs:
658
+ return kwargs['value']
659
+ if extension_arg_index < len(args):
660
+ return args[extension_arg_index]
661
+ raise ValueError(
662
+ 'No value is provided for the `value` argument '
663
+ f'for {func.__name__!r}.'
664
+ )
665
+
666
+ def map_args(
667
+ args: Sequence[Any], kwargs: Dict[str, Any]
668
+ ) -> Dict[str, Any]:
669
+ # `args` does not contain self, therefore we use less than.
670
+ assert len(args) < len(sig.args), (args, sig.args)
671
+ kwargs.update({
672
+ sig.args[i].name: arg
673
+ for i, arg in enumerate(args) if i != extension_arg_index
674
+ })
675
+ return kwargs
676
+
677
+ # We use the original function signature to generate the view method.
678
+ @functools.wraps(func)
679
+ def _generated_view_fn(self, *args, **kwargs):
680
+ # This allows a View method to consume the preset kwargs from the
681
+ # parent call to `pg.view`, yet also customizes the preset kwargs
682
+ # to the calls to other View/Extension methods within this context.
683
+ with cls.preset_args(**kwargs):
684
+ return self._maybe_dispatch( # pylint: disable=protected-access
685
+ *args, **kwargs,
686
+ extension=get_extension(args, kwargs),
687
+ view_method=func,
688
+ extension_method_name=method_name,
689
+ arg_map_fn=map_args
690
+ )
691
+ return _generated_view_fn
692
+ return decorator
693
+
694
+ def __init_subclass__(cls):
695
+ if inspect.isabstract(cls):
696
+ return
697
+
698
+ # Register the view type.
699
+ if cls.VIEW_ID is None:
700
+ raise ValueError(
701
+ f'`VIEW_ID` must be set for non-abstract View subclass {cls!r}.'
702
+ )
703
+ if cls.VIEW_ID == cls.__base__.VIEW_ID:
704
+ raise ValueError(
705
+ f'The `VIEW_ID` {cls.VIEW_ID!r} is the same as the base class '
706
+ f'{cls.__base__.__name__}. Please choose a different ID.'
707
+ )
708
+
709
+ _VIEW_REGISTRY[cls.VIEW_ID] = cls
710
+
711
+ # Enable preset args for all view methods. As a result, function args
712
+ # with `pg.views.View.PresetArgValue` will be able to consume the keyword
713
+ # args from the parent call to `pg.view`.
714
+ supported_preset_args = {}
715
+ for name, method in cls.__dict__.items():
716
+ if inspect.isfunction(method):
717
+ used_preset_args = cls.PresetArgValue.inspect(method)
718
+ if used_preset_args:
719
+ setattr(
720
+ cls, name,
721
+ pg_typing.enable_preset_args(
722
+ include_all_preset_kwargs=True,
723
+ preset_name=_VIEW_ARGS_PRESET_NAME,
724
+ )(method)
725
+ )
726
+ supported_preset_args.update(used_preset_args)
727
+
728
+ setattr(cls, '_SUPPORTED_PRESET_ARGS', supported_preset_args)
729
+ super().__init_subclass__()
730
+
731
+ @classmethod
732
+ def supported_preset_args(
733
+ cls,
734
+ view_id: Optional[str] = None,
735
+ include_all_extensions: bool = True
736
+ ) -> Dict[Type[Any], Dict[str, Any]]:
737
+ """Returns all supported preset args for this view.
738
+
739
+ Args:
740
+ view_id: The view ID to get the supported preset args for. If not
741
+ provided, the default view ID for this class will be used.
742
+ include_all_extensions: Whether to include the supported preset args from
743
+ all extensions of this view. If False, only the supported preset args
744
+ from the base extension class will be included.
745
+
746
+ Returns:
747
+ A dictionary mapping view classes to their supported preset args.
748
+ """
749
+ view_id = view_id or cls.VIEW_ID
750
+ if view_id is None:
751
+ raise ValueError(
752
+ 'No `VIEW_ID` is set for this View class. Please set `VIEW_ID` '
753
+ 'or provide a `view_id` to `supported_preset_args`.'
754
+ )
755
+ supported = {cls: getattr(cls, '_SUPPORTED_PRESET_ARGS', {})}
756
+ extension_classes = [cls.Extension]
757
+ if include_all_extensions:
758
+ extension_classes.extend(cls.Extension.__subclasses__())
759
+
760
+ for cls in extension_classes:
761
+ supported_preset_args = cls.supported_preset_args()
762
+ if supported_preset_args:
763
+ supported[cls] = supported_preset_args
764
+ return supported
765
+
766
+ @classmethod
767
+ def dir(cls) -> Dict[str, Type['View']]:
768
+ """Returns all registered View classes with their view IDs."""
769
+ return {
770
+ view_id: view_cls
771
+ for view_id, view_cls in _VIEW_REGISTRY.items()
772
+ if issubclass(view_cls, cls)
773
+ }
774
+
775
+ @staticmethod
776
+ def create(view_id: str, **kwargs) -> 'View':
777
+ """Creates a View instance with the given view ID."""
778
+ if view_id not in _VIEW_REGISTRY:
779
+ raise ValueError(
780
+ f'No view class found with VIEW_ID: {view_id!r}'
781
+ )
782
+ return _VIEW_REGISTRY[view_id](**kwargs)
783
+
784
+ def __init__(self, **kwargs):
785
+ del kwargs
786
+ super().__init__()
787
+
788
+ @abc.abstractmethod
789
+ def render(
790
+ self,
791
+ value: Any,
792
+ *,
793
+ name: Optional[str] = None,
794
+ root_path: Optional[object_utils.KeyPath] = None,
795
+ **kwargs
796
+ ) -> Content:
797
+ """Renders the input value.
798
+
799
+ Args:
800
+ value: The value to render.
801
+ name: (Optional) The referred name of the value from its container.
802
+ root_path: (Optional) The path of `value` under its object tree.
803
+ **kwargs: Additional keyword arguments passed from `pg.view` or wrapper
804
+ functions (e.g. `pg.to_html`).
805
+
806
+ Returns:
807
+ The rendered content.
808
+ """
809
+
810
+ #
811
+ # Implementation for routing calls to View methods to to Extension methods.
812
+ #
813
+
814
+ def _maybe_dispatch(
815
+ self,
816
+ *args,
817
+ extension: Any,
818
+ view_method: types.FunctionType,
819
+ extension_method_name: str,
820
+ arg_map_fn: Callable[[Sequence[Any], Dict[str, Any]], Dict[str, Any]],
821
+ **kwargs
822
+ ) -> Any:
823
+ """Dispatches the call to View method to corresponding Extension method.
824
+
825
+ The dispatching should take care of these three scenarios.
826
+
827
+ Scenario 1:
828
+ The user code within an Extension delegates the handling of value `v` back
829
+ to a View method.
830
+
831
+ In this case, we should dispatch the call on `v` to the Extension method
832
+ first, and use the View method if we detects that the request is routed
833
+ back to the View method based on the same value.
834
+
835
+ Scenario 2:
836
+ The Extension (user code) employes a View method to handle a value `v`'s
837
+ child value `w`.
838
+
839
+ In this case, we should allow the Extension method to handle `w` first.
840
+
841
+ Scenario 3:
842
+ A Extension (custom render) uses another method of the view to render the
843
+ same value `v`:
844
+
845
+ To address these 3 scenarios, we use a thread-local stack to track the
846
+ value being rendered by each view method.
847
+
848
+ Args:
849
+ *args: Positional arguments passed to the render method.
850
+ extension: Extension to render.
851
+ view_method: The default render method to call.
852
+ extension_method_name: The name of the method in the Extension class
853
+ to call.
854
+ arg_map_fn: A function to map the view method args to the extension method
855
+ args.
856
+ **kwargs: Keyword arguments passed to the render method.
857
+
858
+ Returns:
859
+ The rendered HTML.
860
+ """
861
+ # No Extension involved, call the view's default render method.
862
+ if not isinstance(extension, self.Extension):
863
+ return view_method(self, *args, **kwargs)
864
+
865
+ # Identify whether an extension is calling the view's method
866
+ # within its extension method, in such case, we should delegate the
867
+ # rendering logic to the default render method to avoid infinite recursion.
868
+ with self._track_rendering(extension, view_method) as being_rendered:
869
+ if being_rendered is extension:
870
+ return view_method(self, *args, **kwargs)
871
+
872
+ # Call the extension's method.
873
+ return getattr(extension, extension_method_name)(
874
+ view=self, **arg_map_fn(args, kwargs)
875
+ )
876
+
877
+ @contextlib.contextmanager
878
+ def _track_rendering(
879
+ self,
880
+ value: Extension,
881
+ view_method
882
+ ) -> Iterator[Any]:
883
+ """Context manager for tracking the value being rendered."""
884
+ del self
885
+ rendering_stack = object_utils.thread_local_get(
886
+ _TLS_KEY_OPERAND_STACK_BY_METHOD, {}
887
+ )
888
+ callsite_value = rendering_stack.get(view_method, None)
889
+ rendering_stack[view_method] = value
890
+ object_utils.thread_local_set(
891
+ _TLS_KEY_OPERAND_STACK_BY_METHOD, rendering_stack
892
+ )
893
+ try:
894
+ yield callsite_value
895
+ finally:
896
+ if callsite_value is None:
897
+ rendering_stack.pop(view_method)
898
+ if not rendering_stack:
899
+ object_utils.thread_local_del(_TLS_KEY_OPERAND_STACK_BY_METHOD)
900
+ else:
901
+ rendering_stack[view_method] = callsite_value
902
+
903
+
904
+ _VIEW_ARGS_PRESET_NAME = 'view_args'
905
+ _VIEW_REGISTRY = {}
906
+ _TLS_KEY_OPERAND_STACK_BY_METHOD = '__view_operand_stack__'