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 consistency of JoinedStr inference #2622

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

ericvergnaud
Copy link
Contributor

@ericvergnaud ericvergnaud commented Oct 22, 2024

This PR:

  • improves consistency of JoinedStr inference by always returning either Uninferable or a fully inferred Const
  • optionally, using a flag on JoinedStr, allows returning a partially inferred string such as "a/{MISSING_VALUE}/b" when inferring f"a/{missing}/b" and missing cannot be inferred.

Type of Changes

| ✓ | 🐛 Bug fix
| ✓ | ✨ New feature |

Description

See above

Closes #2621

Eric Vergnaud and others added 5 commits October 22, 2024 18:43
Copy link

codecov bot commented Oct 22, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 93.21%. Comparing base (e380fd1) to head (132cfb1).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #2622      +/-   ##
==========================================
- Coverage   93.24%   93.21%   -0.04%     
==========================================
  Files          93       93              
  Lines       11060    11078      +18     
==========================================
+ Hits        10313    10326      +13     
- Misses        747      752       +5     
Flag Coverage Δ
linux 93.10% <100.00%> (-0.04%) ⬇️
pypy 93.21% <100.00%> (-0.04%) ⬇️
windows 93.21% <100.00%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
astroid/nodes/node_classes.py 94.94% <100.00%> (-0.21%) ⬇️

@Pierre-Sassoulas Pierre-Sassoulas added this to the 4.0.0 milestone Oct 22, 2024
@Pierre-Sassoulas Pierre-Sassoulas added the Needs decision 🔒 Needs a decision before implemention or rejection label Oct 22, 2024
Copy link
Collaborator

@DanielNoord DanielNoord left a comment

Choose a reason for hiding this comment

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

I don't think I want the flag to be a thing on the class. astroid is not really configurable and I think that is actually beneficial in terms of maintainability.

I don't really understand your use case: can't we just return Uninferable when we can't infer the full string? That seems consistent with other nodes and is also the suggestion I already made when inference of JoinedStr was first added.

@ericvergnaud
Copy link
Contributor Author

I don't really understand your use case: can't we just return Uninferable when we can't infer the full string? That seems consistent with other nodes and is also the suggestion I already made when inference of JoinedStr was first added.

Returning Uninferable is not helpful in some usage scenarios.
Consider the following example:

def method_with_arg(arg):
    spark.table(f"/mnt/root/dir/{arg}/logs")

We want to tell our users that reading spark tables from mounts is no longer supported.
With JoinedStr.inferred() returning Uninferable, we're unable to detect that.
The feature proposed in this PR makes it possible, optionally, to return "/mnt/root/dir/{MISSING_VALUE}/logs", which is what we need to detect the "/mnt/" prefix, and also provide better feedback to users.
Happy to place the flag elsewhere.

Copy link
Member

@Pierre-Sassoulas Pierre-Sassoulas left a comment

Choose a reason for hiding this comment

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

/mnt/{MISSING_VALUE}/... should be/mnt/{Uninferable}/... imo. I'm not sure about this, there might be unexpected problems that I don't see at the moment. But it's probably better to infer a fake string when we know for sure it's going to be a string than to just say everything is uninferable, so +0.1 from me.

@jacobtylerwalls
Copy link
Member

The feature proposed in this PR makes it possible, optionally, to return "/mnt/root/dir/{MISSING_VALUE}/logs", which is what we need to detect the "/mnt/" prefix, and also provide better feedback to users.

I guess l'm wondering why you would detect that by calling inferred(), wouldn't you just directly inspect the AST?

>>> from astroid import extract_node
>>> n = extract_node("""
... def method_with_arg(arg):
...     spark.table(f"/mnt/root/dir/{arg}/logs")
... """)
>>> from astroid import nodes
>>> js = list(n.nodes_of_class(nodes.JoinedStr))[0]
>>> js.values[0].value
'/mnt/root/dir/'

I'll comment further on the issue.

return
uninferable_already_generated = False
for prefix in nodes[0]._infer(context, **kwargs):
for prefix in cls._safe_infer_from_node(nodes[0], context, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

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

Ooh, good spot. If we isolate this change I bet we could get quick consensus to merge it 👍

@jacobtylerwalls
Copy link
Member

I guess l'm wondering why you would detect that by calling inferred(), wouldn't you just directly inspect the AST?

Oh, I see, it's so that both cases (mnt injected by the formatted value) can be handled by one call. I guess I'm not sure astroid needs to support that.

@DanielNoord
Copy link
Collaborator

Personally I feel like this use case is best served with a dedicated method, or an implementation in the pylint plugin itself. There just seem to be too many edge cases where the behaviour of the flag would be hard to define.

I think .infer() should just have one behaviour which should be both easily explainable and matching with what we do for other node types. Any specific JoinedStr behaviour should probably not be part of a generic interface.

@ericvergnaud
Copy link
Contributor Author

ericvergnaud commented Oct 23, 2024

The feature proposed in this PR makes it possible, optionally, to return "/mnt/root/dir/{MISSING_VALUE}/logs", which is what we need to detect the "/mnt/" prefix, and also provide better feedback to users.

I guess l'm wondering why you would detect that by calling inferred(), wouldn't you just directly inspect the AST?

>>> from astroid import extract_node
>>> n = extract_node("""
... def method_with_arg(arg):
...     spark.table(f"/mnt/root/dir/{arg}/logs")
... """)
>>> from astroid import nodes
>>> js = list(n.nodes_of_class(nodes.JoinedStr))[0]
>>> js.values[0].value
'/mnt/root/dir/'

I'll comment further on the issue.

Your proposal is not workable with the following code:

def method_with_arg(arg):
    path = f"/mnt/root/dir/{arg}/logs"
    spark.table(path)

not speaking of even more complex scenarios:

def method_with_arg(arg):
    paths = [f"/mnt/root/dir/{arg}/logs", "/some/other/path"]
    tables = [spark.table(path) for path in paths]

That's what makes node.inferred() so valuable!

@ericvergnaud
Copy link
Contributor Author

Personally I feel like this use case is best served with a dedicated method, or an implementation in the pylint plugin itself. There just seem to be too many edge cases where the behaviour of the flag would be hard to define.

I think .infer() should just have one behaviour which should be both easily explainable and matching with what we do for other node types. Any specific JoinedStr behaviour should probably not be part of a generic interface.

We're using astroid directly so a pylint plugin is not an option.
I agree with the above philosophy in theory, but I disagree in practice because it stops me from getting the outcome I need from astroid. We're using it for inference (we could do parsing and transforms using ast)
I guess it's legitimate for users to want to customise behaviour a bit, depending on their requirements.
A better design would be to provide a replaceable NodeFactory, such that users can provide their own implementation of JoinedStr. Happy to provide such PR if it's welcome.

@ericvergnaud
Copy link
Contributor Author

ericvergnaud commented Oct 23, 2024

I don't think I want the flag to be a thing on the class. astroid is not really configurable and I think that is actually beneficial in terms of maintainability.

I don't really understand your use case: can't we just return Uninferable when we can't infer the full string? That seems consistent with other nodes and is also the suggestion I already made when inference of JoinedStr was first added.

TBH this PR is not proposing to change the current behaviour (see FAIL_ON_UNINFERABLE = True), rather it makes it possible to customise it if required.

@ericvergnaud
Copy link
Contributor Author

@jacobtylerwalls @DanielNoord @Pierre-Sassoulas can you opine on the factory proposal ?

@Pierre-Sassoulas
Copy link
Member

Pierre-Sassoulas commented Oct 24, 2024

I think .infer() should just have one behaviour which should be both easily explainable and matching with what we do for other node types. Any specific JoinedStr behaviour should probably not be part of a generic interface.

Agree. But if we know it's a string we can return a string even if it's not 'perfect'.

Still lightly in favor of inferring string the best we can even if we have to include 'Uninferrable' in the middle
(/mnt/{Uninferable}/...). But it should be safeguarded, i.e. :

  • f"/mnt{uninferrable}/..." => we infer a str : /mnt/{Uninferable}/...
  • "/mnt" + uninferrable + "/..." => there's no cast to string for uninferrable so we might have a crash and we need to still detect it

@jacobtylerwalls
Copy link
Member

I guess I'm not certain we even know it's a string. Don't these uninferable cases include failures to format?

@DanielNoord
Copy link
Collaborator

Exactly, or they can include memory overflow issues, or calls to functions that crash or exit the interpreter.

I don't really see why we would special case this infer. If we infer an import we also know that it will be a module but we don't insert dummy module nodes if we can't infer the import.

@Pierre-Sassoulas
Copy link
Member

Let's remove the flag and return fully inferred const or uninferable then.

@Pierre-Sassoulas Pierre-Sassoulas added Enhancement ✨ Improvement to a component and removed Needs decision 🔒 Needs a decision before implemention or rejection labels Oct 24, 2024
@ericvergnaud
Copy link
Contributor Author

That doesn't solve my problem.
Any comments on the node factory proposal ?

@DanielNoord
Copy link
Collaborator

I think without seeing the code to do this we can't really comment on it.
Bigger refactors of astroid have sadly failed before so I'm wary of rewriting how we construct nodes.

Can't you solve your use cases with a function that takes these nodes? Potentially by making that a method in astroid.
Although you're not writing a pylint plug-in your use case does seem to imply you know what kind of node you're dealing with before calling infer.

@ericvergnaud
Copy link
Contributor Author

I think without seeing the code to do this we can't really comment on it. Bigger refactors of astroid have sadly failed before so I'm wary of rewriting how we construct nodes.

Can't you solve your use cases with a function that takes these nodes? Potentially by making that a method in astroid. Although you're not writing a pylint plug-in your use case does seem to imply you know what kind of node you're dealing with before calling infer.

This was just an example. We have dozens of use cases where a partial value is preferable to Uninferable, and the node type could be anything (Name, Attribute).

Re node factory, I'm not looking for pre-approval of a PR, rather I'm not interested in investing time providing one if philosophical concerns make it a no-go. I guess the above means 'possibly ok' ?

@DanielNoord
Copy link
Collaborator

Could you provide more? I'm still leaning towards infer() should be correct or Uninferable but I'm definitely open to suggestions. I just find it hard to wrap my head around how we could introduce this behaviour change without breaking expectations everywhere.

I'm wondering if try_to_infer() would be a nice extension of the Protocol.

"Possibly ok" is correct in the sense that we never reject proposals by default. I must warn though that maintainers time on astroid is very limited so grand refactors of the codebase might be hard to pull off.

@ericvergnaud
Copy link
Contributor Author

A node factory would transfer the responsibility of partial inference behavior to astroid users (via a dedicated class deriving from JoinedStr) rather than have it with the astroid team. From there the builtin JoinedStr.infer() would indeed be either correct or Uninferable, but astroid users would be able to override the behavior at no cost for astroid and astroid team.
I suspect the refactoring would be small in essence (impacting mostly the parser) but may span minor changes across many files.

@DanielNoord
Copy link
Collaborator

I'm still not convinced the effort is worth the result, especially considering the alternatives seems much nicer to me:

  1. Make this part of the package you are working on to get full control
  2. Add a 'partial infer' method that has the behaviour you're looking for

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Enhancement ✨ Improvement to a component
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Inference of JoinedStr is inconsistent
4 participants