Skip to content

Comments

[red-knot] Support unpacking with target#16469

Merged
carljm merged 18 commits intoastral-sh:mainfrom
ericmarkmartin:unpack-with-stmt
Mar 8, 2025
Merged

[red-knot] Support unpacking with target#16469
carljm merged 18 commits intoastral-sh:mainfrom
ericmarkmartin:unpack-with-stmt

Conversation

@ericmarkmartin
Copy link
Contributor

@ericmarkmartin ericmarkmartin commented Mar 3, 2025

Summary

Resolves #16365

Add support for unpacking with statement targets.

Test Plan

Added some test cases, alike the ones added by #15058.

@ericmarkmartin ericmarkmartin force-pushed the unpack-with-stmt branch 2 times, most recently from f28ac19 to 7dd4ff3 Compare March 3, 2025 06:29
@AlexWaygood AlexWaygood added the ty Multi-file analysis & type inference label Mar 3, 2025
@ericmarkmartin ericmarkmartin marked this pull request as ready for review March 5, 2025 03:59
Comment on lines 1275 to 1313
let current_assignment = match optional_vars {
ast::Expr::Tuple(_) | ast::Expr::List(_) => {
Some(CurrentAssignment::WithItem {
item,
first: true,
is_async: *is_async,
unpack: Some(Unpack::new(
self.db,
self.file,
self.current_scope(),
#[allow(unsafe_code)]
unsafe {
AstNodeRef::new(self.module.clone(), optional_vars)
},
UnpackValue::ContextManager(context_manager),
countme::Count::default(),
)),
})
}
ast::Expr::Name(_) => Some(CurrentAssignment::WithItem {
item,
is_async: *is_async,
unpack: None,
// `false` is arbitrary here---we don't actually use it other than in the actual unpacks
first: false,
}),
ast::Expr::Attribute(ast::ExprAttribute {
value: object,
attr,
..
}) => {
self.register_attribute_assignment(
object,
attr,
AttributeAssignment::ContextManager { context_manager },
);
None
}
Copy link
Member

Choose a reason for hiding this comment

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

Nit: This code seems to be the same as for for targets with the exception of UnpackValue::ContextManager, AttributeAssignment::ContextManager. Would it be possible to reuse some more code by having a shared method that takes an UnpackKind argument that then constructs the UnpackValue, the AttributeAssignment

Copy link
Contributor

Choose a reason for hiding this comment

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

Assign has quite similar code as well, they should probably all three share a helper.

At the very least we should extract repetition around creation of the Unpack, things like the unsafe node-ref creation and the countme::Count::default() feel like details/boilerplate that don't belong at this level of abstraction and shouldn't be repeated for each node kind.

That said, I'd also be open to landing this PR with some duplication if we get all the behavior correct, and then doing the refactor as a separate PR for ease of review.

Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

Looks great, very impressive first PR!

I didn't get a chance to explore the (lack of) duplicated diagnostics that we discussed on Discord, will try to look at that later.

Comment on lines 1275 to 1313
let current_assignment = match optional_vars {
ast::Expr::Tuple(_) | ast::Expr::List(_) => {
Some(CurrentAssignment::WithItem {
item,
first: true,
is_async: *is_async,
unpack: Some(Unpack::new(
self.db,
self.file,
self.current_scope(),
#[allow(unsafe_code)]
unsafe {
AstNodeRef::new(self.module.clone(), optional_vars)
},
UnpackValue::ContextManager(context_manager),
countme::Count::default(),
)),
})
}
ast::Expr::Name(_) => Some(CurrentAssignment::WithItem {
item,
is_async: *is_async,
unpack: None,
// `false` is arbitrary here---we don't actually use it other than in the actual unpacks
first: false,
}),
ast::Expr::Attribute(ast::ExprAttribute {
value: object,
attr,
..
}) => {
self.register_attribute_assignment(
object,
attr,
AttributeAssignment::ContextManager { context_manager },
);
None
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Assign has quite similar code as well, they should probably all three share a helper.

At the very least we should extract repetition around creation of the Unpack, things like the unsafe node-ref creation and the countme::Count::default() feel like details/boilerplate that don't belong at this level of abstraction and shouldn't be repeated for each node kind.

That said, I'd also be open to landing this PR with some duplication if we get all the behavior correct, and then doing the refactor as a separate PR for ease of review.

Comment on lines +1298 to +1300
// `false` is arbitrary here---we don't actually use it other than in the actual unpacks
first: false,
Copy link
Contributor

Choose a reason for hiding this comment

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

Small preference for going with arbitrary true over arbitrary false here, since it is technically still the first (of one).

But this change would also apply to the same code in For and Assign, so should be made in whichever PR we refactor for better code reuse.

@carljm
Copy link
Contributor

carljm commented Mar 6, 2025

Ok, I looked into the diagnostics situation, and it is going to need some attention here. A bit of background will be needed to understand the issue.

We have type inference queries at several granularities: expression (but only some "standalone" expressions, so we don't create too many Salsa ingredients), definition (that is, a value assigned to some name), and scope. We use the definition granularity queries to do lazy type inference of names from scopes we aren't actually checking (e.g. imported libraries). When we are actually checking a file, we run the scope-level inference queries for every scope in the file, and it is those queries where we aggregate and emit diagnostics.

So the intent when we run scope-level inference is that as we walk the AST, anywhere we hit a node that is a definition of a name, we use self.infer_definition to find that Definition by key, query its types, and then merge those types and diagnostics into the scope-level TypeInference. What this achieves is that we never double-infer the same types (even if we first lazily query the type of a name from a scope, and then later do full scope type inference on that scope), because we always use the (Salsa-cached) infer_definition_types query whenever we hit a definition.

What is currently happening in this PR is that when we find an unpacked with statement in scope-level inference, we don't eagerly run infer_definition_types for the definitions of the unpack-target names at all. This is not good, because it means we are implicitly depending on a later load of one of those names to actually run infer_definition_types, go through the Unpacker, and catch any unpack errors.

We should add a test like this to demonstrate this:

class ContextManager:
    def __enter__(self) -> tuple[int, str]:
        return (1, "a")

    def __exit__(self, exc_type, exc_value, traceback) -> None:
        pass

with ContextManager() as (a, b, c):
    pass

This should emit an error about trying to unpack two elements into three, but currently in this PR it does not.

The fix is that this PR needs to update TypeInferenceBuilder::infer_with_statement, and currently doesn't; there's even a relevant TODO statement there. In the case where the target is not a simple Name, we are currently just inferring the type of the context expression (just once! which is why you currently can't make duplicate diagnostics happen, regardless of first) and then we visit the optional-vars, and nothing in that visit will ever look up any of the definitions or run self.infer_definition() on them.

The right fix for this involves fixing astral-sh/ty#185, since this duplicate-definition thing is already broken for for statements, as we discovered yesterday. But that fix is not going to be simple, and I don't think we should try to fix it in this PR.

Instead, I think the goal for this PR should be to emulate the current handling of for statements, and be just as broken as for in this regard, and then the fix for astral-sh/ty#185 will fix them both in the same way. That will mean, in this PR, updating TypeInferenceBuilder::infer_with_statement to look a lot more like TypeInferenceBuilder::infer_for_statement, and use TypeInferenceBuilder::infer_target.

Hopefully that all made sense! If not, feel free to ask questions, here or in Discord. Or if it doesn't make sense and you don't have time to wrap your head around it and update the PR, that's totally fine too! Just let me know and one of us can pick it up.

Comment on lines 1983 to 1988
WithItem {
item: &'a ast::WithItem,
first: bool,
is_async: bool,
unpack: Option<Unpack<'a>>,
},
Copy link
Member

Choose a reason for hiding this comment

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

Just for visibility as stated in Discord and it doesn't need to be done in this PR but we could possibly combine first and unpack into a Option<(UnpackPosition, Unpack<'a>)> where the position could be either First or Other. I'm not sure what the fallout of this change would be but that would at least solve the arbitrary boolean problem when the target is a name node.

Comment on lines 3078 to 3083
fn format_call_dunder_errors(
error_a: &CallDunderError<'db>,
name_a: &str,
error_b: &CallDunderError<'db>,
name_b: &str,
) -> String {
Copy link
Member

Choose a reason for hiding this comment

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

nit: I think I'd prefer to make this a closure inside report_diagnostic as I think that's the only place where it's being utilized.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think that's true right now, but we could probably use it elsewhere, right?

@ericmarkmartin ericmarkmartin force-pushed the unpack-with-stmt branch 2 times, most recently from 7e5d772 to 77578aa Compare March 7, 2025 23:43
Copy link
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

Looks great, thank you! Just one failing test with a trivial fix, so I'll push that and then merge.

@carljm carljm enabled auto-merge (squash) March 8, 2025 02:33
@carljm carljm merged commit 24c8b12 into astral-sh:main Mar 8, 2025
20 checks passed
@dhruvmanila
Copy link
Member

@ericmarkmartin Awesome work! This is a really great first contribution. Thank you for taking this on and also surfacing an important issue related to the unpacking diagnostics.

@ericmarkmartin ericmarkmartin deleted the unpack-with-stmt branch March 8, 2025 14:58
dcreager added a commit that referenced this pull request Mar 8, 2025
* main:
  [red-knot] Understand `typing.Callable` (#16493)
  [red-knot] Support unpacking `with` target (#16469)
  [red-knot] Attribute access and the descriptor protocol (#16416)
  [`pep8-naming`] Add links to `ignore-names` options in various rules' documentation (#16557)
  [red-knot] avoid inferring types if unpacking fails (#16530)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[red-knot] Add support for unpacking targets in with statement

5 participants