Conversation
| Some(KnownFunction::RevealType) => { | ||
| let revealed_ty = binding.one_parameter_type().unwrap_or(Type::unknown()); |
There was a problem hiding this comment.
I moved this to infer_call_expression because those checks are only relevant when we perform an actual call (It would be surprising if an implicit call, e.g. from a += emits a revealed diagnostic.
| let mut bindings = Vec::with_capacity(elements.len()); | ||
| let mut not_callable = Vec::new(); | ||
|
|
||
| for element in elements { |
There was a problem hiding this comment.
I don't feel too confident about the unrolling of the inner call result here. I'd appreciate a careful review (it now moved into CallOutcome::try_call so that I can reuse it between call and call_bound
dfcf52e to
5e6583f
Compare
| let call = callable_ty.call(db, arguments)?; | ||
| Err(CallDunderError::PossiblyUnbound(call)) |
There was a problem hiding this comment.
This is a small change but a not callable type error now takes precedence over a possibly unbound error.
| // TODO we should also check for binding errors that would indicate the metaclass | ||
| // does not accept the right arguments |
There was a problem hiding this comment.
This should be easy now ;)
5e6583f to
4893afb
Compare
AlexWaygood
left a comment
There was a problem hiding this comment.
A review of the tests. Haven't looked at the code yet ;)
crates/red_knot_python_semantic/resources/mdtest/assignment/augmented.md
Show resolved
Hide resolved
| match call { | ||
| Ok(outcome) => { | ||
| for binding in outcome.bindings() { | ||
| let Some(known_function) = binding |
There was a problem hiding this comment.
@carljm you might be disappointed by this but I don't think the diagnostics should be part of CallOutcome because they are only relevant if we perform an actual function call. Implicit calls want to use their own custom diagnostics, so it doesn't make sense to expose this call expression specific behavior on the generic CallOutcome.
There was a problem hiding this comment.
they are only relevant if we perform an actual function call. Implicit calls want to use their own custom diagnostics
I'm just not convinced that this is universally true. I still think we will probably need some form of nested or chained diagnostics to provide adequate detailed debugging information to the user in a case where something like (for example) an "object is not callable" error is raised as part of an implicit call, e.g. because some non-callable object has been used as the value of some dunder. So I do expect that we will need the equivalent of some of the below diagnostics to be rendered as part of some errors on implicit calls. But it's possible that this could be done just by extracting the necessary diagnostics into standalone functions in diagnostics.rs and calling them from multiple callsites.
There was a problem hiding this comment.
That's possible. Although it comes with its own challenges because we don't have a call node. So what do you pass as the node that the highlighting works as expected? Either way. My follow up PRs introduce a pattern where the Err type has methods to emit the corresponding diagnostics and get the return type.
|
One case that I'm not entirely sure yet how to handle is how to propagate the error for an incorrect I see two options:
Edit: This is actually unrelated to |
4893afb to
91717f2
Compare
This comment was marked as outdated.
This comment was marked as outdated.
91717f2 to
cd12bc3
Compare
crates/red_knot_python_semantic/resources/mdtest/comparison/intersections.md
Outdated
Show resolved
Hide resolved
crates/red_knot_python_semantic/resources/mdtest/comparison/non_bool_returns.md
Outdated
Show resolved
Hide resolved
crates/red_knot_python_semantic/resources/mdtest/binary/instances.md
Outdated
Show resolved
Hide resolved
|
I'm pretty happy with the test improvements :) |
cd12bc3 to
e178148
Compare
carljm
left a comment
There was a problem hiding this comment.
I've only reviewed the tests so far, not the implementation. Submitting this just in case I don't make it through the implementation before I'm done for the day. It's great to see all those operator TODOs removed!
crates/red_knot_python_semantic/resources/mdtest/binary/instances.md
Outdated
Show resolved
Hide resolved
crates/red_knot_python_semantic/resources/mdtest/binary/integers.md
Outdated
Show resolved
Hide resolved
| else: | ||
| f = f2 | ||
|
|
||
| # error: [call-non-callable] "Object of type `Literal[f1, f2]` is not callable (due to union element `Literal[f2]`)" |
There was a problem hiding this comment.
Similar to another case below, this diagnostic message is not right.
"Not callable" means something very specific in Python: the object does not implement the tp_call slot, or the call protocol, or however you want to name it. Both f1 and f2, and the union f1 | f2, are definitely callable, and we should not say otherwise in our diagnostic or error code (so we should not use the call-non-callable rule code).
The problem here is that we failed to bind the call arguments to one of the union elements, which is very different from one of them not being callable. The diagnostic here should ideally tell us what the argument binding error was, and for which union element (i.e. that Literal[3] is not assignable to str). (This is one of the cases where I think two separate "chained" diagnostics might make sense instead of trying to squeeze it all into one message.)
If we want to delay providing this helpful diagnostic as a later improvement, then I think the message we use for now still should not use the words "not callable", it should say something more vague like "unable to call with the given arguments." (I think this vague message would still deserve a TODO comment to make sure we come back and make it useful, but at least it wouldn't be wrong.)
There was a problem hiding this comment.
I'd prefer improving this for now because I get the impression that we may also want to rename the lint rule itself to something more generic? But I could see how this itself requires some discussion.
crates/red_knot_python_semantic/resources/mdtest/comparison/intersections.md
Outdated
Show resolved
Hide resolved
crates/red_knot_python_semantic/resources/mdtest/comparison/non_bool_returns.md
Outdated
Show resolved
Hide resolved
e178148 to
fa290e8
Compare
AlexWaygood
left a comment
There was a problem hiding this comment.
This looks excellent overall. There's definitely still a few outstanding issues but I'm overall leaning towards adding TODOs for the issues and landing this, then iterating. It's a big PR that is liable to accumulate merge conflicts, and it definitely seems like a big improvement over what we have now
| CustomError(&'db str), | ||
| } | ||
|
|
||
| /// A successfully bound call where all arguments are valid. |
There was a problem hiding this comment.
Is this doc-comment accurate? Both variants of the CallOutcome enum appear to wrap one or more CallBinding instances, and it looks like CallBinding has an errors field:
ruff/crates/red_knot_python_semantic/src/types/call/bind.rs
Lines 149 to 150 in b5cd4f2
So it looks like a CallOutcome instance doesn't necessarily represent a call where all arguments are valid? I think it represents a call of an object that is definitely callable, but the arguments to the call were not necessarily of the correct type?
There was a problem hiding this comment.
I decided not to split CallOutcome into two versions where one is statically known to not have any errors and one that has. Instead, guaranteeing that the Ok variant never contains errors is left to Type::call. I haven't explored how complicated it would be to split CallBinding but it is something we could look into as a follow up (e.g. bind_call could return a Result too? Although we still want to get a binding even if there are errors)
There was a problem hiding this comment.
I'm not objecting to the code organisation. It seems like a reasonable split to me. I just think the docs need to be updated, because right now the doc comment seems to imply that the code is doing something slightly different to what it's actually doing.
There was a problem hiding this comment.
I'm not sure what you mean. We only return CallOutcome if there are no binding errors (all arguments are valid).
There was a problem hiding this comment.
As discussed async. @AlexWaygood and I will follow up on this after landing this PR (if it still is an issue)
carljm
left a comment
There was a problem hiding this comment.
This is great, I definitely think it's a major improvement over the status quo, thank you for digging into this area!
I agree with Alex that I don't see substantive issues here that can't be TODOed and iterated on later. The main thing I would like to address before landing (mostly because I don't think it's hard to address) is some naming stuff, particularly being consistent about what "not callable" means, both in diagnostics and in code.
| match call { | ||
| Ok(outcome) => { | ||
| for binding in outcome.bindings() { | ||
| let Some(known_function) = binding |
There was a problem hiding this comment.
they are only relevant if we perform an actual function call. Implicit calls want to use their own custom diagnostics
I'm just not convinced that this is universally true. I still think we will probably need some form of nested or chained diagnostics to provide adequate detailed debugging information to the user in a case where something like (for example) an "object is not callable" error is raised as part of an implicit call, e.g. because some non-callable object has been used as the value of some dunder. So I do expect that we will need the equivalent of some of the below diagnostics to be rendered as part of some errors on implicit calls. But it's possible that this could be done just by extracting the necessary diagnostics into standalone functions in diagnostics.rs and calling them from multiple callsites.
…e`, `AssertType` and `StaticAssert`
fa290e8 to
d2c93e3
Compare
|
Ha, less code, more tests and higher accuracy! |
| .map_err(|err| match err { | ||
| CallDunderError::Call(CallError::NotCallable { .. }) => { | ||
| // Turn "`<type of illegal '__call__'>` not callable" into | ||
| // "`X` not callable" | ||
| CallError::NotCallable { | ||
| not_callable_ty: self, | ||
| } | ||
| } |
There was a problem hiding this comment.
This patching of the not_callable_ty here seems hacky but we should address this separately
|
Thank you both for the excellent review. I'm sorry for deferring so many diagnostic improvements but I see my priority as solving an architectural problem and I should then get back to working on the CLI. I added the requested todos and pushed some improvements to the There's one behavior change that I want to call out. We now only report the first error if a union variant isn't callable or has binding errors, similar to what TypeScript does (It's no longer "X isn't callable (because of element reason)" but is now "element isn't callable"). This improved some errors but I think we can do better. I added a todo in code. |
* main: (60 commits) [`refurb`] Manual timezone monkeypatching (`FURB162`) (#16113) [`pyupgrade`] Do not upgrade functional TypedDicts with private field names to the class-based syntax (`UP013`) (#16219) Improve docs for PYI019 (#16229) Refactor `CallOutcome` to `Result` (#16161) Fix minor punctuation errors (#16228) Include document specific debug info (#16215) Update server to return the debug info as string (#16214) [`airflow`] Group `ImportPathMoved` and `ProviderName` to avoid misusing (`AIR303`) (#16157) Fix unstable formatting of trailing end-of-line comments of parenthesized attribute values (#16187) Ignore source code actions for a notebook cell (#16154) Add FAQ entry for `source.*` code actions in Notebook (#16212) red-knot: move symbol lookups in `symbol.rs` (#16152) better error messages while loading configuration `extend`s (#15658) Format `index.css` (#16207) Improve API exposed on `ExprStringLiteral` nodes (#16192) Update Rust crate tempfile to v3.17.0 (#16202) Update cloudflare/wrangler-action action to v3.14.0 (#16203) Update NPM Development dependencies (#16199) Update Rust crate smallvec to v1.14.0 (#16201) Update Rust crate codspeed-criterion-compat to v2.8.0 (#16200) ...
|
|
||
| a = NonCallable() | ||
| # error: "Object of type `Unknown | Literal[1]` is not callable (due to union element `Literal[1]`)" | ||
| # error: [call-non-callable] "Object of type `Literal[1]` is not callable" |
There was a problem hiding this comment.
This seems to me like clearly a regression, not an improvement; it's not clear to me why this change was made. Are there other error messages where it resulted in an improvement?
In any case, we can certainly follow up on this later; I can create an issue for it.
There was a problem hiding this comment.
Ah, ok, I suspect it was made to improve cases where we need more detailed errors from "inside" the union (like argument-specific call binding errors). That makes sense, but I continue to feel like in the end we won't be able to get away with choosing just one or the other here (either the union-level info or the per-union-element details), we will ultimately need to be able to chain details.
There was a problem hiding this comment.
I think it is more useful in an editor context where you can just hover the type. But I agree it's not as useful in a CLI context. However, handling union errors is complicated because they're kind of recursive and you probably also don't want to list all errors (because that's overwhelming). This needs some design work.
There was a problem hiding this comment.
It's probably worth tagging @BurntSushi because this might be a use case where we want sub-diagnostics (although we still need a way to truncate to avoid going too deep).
@BurntSushi the diagnostic use case we have here is that we want to warn if a user tries to call a type that can't be called with the given arguments. The part where our current system (and what I did in this PR) falls short is if the called type is a union where only some of the variants can't be called (because it isn't a function or the arguments don't match its signature).
I did look at how different type checker handle this case when I started working on this refactor:
Pyright
from ast import Call
from typing import reveal_type
class NotCallable:
pass
class Callable:
def __call__(self):
pass
def coinflip() -> NotCallable | Callable:
return random.choice([NotCallable(), Callable()])
a = coinflip()
reveal_type(a)
a()It only reports an error for the not callable variant (similar to what I did in this PR). It does have some form of sub diagnostic.
Object of type "NotCallable" is not callable
Attribute "__call__" is unknownPylancereportCallIssue
Typescript
class NonCallable {
}
class AlsoNotCallable {
}
let x: NonCallable | AlsoNotCallable | (<T>(a: T) => T) | undefined = a => a;
x("test") This expression is not callable.
Not all constituents of type 'NonCallable | AlsoNotCallable | (<T>(a: T) => T)' are callable.
Type 'NonCallable' has no call signatures.
Similar to Pyright where it uses sub-diagnostics.
But the quality of errors depends on the use case. E.g. there's no special union handling for iterable:
class MyIterable implements Iterable<number> {
[Symbol.iterator](): Iterator<number> {
return [1, 2, 3, 4]
}
}
class NotIterable {
}
let a:MyIterable | NotIterable= new MyIterable();
for (let i of a) {
console.log(i);
}Type 'MyIterable | NotIterable' must have a '[Symbol.iterator]()' method that returns an iterator.ts(2488)
It doesn't tell you that NotIterable is the problematic element.
Flow
Same examples as for TypeScript
12: x("test")
^ Cannot call `x` because undefined [1] is not a function. [not-a-function]
References:
10: let x: NonCallable| (<T>(a: T) => T) | undefined = undefined;
^ [1]
12: x("test")
^ Cannot call `x` because a call signature declaring the expected parameter / return type is missing in `NonCallable` [1]. [prop-missing]
References:
10: let x: NonCallable| (<T>(a: T) => T) | undefined = undefined;
^ [1]
15: for (let i of a) {
^ property `@@iterator` is missing in `NotIterable` [1] but exists in `$Iterable` [2]. [prop-missing]
References:
13: let a:MyIterable | NotIterable= new MyIterable();
^ [1]
[LIB] ..//[email protected]/flowlib/core.js:1865: interface $Iterable<+Yield,+Return,-Next>{ ^ [2]
There was a problem hiding this comment.
I filed #16241 just so we don't lose track of this discussion (and in particular this excellent comment which I linked to from the issue)
carljm
left a comment
There was a problem hiding this comment.
The updates look great, thank you! Reads much clearer to me now. And really appreciate all the TODOs for things we want to try to further improve.
| asserted_ty, | ||
| if errors.is_empty() { | ||
| Ok(CallOutcome::Union(bindings.into())) | ||
| } else if bindings.is_empty() && not_callable { |
There was a problem hiding this comment.
Unless we would have an empty union (which is not possible), I don't see how we could ever have empty bindings and not_callable not also be true? In other words, why do we need the not_callable boolean? If I remove it (and leave this as just else if bindings.is_empty()), all tests pass.
There was a problem hiding this comment.
Line 47 is important.
You could have a case where one variant is NotCallable and the other variant has a BindingError. We don't want to return NotCallable in this case because we would loose information (and it's not true that the type isn't callable, it's not always callable)
There was a problem hiding this comment.
But I can remove the not_callable boolean, and every reference to it (including line 47), and all tests still pass. So if it's important, either we aren't currently handling it correctly or we are missing some needed test.
In the case you describe, bindings will not be empty, so we won't enter this clause to return NotCallable anyway; we'll short-circuit this test and we won't even check the value of the not_callable boolean.
There was a problem hiding this comment.
I don't think bindings will be empty because we only push to bindings if the call succeeded (without any binding errors). That we miss some tests is not unlikely
There was a problem hiding this comment.
Oh, I see! I missed that we don't push bindings with errors to bindings. Makes sense, I agree then it just looks like we don't have a test for the case where one union variant has a binding error and the other one is not callable (or we do have a test for that case but its assertions fail to distinguish?)
There was a problem hiding this comment.
I tried writing a test here and realized that the problem with this flag is that it was initialized to true and only updated with |=, meaning it could never become false. Follow-up PR in #16387
Minor follow-up to #16161 This `not_callable` flag wasn't functional, because it could never be `false`. It was initialized to `true` and then only ever updated with `|=`, which can never make it `false`. Add a test that exercises the case where it _should_ be `false` (all of the union elements are callable) but `bindings` is also empty (all union elements have binding errors). Before this PR, the added test wrongly emits a diagnostic that the union `Literal[f1] | Literal[f2]` is not callable. And add a test where a union call results in one binding error and one not-callable error, where we currently give the wrong result (we show only the binding error), with a TODO. Also add TODO comments in a couple other tests where ideally we'd report more than just one error out of a union call. Also update the flag name to `all_errors_not_callable` to more clearly indicate the semantics of the flag.
Summary
This PR refactors
Type::callto better support the three main use cases:a(1, 2): We want to warn about incorrect arguments.a += 1,with a:,if a(__bool__),len(a): We want to test if the operation is supported or use a specific error message if the operation is not supported because the type doesn’t implement the expected Protocol or convention (“protocol”) but we don’t want to emit errors about missing or incorrect arguments__bool__) but may also be used to test if a type implements a specific “protocol” or Protocol. We don’t want to emit any errors.The existing implementation already supported checking explicit calls well. However, it did emit diagnostics for implicit calls when the arguments didn't match the expected signature.
Red Knot emitted a rather confusing error about incorrect arguments before:
My main finding is that
Type::callshould consider the call as failed if there are any errors, including binding errors. This is important for implicit call checking as demonstrated by the error above or when probing if aClassimplements a specific dunder method.Type::callshould return anErrif the call fails for any reason and leave the proper handling to the caller. However, it should retain enough information for the call site to create a useful diagnostic. There's a tension here because including very accurate information is expensive to collect and the work is wasted if the caller doesn't care about it (because it only wants to know if the call succeeded). I also think that collecting the appropriate information probably requires knowing the context in which the method was called. I don't have a concrete example but we may want to implement custom diagnostics for when an operator failed and I suspect that collecting the necessary information will be its own side-adventure (@BurntSushi told me that this is at least the case for Rustc).The main work of this PR is to change
Type::call,Type::dunder_callandType::bound_callto now return aResultand dealing with the fallout from downstream usages. I also had to implement a few workarounds where the new behavior is too accurate. E.g. I had to explicitly ignore binding errors in a few cases where we also did so before. We should tackle those TODOs in separate PRs (my goal was to preserve existing behavior in most places).Returning a
Resultis now often less ergonomic on the call site than the oldoutcome.return_type. This is intentional because usingreturn_typeis often wrong. I want to make the decision about what should happen if the call fails explicit: Do we have to emit a diagnostic? Can we ignore certain errors? What's the best recovery logic for this specific inference? That's why it's now necessary to match the variants and callOk(outcome) => outcome.return_type(db),Err(err) => err.unwrap_return_type(db), orErr(err) => err.return_type(db)to get the return type best suited for this call site.What's next
We should refactor our other
Outcometypes to useResult. We should explore whether we can move some methods that take acontextand node intoinfer.rs. We should also consider whetherbool,len, andmember(and evento_instance) should return aResult,.