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.
- pyglove/core/__init__.py +54 -20
- pyglove/core/coding/__init__.py +42 -0
- pyglove/core/coding/errors.py +111 -0
- pyglove/core/coding/errors_test.py +98 -0
- pyglove/core/coding/execution.py +309 -0
- pyglove/core/coding/execution_test.py +333 -0
- pyglove/core/{object_utils/codegen.py → coding/function_generation.py} +10 -4
- pyglove/core/{object_utils/codegen_test.py → coding/function_generation_test.py} +5 -7
- pyglove/core/coding/parsing.py +153 -0
- pyglove/core/coding/parsing_test.py +150 -0
- pyglove/core/coding/permissions.py +100 -0
- pyglove/core/coding/permissions_test.py +93 -0
- pyglove/core/geno/base.py +54 -41
- pyglove/core/geno/base_test.py +2 -4
- pyglove/core/geno/categorical.py +37 -28
- pyglove/core/geno/custom.py +19 -16
- pyglove/core/geno/numerical.py +20 -17
- pyglove/core/geno/space.py +4 -5
- pyglove/core/hyper/base.py +6 -6
- pyglove/core/hyper/categorical.py +94 -55
- pyglove/core/hyper/custom.py +7 -7
- pyglove/core/hyper/custom_test.py +9 -10
- pyglove/core/hyper/derived.py +30 -22
- pyglove/core/hyper/derived_test.py +2 -4
- pyglove/core/hyper/dynamic_evaluation.py +5 -6
- pyglove/core/hyper/evolvable.py +57 -46
- pyglove/core/hyper/numerical.py +48 -24
- pyglove/core/hyper/numerical_test.py +9 -9
- pyglove/core/hyper/object_template.py +58 -46
- pyglove/core/io/__init__.py +1 -0
- pyglove/core/io/file_system.py +17 -7
- pyglove/core/io/file_system_test.py +2 -0
- pyglove/core/io/sequence.py +299 -0
- pyglove/core/io/sequence_test.py +124 -0
- pyglove/core/logging_test.py +0 -2
- pyglove/core/patching/object_factory.py +4 -4
- pyglove/core/patching/pattern_based.py +4 -4
- pyglove/core/patching/rule_based.py +17 -5
- pyglove/core/patching/rule_based_test.py +27 -4
- pyglove/core/symbolic/__init__.py +2 -7
- pyglove/core/symbolic/base.py +320 -183
- pyglove/core/symbolic/base_test.py +123 -19
- pyglove/core/symbolic/boilerplate.py +7 -13
- pyglove/core/symbolic/boilerplate_test.py +25 -23
- pyglove/core/symbolic/class_wrapper.py +48 -45
- pyglove/core/symbolic/class_wrapper_test.py +2 -2
- pyglove/core/symbolic/compounding.py +9 -15
- pyglove/core/symbolic/compounding_test.py +2 -4
- pyglove/core/symbolic/dict.py +154 -110
- pyglove/core/symbolic/dict_test.py +238 -130
- pyglove/core/symbolic/diff.py +199 -10
- pyglove/core/symbolic/diff_test.py +226 -0
- pyglove/core/symbolic/flags.py +1 -1
- pyglove/core/symbolic/functor.py +29 -26
- pyglove/core/symbolic/functor_test.py +102 -50
- pyglove/core/symbolic/inferred.py +2 -2
- pyglove/core/symbolic/list.py +81 -50
- pyglove/core/symbolic/list_test.py +119 -97
- pyglove/core/symbolic/object.py +225 -113
- pyglove/core/symbolic/object_test.py +320 -108
- pyglove/core/symbolic/origin.py +17 -14
- pyglove/core/symbolic/origin_test.py +4 -2
- pyglove/core/symbolic/pure_symbolic.py +4 -3
- pyglove/core/symbolic/ref.py +108 -21
- pyglove/core/symbolic/ref_test.py +93 -0
- pyglove/core/symbolic/symbolize_test.py +10 -2
- pyglove/core/tuning/local_backend.py +2 -2
- pyglove/core/tuning/protocols.py +3 -3
- pyglove/core/tuning/sample_test.py +3 -3
- pyglove/core/typing/__init__.py +14 -5
- pyglove/core/typing/annotation_conversion.py +43 -27
- pyglove/core/typing/annotation_conversion_test.py +23 -0
- pyglove/core/typing/callable_ext.py +241 -3
- pyglove/core/typing/callable_ext_test.py +255 -0
- pyglove/core/typing/callable_signature.py +510 -66
- pyglove/core/typing/callable_signature_test.py +619 -99
- pyglove/core/typing/class_schema.py +229 -154
- pyglove/core/typing/class_schema_test.py +149 -95
- pyglove/core/typing/custom_typing.py +5 -4
- pyglove/core/typing/inspect.py +63 -0
- pyglove/core/typing/inspect_test.py +39 -0
- pyglove/core/typing/key_specs.py +10 -11
- pyglove/core/typing/key_specs_test.py +7 -4
- pyglove/core/typing/type_conversion.py +4 -5
- pyglove/core/typing/type_conversion_test.py +12 -12
- pyglove/core/typing/typed_missing.py +6 -7
- pyglove/core/typing/typed_missing_test.py +7 -8
- pyglove/core/typing/value_specs.py +604 -362
- pyglove/core/typing/value_specs_test.py +328 -90
- pyglove/core/utils/__init__.py +164 -0
- pyglove/core/{object_utils → utils}/common_traits.py +3 -67
- pyglove/core/utils/common_traits_test.py +36 -0
- pyglove/core/{object_utils → utils}/docstr_utils.py +23 -0
- pyglove/core/{object_utils → utils}/docstr_utils_test.py +36 -4
- pyglove/core/{object_utils → utils}/error_utils.py +78 -9
- pyglove/core/{object_utils → utils}/error_utils_test.py +61 -5
- pyglove/core/utils/formatting.py +464 -0
- pyglove/core/utils/formatting_test.py +453 -0
- pyglove/core/{object_utils → utils}/hierarchical.py +23 -25
- pyglove/core/{object_utils → utils}/hierarchical_test.py +3 -5
- pyglove/core/{object_utils → utils}/json_conversion.py +177 -52
- pyglove/core/{object_utils → utils}/json_conversion_test.py +97 -16
- pyglove/core/{object_utils → utils}/missing.py +3 -3
- pyglove/core/{object_utils → utils}/missing_test.py +2 -4
- pyglove/core/utils/text_color.py +128 -0
- pyglove/core/utils/text_color_test.py +94 -0
- pyglove/core/{object_utils → utils}/thread_local_test.py +1 -3
- pyglove/core/utils/timing.py +236 -0
- pyglove/core/utils/timing_test.py +154 -0
- pyglove/core/{object_utils → utils}/value_location.py +275 -6
- pyglove/core/utils/value_location_test.py +707 -0
- pyglove/core/views/__init__.py +32 -0
- pyglove/core/views/base.py +804 -0
- pyglove/core/views/base_test.py +580 -0
- pyglove/core/views/html/__init__.py +27 -0
- pyglove/core/views/html/base.py +547 -0
- pyglove/core/views/html/base_test.py +830 -0
- pyglove/core/views/html/controls/__init__.py +35 -0
- pyglove/core/views/html/controls/base.py +275 -0
- pyglove/core/views/html/controls/label.py +207 -0
- pyglove/core/views/html/controls/label_test.py +157 -0
- pyglove/core/views/html/controls/progress_bar.py +183 -0
- pyglove/core/views/html/controls/progress_bar_test.py +97 -0
- pyglove/core/views/html/controls/tab.py +320 -0
- pyglove/core/views/html/controls/tab_test.py +87 -0
- pyglove/core/views/html/controls/tooltip.py +99 -0
- pyglove/core/views/html/controls/tooltip_test.py +99 -0
- pyglove/core/views/html/tree_view.py +1517 -0
- pyglove/core/views/html/tree_view_test.py +1461 -0
- {pyglove-0.4.5.dev20240318.dist-info → pyglove-0.4.5.dev202501132210.dist-info}/METADATA +18 -4
- pyglove-0.4.5.dev202501132210.dist-info/RECORD +214 -0
- {pyglove-0.4.5.dev20240318.dist-info → pyglove-0.4.5.dev202501132210.dist-info}/WHEEL +1 -1
- pyglove/core/object_utils/__init__.py +0 -154
- pyglove/core/object_utils/common_traits_test.py +0 -82
- pyglove/core/object_utils/formatting.py +0 -234
- pyglove/core/object_utils/formatting_test.py +0 -223
- pyglove/core/object_utils/value_location_test.py +0 -385
- pyglove/core/symbolic/schema_utils.py +0 -327
- pyglove/core/symbolic/schema_utils_test.py +0 -57
- pyglove/core/typing/class_schema_utils.py +0 -202
- pyglove/core/typing/class_schema_utils_test.py +0 -194
- pyglove-0.4.5.dev20240318.dist-info/RECORD +0 -185
- /pyglove/core/{object_utils → utils}/thread_local.py +0 -0
- {pyglove-0.4.5.dev20240318.dist-info → pyglove-0.4.5.dev202501132210.dist-info}/LICENSE +0 -0
- {pyglove-0.4.5.dev20240318.dist-info → pyglove-0.4.5.dev202501132210.dist-info}/top_level.txt +0 -0
@@ -14,13 +14,13 @@
|
|
14
14
|
"""Handling locations in a hierarchical object."""
|
15
15
|
|
16
16
|
import abc
|
17
|
-
import copy
|
17
|
+
import copy as copy_lib
|
18
18
|
import operator
|
19
|
-
from typing import Any, Callable, List, Optional, Union
|
20
|
-
from pyglove.core.
|
19
|
+
from typing import Any, Callable, Iterable, Iterator, List, Optional, Union
|
20
|
+
from pyglove.core.utils import formatting
|
21
21
|
|
22
22
|
|
23
|
-
class KeyPath(
|
23
|
+
class KeyPath(formatting.Formattable):
|
24
24
|
"""Represents a path of keys from the root to a node in a tree.
|
25
25
|
|
26
26
|
``KeyPath`` is an important concept in PyGlove, which is used for representing
|
@@ -187,7 +187,7 @@ class KeyPath(common_traits.Formattable):
|
|
187
187
|
@property
|
188
188
|
def keys(self) -> List[Any]:
|
189
189
|
"""A list of keys in this path."""
|
190
|
-
return
|
190
|
+
return copy_lib.copy(self._keys)
|
191
191
|
|
192
192
|
@property
|
193
193
|
def key(self) -> Any:
|
@@ -287,6 +287,10 @@ class KeyPath(common_traits.Formattable):
|
|
287
287
|
return self
|
288
288
|
if isinstance(other, str):
|
289
289
|
other = KeyPath.parse(other)
|
290
|
+
elif isinstance(other, KeyPathSet):
|
291
|
+
other = other.copy()
|
292
|
+
other.rebase(self)
|
293
|
+
return other
|
290
294
|
elif not isinstance(other, KeyPath):
|
291
295
|
other = KeyPath(other)
|
292
296
|
assert isinstance(other, KeyPath)
|
@@ -400,6 +404,16 @@ class KeyPath(common_traits.Formattable):
|
|
400
404
|
except KeyError:
|
401
405
|
return False
|
402
406
|
|
407
|
+
def is_relative_to(self, other: Union[int, str, 'KeyPath']) -> bool:
|
408
|
+
"""Returns whether current path is relative to another path."""
|
409
|
+
other = KeyPath.from_value(other)
|
410
|
+
if len(self) < len(other):
|
411
|
+
return False
|
412
|
+
for i in range(len(other)):
|
413
|
+
if self.keys[i] != other.keys[i]:
|
414
|
+
return False
|
415
|
+
return True
|
416
|
+
|
403
417
|
@property
|
404
418
|
def path(self) -> str:
|
405
419
|
"""JSONPath representation of current path."""
|
@@ -437,6 +451,14 @@ class KeyPath(common_traits.Formattable):
|
|
437
451
|
"""Use depth as length of current path."""
|
438
452
|
return self.depth
|
439
453
|
|
454
|
+
# Use path for both string/repr, which will not be controlled by
|
455
|
+
# `pg.str_format` and `pg.repr_format`.
|
456
|
+
def __str__(self) -> str:
|
457
|
+
return self.path
|
458
|
+
|
459
|
+
def __repr__(self) -> str:
|
460
|
+
return self.path
|
461
|
+
|
440
462
|
def format(self, *args, **kwargs):
|
441
463
|
"""Format current path."""
|
442
464
|
return self.path
|
@@ -552,6 +574,245 @@ class KeyPath(common_traits.Formattable):
|
|
552
574
|
return comparison(self.key, other.key)
|
553
575
|
|
554
576
|
|
577
|
+
class KeyPathSet(formatting.Formattable):
|
578
|
+
"""A KeyPath set based on trie-like data structure."""
|
579
|
+
|
580
|
+
def __init__(
|
581
|
+
self,
|
582
|
+
paths: Optional[Iterable[KeyPath]] = None,
|
583
|
+
*,
|
584
|
+
include_intermediate: bool = False
|
585
|
+
):
|
586
|
+
self._trie = {}
|
587
|
+
if paths:
|
588
|
+
for path in paths:
|
589
|
+
self.add(path, include_intermediate=include_intermediate)
|
590
|
+
|
591
|
+
def add(
|
592
|
+
self,
|
593
|
+
path: Union[str, int, KeyPath],
|
594
|
+
include_intermediate: bool = False,
|
595
|
+
) -> bool:
|
596
|
+
"""Adds a path to the set."""
|
597
|
+
path = KeyPath.from_value(path)
|
598
|
+
root = self._trie
|
599
|
+
updated = False
|
600
|
+
for key in path.keys:
|
601
|
+
if key not in root:
|
602
|
+
root[key] = {}
|
603
|
+
if include_intermediate:
|
604
|
+
root['$'] = True
|
605
|
+
updated = True
|
606
|
+
root = root[key]
|
607
|
+
|
608
|
+
assert isinstance(root, dict), root
|
609
|
+
if '$' not in root:
|
610
|
+
root['$'] = True
|
611
|
+
updated = True
|
612
|
+
return updated
|
613
|
+
|
614
|
+
def remove(self, path: Union[str, int, KeyPath]) -> bool:
|
615
|
+
"""Removes a path from the set."""
|
616
|
+
path = KeyPath.from_value(path)
|
617
|
+
stack = [self._trie]
|
618
|
+
for key in path.keys:
|
619
|
+
if key not in stack[-1]:
|
620
|
+
return False
|
621
|
+
value = stack[-1][key]
|
622
|
+
assert isinstance(value, dict), value
|
623
|
+
stack.append(value)
|
624
|
+
|
625
|
+
if '$' in stack[-1]:
|
626
|
+
stack[-1].pop('$')
|
627
|
+
stack.pop(-1)
|
628
|
+
assert len(stack) == len(path.keys), (path.keys, stack)
|
629
|
+
for key, parent_node in zip(reversed(path.keys), reversed(stack)):
|
630
|
+
if not parent_node[key]:
|
631
|
+
del parent_node[key]
|
632
|
+
return True
|
633
|
+
return False
|
634
|
+
|
635
|
+
def __contains__(self, path: Union[str, int, KeyPath]) -> bool:
|
636
|
+
"""Returns True if the path is in the set."""
|
637
|
+
path = KeyPath.from_value(path)
|
638
|
+
root = self._trie
|
639
|
+
for key in path.keys:
|
640
|
+
if key not in root:
|
641
|
+
return False
|
642
|
+
root = root[key]
|
643
|
+
return '$' in root
|
644
|
+
|
645
|
+
def __bool__(self) -> bool:
|
646
|
+
"""Returns True if the set is not empty."""
|
647
|
+
return bool(self._trie)
|
648
|
+
|
649
|
+
def __iter__(self) -> Iterator[KeyPath]:
|
650
|
+
"""Iterates all paths in the set."""
|
651
|
+
def _traverse(node, keys):
|
652
|
+
for k, v in node.items():
|
653
|
+
if k == '$':
|
654
|
+
yield KeyPath(keys)
|
655
|
+
else:
|
656
|
+
keys.append(k)
|
657
|
+
for path in _traverse(v, keys):
|
658
|
+
yield path
|
659
|
+
keys.pop(-1)
|
660
|
+
return _traverse(self._trie, [])
|
661
|
+
|
662
|
+
def __eq__(self, other: Any):
|
663
|
+
return isinstance(other, KeyPathSet) and self._trie == other._trie
|
664
|
+
|
665
|
+
def __ne__(self, other: Any) -> bool:
|
666
|
+
return not self.__eq__(other)
|
667
|
+
|
668
|
+
def has_prefix(self, root_path: Union[int, str, KeyPath]) -> bool:
|
669
|
+
"""Returns True if the set has a path with the given prefix."""
|
670
|
+
root_path = KeyPath.from_value(root_path)
|
671
|
+
root = self._trie
|
672
|
+
for key in root_path.keys:
|
673
|
+
if key not in root:
|
674
|
+
return False
|
675
|
+
root = root[key]
|
676
|
+
return True
|
677
|
+
|
678
|
+
def __add__(self, other: 'KeyPathSet') -> 'KeyPathSet':
|
679
|
+
return self.union(other, copy=True)
|
680
|
+
|
681
|
+
def rebase(
|
682
|
+
self,
|
683
|
+
root_path: Union[int, str, KeyPath],
|
684
|
+
) -> None:
|
685
|
+
"""Returns a KeyPathSet with the given prefix path added."""
|
686
|
+
root_path = KeyPath.from_value(root_path)
|
687
|
+
root = self._trie
|
688
|
+
for key in reversed(root_path.keys):
|
689
|
+
root = {key: root}
|
690
|
+
self._trie = root
|
691
|
+
|
692
|
+
def clear(self) -> None:
|
693
|
+
"""Clears the set."""
|
694
|
+
self._trie.clear()
|
695
|
+
|
696
|
+
def copy(self) -> 'KeyPathSet':
|
697
|
+
"""Returns a deep copy of the set."""
|
698
|
+
return copy_lib.deepcopy(self)
|
699
|
+
|
700
|
+
def difference_update(self, other: 'KeyPathSet') -> None:
|
701
|
+
"""Removes the paths in the other set from the current set."""
|
702
|
+
def _remove_same(target_dict, src_dict):
|
703
|
+
keys_to_remove = []
|
704
|
+
for key, value in target_dict.items():
|
705
|
+
if key in src_dict:
|
706
|
+
if key == '$' or _remove_same(value, src_dict[key]):
|
707
|
+
keys_to_remove.append(key)
|
708
|
+
for key in keys_to_remove:
|
709
|
+
del target_dict[key]
|
710
|
+
if not target_dict:
|
711
|
+
return True
|
712
|
+
return False
|
713
|
+
_remove_same(self._trie, other._trie) # pylint: disable=protected-access
|
714
|
+
|
715
|
+
def difference(
|
716
|
+
self, other: 'KeyPathSet',
|
717
|
+
) -> 'KeyPathSet':
|
718
|
+
"""Returns the subset KeyPathSet based on a prefix path."""
|
719
|
+
x = self.copy()
|
720
|
+
x.difference_update(other)
|
721
|
+
return x
|
722
|
+
|
723
|
+
def intersection_update(self, other: 'KeyPathSet') -> None:
|
724
|
+
"""Removes the paths in the other set from the current set."""
|
725
|
+
def _remove_diff(target_dict, src_dict):
|
726
|
+
keys_to_remove = []
|
727
|
+
for key, value in target_dict.items():
|
728
|
+
if key not in src_dict:
|
729
|
+
keys_to_remove.append(key)
|
730
|
+
elif key != '$':
|
731
|
+
_remove_diff(value, src_dict[key])
|
732
|
+
if not value:
|
733
|
+
keys_to_remove.append(key)
|
734
|
+
for key in keys_to_remove:
|
735
|
+
del target_dict[key]
|
736
|
+
_remove_diff(self._trie, other._trie) # pylint: disable=protected-access
|
737
|
+
|
738
|
+
def intersection(self, other: 'KeyPathSet') -> 'KeyPathSet':
|
739
|
+
"""Returns the intersection KeyPathSet."""
|
740
|
+
copy = self.copy()
|
741
|
+
copy.intersection_update(other)
|
742
|
+
return copy
|
743
|
+
|
744
|
+
def update(self, other: 'KeyPathSet') -> None:
|
745
|
+
"""Updates the current set with the other set."""
|
746
|
+
def _merge(target_dict, src_dict):
|
747
|
+
for key, value in src_dict.items():
|
748
|
+
if key != '$' and key in target_dict:
|
749
|
+
_merge(target_dict[key], value)
|
750
|
+
else:
|
751
|
+
target_dict[key] = copy_lib.deepcopy(value)
|
752
|
+
_merge(self._trie, other._trie) # pylint: disable=protected-access
|
753
|
+
|
754
|
+
def union(
|
755
|
+
self, other: 'KeyPathSet', copy: bool = False) -> 'KeyPathSet':
|
756
|
+
"""Returns the union KeyPathSet."""
|
757
|
+
x = self.copy()
|
758
|
+
x.update(other)
|
759
|
+
return x
|
760
|
+
|
761
|
+
def subtree(
|
762
|
+
self,
|
763
|
+
root_path: Union[int, str, KeyPath],
|
764
|
+
) -> Optional['KeyPathSet']:
|
765
|
+
"""Returns the relative paths of the sub-tree rooted at the given path.
|
766
|
+
|
767
|
+
Args:
|
768
|
+
root_path: A KeyPath for the root of the sub-tree.
|
769
|
+
|
770
|
+
Returns:
|
771
|
+
A KeyPathSet that contains all the child paths of the given root path.
|
772
|
+
Please note that the returned value share the same trie as the current
|
773
|
+
value. So addition/removal of paths in the returned value will also
|
774
|
+
affect the current value. If there is no child path under the given root
|
775
|
+
path, None will be returned.
|
776
|
+
"""
|
777
|
+
root_path = KeyPath.from_value(root_path)
|
778
|
+
if not root_path:
|
779
|
+
return self
|
780
|
+
root = self._trie
|
781
|
+
for key in root_path.keys:
|
782
|
+
if key not in root:
|
783
|
+
return None
|
784
|
+
root = root[key]
|
785
|
+
ret = KeyPathSet()
|
786
|
+
ret._trie = root # pylint: disable=protected-access
|
787
|
+
return ret
|
788
|
+
|
789
|
+
def format(self, *args, **kwargs) -> str:
|
790
|
+
"""Formats the set."""
|
791
|
+
return formatting.kvlist_str(
|
792
|
+
[
|
793
|
+
('', list(self), [])
|
794
|
+
],
|
795
|
+
label=self.__class__.__name__,
|
796
|
+
**kwargs
|
797
|
+
)
|
798
|
+
|
799
|
+
@classmethod
|
800
|
+
def from_value(
|
801
|
+
cls,
|
802
|
+
value: Union[Iterable[Union[int, str, KeyPath]], 'KeyPathSet'],
|
803
|
+
include_intermediate: bool = False,
|
804
|
+
):
|
805
|
+
"""Returns a KeyPathSet from a compatible value."""
|
806
|
+
if isinstance(value, KeyPathSet):
|
807
|
+
return value
|
808
|
+
if isinstance(value, (list, set, tuple)):
|
809
|
+
return cls(value, include_intermediate=include_intermediate)
|
810
|
+
raise ValueError(
|
811
|
+
f'Cannot convert {value!r} to KeyPathSet. '
|
812
|
+
f'Expected a list, set, tuple, or KeyPathSet.'
|
813
|
+
)
|
814
|
+
|
815
|
+
|
555
816
|
class StrKey(metaclass=abc.ABCMeta):
|
556
817
|
"""Interface for classes whose instances can be treated as str in ``KeyPath``.
|
557
818
|
|
@@ -561,7 +822,7 @@ class StrKey(metaclass=abc.ABCMeta):
|
|
561
822
|
|
562
823
|
Example::
|
563
824
|
|
564
|
-
class MyKey(pg.
|
825
|
+
class MyKey(pg.utils.StrKey):
|
565
826
|
|
566
827
|
def __init__(self, name):
|
567
828
|
self.name = name
|
@@ -572,3 +833,11 @@ class StrKey(metaclass=abc.ABCMeta):
|
|
572
833
|
path = pg.KeyPath(['a', MyKey('b')])
|
573
834
|
print(str(path)) # Should print "a.__b__"
|
574
835
|
"""
|
836
|
+
|
837
|
+
|
838
|
+
def message_on_path(
|
839
|
+
message: str, path: KeyPath) -> str:
|
840
|
+
"""Formats a message that is associated with a `KeyPath`."""
|
841
|
+
if path is None:
|
842
|
+
return message
|
843
|
+
return f'{message} (path={path})'
|