diff --git a/docs/source/structured_config.rst b/docs/source/structured_config.rst index 706a9a25a..f76333f4f 100644 --- a/docs/source/structured_config.rst +++ b/docs/source/structured_config.rst @@ -309,7 +309,7 @@ Optional fields Interpolations ^^^^^^^^^^^^^^ -:ref:`interpolation` works normally with Structured configs but static type checkers may object to you assigning a string to an other types. +:ref:`interpolation` works normally with Structured configs but static type checkers may object to you assigning a string to another type. To work around it, use SI and II described below. .. doctest:: @@ -333,18 +333,38 @@ To work around it, use SI and II described below. >>> assert conf.c == 100 -Type validation is performed on assignment, but not on values returned by interpolation, e.g: +Interpolated values are validated, and converted when possible, to the annotated type when the interpolation is accessed, e.g: .. doctest:: - >>> from omegaconf import SI + >>> from omegaconf import II >>> @dataclass ... class Interpolation: - ... int_key: int = II("str_key") ... str_key: str = "string" + ... int_key: int = II("str_key") >>> cfg = OmegaConf.structured(Interpolation) - >>> assert cfg.int_key == "string" + >>> cfg.int_key # fails due to type mismatch + Traceback (most recent call last): + ... + omegaconf.errors.InterpolationValidationError: Value 'string' could not be converted to Integer + full_key: int_key + object_type=Interpolation + >>> cfg.str_key = "1234" # string value + >>> assert cfg.int_key == 1234 # automatically convert str to int + +Note however that this validation step is currently skipped for container node interpolations: + +.. doctest:: + + >>> @dataclass + ... class NotValidated: + ... some_int: int = 0 + ... some_dict: Dict[str, str] = II("some_int") + + >>> cfg = OmegaConf.structured(NotValidated) + >>> assert cfg.some_dict == 0 # type mismatch, but no error + Frozen ^^^^^^ diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 000a187e0..8e7f370fc 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -472,6 +472,19 @@ simply use quotes to bypass character limitations in strings. 'Hello, World' +Custom resolvers can return lists or dictionaries, that are automatically converted into DictConfig and ListConfig: + +.. doctest:: + + >>> OmegaConf.register_new_resolver( + ... "min_max", lambda *a: {"min": min(a), "max": max(a)} + ... ) + >>> c = OmegaConf.create({'stats': '${min_max: -1, 3, 2, 5, -10}'}) + >>> assert isinstance(c.stats, DictConfig) + >>> c.stats.min, c.stats.max + (-10, 5) + + You can take advantage of nested interpolations to perform custom operations over variables: .. doctest:: diff --git a/news/488.api_change b/news/488.api_change new file mode 100644 index 000000000..a7b269636 --- /dev/null +++ b/news/488.api_change @@ -0,0 +1 @@ +When resolving an interpolation of a typed config value, the interpolated value is validated and possibly converted based on the node's type. diff --git a/news/540.api_change b/news/540.api_change new file mode 100644 index 000000000..b81ba0144 --- /dev/null +++ b/news/540.api_change @@ -0,0 +1 @@ +A custom resolver interpolation whose output is a list or dictionary is now automatically converted into a ListConfig or DictConfig. diff --git a/news/540.feature b/news/540.feature new file mode 100644 index 000000000..a3d2fd8b8 --- /dev/null +++ b/news/540.feature @@ -0,0 +1 @@ +Custom resolvers can now generate transient config nodes dynamically. diff --git a/omegaconf/base.py b/omegaconf/base.py index 339a39777..e4b26c45b 100644 --- a/omegaconf/base.py +++ b/omegaconf/base.py @@ -20,9 +20,12 @@ InterpolationKeyError, InterpolationResolutionError, InterpolationToMissingValueError, + InterpolationValidationError, + KeyValidationError, MissingMandatoryValue, OmegaConfBaseException, UnsupportedInterpolationType, + ValidationError, ) from .grammar.gen.OmegaConfGrammarParser import OmegaConfGrammarParser from .grammar_parser import parse @@ -358,8 +361,6 @@ def _select_impl( ) -> Tuple[Optional["Container"], Optional[str], Optional[Node]]: """ Select a value using dot separated key sequence - :param key: - :return: """ from .omegaconf import _select_one @@ -421,8 +422,34 @@ def _resolve_interpolation_from_parse_tree( parse_tree: OmegaConfGrammarParser.ConfigValueContext, throw_on_resolution_failure: bool, ) -> Optional["Node"]: - from .nodes import StringNode - + """ + Resolve an interpolation. + + This happens in two steps: + 1. The parse tree is visited, which outputs either a `Node` (e.g., + for node interpolations "${foo}"), a string (e.g., for string + interpolations "hello ${name}", or any other arbitrary value + (e.g., or custom interpolations "${foo:bar}"). + 2. This output is potentially validated and converted when the node + being resolved (`value`) is typed. + + If an error occurs in one of the above steps, an `InterpolationResolutionError` + (or a subclass of it) is raised, *unless* `throw_on_resolution_failure` is set + to `False` (in which case the return value is `None`). + + :param parent: Parent of the node being resolved. + :param value: Node being resolved. + :param key: The associated key in the parent. + :param parse_tree: The parse tree as obtained from `grammar_parser.parse()`. + :param throw_on_resolution_failure: If `False`, then exceptions raised during + the resolution of the interpolation are silenced, and instead `None` is + returned. + + :return: A `Node` that contains the interpolation result. This may be an existing + node in the config (in the case of a node interpolation "${foo}"), or a new + node that is created to wrap the interpolated value. It is `None` if and only if + `throw_on_resolution_failure` is `False` and an error occurs during resolution. + """ try: resolved = self.resolve_parse_tree( parse_tree=parse_tree, @@ -434,19 +461,98 @@ def _resolve_interpolation_from_parse_tree( raise return None - assert resolved is not None - if isinstance(resolved, str): - # Result is a string: create a new StringNode for it. - return StringNode( - value=resolved, - key=key, + return self._validate_and_convert_interpolation_result( + parent=parent, + value=value, + key=key, + resolved=resolved, + throw_on_resolution_failure=throw_on_resolution_failure, + ) + + def _validate_and_convert_interpolation_result( + self, + parent: Optional["Container"], + value: "Node", + key: Any, + resolved: Any, + throw_on_resolution_failure: bool, + ) -> Optional["Node"]: + from .nodes import AnyNode, ValueNode + + # If the output is not a Node already (e.g., because it is the output of a + # custom resolver), then we will need to wrap it within a Node. + must_wrap = not isinstance(resolved, Node) + + # If the node is typed, validate (and possibly convert) the result. + if isinstance(value, ValueNode) and not isinstance(value, AnyNode): + res_value = _get_value(resolved) + try: + conv_value = value.validate_and_convert(res_value) + except ValidationError as e: + if throw_on_resolution_failure: + self._format_and_raise( + key=key, + value=res_value, + cause=e, + type_override=InterpolationValidationError, + ) + return None + + # If the converted value is of the same type, it means that no conversion + # was actually needed. As a result, we can keep the original `resolved` + # (and otherwise, the converted value must be wrapped into a new node). + if type(conv_value) != type(res_value): + must_wrap = True + resolved = conv_value + + if must_wrap: + return self._wrap_interpolation_result( parent=parent, - is_optional=value._metadata.optional, + value=value, + key=key, + resolved=resolved, + throw_on_resolution_failure=throw_on_resolution_failure, ) else: assert isinstance(resolved, Node) return resolved + def _wrap_interpolation_result( + self, + parent: Optional["Container"], + value: "Node", + key: Any, + resolved: Any, + throw_on_resolution_failure: bool, + ) -> Optional["Node"]: + from .basecontainer import BaseContainer + from .omegaconf import _node_wrap + + assert parent is None or isinstance(parent, BaseContainer) + try: + wrapped = _node_wrap( + type_=value._metadata.ref_type, + parent=parent, + is_optional=value._metadata.optional, + value=resolved, + key=key, + ref_type=value._metadata.ref_type, + ) + except (KeyValidationError, ValidationError) as e: + if throw_on_resolution_failure: + self._format_and_raise( + key=key, + value=resolved, + cause=e, + type_override=InterpolationValidationError, + ) + return None + # Since we created a new node on the fly, future changes to this node are + # likely to be lost. We thus set the "readonly" flag to `True` to reduce + # the risk of accidental modifications. + wrapped._set_flag("readonly", True) + return wrapped + def _resolve_node_interpolation( self, inter_key: str, @@ -488,19 +594,10 @@ def _evaluate_custom_resolver( ) -> Any: from omegaconf import OmegaConf - from .nodes import ValueNode - resolver = OmegaConf.get_resolver(inter_type) if resolver is not None: root_node = self._get_root() - value = resolver(root_node, inter_args, inter_args_str) - return ValueNode( - value=value, - parent=self, - metadata=Metadata( - ref_type=Any, object_type=Any, key=key, optional=True - ), - ) + return resolver(root_node, inter_args, inter_args_str) else: raise UnsupportedInterpolationType( f"Unsupported interpolation type {inter_type}" @@ -561,7 +658,7 @@ def quoted_string_callback(quoted_str: str) -> str: value=quoted_str, key=key, parent=parent, - is_optional=False, + is_optional=True, ), throw_on_resolution_failure=True, ) diff --git a/omegaconf/dictconfig.py b/omegaconf/dictconfig.py index e5494d5d2..a8532602c 100644 --- a/omegaconf/dictconfig.py +++ b/omegaconf/dictconfig.py @@ -291,9 +291,7 @@ def _s_validate_and_normalize_key(self, key_type: Any, key: Any) -> DictKeyType: return key # type: ignore elif issubclass(key_type, Enum): try: - ret = EnumNode.validate_and_convert_to_enum(key_type, key) - assert ret is not None - return ret + return EnumNode.validate_and_convert_to_enum(key_type, key) except ValidationError: valid = ", ".join([x for x in key_type.__members__.keys()]) raise KeyValidationError( diff --git a/omegaconf/errors.py b/omegaconf/errors.py index a7a2cf858..96812ca44 100644 --- a/omegaconf/errors.py +++ b/omegaconf/errors.py @@ -81,6 +81,12 @@ class InterpolationToMissingValueError(InterpolationResolutionError): """ +class InterpolationValidationError(InterpolationResolutionError, ValidationError): + """ + Thrown when the result of an interpolation fails the validation step. + """ + + class ConfigKeyError(OmegaConfBaseException, KeyError): """ Thrown from DictConfig when a regular dict access would have caused a KeyError. diff --git a/omegaconf/grammar_visitor.py b/omegaconf/grammar_visitor.py index 002040785..56111e557 100644 --- a/omegaconf/grammar_visitor.py +++ b/omegaconf/grammar_visitor.py @@ -39,7 +39,7 @@ class GrammarVisitor(OmegaConfGrammarParserVisitor): def __init__( self, node_interpolation_callback: Callable[[str], Optional["Node"]], - resolver_interpolation_callback: Callable[..., Optional["Node"]], + resolver_interpolation_callback: Callable[..., Any], quoted_string_callback: Callable[[str], str], **kw: Dict[Any, Any], ): @@ -96,9 +96,7 @@ def visitConfigKey(self, ctx: OmegaConfGrammarParser.ConfigKeyContext) -> str: ) return child.symbol.text - def visitConfigValue( - self, ctx: OmegaConfGrammarParser.ConfigValueContext - ) -> Union[str, Optional["Node"]]: + def visitConfigValue(self, ctx: OmegaConfGrammarParser.ConfigValueContext) -> Any: # (toplevelStr | (toplevelStr? (interpolation toplevelStr?)+)) EOF # Visit all children (except last one which is EOF) vals = [self.visit(c) for c in list(ctx.getChildren())[:-1]] @@ -106,12 +104,8 @@ def visitConfigValue( if len(vals) == 1 and isinstance( ctx.getChild(0), OmegaConfGrammarParser.InterpolationContext ): - from .base import Node # noqa F811 - - # Single interpolation: return the resulting node "as is". - ret = vals[0] - assert ret is None or isinstance(ret, Node), ret - return ret + # Single interpolation: return the result "as is". + return vals[0] # Concatenation of multiple components. return "".join(map(str, vals)) @@ -135,13 +129,9 @@ def visitElement(self, ctx: OmegaConfGrammarParser.ElementContext) -> Any: def visitInterpolation( self, ctx: OmegaConfGrammarParser.InterpolationContext - ) -> Optional["Node"]: - from .base import Node # noqa F811 - + ) -> Any: assert ctx.getChildCount() == 1 # interpolationNode | interpolationResolver - ret = self.visit(ctx.getChild(0)) - assert ret is None or isinstance(ret, Node) - return ret + return self.visit(ctx.getChild(0)) def visitInterpolationNode( self, ctx: OmegaConfGrammarParser.InterpolationNodeContext @@ -168,7 +158,7 @@ def visitInterpolationNode( def visitInterpolationResolver( self, ctx: OmegaConfGrammarParser.InterpolationResolverContext - ) -> Optional["Node"]: + ) -> Any: # INTER_OPEN resolverName COLON sequence? BRACE_CLOSE assert 4 <= ctx.getChildCount() <= 5 diff --git a/omegaconf/nodes.py b/omegaconf/nodes.py index acc344629..d06ba546a 100644 --- a/omegaconf/nodes.py +++ b/omegaconf/nodes.py @@ -1,6 +1,7 @@ import copy import math import sys +from abc import abstractmethod from enum import Enum from typing import Any, Dict, Optional, Type, Union @@ -55,8 +56,9 @@ def validate_and_convert(self, value: Any) -> Any: # Subclasses can assume that `value` is not None in `_validate_and_convert_impl()`. return self._validate_and_convert_impl(value) + @abstractmethod def _validate_and_convert_impl(self, value: Any) -> Any: - return value + ... def __str__(self) -> str: return str(self._val) diff --git a/tests/conftest.py b/tests/conftest.py index 304282e4d..d86a4a405 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import pytest +from omegaconf import OmegaConf from omegaconf.basecontainer import BaseContainer @@ -16,3 +17,19 @@ def restore_resolvers() -> Any: state = copy.deepcopy(BaseContainer._resolvers) yield BaseContainer._resolvers = state + + +@pytest.fixture(scope="function") +def common_resolvers(restore_resolvers: Any) -> Any: + """ + A fixture to register the common `identity` resolver. + It depends on `restore_resolvers` to make it easier and safer to use. + """ + + def cast(t: Any, v: Any) -> Any: + return {"str": str, "int": int}[t](v) # cast `v` to type `t` + + OmegaConf.register_new_resolver("cast", cast) + OmegaConf.register_new_resolver("identity", lambda x: x) + + yield diff --git a/tests/test_base_config.py b/tests/test_base_config.py index 50578de53..7ece5f377 100644 --- a/tests/test_base_config.py +++ b/tests/test_base_config.py @@ -5,6 +5,7 @@ from pytest import raises from omegaconf import ( + AnyNode, Container, DictConfig, IntegerNode, @@ -519,7 +520,7 @@ def test_resolve_str_interpolation(query: str, result: Any) -> None: cfg._maybe_resolve_interpolation( parent=None, key=None, - value=StringNode(value=query), + value=AnyNode(value=query), throw_on_resolution_failure=True, ) == result diff --git a/tests/test_interpolation.py b/tests/test_interpolation.py index 5c6eec533..3947fc3b1 100644 --- a/tests/test_interpolation.py +++ b/tests/test_interpolation.py @@ -1,16 +1,19 @@ import copy import random import re -from typing import Any, Optional, Tuple +from textwrap import dedent +from typing import Any, Dict, List, Optional, Tuple import pytest from _pytest.python_api import RaisesContext from omegaconf import ( II, + SI, Container, DictConfig, IntegerNode, + ListConfig, Node, OmegaConf, Resolver, @@ -21,11 +24,12 @@ GrammarParseError, InterpolationKeyError, InterpolationResolutionError, + InterpolationValidationError, OmegaConfBaseException, UnsupportedInterpolationType, ) -from . import StructuredWithMissing +from . import MissingDict, MissingList, StructuredWithMissing, SubscriptedList, User # file deepcode ignore CopyPasteError: # The above comment is a statement to stop DeepCode from raising a warning on @@ -36,6 +40,14 @@ INVALID_CHARS_IN_KEY_NAMES = "\\${}()[].: '\"" +def dereference(cfg: Container, key: Any) -> Node: + node = cfg._get_node(key) + assert isinstance(node, Node) + node = node._dereference_node() + assert isinstance(node, Node) + return node + + @pytest.mark.parametrize( "cfg,key,expected", [ @@ -465,11 +477,10 @@ def test_resolver_no_cache(restore_resolvers: Any) -> None: assert c.k != c.k -def test_resolver_dot_start(restore_resolvers: Any) -> None: +def test_resolver_dot_start(common_resolvers: Any) -> None: """ Regression test for #373 """ - OmegaConf.register_new_resolver("identity", lambda x: x) c = OmegaConf.create( {"foo_nodot": "${identity:bar}", "foo_dot": "${identity:.bar}"} ) @@ -477,8 +488,7 @@ def test_resolver_dot_start(restore_resolvers: Any) -> None: assert c.foo_dot == ".bar" -def test_resolver_dot_start_legacy(restore_resolvers: Any) -> None: - OmegaConf.legacy_register_resolver("identity", lambda x: x) +def test_resolver_dot_start_legacy(common_resolvers: Any) -> None: c = OmegaConf.create( {"foo_nodot": "${identity:bar}", "foo_dot": "${identity:.bar}"} ) @@ -744,3 +754,236 @@ def test_none_value_in_quoted_string(restore_resolvers: Any) -> None: OmegaConf.register_new_resolver("test", lambda x: x) cfg = OmegaConf.create({"x": "${test:'${missing}'}", "missing": None}) assert cfg.x == "None" + + +@pytest.mark.parametrize( + ("cfg", "key", "expected_value", "expected_node_type"), + [ + pytest.param( + User(name="Bond", age=SI("${cast:int,'7'}")), + "age", + 7, + IntegerNode, + id="expected_type", + ), + pytest.param( + # This example specifically test the case where intermediate resolver results + # cannot be cast to the same type as the key. + User(name="Bond", age=SI("${cast:int,${drop_last:${drop_last:7xx}}}")), + "age", + 7, + IntegerNode, + id="intermediate_type_mismatch_ok", + ), + pytest.param( + # This example relies on the automatic casting of a string to int when + # assigned to an IntegerNode. + User(name="Bond", age=SI("${cast:str,'7'}")), + "age", + 7, + IntegerNode, + id="convert_str_to_int", + ), + pytest.param( + MissingList(list=SI("${identity:[a, b, c]}")), + "list", + ["a", "b", "c"], + ListConfig, + id="list_str", + ), + pytest.param( + MissingList(list=SI("${identity:[0, 1, 2]}")), + "list", + ["0", "1", "2"], + ListConfig, + id="list_int_to_str", + ), + pytest.param( + MissingDict(dict=SI("${identity:{key1: val1, key2: val2}}")), + "dict", + {"key1": "val1", "key2": "val2"}, + DictConfig, + id="dict_str", + ), + pytest.param( + MissingDict(dict=SI("${identity:{a: 0, b: 1}}")), + "dict", + {"a": "0", "b": "1"}, + DictConfig, + id="dict_int_to_str", + ), + ], +) +def test_interpolation_type_validated_ok( + cfg: Any, + key: str, + expected_value: Any, + expected_node_type: Any, + common_resolvers: Any, +) -> Any: + def drop_last(s: str) -> str: + return s[0:-1] # drop last character from string `s` + + OmegaConf.register_new_resolver("drop_last", drop_last) + + cfg = OmegaConf.structured(cfg) + + val = cfg[key] + assert val == expected_value + + node = cfg._get_node(key) + assert isinstance(node, Node) + assert isinstance(node._dereference_node(), expected_node_type) + + +@pytest.mark.parametrize( + ("cfg", "key", "expected_error"), + [ + pytest.param( + User(name="Bond", age=SI("${cast:str,seven}")), + "age", + pytest.raises( + InterpolationValidationError, + match=re.escape( + dedent( + """\ + Value 'seven' could not be converted to Integer + full_key: age + """ + ) + ), + ), + id="type_mismatch_resolver", + ), + pytest.param( + User(name="Bond", age=SI("${name}")), + "age", + pytest.raises( + InterpolationValidationError, + match=re.escape("'Bond' could not be converted to Integer"), + ), + id="type_mismatch_node_interpolation", + ), + pytest.param( + StructuredWithMissing(opt_num=None, num=II("opt_num")), + "num", + pytest.raises( + InterpolationValidationError, + match=re.escape("Non optional field cannot be assigned None"), + ), + id="non_optional_node_interpolation", + ), + pytest.param( + SubscriptedList(list=SI("${identity:[a, b]}")), + "list", + pytest.raises( + InterpolationValidationError, + match=re.escape("Value 'a' could not be converted to Integer"), + ), + id="list_type_mismatch", + ), + pytest.param( + MissingDict(dict=SI("${identity:{0: b, 1: d}}")), + "dict", + pytest.raises( + InterpolationValidationError, + match=re.escape("Key 0 (int) is incompatible with (str)"), + ), + id="dict_key_type_mismatch", + ), + ], +) +def test_interpolation_type_validated_error( + cfg: Any, + key: str, + expected_error: Any, + common_resolvers: Any, +) -> None: + cfg = OmegaConf.structured(cfg) + + with expected_error: + cfg[key] + + assert OmegaConf.select(cfg, key, throw_on_resolution_failure=False) is None + + +@pytest.mark.parametrize( + ("cfg", "key"), + [ + pytest.param({"dict": "${identity:{a: 0, b: 1}}"}, "dict.a", id="dict"), + pytest.param( + {"dict": "${identity:{a: 0, b: {c: 1}}}"}, + "dict.b.c", + id="dict_nested", + ), + pytest.param({"list": "${identity:[0, 1]}"}, "list.0", id="list"), + pytest.param({"list": "${identity:[0, [1, 2]]}"}, "list.1.1", id="list_nested"), + ], +) +def test_interpolation_readonly_resolver_output( + common_resolvers: Any, cfg: Any, key: str +) -> None: + cfg = OmegaConf.create(cfg) + sub_key: Any + parent_key, sub_key = key.rsplit(".", 1) + try: + sub_key = int(sub_key) # convert list index to integer + except ValueError: + pass + parent_node = OmegaConf.select(cfg, parent_key) + assert parent_node._get_flag("readonly") + + +def test_interpolation_readonly_node() -> None: + cfg = OmegaConf.structured(User(name="7", age=II("name"))) + resolved = dereference(cfg, "age") + assert resolved == 7 + # The `resolved` node must be read-only because `age` is an integer, so the + # interpolation cannot return directly the `name` node. + assert resolved._get_flag("readonly") + + +def test_type_validation_error_no_throw() -> None: + cfg = OmegaConf.structured(User(name="Bond", age=SI("${name}"))) + bad_node = cfg._get_node("age") + assert bad_node._dereference_node(throw_on_resolution_failure=False) is None + + +@pytest.mark.parametrize( + ("cfg", "expected"), + [ + ({"a": 0, "b": 1}, {"a": 0, "b": 1}), + ({"a": "${y}"}, {"a": -1}), + ({"a": 0, "b": "${x.a}"}, {"a": 0, "b": 0}), + ({"a": 0, "b": "${.a}"}, {"a": 0, "b": 0}), + ({"a": "${..y}"}, {"a": -1}), + ], +) +def test_resolver_output_dict_to_dictconfig( + restore_resolvers: Any, cfg: Dict[str, Any], expected: Dict[str, Any] +) -> None: + OmegaConf.register_new_resolver("dict", lambda: cfg) + c = OmegaConf.create({"x": "${dict:}", "y": -1}) + assert isinstance(c.x, DictConfig) + assert c.x == expected + assert dereference(c, "x")._get_flag("readonly") + + +@pytest.mark.parametrize( + ("cfg", "expected"), + [ + ([0, 1], [0, 1]), + (["${y}"], [-1]), + ([0, "${x.0}"], [0, 0]), + ([0, "${.0}"], [0, 0]), + (["${..y}"], [-1]), + ], +) +def test_resolver_output_list_to_listconfig( + restore_resolvers: Any, cfg: List[Any], expected: List[Any] +) -> None: + OmegaConf.register_new_resolver("list", lambda: cfg) + c = OmegaConf.create({"x": "${list:}", "y": -1}) + assert isinstance(c.x, ListConfig) + assert c.x == expected + assert dereference(c, "x")._get_flag("readonly") diff --git a/tests/test_matrix.py b/tests/test_matrix.py index fa1d5dadf..bdb94fdee 100644 --- a/tests/test_matrix.py +++ b/tests/test_matrix.py @@ -181,7 +181,7 @@ def test_none_construction(self, node_type: Any, values: Any) -> None: def test_interpolation( self, node_type: Any, values: Any, restore_resolvers: Any, register_func: Any ) -> None: - resolver_output = 9999 + resolver_output = "9999" register_func("func", lambda: resolver_output) values = copy.deepcopy(values) for value in values: