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

[red-knot] knot_extensions Python API #15103

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

Conversation

sharkdp
Copy link
Contributor

@sharkdp sharkdp commented Dec 22, 2024

Summary

Adds a type-check-time Python API that allows us to create and manipulate types and to test various of their properties. For example, this can be used to write a Markdown test to make sure that A & B is a subtype of A and B, but not of an unrelated class C (something that requires quite a bit more code to do in Rust):

from knot_extensions import Intersection, is_subtype_of, static_assert

class A: ...
class B: ...

type AB = Intersection[A, B]

static_assert(is_subtype_of(AB, A))
static_assert(is_subtype_of(AB, B))

class C: ...
static_assert(not is_subtype_of(AB, C))

I think this functionality is also helpful for interactive debugging sessions, in order to query various properties of Red Knot's type system. Which is something that otherwise requires a custom Rust unit test, some boilerplate code and constant re-compilation.

Comparison Rust ↔ Python: is_assignable_to tests

Rust
#[test_case(Ty::BuiltinInstance("str"), Ty::BuiltinInstance("object"))]
#[test_case(Ty::BuiltinInstance("int"), Ty::BuiltinInstance("object"))]
#[test_case(Ty::Unknown, Ty::IntLiteral(1))]
#[test_case(Ty::Any, Ty::IntLiteral(1))]
#[test_case(Ty::Never, Ty::IntLiteral(1))]
#[test_case(Ty::IntLiteral(1), Ty::Unknown)]
#[test_case(Ty::IntLiteral(1), Ty::Any)]
#[test_case(Ty::IntLiteral(1), Ty::BuiltinInstance("int"))]
#[test_case(Ty::StringLiteral("foo"), Ty::BuiltinInstance("str"))]
#[test_case(Ty::StringLiteral("foo"), Ty::LiteralString)]
#[test_case(Ty::LiteralString, Ty::BuiltinInstance("str"))]
#[test_case(Ty::BytesLiteral("foo"), Ty::BuiltinInstance("bytes"))]
#[test_case(Ty::IntLiteral(1), Ty::Union(vec![Ty::BuiltinInstance("int"), Ty::BuiltinInstance("str")]))]
#[test_case(Ty::IntLiteral(1), Ty::Union(vec![Ty::Unknown, Ty::BuiltinInstance("str")]))]
#[test_case(Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]), Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]))]
#[test_case(
    Ty::Union(vec![Ty::IntLiteral(1), Ty::IntLiteral(2)]),
    Ty::BuiltinInstance("int")
)]
#[test_case(
    Ty::Union(vec![Ty::IntLiteral(1), Ty::None]),
    Ty::Union(vec![Ty::BuiltinInstance("int"), Ty::None])
)]
#[test_case(Ty::Tuple(vec![Ty::Todo]), Ty::Tuple(vec![Ty::IntLiteral(2)]))]
#[test_case(Ty::Tuple(vec![Ty::IntLiteral(2)]), Ty::Tuple(vec![Ty::Todo]))]
#[test_case(Ty::SubclassOfAny, Ty::SubclassOfAny)]
#[test_case(Ty::SubclassOfAny, Ty::SubclassOfBuiltinClass("object"))]
#[test_case(Ty::SubclassOfAny, Ty::SubclassOfBuiltinClass("str"))]
#[test_case(Ty::SubclassOfAny, Ty::BuiltinInstance("type"))]
#[test_case(Ty::SubclassOfBuiltinClass("object"), Ty::SubclassOfAny)]
#[test_case(
    Ty::SubclassOfBuiltinClass("object"),
    Ty::SubclassOfBuiltinClass("object")
)]
#[test_case(Ty::SubclassOfBuiltinClass("object"), Ty::BuiltinInstance("type"))]
#[test_case(Ty::SubclassOfBuiltinClass("str"), Ty::SubclassOfAny)]
#[test_case(
    Ty::SubclassOfBuiltinClass("str"),
    Ty::SubclassOfBuiltinClass("object")
)]
#[test_case(Ty::SubclassOfBuiltinClass("str"), Ty::SubclassOfBuiltinClass("str"))]
#[test_case(Ty::SubclassOfBuiltinClass("str"), Ty::BuiltinInstance("type"))]
#[test_case(Ty::BuiltinInstance("type"), Ty::SubclassOfAny)]
#[test_case(Ty::BuiltinInstance("type"), Ty::SubclassOfBuiltinClass("object"))]
#[test_case(Ty::BuiltinInstance("type"), Ty::BuiltinInstance("type"))]
#[test_case(Ty::BuiltinClassLiteral("str"), Ty::SubclassOfAny)]
#[test_case(Ty::SubclassOfBuiltinClass("str"), Ty::SubclassOfUnknown)]
#[test_case(Ty::SubclassOfUnknown, Ty::SubclassOfBuiltinClass("str"))]
#[test_case(Ty::SubclassOfAny, Ty::AbcInstance("ABCMeta"))]
#[test_case(Ty::SubclassOfUnknown, Ty::AbcInstance("ABCMeta"))]
fn is_assignable_to(from: Ty, to: Ty) {
    let db = setup_db();
    assert!(from.into_type(&db).is_assignable_to(&db, to.into_type(&db)));
}

#[test_case(Ty::BuiltinInstance("object"), Ty::BuiltinInstance("int"))]
#[test_case(Ty::IntLiteral(1), Ty::BuiltinInstance("str"))]
#[test_case(Ty::BuiltinInstance("int"), Ty::BuiltinInstance("str"))]
#[test_case(Ty::BuiltinInstance("int"), Ty::IntLiteral(1))]
#[test_case(
    Ty::Union(vec![Ty::IntLiteral(1), Ty::None]),
    Ty::BuiltinInstance("int")
)]
#[test_case(
    Ty::Union(vec![Ty::IntLiteral(1), Ty::None]),
    Ty::Union(vec![Ty::BuiltinInstance("str"), Ty::None])
)]
#[test_case(
    Ty::SubclassOfBuiltinClass("object"),
    Ty::SubclassOfBuiltinClass("str")
)]
#[test_case(Ty::BuiltinInstance("type"), Ty::SubclassOfBuiltinClass("str"))]
fn is_not_assignable_to(from: Ty, to: Ty) {
    let db = setup_db();
    assert!(!from.into_type(&db).is_assignable_to(&db, to.into_type(&db)));
}
Python (grouped by topic instead of assignable/not-assignable)
from knot_extensions import static_assert, is_assignable_to, Unknown, TypeOf
from typing_extensions import Never, Any, Literal, LiteralString
from abc import ABCMeta

static_assert(is_assignable_to(str, object))
static_assert(is_assignable_to(int, object))
static_assert(not is_assignable_to(object, int))
static_assert(not is_assignable_to(int, str))

static_assert(is_assignable_to(Unknown, Literal[1]))
static_assert(is_assignable_to(Any, Literal[1]))
static_assert(is_assignable_to(Never, Literal[1]))
static_assert(is_assignable_to(Literal[1], Unknown))
static_assert(is_assignable_to(Literal[1], Any))
static_assert(is_assignable_to(Literal[1], int))
static_assert(not is_assignable_to(Literal[1], str))
static_assert(not is_assignable_to(int, Literal[1]))

static_assert(is_assignable_to(Literal["foo"], str))
static_assert(is_assignable_to(Literal["foo"], LiteralString))
static_assert(is_assignable_to(LiteralString, str))
static_assert(is_assignable_to(Literal[b"foo"], bytes))

static_assert(is_assignable_to(Literal[1], int | str))
static_assert(is_assignable_to(Literal[1], Unknown | str))
static_assert(is_assignable_to(Literal[1] | Literal[2], Literal[1] | Literal[2]))
static_assert(is_assignable_to(Literal[1] | Literal[2], int))
static_assert(is_assignable_to(Literal[1] | None, int | None))
static_assert(not is_assignable_to(Literal[1] | None, int))
static_assert(not is_assignable_to(Literal[1] | None, str | None))

static_assert(is_assignable_to(type[Any], type[Any]))
static_assert(is_assignable_to(type[Any], type[object]))
static_assert(is_assignable_to(type[Any], type[str]))
static_assert(is_assignable_to(type[object], type[Any]))
static_assert(is_assignable_to(type[object], type[object]))
static_assert(is_assignable_to(type[object], type))
static_assert(is_assignable_to(type[str], type[Any]))
static_assert(is_assignable_to(type[str], type[object]))
static_assert(not is_assignable_to(type[object], type[str]))
static_assert(is_assignable_to(type[str], type[str]))
static_assert(is_assignable_to(type[str], type))
static_assert(not is_assignable_to(type, type[str]))
static_assert(is_assignable_to(type, type[Any]))
static_assert(is_assignable_to(type, type[object]))
static_assert(is_assignable_to(type, type))
static_assert(is_assignable_to(TypeOf[str], type[Any]))
static_assert(is_assignable_to(type[str], type[Unknown]))
static_assert(is_assignable_to(type[Unknown], type[str]))
static_assert(is_assignable_to(type[Any], type[Unknown]))
static_assert(is_assignable_to(type[Any], ABCMeta))
static_assert(is_assignable_to(type[Unknown], ABCMeta))

Other ideas

  • Introduce assert_true(…) to replace reveal_type(…) # revealed: Literal[True]
  • Introduce knot_extensions.Unknown as a spelling for the Unknown type.
  • Should we have a way to spell the empty intersection type? Like Intersection[()]? It's probably okay not to support this. Empty Unions or Unions with a single type are not supported either (even if they would have a well defined meaning).
  • Support typing.assert_type, and maybe alias it as knot_extensions.assert_type

To do

  • Proper error handling
  • Put the stubs for the knot_extensions module in a dedicated package
  • Think about whether or not TypeOf is needed. Maybe introduce something like knot_extensions.ClassLiteral[…] instead for that specific purpose?

Test Plan

New Markdown tests

@sharkdp sharkdp added the red-knot Multi-file analysis & type inference label Dec 22, 2024

This comment was marked as off-topic.

@@ -341,3 +341,4 @@ zipfile._path: 3.12-
zipimport: 3.0-
zlib: 3.0-
zoneinfo: 3.9-
red_knot: 3.0-
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We would obviously not put this in our vendored copy of typeshed. This was just the easiest option for this PoC.

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 tried to implement this, but it seems a bit involved. I'm opening this PR for review first, so we can discuss if we actually want this, before I invest more time.

Copy link
Member

Choose a reason for hiding this comment

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

Mypy has a solution for this for mypy_extensions that doesn't involve tinkering with typeshed's VERSIONS file. mypy_extensions exists as a runtime package published to PyPI, but the runtime package isn't py.typed. The type information for the package is published as a separate stubs package, types-mypy-extensions, and the source code for that stubs package is found in the stubs/mypy-extensions directory in typeshed.

The stubs/ directory in typeshed confusingly does not contain all typeshed's stubs. It only contains typeshed's stubs for third-party packages outside the stdlib. Unlike typeshed's standard-library stubs, the stubs for these third-party packages in typeshed are not generally vendored by mypy (though pyright does vendor them), as they're published as standalone packages to PyPI that you can (uv) pip install if you want your type checker to benefit from the type information provided by these packages. However, mypy makes an exception for the mypy_extensions package: it needs these types to always be available, and so it vendors just that package from typeshed's stubs/ directory: https://github.com/python/mypy/tree/master/mypy/typeshed.

Is this relevant for us? Eh, after typing this all out, I'm not sure 🤷 We almost certainly don't want to contribute our stubs for this package to typeshed upstream just yet, so that would mean that we'd be "injecting" these stubs into a stubs/knot_extensions package at typeshed-sync time. This would involve a bunch of special casing in both our module resolver (we'd need to understand the stubs/ directory) and our typeshed-sync GitHub workflow. So a solution where we just apply a patch to typeshed's VERSIONS file at typeshed-sync time might indeed be simplest for now...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The type information for the package is published as a separate stubs package, types-mypy-extensions, and the source code for that stubs package is found in the stubs/mypy-extensions directory in typeshed.

Oh, interesting.

Unlike typeshed's standard-library stubs, the stubs for these third-party packages in typeshed are not generally vendored by mypy (though pyright does vendor them), as they're published as standalone packages to PyPI that you can (uv) pip install if you want your type checker to benefit from the type information provided by these packages. However, mypy makes an exception for the mypy_extensions package

🙃

Is this relevant for us? Eh, after typing this all out, I'm not sure 🤷

I think so! If there is precedent for having these stubs in typeshed, that seems like a valid (future) solution to me.

We almost certainly don't want to contribute our stubs for this package to typeshed upstream just yet

Of course

This would involve a bunch of special casing in both our module resolver (we'd need to understand the stubs/ directory) and our typeshed-sync GitHub workflow.

Seems like something I could try. Should we wait for the next Red Knot meeting to decide on whether we want this changeset or not?

So a solution where we just apply a patch to typeshed's VERSIONS file at typeshed-sync time might indeed be simplest for now...

To be clear, here you are talking about yet another (simpler) solution that wouldn't put red_knot.pyi in stubs/knot_extensions, but in stdlib/, and then apply a patch to stdlib/VERSIONS, in order not having to add special casing logic to the module resolver?

Copy link
Member

Choose a reason for hiding this comment

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

To be clear, here you are talking about yet another (simpler) solution that wouldn't put red_knot.pyi in stubs/knot_extensions, but in stdlib/, and then apply a patch to stdlib/VERSIONS, in order not having to add special casing logic to the module resolver?

correct!

class _IsDisjointFrom[S, T]: ...
class _IsFullyStatic[T]: ...

def is_equivalent_to[S, T](x: S, y: T) -> _IsEquivalentTo[S, T]: ...
Copy link
Contributor Author

@sharkdp sharkdp Dec 23, 2024

Choose a reason for hiding this comment

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

These functions could potentially be annotated using https://discuss.python.org/t/pep-747-typeexpr-type-hint-for-a-type-expression/55984 (?)

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 is great! I'm impressed how simple the implementation is

crates/red_knot_python_semantic/src/types/type_api.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/type_api.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/infer.rs Outdated Show resolved Hide resolved
@sharkdp sharkdp force-pushed the david/type-level-api branch from 6cd62cf to f286bc5 Compare January 2, 2025 08:17
@sharkdp sharkdp force-pushed the david/type-level-api branch from 9795b39 to 9943e95 Compare January 2, 2025 15:26
@sharkdp sharkdp marked this pull request as ready for review January 3, 2025 09:18
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 is really nice. Considering how much more readable this makes our tests, I'm sold that this is the way to go. As such, my review here is mostly a review/commentary of your implementation rather than of the concept itself :-)

crates/red_knot_python_semantic/src/types.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/infer.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/infer.rs Outdated Show resolved Hide resolved
@@ -341,3 +341,4 @@ zipfile._path: 3.12-
zipimport: 3.0-
zlib: 3.0-
zoneinfo: 3.9-
red_knot: 3.0-
Copy link
Member

Choose a reason for hiding this comment

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

Mypy has a solution for this for mypy_extensions that doesn't involve tinkering with typeshed's VERSIONS file. mypy_extensions exists as a runtime package published to PyPI, but the runtime package isn't py.typed. The type information for the package is published as a separate stubs package, types-mypy-extensions, and the source code for that stubs package is found in the stubs/mypy-extensions directory in typeshed.

The stubs/ directory in typeshed confusingly does not contain all typeshed's stubs. It only contains typeshed's stubs for third-party packages outside the stdlib. Unlike typeshed's standard-library stubs, the stubs for these third-party packages in typeshed are not generally vendored by mypy (though pyright does vendor them), as they're published as standalone packages to PyPI that you can (uv) pip install if you want your type checker to benefit from the type information provided by these packages. However, mypy makes an exception for the mypy_extensions package: it needs these types to always be available, and so it vendors just that package from typeshed's stubs/ directory: https://github.com/python/mypy/tree/master/mypy/typeshed.

Is this relevant for us? Eh, after typing this all out, I'm not sure 🤷 We almost certainly don't want to contribute our stubs for this package to typeshed upstream just yet, so that would mean that we'd be "injecting" these stubs into a stubs/knot_extensions package at typeshed-sync time. This would involve a bunch of special casing in both our module resolver (we'd need to understand the stubs/ directory) and our typeshed-sync GitHub workflow. So a solution where we just apply a patch to typeshed's VERSIONS file at typeshed-sync time might indeed be simplest for now...

Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

More of a question than a comment: What are your thoughts on whether we should gate this behind a feature or make it testing only?

@sharkdp
Copy link
Contributor Author

sharkdp commented Jan 3, 2025

More of a question than a comment: What are your thoughts on whether we should gate this behind a feature or make it testing only?

Seeing that mypy and pyre have similar public APIs, I could imagine (part of) this becoming a public API for red knot that isn't feature-gated? The Intersection/Not constructors and static_assert are potentially interesting for users which don't care too much about compatibility with other type checkers (cf python/typing#213). Some less-interesting parts like is_singleton could also be moved to knot_extensions.internal maybe?

Do we need to decide this right away? If not, can this stay public for now, or should we approach it very carefully and hide everything (by default)?

@@ -341,3 +341,4 @@ zipfile._path: 3.12-
zipimport: 3.0-
zlib: 3.0-
zoneinfo: 3.9-
red_knot: 3.0-
Copy link
Member

Choose a reason for hiding this comment

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

To be clear, here you are talking about yet another (simpler) solution that wouldn't put red_knot.pyi in stubs/knot_extensions, but in stdlib/, and then apply a patch to stdlib/VERSIONS, in order not having to add special casing logic to the module resolver?

correct!

crates/red_knot_python_semantic/src/types.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/class_base.rs Outdated Show resolved Hide resolved
crates/red_knot_python_semantic/src/types/infer.rs Outdated Show resolved Hide resolved
@MichaReiser
Copy link
Member

Do we need to decide this right away? If not, can this stay public for now, or should we approach it very carefully and hide everything (by default)?

I'm generally leaning toward keeping the API intentionally limited and only expose features that we want to support long term (and consider part of the public API) because users will using them and it's very easy that we forget to revisit this decision before the release (unless we note it down somewhere?)

@sharkdp sharkdp changed the title [red-knot] Red Knot Type API [red-knot] knot_extensions Python API Jan 3, 2025
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.

LGTM, thank you!!

A couple more minor comments below, and we need to sort out the open question of where the knot_extensions stub will live (or it will get deleted in the next automated typeshed-sync PR). But other than that, this now looks great to me.

crates/red_knot_python_semantic/src/types/type_api.rs Outdated Show resolved Hide resolved

use std::result::Result;

#[derive(Debug)]
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
#[derive(Debug)]
// TODO: redundant once more generalized call-signature checking is implemented?
#[derive(Debug)]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that this error is also returned when passing a wrong number of arguments to special forms like Not and TypeOf, which will not be handled by call signature checking.

Copy link
Member

Choose a reason for hiding this comment

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

Oh, I missed that. Hmm, I think I'd probably prefer to emit those errors in the same match as we emit the equivalent errors for all our other special forms here:

fn infer_parameterized_known_instance_type_expression(
&mut self,
subscript: &ast::ExprSubscript,
known_instance: KnownInstanceType,
) -> Type<'db> {
let arguments_slice = &*subscript.slice;
match known_instance {
KnownInstanceType::Annotated => {
let report_invalid_arguments = || {
self.context.report_lint(
&INVALID_TYPE_FORM,
subscript.into(),
format_args!(
"Special form `{}` expected at least 2 arguments (one type and at least one metadata element)",
known_instance.repr(self.db())
),
);
};

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My initial idea (and my initial implementation) was trying to be very defensive in the sense that I tried to move the type API "out of the way", with most of the code in a separate module and only very few places where it interacted with existing type inference code. With all of your (great) suggestions, we have now moved to a version that has a tighter coupling to the existing code. This is fine for me, but it might be rather annoying to feature-gate all of this (see @MichaReiser's comment).

I can write separate argument handling logic for TypeOf/Not similar to what we have for Annotated, or try to unite that somehow, but it will make the coupling even stronger (and maybe lead to more code overall?). Does it make sense to wait with this until with have decisions on some higher-level questions (do we want to keep the currently proposed API? do we want to make it public or will all of the code be feature-gated? …)?

Comment on lines +4237 to +4242
match function.into_function_literal() {
Some(function)
if file_to_module(db, function.body_scope(db).file(db))
.is_some_and(|module| module.is_known(KnownModule::KnotExtensions)) =>
{
let function = TypeApiFunction::try_from(function.name(db).as_str()).ok()?;
Copy link
Member

Choose a reason for hiding this comment

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

Oh, when I suggested using KnownFunction, I was imagining we'd set the known field on the FunctionType at inference time, like we do for our other KnownFunctions -- something like this (which is admittedly more verbose than what you currently have, but means that the known() and is_known() methods on FunctionType will continue to work as expected for all KnownFunction variants):

diff --git a/crates/red_knot_python_semantic/src/semantic_index/definition.rs b/crates/red_knot_python_semantic/src/semantic_index/definition.rs
index fc75d252d..d8d60d048 100644
--- a/crates/red_knot_python_semantic/src/semantic_index/definition.rs
+++ b/crates/red_knot_python_semantic/src/semantic_index/definition.rs
@@ -74,6 +74,11 @@ impl<'db> Definition<'db> {
             Some(KnownModule::Typing | KnownModule::TypingExtensions)
         )
     }
+
+    pub(crate) fn is_knot_extensions_definition(self, db: &'db dyn Db) -> bool {
+        file_to_module(db, self.file(db))
+            .is_some_and(|module| module.is_known(KnownModule::KnotExtensions))
+    }
 }
 
 #[derive(Copy, Clone, Debug)]
diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs
index 64103b7a1..e42b2fa52 100644
--- a/crates/red_knot_python_semantic/src/types.rs
+++ b/crates/red_knot_python_semantic/src/types.rs
@@ -3137,6 +3137,17 @@ impl KnownFunction {
         }
     }
 
+    pub fn into_type_api_function(self) -> Option<TypeApiFunction> {
+        match self {
+            Self::TypeApiFunction(function) => Some(function),
+            Self::RevealType
+            | Self::Len
+            | Self::Final
+            | Self::NoTypeCheck
+            | Self::ConstraintFunction(_) => None,
+        }
+    }
+
     fn try_from_definition_and_name<'db>(
         db: &'db dyn Db,
         definition: Definition<'db>,
@@ -3155,6 +3166,31 @@ impl KnownFunction {
             "no_type_check" if definition.is_typing_definition(db) => {
                 Some(KnownFunction::NoTypeCheck)
             }
+            "static_assert" if definition.is_knot_extensions_definition(db) => Some(
+                KnownFunction::TypeApiFunction(TypeApiFunction::StaticAssert),
+            ),
+            "is_subtype_of" if definition.is_knot_extensions_definition(db) => {
+                Some(KnownFunction::TypeApiFunction(TypeApiFunction::IsSubtypeOf))
+            }
+            "is_disjoint_from" if definition.is_knot_extensions_definition(db) => {
+                Some(KnownFunction::TypeApiFunction(TypeApiFunction::IsDisjointFrom))
+            }
+            "is_equivalent_to" if definition.is_knot_extensions_definition(db) => Some(
+                KnownFunction::TypeApiFunction(TypeApiFunction::IsEquivalentTo),
+            ),
+            "is_assignable_to" if definition.is_knot_extensions_definition(db) => Some(
+                KnownFunction::TypeApiFunction(TypeApiFunction::IsAssignableTo),
+            ),
+            "is_fully_static" if definition.is_knot_extensions_definition(db) => Some(
+                KnownFunction::TypeApiFunction(TypeApiFunction::IsFullyStatic),
+            ),
+            "is_singleton" if definition.is_knot_extensions_definition(db) => {
+                Some(KnownFunction::TypeApiFunction(TypeApiFunction::IsSingleton))
+            }
+            "is_single_valued" if definition.is_knot_extensions_definition(db) => Some(
+                KnownFunction::TypeApiFunction(TypeApiFunction::IsSingleValued),
+            ),
+
             _ => None,
         }
     }
diff --git a/crates/red_knot_python_semantic/src/types/infer.rs b/crates/red_knot_python_semantic/src/types/infer.rs
index b92722c36..cdea434c0 100644
--- a/crates/red_knot_python_semantic/src/types/infer.rs
+++ b/crates/red_knot_python_semantic/src/types/infer.rs
@@ -69,12 +69,12 @@ use crate::types::{
     typing_extensions_symbol, Boundness, CallDunderResult, Class, ClassLiteralType, FunctionType,
     InstanceType, IntersectionBuilder, IntersectionType, IterationOutcome, KnownClass,
     KnownFunction, KnownInstanceType, MetaclassCandidate, MetaclassErrorKind, SliceLiteralType,
-    Symbol, Truthiness, TupleType, Type, TypeAliasType, TypeApiFunction, TypeArrayDisplay,
+    Symbol, Truthiness, TupleType, Type, TypeAliasType, TypeArrayDisplay,
     TypeVarBoundOrConstraints, TypeVarInstance, UnionBuilder, UnionType,
 };
 use crate::unpack::Unpack;
 use crate::util::subscript::{PyIndex, PySlice};
-use crate::{Db, KnownModule};
+use crate::Db;
 
 use super::context::{InNoTypeCheck, InferContext, WithDiagnostics};
 use super::diagnostic::{
@@ -4234,22 +4234,21 @@ impl<'db> TypeInferenceBuilder<'db> {
     ) -> Option<Type<'db>> {
         let db = self.db();
 
-        match function.into_function_literal() {
-            Some(function)
-                if file_to_module(db, function.body_scope(db).file(db))
-                    .is_some_and(|module| module.is_known(KnownModule::KnotExtensions)) =>
-            {
-                let function = TypeApiFunction::try_from(function.name(db).as_str()).ok()?;
-
+        match function
+            .into_function_literal()
+            .and_then(|function| function.known(db))
+            .and_then(KnownFunction::into_type_api_function)
+        {
+            Some(api_function) => {
                 let argument_types = arguments.args.iter().map(|arg| {
-                    if function == TypeApiFunction::StaticAssert {
+                    if api_function.is_static_assert() {
                         self.infer_expression(arg)
                     } else {
                         self.infer_type_expression(arg)
                     }
                 });
 
-                let result = type_api::resolve_predicate(db, function, argument_types);
+                let result = type_api::resolve_predicate(db, api_function, argument_types);
 
                 match result {
                     Ok(ty) => Some(ty),
diff --git a/crates/red_knot_python_semantic/src/types/type_api.rs b/crates/red_knot_python_semantic/src/types/type_api.rs
index f1d5f945e..72360d8fb 100644
--- a/crates/red_knot_python_semantic/src/types/type_api.rs
+++ b/crates/red_knot_python_semantic/src/types/type_api.rs
@@ -81,21 +81,9 @@ pub enum TypeApiFunction {
     IsSingleValued,
 }
 
-impl TryFrom<&str> for TypeApiFunction {
-    type Error = ();
-
-    fn try_from(value: &str) -> Result<Self, Self::Error> {
-        match value {
-            "static_assert" => Ok(Self::StaticAssert),
-            "is_equivalent_to" => Ok(Self::IsEquivalentTo),
-            "is_subtype_of" => Ok(Self::IsSubtypeOf),
-            "is_assignable_to" => Ok(Self::IsAssignableTo),
-            "is_disjoint_from" => Ok(Self::IsDisjointFrom),
-            "is_fully_static" => Ok(Self::IsFullyStatic),
-            "is_singleton" => Ok(Self::IsSingleton),
-            "is_single_valued" => Ok(Self::IsSingleValued),
-            _ => Err(()),
-        }
+impl TypeApiFunction {
+    pub const fn is_static_assert(self) -> bool {
+        matches!(self, TypeApiFunction::StaticAssert)
     }
 }

But this would make the problem you pointed out elsewhere even worse -- we'd be integrating the handling of these functions into red-knot in a way such that they'd be harder to feature-gate.

I'm not sure feature-gating is a realistic possibility though... if we want to stop external users from making use of these APIs for now (which I agree is a reasonable idea), I think we might want to do that using a runtime check somehow rather than a compile-time check. While it would be possible to try to squirrel away all the logic in a separate module, I think in the long run it'll be really confusing to have the logic for some special forms in a different place to the logic for all our other special forms. (And same for type-predicate functions -- I don't really want the logic for is_subtype_of to live in a completely different submodule to the logic for assert_type, since they're so similar conceptually!)

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.

3 participants