-
Notifications
You must be signed in to change notification settings - Fork 116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Type validation of interpolations #578
Changes from all commits
21224f2
9923827
1749581
a38eb8a
69a32d2
dca7e01
36167ba
6159f3e
3d350db
d09277d
a99f776
0d63bf9
c2c1c03
4af8eb5
804845f
96e55f3
b9a3197
fefffc1
5080322
572e125
ce92f60
57ee921
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line 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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
A custom resolver interpolation whose output is a list or dictionary is now automatically converted into a ListConfig or DictConfig. | ||
omry marked this conversation as resolved.
Show resolved
Hide resolved
odelalleau marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Custom resolvers can now generate transient config nodes dynamically. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,9 +20,12 @@ | |
InterpolationKeyError, | ||
InterpolationResolutionError, | ||
InterpolationToMissingValueError, | ||
InterpolationValidationError, | ||
KeyValidationError, | ||
MissingMandatoryValue, | ||
OmegaConfBaseException, | ||
UnsupportedInterpolationType, | ||
ValidationError, | ||
) | ||
from .grammar.gen.OmegaConfGrammarParser import OmegaConfGrammarParser | ||
from .grammar_parser import parse | ||
|
@@ -353,8 +356,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 | ||
|
||
|
@@ -416,8 +417,34 @@ def _resolve_interpolation_from_parse_tree( | |
parse_tree: OmegaConfGrammarParser.ConfigValueContext, | ||
throw_on_resolution_failure: bool, | ||
) -> Optional["Node"]: | ||
omry marked this conversation as resolved.
Show resolved
Hide resolved
|
||
from .nodes import StringNode | ||
|
||
""" | ||
Resolve an interpolation. | ||
odelalleau marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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, | ||
|
@@ -429,19 +456,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) | ||
omry marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is now a kilometer long. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in d09277d |
||
|
||
# If the node is typed, validate (and possibly convert) the result. | ||
if isinstance(value, ValueNode) and not isinstance(value, AnyNode): | ||
Comment on lines
+481
to
+482
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While I am okay with not validating config nodes at this time (could be expensive), I think the docs should reflect that. The use case is for custom resolvers returning a config: @dataclass
class Client:
ports: List[int] = II("oc.decode:${oc.env:PORTS}")
# env.PORTS="[80,8080]" => ListConfig([80, 8080], element_type=int) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a doc on the lack of validation for container nodes in 1a77110 I tried to keep it simple, so I didn't go into the details of how exactly validation can still occur for resolver interpolations. |
||
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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deducing it from type of the result type seems fragile. def is_valid_value(self, value) -> bool:
...
def convert_value(self, value) -> Any:
...
def validate_and_convert(self, value) -> Any:
if self.is_valid_value(value):
return value
else:
return self.convert_value(value) This can be a sizeable change. If we do it I suggest we do it as a standalone diff independently of this diff. Once it's in - this diff can utilize the more fine-grained API instead of calling validate_and_convert(). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Or, we impose the contract that it should return the same object if it is not converted -- back to my first version :)
A potential concern with breaking into two functions is that it may do the same checks twice. Let me know what you think makes most sense for this PR, and I'll go with it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think having the same type checks twice during assignment is minor. Breaking the function into two will also play better when we try to deprecate the automatic conversion on assignment of primitive types. (as opposed to automatic conversion on merge, which I think we want to keep). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok but how should I proceed for this PR? I think there are 4 options:
|
||
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, | ||
|
@@ -483,19 +589,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}" | ||
|
@@ -556,7 +653,7 @@ def quoted_string_callback(quoted_str: str) -> str: | |
value=quoted_str, | ||
key=key, | ||
parent=parent, | ||
is_optional=False, | ||
is_optional=True, | ||
odelalleau marked this conversation as resolved.
Show resolved
Hide resolved
|
||
), | ||
throw_on_resolution_failure=True, | ||
) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am starting to think that we need a dedicated page for interpolations (not as a part of this PR).
Interpoaltion docs are now about 300 lines.
We can keep the really minimal use cases in usage and mention more advanced use cases and point to the interpolations doc page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added to #535 => resolving
Edit 2021-11-22: actually unresolving as otherwise linking to this discussion doesn't work