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

Refactor CallOutcome to Result #16161

Merged
merged 7 commits into from
Feb 18, 2025
Merged

Refactor CallOutcome to Result #16161

merged 7 commits into from
Feb 18, 2025

Conversation

MichaReiser
Copy link
Member

@MichaReiser MichaReiser commented Feb 14, 2025

Summary

This PR refactors Type::call to better support the three main use cases:

  • To check an explicit call, e.g. a(1, 2): We want to warn about incorrect arguments.
  • To check an implicit call, e.g. 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
  • To query the “best known” return type. This is, indirectly, used when evaluating visibility constraints (__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.

class Manager:
    def __enter__() -> str:
        return "foo"

    def __exit__(self, exc_type, exc_value, traceback): ...

context_expr = Manager()

with context_expr as f:
    reveal_type(f) 

Red Knot emitted a rather confusing error about incorrect arguments before:

crates/red_knot_python_semantic/resources/mdtest/with/sync.md:55 unexpected error: [too-many-positional-arguments] "Too many positional arguments to function `__enter__`: expected 0, got 1"

My main finding is that Type::call should 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 a Class implements a specific dunder method.

Type::call should return an Err if 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_call and Type::bound_call to now return a Result and 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 Result is now often less ergonomic on the call site than the old outcome.return_type. This is intentional because using return_type is 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 call Ok(outcome) => outcome.return_type(db), Err(err) => err.unwrap_return_type(db), or Err(err) => err.return_type(db) to get the return type best suited for this call site.

What's next

We should refactor our other Outcome types to use Result. We should explore whether we can move some methods that take a context and node into infer.rs. We should also consider whether bool, len, and member (and even to_instance) should return a Result,.

@MichaReiser MichaReiser added the red-knot Multi-file analysis & type inference label Feb 14, 2025
Comment on lines -2060 to -1566
Some(KnownFunction::RevealType) => {
let revealed_ty = binding.one_parameter_type().unwrap_or(Type::unknown());
Copy link
Member Author

Choose a reason for hiding this comment

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

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 {
Copy link
Member Author

@MichaReiser MichaReiser Feb 14, 2025

Choose a reason for hiding this comment

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

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

@MichaReiser MichaReiser force-pushed the micha/refactor-call-outcome branch 3 times, most recently from dfcf52e to 5e6583f Compare February 14, 2025 13:33
Comment on lines +2375 to +1773
let call = callable_ty.call(db, arguments)?;
Err(CallDunderError::PossiblyUnbound(call))
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a small change but a not callable type error now takes precedence over a possibly unbound error.

Comment on lines 4259 to 3716
// TODO we should also check for binding errors that would indicate the metaclass
// does not accept the right arguments
Copy link
Member Author

Choose a reason for hiding this comment

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

This should be easy now ;)

@MichaReiser MichaReiser force-pushed the micha/refactor-call-outcome branch from 5e6583f to 4893afb Compare February 14, 2025 14:39
Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

A review of the tests. Haven't looked at the code yet ;)

match call {
Ok(outcome) => {
for binding in outcome.bindings() {
let Some(known_function) = binding
Copy link
Member Author

Choose a reason for hiding this comment

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

@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.

Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

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.

@MichaReiser
Copy link
Member Author

MichaReiser commented Feb 14, 2025

One case that I'm not entirely sure yet how to handle is how to propagate the error for an incorrect bool(argument) call. It's the only case where we use bool today where we need to propagate an error. I expect len to be similar

https://github.com/astral-sh/ruff/blob/6e4cd33f529999d736874cd296e123c83ace3051/crates/red_knot_python_semantic/src/types.rs#L2148-L2154

I see two options:

  • This is sort of a binding check. We could check the argument inside of Type::call and push a binding error if bool(argument) isn't a valid operation. The nice thing about that is that it integrates nicely into the rest of call checking.
  • We move the checking out from Type::call (we only keep the narrowing of the return type) into infer_expression_call where we duplicate some of the logic.

Edit: This is actually unrelated to bool. It seems we don't type check those constructors at all. So maybe this will automatically fall out from binding the calls?

@MichaReiser MichaReiser force-pushed the micha/refactor-call-outcome branch from 4893afb to 91717f2 Compare February 14, 2025 20:57
@MichaReiser

This comment was marked as outdated.

@MichaReiser MichaReiser force-pushed the micha/refactor-call-outcome branch from 91717f2 to cd12bc3 Compare February 14, 2025 21:31
@MichaReiser
Copy link
Member Author

I'm pretty happy with the test improvements :)

@MichaReiser MichaReiser force-pushed the micha/refactor-call-outcome branch from cd12bc3 to e178148 Compare February 14, 2025 21:37
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.

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!

else:
f = f2

# error: [call-non-callable] "Object of type `Literal[f1, f2]` is not callable (due to union element `Literal[f2]`)"
Copy link
Contributor

Choose a reason for hiding this comment

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

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.)

Copy link
Member Author

Choose a reason for hiding this comment

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

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.

@MichaReiser MichaReiser force-pushed the micha/refactor-call-outcome branch from e178148 to fa290e8 Compare February 17, 2025 14:46
Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

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

crates/red_knot_python_semantic/src/types.rs Show resolved Hide resolved
crates/red_knot_python_semantic/src/types.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/call.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types.rs Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/call.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/call.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/call.rs Outdated Show resolved Hide resolved
CustomError(&'db str),
}

/// A successfully bound call where all arguments are valid.
Copy link
Member

Choose a reason for hiding this comment

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

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:

/// Call binding errors, if any.
errors: Vec<CallBindingError<'db>>,

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?

Copy link
Member Author

Choose a reason for hiding this comment

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

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)

Copy link
Member

@AlexWaygood AlexWaygood Feb 17, 2025

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure what you mean. We only return CallOutcome if there are no binding errors (all arguments are valid).

Copy link
Member Author

Choose a reason for hiding this comment

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

As discussed async. @AlexWaygood and I will follow up on this after landing this PR (if it still is an issue)

crates/red_knot_python_semantic/src/types/infer.rs Outdated Show resolved Hide resolved
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.

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.

crates/red_knot_python_semantic/src/types/call.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/call.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/call.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/call.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/call.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types.rs Show resolved Hide resolved
match call {
Ok(outcome) => {
for binding in outcome.bindings() {
let Some(known_function) = binding
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

crates/red_knot_python_semantic/src/types/infer.rs Outdated Show resolved Hide resolved
@MichaReiser MichaReiser force-pushed the micha/refactor-call-outcome branch from fa290e8 to d2c93e3 Compare February 18, 2025 10:20
@MichaReiser
Copy link
Member Author

MichaReiser commented Feb 18, 2025

Ha, less code, more tests and higher accuracy!

Comment on lines +1676 to 1683
.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,
}
}
Copy link
Member Author

Choose a reason for hiding this comment

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

This patching of the not_callable_ty here seems hacky but we should address this separately

@MichaReiser
Copy link
Member Author

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 NotCallable that hopefully addresses all terminology concerns.

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.

@MichaReiser MichaReiser merged commit 4ed5db0 into main Feb 18, 2025
20 checks passed
@MichaReiser MichaReiser deleted the micha/refactor-call-outcome branch February 18, 2025 12:34
dcreager added a commit that referenced this pull request Feb 18, 2025
* 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)
  ...
@@ -52,7 +52,7 @@ class NonCallable:
__call__ = 1

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"
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

@MichaReiser MichaReiser Feb 18, 2025

Choose a reason for hiding this comment

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

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] ..//try-flow-website-js@0.260.0/flowlib/core.js:1865: interface $Iterable<+Yield,+Return,-Next>{                                                                      ^ [2]
    

Copy link
Contributor

Choose a reason for hiding this comment

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

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)

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.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

@MichaReiser MichaReiser Feb 18, 2025

Choose a reason for hiding this comment

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

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)

Copy link
Contributor

@carljm carljm Feb 18, 2025

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

@MichaReiser MichaReiser Feb 18, 2025

Choose a reason for hiding this comment

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

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

Copy link
Contributor

Choose a reason for hiding this comment

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

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?)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
red-knot Multi-file analysis & type inference
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants