diff --git a/doc/user_guide/References.ipynb b/doc/user_guide/References.ipynb index e45c842c..ad5bee9a 100644 --- a/doc/user_guide/References.ipynb +++ b/doc/user_guide/References.ipynb @@ -265,6 +265,92 @@ ":::" ] }, + { + "cell_type": "markdown", + "id": "ae3232e6-91dd-4a09-8301-b5a79ae42f8f", + "metadata": {}, + "source": [ + "## Assigning refs literally with `LiteralRef`\n", + "\n", + "Sometimes you don’t want Param to resolve a ref-like value on assignment—you want to store the ref object itself.\n", + "\n", + "Use `LiteralRef` to assign a ref as-is (verbatim), so you can forward it, serialize it, or resolve it later under different conditions." + ] + }, + { + "cell_type": "markdown", + "id": "15985908-d821-41a6-a4e0-41a8a49d9aae", + "metadata": {}, + "source": [ + "\n", + "By default, assigning a ref-like value resolves and links it:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b2c80606-f60f-4534-b2da-608efd2c9ab8", + "metadata": {}, + "outputs": [], + "source": [ + "class Example(param.Parameterized):\n", + " value = param.Parameter(allow_refs=True)\n", + "\n", + "src = Example(value=\"A\")\n", + "dst = Example()\n", + "\n", + "dst.value = src.param.value\n", + "assert dst.value == \"A\"\n", + "src.value = \"B\"\n", + "assert dst.value == \"B\" # still linked" + ] + }, + { + "cell_type": "markdown", + "id": "3942b3d3-3d0f-4f99-bb9e-b79ee991b635", + "metadata": {}, + "source": [ + "i.e. the `dst.value` tracks the `src.value`. Wrapping the reference in the `LiteralRef` skips the resolution:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cf3fae7b-7dc6-47b5-9ed9-78cd5799ff52", + "metadata": {}, + "outputs": [], + "source": [ + "dst.value = param.parameterized.LiteralRef(src.param.value)\n", + "\n", + "dst.value" + ] + }, + { + "cell_type": "markdown", + "id": "e7174aad-4751-436e-8b68-36557597fccc", + "metadata": {}, + "source": [ + "i.e. Param unwraps the `LiteralRef` object but does not resolve the `Parameter` reference. `dst` now holds the actual `Parameter` object." + ] + }, + { + "cell_type": "markdown", + "id": "481223e8-750e-41b4-8376-63381b2012c6", + "metadata": {}, + "source": [ + "#### Key points\n", + "\n", + "- The wrapper is transient: it's unwrapped at assignment time; the stored value is the inner ref object.\n", + "- No subscription, no evaluation, no resolution occurs during assignment.\n", + "\n", + "#### When to use LiteralRef\n", + "\n", + "- You're wiring a graph of components and need to pass a ref downstream without linking it yet.\n", + "- You're building a config/serialization format that stores refs (not their current values).\n", + "- You have reactive/async producers you don't want to start/subscribe/consume during assignment.\n", + "- You want to preserve intent (“this is a ref”) instead of collapsing it to a concrete value." + ] + }, { "cell_type": "markdown", "id": "6ffd34f8-211c-4945-95b8-e87ec712e028", diff --git a/param/parameterized.py b/param/parameterized.py index dec6498a..0a3f6241 100644 --- a/param/parameterized.py +++ b/param/parameterized.py @@ -162,6 +162,36 @@ def get_logger(name: Optional[str] = None)->"logging.Logger": # Hook to apply to depends and bind arguments to turn them into valid parameters _reference_transforms = [] +class LiteralRef: + """ + Wrapper type used to assign ref-like objects to Parameters *without* + triggering automatic resolution. + + Normally, when a ref-like value (e.g. a Parameter, reactive expression, + async generator, etc.) is assigned to a Parameter attribute, Param + resolves it to its underlying value. Wrapping the object in ``LiteralRef`` + signals that the value should instead be stored as-is. + + Example + ------- + >>> obj.some_param = param.LiteralRef(other.param.value) + >>> assert obj.some_param is other.param.value + + Notes + ----- + - ``LiteralRef`` is only meaningful at assignment time; the wrapper is + unwrapped and not stored. + - The stored value is the inner object itself, not the ``LiteralRef`` instance. + - This allows safe serialization, forwarding, or deferred resolution of + ref-like values. + """ + + __slots__ = ["value"] + + def __init__(self, value): self.value = value + def __repr__(self): return f"LiteralRef({self.value!r})" + + def register_reference_transform(transform): """ Append a transform to extract potential parameter dependencies @@ -170,7 +200,6 @@ def register_reference_transform(transform): Parameters ---------- transform: Callable[Any, Any] - """ return _reference_transforms.append(transform) @@ -182,6 +211,8 @@ def transform_reference(arg): that are not simple Parameters or functions with dependency definitions. """ + if isinstance(arg, LiteralRef): + return arg.value for transform in _reference_transforms: if isinstance(arg, Parameter) or hasattr(arg, '_dinfo'): break @@ -207,7 +238,9 @@ def eval_function_with_deps(function): def resolve_value(value, recursive=True): """Resolve the current value of a dynamic reference.""" - if not recursive: + if isinstance(value, LiteralRef): + return value.value + elif not recursive: pass elif isinstance(value, (list, tuple)): return type(value)(resolve_value(v) for v in value) @@ -231,7 +264,9 @@ def resolve_value(value, recursive=True): def resolve_ref(reference, recursive=False): """Resolve all parameters a dynamic reference depends on.""" - if recursive: + if isinstance(reference, LiteralRef): + return [] + elif recursive: if isinstance(reference, (list, tuple, set)): return [r for v in reference for r in resolve_ref(v, recursive)] elif isinstance(reference, dict): @@ -2442,10 +2477,12 @@ def _sync_refs(self_, *events): self_.update(updates) def _resolve_ref(self_, pobj, value): + if isinstance(value, LiteralRef): + return None, None, value.value, False is_gen = inspect.isgeneratorfunction(value) is_async = iscoroutinefunction(value) or is_gen deps = resolve_ref(value, recursive=pobj.nested_refs) - if not (deps or is_async or is_gen): + if not (deps or is_async or is_gen or pobj.nested_refs): return None, None, value, False ref = value try: diff --git a/tests/testrefs.py b/tests/testrefs.py index e3786df9..86577e2b 100644 --- a/tests/testrefs.py +++ b/tests/testrefs.py @@ -5,7 +5,7 @@ import param import pytest -from param.parameterized import Skip, resolve_ref +from param.parameterized import LiteralRef, Skip, resolve_ref from param.reactive import bind, rx @@ -39,8 +39,12 @@ class Parameters(param.Parameterized): string_list = param.List(default=[], item_type=str, allow_refs=True, nested_refs=True) + list = param.List(default=[], allow_refs=True, nested_refs=True) + no_refs = param.Parameter(allow_refs=False) + allows_ref = param.Parameter(allow_refs=True) + @param.depends('string') def formatted_string(self): if self.string.endswith('?'): @@ -363,3 +367,137 @@ def test_resolve_ref_recursive_slice(): refs = resolve_ref(nested, recursive=True) assert len(refs) == 1 assert refs[0] is p.param.string + +def test_literal_ref_parameter_ref_not_resolved_errors(): + p = Parameters(string='base') + + with pytest.raises(ValueError): + Parameters(string=LiteralRef(p.param.string)) + +def test_literal_ref_plain_value_unchanged(): + p = Parameters(allows_ref=LiteralRef('literal')) + assert p.allows_ref == 'literal' + +def test_literal_ref_is_transient_unwrapped(): + p0 = Parameters() + r = p0.param.string + p = Parameters(allows_ref=LiteralRef(r)) + assert p.allows_ref is r + +def test_literal_ref_nested_list_parameter_ref_preserved(): + p_src = Parameters(string='alpha') + p = Parameters(list=LiteralRef([p_src.param.string, 'other'])) + # With literal_ref, nested refs are preserved (not resolved) + assert isinstance(p.list, list) + assert p.list[0] is p_src.param.string + assert p.list[1] == 'other' + + # Changing the source has no effect — we stored the ref object, not a live link + p_src.string = 'beta' + assert p.list[0] is p_src.param.string + +def test_literal_ref_nested_list_mixed_refs_preserved(): + s = rx('x') + expr = s + '!' + p_src = Parameters(string='y') + p = Parameters(list=LiteralRef([expr, p_src.param.string, 'z'])) + + assert p.list[0] is expr + assert p.list[1] is p_src.param.string + assert p.list[2] == 'z' + + s.rx.value = 'xx' + p_src.string = 'yy' + # Nothing auto-updates because we stored verbatim objects + assert p.list[0] is expr + assert p.list[1] is p_src.param.string + +def test_literal_ref_nested_dict_value_parameter_ref_preserved(): + p_src = Parameters(string='keyed') + p = Parameters(dictionary=LiteralRef({'k': p_src.param.string, 'n': 1})) + # Values kept as-is + assert p.dictionary['k'] is p_src.param.string + assert p.dictionary['n'] == 1 + # Changing source does not propagate + p_src.string = 'changed' + assert p.dictionary['k'] is p_src.param.string + +def test_literal_ref_nested_dict_deep_structure_preserved(): + p_src = Parameters(string='deep') + expr = (rx('a') + rx('b')) + nested = { + 'level1': { + 'list': [p_src.param.string, expr, {'leaf': p_src.param.string}] + } + } + p = Parameters(dictionary=LiteralRef(nested)) + + got = p.dictionary + assert got['level1']['list'][0] is p_src.param.string + assert got['level1']['list'][1] is expr + assert got['level1']['list'][2]['leaf'] is p_src.param.string + +def test_literal_ref_nested_refs_do_not_resolve_even_when_param_has_nested_refs_true(): + p_src = Parameters(string='s') + obj = Parameters( + dictionary=LiteralRef({'inner': [p_src.param.string]}), + list=LiteralRef([p_src.param.string, 'x']) + ) + assert obj.dictionary['inner'][0] is p_src.param.string + assert obj.list[0] is p_src.param.string + +def test_literal_ref_inner_nested_refs_do_not_resolve_even_when_param_has_nested_refs_true(): + p_src = Parameters(string='s') + obj = Parameters( + dictionary={'inner': [LiteralRef(p_src.param.string)]}, + list=[LiteralRef(p_src.param.string), 'x'] + ) + assert obj.dictionary['inner'][0] is p_src.param.string + assert obj.list[0] is p_src.param.string + +def test_literal_ref_survives_param_update_context_and_reassignments(): + p_src = Parameters(string='A') + p = Parameters(allows_ref=LiteralRef(p_src.param.string)) + assert p.allows_ref is p_src.param.string + + with p.param.update(allows_ref=LiteralRef(p_src.param.string)): + assert p.allows_ref is p_src.param.string + p_src.string = 'B' + assert p.allows_ref is p_src.param.string + + p.allows_ref = p_src.param.string + assert p.allows_ref == 'B' + p_src.string = 'C' + assert p.allows_ref == 'C' + +def test_literal_ref_stores_callables_or_generators_without_consuming(): + started = {'gen': False, 'async': False} + + def gen(): + started['gen'] = True + yield 'x' + + async def agen(): + started['async'] = True + if False: + yield None + + p = Parameters() + p.allows_ref = LiteralRef(gen) + assert p.allows_ref is gen + assert started['gen'] is False + + p.allows_ref = LiteralRef(agen) + assert p.allows_ref is agen + assert started['async'] is False + +def test_resolve_ref_hides_inner_when_given_literal_ref_directly(): + p_src = Parameters() + refs = resolve_ref(LiteralRef(p_src.param.string)) + assert len(refs) == 0 + +def test_resolve_ref_recursive_on_container_from_literal_ref(): + p_src = Parameters() + nested = LiteralRef([{'k': (p_src.param.string,)}]) + refs = resolve_ref(nested, recursive=True) + assert len(refs) == 0