Skip to content
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

Improve handling of interpolations pointing to missing nodes #545

Merged
merged 42 commits into from
Mar 3, 2021

Conversation

odelalleau
Copy link
Collaborator

@odelalleau odelalleau commented Feb 12, 2021

  • Interpolations are never considered to be missing anymore, even if
    they point to a missing node

  • When resolving an expression containing an interpolation pointing to a
    missing node, an InterpolationToMissingValueError exception is raised

  • When resolving an expression containing an interpolation pointing to a
    node that does not exist, an InterpolationKeyError exception is raised

  • key in cfg returns True whenever key is an interpolation, even if it cannot be resolved (including interpolations to missing nodes)

  • get() and pop() no longer return the default value in case of interpolation resolution failure (same thing for OmegaConf.select())

  • If throw_on_resolution_failure is False, then resolving an
    interpolation resulting in a resolution failure always leads to the
    result being None (instead of potentially being an expression computed
    from None)

Fixes #543
Fixes #561
Fixes #562
Fixes #565

tests/test_omegaconf.py Outdated Show resolved Hide resolved
omegaconf/base.py Show resolved Hide resolved
Comment on lines 715 to 731
@pytest.mark.parametrize("ref", ["missing", "invalid"])
def test_invalid_intermediate_result_when_not_throwing(
ref: str, restore_resolvers: Any
) -> None:
"""
Check that the resolution of nested interpolations stops on missing / resolution failure.
"""
OmegaConf.register_new_resolver("check_none", lambda x: x is None)
cfg = OmegaConf.create({"x": f"${{check_none:${{{ref}}}}}", "missing": "???"})
x_node = cfg._get_node("x")
assert isinstance(x_node, Node)
assert (
x_node._dereference_node(
throw_on_missing=False, throw_on_resolution_failure=False
)
is None
)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the purpose of the change this is testing.
Why do ywe want to ever get None from _dereference_node?

Copy link
Collaborator Author

@odelalleau odelalleau Feb 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a hopefully better explanation in dfea0a0
Before this PR, what would happen is that for instance ${invalid} would be resolved to None (because throw_on_resolution_failure is False), and the resolver would thus be called with None as input (which in general could result in an arbitrary result).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do ywe want to ever get None from _dereference_node?

One example is ValueNode._is_none()

omegaconf/base.py Show resolved Hide resolved
key=key,
parent=parent,
)
except Exception:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch is very broad.
Is this to handle userland exceptions from custom resolvers?

Worth considering to never suppress those and have a narrow catch here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch is very broad.
Is this to handle userland exceptions from custom resolvers?

Yes.

Worth considering to never suppress those and have a narrow catch here.

Maybe, but that's how it was working before as well => best left for another PR?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the exceptions we are expecting here after the recent conclusions?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, can now be restricted to InterpolationResolutionError, done in 2fa1d7f

omegaconf/base.py Outdated Show resolved Hide resolved
tests/test_interpolation.py Outdated Show resolved Hide resolved
tests/test_omegaconf.py Outdated Show resolved Hide resolved
Comment on lines 58 to 62

if has_default and val is None:
return default_value

if is_mandatory_missing(val):
if has_default:
return default_value
raise MissingMandatoryValue("Missing mandatory value: $FULL_KEY")

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we simplify by combining the has_default?

if has_default:
  if val is None:
    return default_value
  if is_mandatory_missing(val):
    raise ...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not seeing it -- the problem is to make sure that we call is_mandatory_missing() only once (and ideally that we don't call it at all if we don't need to). Can you provide a full snippet?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. I looked at the existing code and I am a bit confused by this change.
The current code is checking if a value is mandatory missing, if it is it returns the default if provided.
it then resolves it, and check again on the resolved value. if it's missing - it returns the default otherwise it raises.

not sure how you can consolidate the two checks into one.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I think I understand the confusion. The reason why I could move up the check (that was after the resolution) is because it's not possible anymore for _maybe_resolve_interpolation() to return a node that is missing unless val itself is missing (since an interpolation pointing to a missing node isn't considered as missing now).

But yes, this change is a bit tricky and it's probably easier to understand by considering two scenarios to see how they play out (before / after this change):

  1. val is missing ("???")
  2. val is an interpolation pointing to a missing node ("${foo}" with foo set to "???")

In scenario 1:

  • If there is a default value it is obvious that both before/after return this default value
  • If there is no default value, before we would resolve val, which would return "???", and since it is missing the MissingMandatoryValue exception would be raised. After this PR, this is now caught before resolving, and the same exception is raised.

In scenario 2:

  • If there is a default value, before we would still resolve val (because it's an interpolation and not the missing string), which would result in a missing value, and the default value would be returned. After this PR, resolved_node will be None because the resolution will fail (due to foo being missing), and as a result the default value will also be returned.
  • If there is no default value, before we would resolve val, resulting in a missing value and the MissingMandatoryValue exception being thrown here. After this PR, since throw_on_resolution_failure is True, the resolution will fail (also with a MissingMandatoryValue exception)

==> in the end the behavior is the same in all situations

omegaconf/basecontainer.py Outdated Show resolved Hide resolved
omegaconf/nodes.py Show resolved Hide resolved
tests/test_matrix.py Show resolved Hide resolved
omegaconf/base.py Show resolved Hide resolved
omegaconf/base.py Show resolved Hide resolved
tests/test_interpolation.py Outdated Show resolved Hide resolved
tests/test_errors.py Show resolved Hide resolved
tests/test_matrix.py Show resolved Hide resolved
omegaconf/basecontainer.py Outdated Show resolved Hide resolved
Comment on lines 58 to 62

if has_default and val is None:
return default_value

if is_mandatory_missing(val):
if has_default:
return default_value
raise MissingMandatoryValue("Missing mandatory value: $FULL_KEY")

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. I looked at the existing code and I am a bit confused by this change.
The current code is checking if a value is mandatory missing, if it is it returns the default if provided.
it then resolves it, and check again on the resolved value. if it's missing - it returns the default otherwise it raises.

not sure how you can consolidate the two checks into one.

@Jasha10 Jasha10 requested review from Jasha10 and removed request for Jasha10 February 17, 2021 18:57
@Jasha10
Copy link
Collaborator

Jasha10 commented Feb 18, 2021

I feel that the throw_on_resolution_failure argument to _dereference_node is overloaded.

There are three possible error states when calling node._dereference_node:

  1. node itself is missing, i.e. node._is_missing()==True. This is handled by the throw_on_missing argument to _dereference_node.
  2. node is an interpolation, and the interpolation points to a node that is missing, e.g. in DictConfig({'missing': '???', 'x': '${missing}'}), where cfg._get_node('x') gets dereferenced to a node that exists but is missing.
  3. node is an interpolation, and the interpolation cannot be resolved because it points to a node that does not exist. For example, in DictConfig('x': '${does_not_exist}'), where cfg._get_node('x') cannot be dereferenced.

In this branch, it seems that if throw_on_resolution_failure==True then MandatoryMissingValue gets raised in both cases (2) and (3).

Would it make sense to introduce another argument to the _dereference_node signature so that these cases can be handled independently?
I think that there is a use case where we do want an error in case (3) but not want an error in case (2):

OmegaConf.to_container(cfg, resolve=True, throw_on_missing=False)

(#503 is a work in progress, introducing a throw_on_missing flag to OmegaConf.to_container)

@odelalleau This is related to your comments the other day (#516 (comment)), where we discussed interpolations that might contain typos.

@odelalleau
Copy link
Collaborator Author

In this branch, it seems that if throw_on_resolution_failure==True then MandatoryMissingValue gets raised in both cases (2) and (3).

Actually no, in (3) it raises an InterpolationResolutionError

I think that there is a use case where we do want an error in case (3) but not want an error in case (2):

OmegaConf.to_container(cfg, resolve=True, throw_on_missing=False)

I'm not familiar with all use cases for to_container(), but what behavior would you expect in case (2) here? Besides an error, the only other option that would make sense to me is to consider that any interpolation depending on a missing value is set to "???" too. That's a possibility I also considered for this PR (for the behavior of is_missing()), but I feel like it's more likely to hide other issues and cause unexpected behavior. So I'd rather keep the simpler "crash if the interpolation can't be resolved" approach, and only implement this alternative option if there is a strong use case for it.

@Jasha10
Copy link
Collaborator

Jasha10 commented Feb 18, 2021

I'm not familiar with all use cases for to_container()

One use-case is motivated by the work-in-progress PR #502, which will introduce an instantiate argument to the OmegaConf.to_container function. The idea is that OmegaConf.to_container(cfg, instantiate=True) will result in StructuredConfig being replaced with instances of the actual datalcass that backs the config type.

In any case, the spirit of OmegaConf.to_container is to leave behind the OmegaConf metadata and convert to native python types.

what behavior would you expect in case (2) here? Besides an error, the only other option that would make sense to me is to consider that any interpolation depending on a missing value is set to "???" too.

My sense is that

OmegaConf.to_container(cfg, resolve=True, throw_on_missing=False)

should never raise MandatoryMissingValue, and

OmegaConf.to_container(cfg, resolve=True, throw_on_missing=True)

should raise MandatoryMissingValue in both cases (1) and (2).

(to_container only calls _dereference_node if resolve==True.)

Actually no, in (3) it raises an InterpolationResolutionError

Oh, I see. In this case the to_container implementation should be able to handle cases (2) and (3) as needed depending on what type of exception gets raised.

@Jasha10
Copy link
Collaborator

Jasha10 commented Feb 19, 2021

So I'd rather keep the simpler "crash if the interpolation can't be resolved" approach, and only implement this alternative option if there is a strong use case for it.

Sounds good. I think that "simpler" is often good design.

For the record, here is the current _dereference_node behavior in each case:

throw_on_resolution_failure=True
    throw_on_missing=True
        case1: MissingMandatoryValue
        case2: MissingMandatoryValue
        case3: InterpolationResolutionError
    throw_on_missing=False
        case1: '???'
        case2: MissingMandatoryValue
        case3: InterpolationResolutionError
throw_on_resolution_failure=False
    throw_on_missing=True
        case1: MissingMandatoryValue
        case2: None
        case3: None
    throw_on_missing=False
        case1: '???'
        case2: None
        case3: None

Here is the script that I used to generate the above output:
print_cases.py.txt
I had to upload it as a .txt file so that github would not complain.

@odelalleau
Copy link
Collaborator Author

My sense is that

OmegaConf.to_container(cfg, resolve=True, throw_on_missing=False)

should never raise MandatoryMissingValue

I agree it may be weird to get a MandatoryMissingValue with throw_on_missing=False. Note that the same question may be asked regarding _dereference_node(throw_on_missing=False), and regardless of what is decided, I believe both should behave in a consistent way.

For _dereference_node(), I chose to interpret throw_on_missing as whether to throw an exception if the node being dereferenced is missing. Since an interpolation is not missing (anymore), this flag should not be relevant for interpolations.

Maybe one way to reduce the potential confusion could be to replace the MandatoryMissingValue in that situation with an InterpolationResolutionError?

@Jasha10
Copy link
Collaborator

Jasha10 commented Feb 19, 2021

I believe both should behave in a consistent way.

Are you saying that throw_on_missing should behave in the same way for to_container and _dereference_node? Note that to_container is a "global" operation, in the sense that it traverses the entire DictConfig/ListConfig structure, and it visits every subconfig recursively. Given throw_on_missing=True, to_container must throw MandatoryMissingValue if it encounters a missing node anywhere in this traversal.

Maybe one way to reduce the potential confusion could be to replace the MandatoryMissingValue in that situation with an InterpolationResolutionError?

Hmm... My personal opinion is that the two error modes (2) and (3), which mean "interpolated node is missing" and "interpolated node does not exist" are fundamentally different, and it is useful for the end user if they are distinguished.
This is to say, I like the current behavior where InterpolationResolutionError is reserved for the case where the interpolation points to something that does not exist.

@omry
Copy link
Owner

omry commented Feb 19, 2021

For _dereference_node(), I chose to interpret throw_on_missing as whether to throw an exception if the node being dereferenced is missing. Since an interpolation is not missing (anymore), this flag should not be relevant for interpolations.

a missing node cannot be dereferenced, just like a node with invalid interpolation key cannot be dereferenced.
so for a first level:

foo: ???  #  mandatory missing exception
bar: ${zzz}  # interpolation resolution exception

If an interpolation pointing to a missing node:

foo : ${bar}
bar: ???

To me it's clear that this is not a resolution error.
my intuition is that if throw_on_missing is true, it raises an mandatory missing exception, and if it's false it returns "???".

One case that can confuse things is string interpolation:

foo: abc_${bar}
bar: ???

My intuition is that if throw_on_missing is true, this raises, otherwise is returns abc_???.

@Jasha10
Copy link
Collaborator

Jasha10 commented Feb 19, 2021

My intuition is that if throw_on_missing is true, this raises, otherwise is returns abc_???.

If my understanding is correct, this is described by the following table for _dereference_node:

throw_on_resolution_failure=True
    throw_on_missing=True
        case1: MissingMandatoryValue
        case2: MissingMandatoryValue
        case3: InterpolationResolutionError
    throw_on_missing=False
        case1: '???'
        case2: '???'
        case3: InterpolationResolutionError
throw_on_resolution_failure=False
    throw_on_missing=True
        case1: MissingMandatoryValue
        case2: MissingMandatoryValue
        case3: None
    throw_on_missing=False
        case1: '???'
        case2: '???'
        case3: None

where

  • case1 is a missing node,
  • case2 is a pointer to a missing node, and
  • case3 is a pointer to a node that does not exist.

@omry
Copy link
Owner

omry commented Feb 19, 2021

Updated your script.
This is on master:

from omegaconf import OmegaConf

cases = dict(
    direct_missing={"x": "???"},
    indirect_missing={"x": "${missing}", "missing": "???"},
    str_indirect_missing={"x": "foo_${missing}", "missing": "???"},
    inter_not_found={"x": "${does_not_exist}"},
    str_inter_not_found={"x": "foo_${does_not_exist}"},
)

print("--")
for throw_on_resolution_failure in [True, False]:
    for throw_on_missing in [True, False]:
        print(f"{throw_on_resolution_failure = }")
        print(f"{throw_on_missing = }")
        for case_name, case in cases.items():
            case = OmegaConf.create(case)
            try:
                node = case._get_node("x")
                result = repr(
                    node._dereference_node(
                        throw_on_missing=throw_on_missing,
                        throw_on_resolution_failure=throw_on_resolution_failure,
                    )
                )
            except Exception as excinfo:
                result = "ERR: " + type(excinfo).__name__
            print(f"        {case_name}: {result}")
        print("--")

Output:

--
throw_on_resolution_failure = True
throw_on_missing = True
        direct_missing: ERR: MissingMandatoryValue
        indirect_missing: ERR: MissingMandatoryValue
        str_indirect_missing: ERR: MissingMandatoryValue
        inter_not_found: ERR: InterpolationResolutionError
        str_inter_not_found: ERR: InterpolationResolutionError
--
throw_on_resolution_failure = True
throw_on_missing = False
        direct_missing: '???'
        indirect_missing: '???'
        str_indirect_missing: 'foo_???'
        inter_not_found: ERR: InterpolationResolutionError
        str_inter_not_found: ERR: InterpolationResolutionError
--
throw_on_resolution_failure = False
throw_on_missing = True
        direct_missing: ERR: MissingMandatoryValue
        indirect_missing: ERR: MissingMandatoryValue
        str_indirect_missing: ERR: MissingMandatoryValue
        inter_not_found: None
        str_inter_not_found: 'foo_None'
--
throw_on_resolution_failure = False
throw_on_missing = False
        direct_missing: '???'
        indirect_missing: '???'
        str_indirect_missing: 'foo_???'
        inter_not_found: None
        str_inter_not_found: 'foo_None'
--

@omry
Copy link
Owner

omry commented Feb 19, 2021

I think str_inter_not_found: 'foo_None' never makes sense.
What if we threw InterpolationResolutionError even if throw_on_resolution_failure is False for string interpolation?

@omry
Copy link
Owner

omry commented Feb 19, 2021

Can we articulate what is the value of those two flags right now?
what if we always threw from dereference_node and allowed the caller to handle the exceptions as it sees fit?

@Jasha10
Copy link
Collaborator

Jasha10 commented Feb 19, 2021

Can we articulate what is the value of those two flags right now?

Looking at current use of _dereference_node in the codebase, it seems that the main value is that callers can avoid wrapping _dereference_node in try/except blocks.

@Jasha10
Copy link
Collaborator

Jasha10 commented Feb 19, 2021

what if we always threw from dereference_node and allowed the caller to handle the exceptions as it sees fit?

Sounds reasonable. If the caller needs to distinguish between direct_missing and indirect_missing, they would need to test node._is_missing() before invoking node._dereference_node().

@odelalleau
Copy link
Collaborator Author

odelalleau commented Feb 19, 2021

Given throw_on_missing=True, to_container must throw MandatoryMissingValue if it encounters a missing node anywhere in this traversal.

But should we consider that by "traversal" we also mean the operations performed during interpolation resolution? I suggest that we shouldn't. This has the advantage that throw_on_missing and resolve are independent. In addition, if we consider that throw_on_missing should also affect the behavior of interpolation resolution, I still believe it should also be the case with _dereference_node().

I like the current behavior where InterpolationResolutionError is reserved for the case where the interpolation points to something that does not exist.

Ok, I was thinking that maybe we could extend it to any kind of exception raised during interpolation resolution (including also resolver failures by the way, which right now can be anything since we don't control this custom code). But it was just a suggestion, I don't feel particularly strongly about it.

foo : ${bar}
bar: ???

my intuition is that if throw_on_missing is true, it raises an mandatory missing exception, and if it's false it returns "???".

If we decide to return "???" then I think this PR should be changed so that is_missing() called on foo also returns True.

foo: abc_${bar}
bar: ???

My intuition is that if throw_on_missing is true, this raises, otherwise is returns abc_???.

That's something I feel more strongly against. It might seem intuitive for string interpolations (although it did surprise you), but what about accessing foo, foo2 or foo3 in:

foo: ${x.${bar}}
foo2: ${resolver:${bar}}
foo3: ${resolver:abc_${bar}}
bar: ???
x:
  y: 0

What I believe makes more sense is one of these two options:

  • Raise an exception (this is what I went for)
  • Resolve the interpolation to "???". I considered it, but again, in that case this PR should be changed to consider these interpolations as missing. At this time I still feel like this behavior is more likely to lead to potentially confusing / weird behavior (while raising an exception immediately tells you there is a problem and where). So I'd like to see a good motivation for it before going this route.

@odelalleau
Copy link
Collaborator Author

Can we articulate what is the value of those two flags right now?

I think we should articulate what they mean :) So far I've interpreted throw_on_resolution_failure as whether or not to raise an exception in case anything wrong happens during the resolution of an interpolation. This was motivated in particular by the fact that resolver failures are silenced when throw_on_resolution_failure is False.

We can change it to say that it's only meant to silence exceptions raised when an interpolated node (or resolver?) doesn't exist, but I'm not entirely sure of the potential consequences. I feel like it'd be better to do it in a different PR too, to avoid mixing too many changes together.

@omry
Copy link
Owner

omry commented Feb 20, 2021

It's becoming hard to follow what's going on in the discussions because it's changing many things at the same time.

Updated script producing results. (Added line numbers for easier discussion).

I ran the script on master and with this PR to compare the results.

omry@Coronadev:~/dev/omegaconf$ git co master
Already on 'master'
Your branch is up to date with 'origin/master'.
$ python 1.py > master.txt
$ git co odelalleau-interpolation_missing 
Switched to branch 'odelalleau-interpolation_missing'
$ python 1.py > change.txt

wdiff shows word difference with a {} block on the right:

$ wdiff master.txt change.txt 
--
throw_on_resolution_failure = True
throw_on_missing = True
0:       direct_missing: ERR: MissingMandatoryValue
1:       indirect_missing: ERR: MissingMandatoryValue
2:       str_indirect_missing: ERR: MissingMandatoryValue
3:       inter_not_found: ERR: InterpolationResolutionError
4:       str_inter_not_found: ERR: InterpolationResolutionError
--
throw_on_resolution_failure = True
throw_on_missing = False
5:       direct_missing: '???'
6:       indirect_missing: [-'???'-] {+ERR: MissingMandatoryValue+}
7:       str_indirect_missing: [-'foo_???'-] {+ERR: MissingMandatoryValue+}
8:       inter_not_found: ERR: InterpolationResolutionError
9:       str_inter_not_found: ERR: InterpolationResolutionError
--
throw_on_resolution_failure = False
throw_on_missing = True
10:       direct_missing: ERR: MissingMandatoryValue
11:       indirect_missing: [-ERR: MissingMandatoryValue-] {+None+}
12:       str_indirect_missing: [-ERR: MissingMandatoryValue-] {+None+}
13:       inter_not_found: None
14:       str_inter_not_found: [-'foo_None'-] {+None+}
--
throw_on_resolution_failure = False
throw_on_missing = False
15:       direct_missing: '???'
16:       indirect_missing: [-'???'-] {+None+}
17:       str_indirect_missing: [-'foo_???'-] {+None+}
18:       inter_not_found: None
19:       str_inter_not_found: [-'foo_None'-] {+None+}
--

Let's try to get a consensus on each of the changing lines (6,7,11,12,16,17,19).
There are quiet a few changes, not sure how it's best to slice those (by line number, by flag value? by use case?).

@omry
Copy link
Owner

omry commented Feb 20, 2021

To make this easier, here is the same output with all the unchanged lines removed:

--
throw_on_resolution_failure = True
throw_on_missing = False
6:       indirect_missing: [-'???'-] {+ERR: MissingMandatoryValue+}
7:       str_indirect_missing: [-'foo_???'-] {+ERR: MissingMandatoryValue+}
--
throw_on_resolution_failure = False
throw_on_missing = True
11:       indirect_missing: [-ERR: MissingMandatoryValue-] {+None+}
12:       str_indirect_missing: [-ERR: MissingMandatoryValue-] {+None+}
14:       str_inter_not_found: [-'foo_None'-] {+None+}
--
throw_on_resolution_failure = False
throw_on_missing = False
16:       indirect_missing: [-'???'-] {+None+}
17:       str_indirect_missing: [-'foo_???'-] {+None+}
19:       str_inter_not_found: [-'foo_None'-] {+None+}
--

@omry
Copy link
Owner

omry commented Feb 20, 2021

Let me try to slice this by use case.
Changes in indirect_missing first.

line 6: seems wrong to me, should not throw MissingMandatoryValue if throw_on_missing = False.
I think it should return "???".

line 11:
I think it should still throw MissingMandatoryValue

Line 16:
I think it should still return "???"

That's it for indirect missing. let's get a consensus on it before trying to get it on the other ones.

@odelalleau
Copy link
Collaborator Author

That's it for indirect missing. let's get a consensus on it before trying to get it on the other ones.

My main comment is that what you suggest goes against what I proposed in #543 (section "Expected behavior" at the bottom, point 1): if "a node is never missing if it is an interpolation", then line 6 should not return "???".

We can go back to the previous "an interpolation pointing to a missing node is also missing": I think it can work too, I just personally believe it makes things a bit more complex and I still don't see what we gain from it (#462 is an example where things would have been simpler if interpolations were never missing). But I don't really feel strongly about it, so I'm happy to implement either version.

@Jasha10
Copy link
Collaborator

Jasha10 commented Feb 20, 2021

#462 is an example where things would have been simpler

I see what you're saying about #462: For the purposes of OmegaConf.merge, it makes sense for direct_missing to be treated very differently from indirect_missing.
If cfg2.a is direct_missing then we don't want to overwrite cfg1.a:

cfg1 = OmegaConf.create({"a" : 1, "b" : "???"})
cfg2 = OmegaConf.create({"a" : "???"})
assert OmegaConf.merge(cfg1, cfg2) == {"a": 1, "b" : "???"}

But cfg2.a is indirect_missing then we do want to overwrite cfg1.a:

cfg1 = OmegaConf.create({"a" : 1, "b" : "???"})
cfg2 = OmegaConf.create({"a" : "${b}"})
assert OmegaConf.merge(cfg1, cfg2) == {"a": "${b}", "b": "???"}

This is a strong argument that direct_missing and indirect_missing should not be considered as equivalent under all circumstances.

Given throw_on_missing=True, to_container must throw MandatoryMissingValue if it encounters a missing node anywhere in this traversal.

But should we consider that by "traversal" we also mean the operations performed during interpolation resolution? I suggest that we shouldn't.

I still feel that if a user calls OmegaConf.to_container with throw_on_missing=True, they should be guaranteed the returned container will not contain any missing values (assuming that no exception is raised). In this circumstance, I think that direct_missing and indirect_missing should be considered as equivalent.

I feel like OmegaConf's API can be divided into two broad categories:

  • building / modifying the config
  • querying / exporting the config

OmegaConf.merge is in the first category, OmegaConf.to_container(..., resolve=True) is in the second.
In the first category it makes sense to treat direct_missing as very different from indirect_missing. In the second category, you treat direct_missing and indirect_missing as equivalent, because (from your app's point of view) you only care about whether the value is set or not.

@Jasha10
Copy link
Collaborator

Jasha10 commented Feb 20, 2021

One idea that came to my mind is to change the _dereference_node signature from

    def _dereference_node(
        self,
        throw_on_missing: bool = False,
        throw_on_resolution_failure: bool = True,
    ) -> Optional["Node"]:

to something like

    def _dereference_node(
        self,
        throw_on_str_indirect_missing: bool = False,
        throw_on_resolution_failure: bool = True,
    ) -> Optional["Node"]:

The motivation for this change is:

  • The throw_on_missing flag affects the behavior of three cases: direct_missing, indirect_missing, and str_indirect_missing
  • The caller can easily check for direct_missing immediately before calling _dereference_node
  • The caller can easily check for indirect_missing immediately after calling _dereference_node
  • The caller cannot easily check for str_indirect_missing (which might involve searching for "???" or "None" as a substring of the returned value).

So the new behavior would be that _dereference_node never throws a MandatoryMissingValue (except maybe in the str_indirect_missing case).

@odelalleau
Copy link
Collaborator Author

odelalleau commented Feb 20, 2021

I still feel that if a user calls OmegaConf.to_container with throw_on_missing=True, they should be guaranteed the returned container will not contain any missing values (assuming that no exception is raised).

That would actually be the case with what I'm suggesting, i.e., in short: interpolations can never be missing (and encoutering a missing value while trying to resolve an interpolations should be considered as an error, that may or may not be silenced with throw_on_resolution_failure depending on what we decide this flag is supposed to mean -- right now it would silence it since it catches any exception raised during interpolation resolution)

My suggestion is based on previous experience with OmegaConf internals (#462 that I mentioned), but also trying to think of use cases for OmegaConf.is_missing(). Two use cases I could think of are the following:

if OmegaConf.is_missing(cfg, "foo"):
    logging.error("You forgot to set the `foo` variable: edit `config.yaml` and edit the line `foo: ???`")
    sys.exit(1)

=> In that case, if foo was set to an interpolation pointing to a missing node, the error message would give incorrect instructions on how to fix it (and finding the variable that is actually missing might not be obvious, since it could be in a different file and hidden behind a chain of interpolations).

if OmegaConf.is_missing(cfg, "n_processes"):
    logging.info("`n_processes` is not set, defaulting to 4 processes")
    cfg.n_processes = 4
if OmegaConf.is_missing(cfg, "n_cpus"):
    logging.info("`n_cpus` is not set, defaulting to twice the number of processes")
    cfg.n_cpus = cfg.n_processes * 2

=> in that case, if n_cpus is set to ${n_processes) but n_processes is missing, we would end up going through both if blocks and n_cpus would be 8 (instead of the intended 4).
[EDIT: actually this is wrong, the second if would resolve to False since at this point n_processes isn't missing anymore, and thus n_cpus isn't either. So this was a bad example. But there could be close variants where this problem would arise]

If we agree that "being missing" only means "being set to ???", then things become simpler IMO, both in these examples and for internal operations like #462.

One idea that came to my mind is to change the _dereference_node signature from (...)

Stepping back for a minute, can you think of a use case where we would want "abc_${foo}" to be resolved into either "abc_???", "abc_None", or "???", when foo is set to "???"?
The last option is the only one that could make sense to me, though I can't think of a situation where resolving to "???" would be more useful than None (edit: that last bit wasn't clear, what I meant is that I don't see why you'd want to resolve it to "???" instead of treating it like an error, that may or may not lead to None depending on how you choose to handle errors)

@lgtm-com
Copy link

lgtm-com bot commented Feb 26, 2021

This pull request introduces 2 alerts when merging ef1d38f into d46d058 - view on LGTM.com

new alerts:

  • 2 for Unused import

@lgtm-com
Copy link

lgtm-com bot commented Feb 26, 2021

This pull request introduces 1 alert when merging 5718bf0 into d46d058 - view on LGTM.com

new alerts:

  • 1 for Unused import

news/562.api_change Outdated Show resolved Hide resolved
omegaconf/basecontainer.py Show resolved Hide resolved
omegaconf/dictconfig.py Show resolved Hide resolved
tests/test_basic_ops_dict.py Outdated Show resolved Hide resolved
tests/test_basic_ops_dict.py Outdated Show resolved Hide resolved
c = OmegaConf.create(dict(my_key="${env:my_key}", other_key=123))
with pytest.raises(InterpolationResolutionError):
monkeypatch.setenv("MYKEY", "${other_key}")
c = OmegaConf.create(dict(my_key="${env:MYKEY}", other_key=123))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when you touch dict() in tests, can you convert to {} style?

Suggested change
c = OmegaConf.create(dict(my_key="${env:MYKEY}", other_key=123))
c = OmegaConf.create({"my_key": "${env:MYKEY}", "other_key": 123})

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done for the whole file in 85478e4

tests/test_select.py Outdated Show resolved Hide resolved
Comment on lines 92 to 98
if exc_no_default is None:
# Not providing a default value is expected to work and return `None`.
assert OmegaConf.select(cfg, key) is None
else:
# Not providing a default value is expected to raise an exception.
with exc_no_default:
OmegaConf.select(cfg, key)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer splitting tests into positive and negative cases in scenarios like this one.
It makes the tests significantly simpler.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in two steps:

First, c396527 addresses this specific issue. It's easier to see the diff against master after applying this commit, which looks like this:

@pytest.mark.parametrize("struct", [True, False, None])
@@ -60,11 +61,11 @@ def test_select(
 @pytest.mark.parametrize("struct", [False, True])
 @pytest.mark.parametrize("default", [10, None])
 @pytest.mark.parametrize(
-    "cfg, key",
+    ("cfg", "key"),
     [
         pytest.param({}, "not_found", id="empty"),
         pytest.param({"missing": "???"}, "missing", id="missing"),
-        pytest.param({"inter": "${bad_key}"}, "inter", id="inter_bad_key"),
+        pytest.param({"int": 0}, "int.y", id="non_container"),
     ],
 )
 def test_select_default(
@@ -78,6 +79,31 @@ def test_select_default(
     assert OmegaConf.select(cfg, key, default=default) == default


+@pytest.mark.parametrize("struct", [False, True])
+@pytest.mark.parametrize(
+    ("cfg", "key", "exc"),
+    [
+        pytest.param(
+            {"int": 0},
+            "int.y",
+            pytest.raises(
+                ConfigKeyError,
+                match=re.escape(
+                    "Error trying to access int.y: node `int` is not a container "
+                    "and thus cannot contain `y`"
+                ),
+            ),
+            id="non_container",
+        ),
+    ],
+)
+def test_select_error(cfg: Any, key: Any, exc: Any, struct: bool) -> None:
+    cfg = _ensure_container(cfg)
+    OmegaConf.set_struct(cfg, struct)
+    with exc:
+        OmegaConf.select(cfg, key)
+

Second, in ef61b9a I refactored test_select.py so that all tests are within a common class parameterized on the struct flag (otherwise it seemed a bit random)

odelalleau and others added 7 commits March 2, 2021 14:47
Co-authored-by: Omry Yadan <omry@fb.com>
* Remove test_select_key_from_empty() which is already tested in
  test_select()

* Move all tests inside a common class that is parameterized on the
  "struct" flag
@omry
Copy link
Owner

omry commented Mar 2, 2021

re-request review when ready.

@odelalleau odelalleau requested a review from omry March 2, 2021 21:50
Copy link
Owner

@omry omry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. a couple of nits about the news fragments.
otherwise we can merge this.

news/543.api_change Outdated Show resolved Hide resolved
news/565.api_change Show resolved Hide resolved
odelalleau and others added 2 commits March 2, 2021 18:48
@odelalleau odelalleau requested a review from omry March 2, 2021 23:50
@omry omry merged commit cb5e556 into omry:master Mar 3, 2021
odelalleau added a commit to odelalleau/omegaconf that referenced this pull request Mar 10, 2021
* Interpolations are never considered to be missing anymore, even if
  they point to a missing node

* When resolving an expression containing an interpolation pointing to a
  missing node, an `InterpolationToMissingValueError` exception is raised

* When resolving an expression containing an interpolation pointing to a
  node that does not exist, an `InterpolationKeyError` exception is raised

* `key in cfg` returns True whenever `key` is an interpolation, even if it cannot be resolved (including interpolations to missing nodes)

* `get()` and `pop()` no longer return the default value in case of interpolation resolution failure (same thing for `OmegaConf.select()`)

* If `throw_on_resolution_failure` is False, then resolving an
  interpolation resulting in a resolution failure always leads to the
  result being `None` (instead of potentially being an expression computed
  from `None`)

Fixes omry#543
Fixes omry#561
Fixes omry#562
Fixes omry#565
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment