Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 97 additions & 67 deletions crates/ty/docs/rules.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion crates/ty_ide/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ impl<'db> Completion<'db> {
Type::NominalInstance(_)
| Type::PropertyInstance(_)
| Type::BoundSuper(_)
| Type::TypedDict(_) => CompletionKind::Struct,
| Type::TypedDict(_)
| Type::NewTypeInstance(_) => CompletionKind::Struct,
Type::IntLiteral(_)
| Type::BooleanLiteral(_)
| Type::TypeIs(_)
Expand Down
15 changes: 5 additions & 10 deletions crates/ty_ide/src/goto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,16 +209,11 @@ impl<'db> DefinitionsOrTargets<'db> {
ty_python_semantic::types::TypeDefinition::Module(module) => {
ResolvedDefinition::Module(module.file(db)?)
}
ty_python_semantic::types::TypeDefinition::Class(definition) => {
ResolvedDefinition::Definition(definition)
}
ty_python_semantic::types::TypeDefinition::Function(definition) => {
ResolvedDefinition::Definition(definition)
}
ty_python_semantic::types::TypeDefinition::TypeVar(definition) => {
ResolvedDefinition::Definition(definition)
}
ty_python_semantic::types::TypeDefinition::TypeAlias(definition) => {
ty_python_semantic::types::TypeDefinition::Class(definition)
| ty_python_semantic::types::TypeDefinition::Function(definition)
| ty_python_semantic::types::TypeDefinition::TypeVar(definition)
| ty_python_semantic::types::TypeDefinition::TypeAlias(definition)
| ty_python_semantic::types::TypeDefinition::NewType(definition) => {
ResolvedDefinition::Definition(definition)
}
};
Expand Down
250 changes: 246 additions & 4 deletions crates/ty_python_semantic/resources/mdtest/annotations/new_types.md
Copy link
Member

Choose a reason for hiding this comment

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

One pathological thing I wondered while reviewing this PR was "What happens if you have a newtype of an enum?" I think our behaviour makes more sense than mypy/pyright here, so it might be worth adding a test for it. (I think it's correct not to narrow the type of x in the first match case to Literal[Foo.X], for example, because Literal[Foo.X] is actually disjoint from N:

from enum import Enum
from typing import NewType, reveal_type

class Foo(Enum):
    X = 0
    Y = 1

N = NewType("N", Foo)

def f(x: N):
    match x:
        case Foo.X:
            reveal_type(x)  # revealed: N
        case Foo.Y:
            reveal_type(x)  # revealed: N
        case _:
            reveal_type(x)  # revealed: N

Copy link
Member

Choose a reason for hiding this comment

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

I think we're also still missing a test that demonstrates our NewType behaviour on Python 3.9, too (#21157 (comment))

It's fine to leave support for 3.9 NewType out of this PR, I think (and possibly never add it, since Python 3.9 is end-of-life). But I'd love a test that just demonstrates what our behaviour is when you use typing.NewType on Python 3.9

Copy link
Member

Choose a reason for hiding this comment

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

One thing the typing conformance suite points out is that we need to emit an error if you try to create a generic NewType -- e.g. we should emit an error on this snippet, but that's not currently implemented in this PR:

from typing import NewType, TypeVar

T = TypeVar("T")
BadNewType2 = NewType("BadNewType2", list[T])

For this as well, I think it's fine to leave it out of this PR, but it would be great to add a test with a TODO comment saying that we should try to emit a diagnostic on this in the future

Copy link
Member

Choose a reason for hiding this comment

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

it would also be nice to add a test somewhere that demonstrates that we infer member access on the pseudo-class itself correctly, e.g.

from typing import NewType

N = NewType("N", int)
reveal_type(N.__supertype__)  # revealed: type | NewType

Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# NewType

Currently, ty doesn't support `typing.NewType` in type annotations.

## Valid forms

```py
Expand All @@ -12,13 +10,257 @@ X = GenericAlias(type, ())
A = NewType("A", int)
# TODO: typeshed for `typing.GenericAlias` uses `type` for the first argument. `NewType` should be special-cased
# to be compatible with `type`
# error: [invalid-argument-type] "Argument to function `__new__` is incorrect: Expected `type`, found `NewType`"
# error: [invalid-argument-type] "Argument to function `__new__` is incorrect: Expected `type`, found `<NewType pseudo-class 'A'>`"
B = GenericAlias(A, ())

def _(
a: A,
b: B,
):
reveal_type(a) # revealed: @Todo(Support for `typing.NewType` instances in type expressions)
reveal_type(a) # revealed: A
reveal_type(b) # revealed: @Todo(Support for `typing.GenericAlias` instances in type expressions)
```

## Subtyping

The basic purpose of `NewType` is that it acts like a subtype of its base, but not the exact same
type (i.e. not an alias).

```py
from typing_extensions import NewType

Foo = NewType("Foo", int)
Bar = NewType("Bar", Foo)
Comment on lines +30 to +33
Copy link
Member

Choose a reason for hiding this comment

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

we could add some more assertions here that directly check the subtype relationship:

Suggested change
from typing_extensions import NewType
Foo = NewType("Foo", int)
Bar = NewType("Bar", Foo)
from typing_extensions import NewType
from ty_extensions import static_assert, is_subtype_of, is_equivalent_to
Foo = NewType("Foo", int)
Bar = NewType("Bar", Foo)
static_assert(is_subtype_of(Foo, int))
static_assert(not is_equivalent_to(Foo, int))
static_assert(is_subtype_of(Bar, Foo))
static_assert(is_subtype_of(Bar, int))
static_assert(not is_equivalent_to(Bar, Foo))


Foo(42)
Foo(Foo(42)) # allowed: `Foo` is a subtype of `int`.
Foo(Bar(Foo(42))) # allowed: `Bar` is a subtype of `int`.
Foo(True) # allowed: `bool` is a subtype of `int`.
Foo("forty-two") # error: [invalid-argument-type] "Argument is incorrect: Expected `int`, found `Literal["forty-two"]`"

def f(_: int): ...
def g(_: Foo): ...
def h(_: Bar): ...

f(42)
f(Foo(42))
f(Bar(Foo(42)))

g(42) # error: [invalid-argument-type] "Argument to function `g` is incorrect: Expected `Foo`, found `Literal[42]`"
g(Foo(42))
g(Bar(Foo(42)))

h(42) # error: [invalid-argument-type] "Argument to function `h` is incorrect: Expected `Bar`, found `Literal[42]`"
h(Foo(42)) # error: [invalid-argument-type] "Argument to function `h` is incorrect: Expected `Bar`, found `Foo`"
h(Bar(Foo(42)))
```

## Member and method lookup work

```py
from typing_extensions import NewType

class Foo:
foo_member: str = "hello"
def foo_method(self) -> int:
return 42

Bar = NewType("Bar", Foo)
Baz = NewType("Baz", Bar)
baz = Baz(Bar(Foo()))
reveal_type(baz.foo_member) # revealed: str
reveal_type(baz.foo_method()) # revealed: int
```

## `NewType` wrapper functions are `Callable`

```py
from collections.abc import Callable
from typing_extensions import NewType

Foo = NewType("Foo", int)
Comment on lines +78 to +81
Copy link
Member

Choose a reason for hiding this comment

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

again here, it could be worth adding a direct assertion for the property we're trying to test (the callable supertype of the NewType pseudo-class) as well as the existing assertions you have for the impact this has in user code:

Suggested change
from collections.abc import Callable
from typing_extensions import NewType
Foo = NewType("Foo", int)
from collections.abc import Callable
from typing_extensions import NewType
from ty_extensions import CallableTypeOf
Foo = NewType("Foo", int)
def _(obj: CallableTypeOf[Foo]):
reveal_type(obj) # revealed: (int) -> Foo


def f(_: Callable[[int], Foo]): ...

f(Foo)
map(Foo, [1, 2, 3])

def g(_: Callable[[str], Foo]): ...

g(Foo) # error: [invalid-argument-type]
```

## `NewType` instances are `Callable` if the base type is

```py
from typing import NewType, Callable, Any
from ty_extensions import CallableTypeOf

N = NewType("N", int)
i = N(42)

y: Callable[..., Any] = i # error: [invalid-assignment] "Object of type `N` is not assignable to `(...) -> Any`"

# error: [invalid-type-form] "Expected the first argument to `ty_extensions.CallableTypeOf` to be a callable object, but got an object of type `N`"
def f(x: CallableTypeOf[i]):
reveal_type(x) # revealed: Unknown
```

## The name must be a string literal

```py
from typing_extensions import NewType

def _(name: str) -> None:
_ = NewType(name, int) # error: [invalid-newtype] "The first argument to `NewType` must be a string literal"
```

However, the literal doesn't necessarily need to be inline, as long as we infer it:

```py
name = "Foo"
Foo = NewType(name, int) # allowed
Comment on lines +133 to +134
Copy link
Member

Choose a reason for hiding this comment

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

maybe demonstrate here that we infer the name just fine?

Suggested change
name = "Foo"
Foo = NewType(name, int) # allowed
name = "Foo"
Foo = NewType(name, int)
reveal_type(Foo) # revealed: <NewType pseudo-class 'Foo'>

```

## The second argument must be a class type or another newtype

Other typing constructs like `Union` are not allowed.

```py
from typing_extensions import NewType

# error: [invalid-newtype] "invalid base for `typing.NewType`"
Foo = NewType("Foo", int | str)
```

We don't emit the "invalid base" diagnostic for `Unknown`, because that typically results from other
errors that already have a diagnostic, and there's no need to pile on. For example:

```py
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
Foo = NewType("Foo", 42)
```
Comment on lines +151 to +154
Copy link
Member

Choose a reason for hiding this comment

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

I had to read this twice because the prose describes how we aren't going to emit a diagnostic but then the example shows us emitting a diagnostic 😆

Suggested change
```py
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
Foo = NewType("Foo", 42)
```
```py
# Here we only emit one `invalid-type-form` diagnostic,
# rather than one `invalid-type-form` diagnostic and another `invalid-base` diagnostic:
#
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
Foo = NewType("Foo", 42)
```


## Newtypes can be cyclic in various ways

Cyclic newtypes are kind of silly, but it's possible for the user to express them, and it's
important that we don't go into infinite recursive loops and crash with a stack overflow. In fact,
this is *why* base type evaluation is deferred; otherwise Salsa itself would crash.

```py
from typing_extensions import NewType, reveal_type, cast

# Define a directly cyclic newtype.
A = NewType("A", "A")
reveal_type(A) # revealed: <NewType pseudo-class 'A'>

# Typechecking still works. We can't construct an `A` "honestly", but we can `cast` into one.
a: A
a = 42 # error: [invalid-assignment] "Object of type `Literal[42]` is not assignable to `A`"
a = A(42) # error: [invalid-argument-type] "Argument is incorrect: Expected `A`, found `Literal[42]`"
a = cast(A, 42)
reveal_type(a) # revealed: A

# A newtype cycle might involve more than one step.
B = NewType("B", "C")
C = NewType("C", "B")
reveal_type(B) # revealed: <NewType pseudo-class 'B'>
reveal_type(C) # revealed: <NewType pseudo-class 'C'>
b: B = cast(B, 42)
c: C = C(b)
reveal_type(b) # revealed: B
reveal_type(c) # revealed: C
# Cyclic types behave in surprising ways. These assignments are legal, even though B and C aren't
# the same type, because each of them is a subtype of the other.
b = c
c = b

# Another newtype could inherit from a cyclic one.
D = NewType("D", C)
reveal_type(D) # revealed: <NewType pseudo-class 'D'>
d: D
d = D(42) # error: [invalid-argument-type] "Argument is incorrect: Expected `C`, found `Literal[42]`"
d = D(c)
d = D(b) # Allowed, the same surprise as above. B and C are subtypes of each other.
reveal_type(d) # revealed: D
```

Normal classes can't inherit from newtypes, but generic classes can be parametrized with them, so we
also need to detect "ordinary" type cycles that happen to involve a newtype.

```py
E = NewType("E", list["E"])
reveal_type(E) # revealed: <NewType pseudo-class 'E'>
e: E = E([])
reveal_type(e) # revealed: E
Comment on lines +200 to +207
Copy link
Member

Choose a reason for hiding this comment

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

We could add some more stress tests here to show that we really do understand the recursive nature of the newtype (which is really cool!)

Suggested change
Normal classes can't inherit from newtypes, but generic classes can be parametrized with them, so we
also need to detect "ordinary" type cycles that happen to involve a newtype.
```py
E = NewType("E", list["E"])
reveal_type(E) # revealed: <NewType pseudo-class 'E'>
e: E = E([])
reveal_type(e) # revealed: E
Normal classes can't inherit from newtypes, but generic classes can be parametrized with them, so we
also need to detect "ordinary" type cycles that happen to involve a newtype.
```py
E = NewType("E", list["E"])
reveal_type(E) # revealed: <NewType pseudo-class 'E'>
e: E = E([])
reveal_type(e) # revealed: E
reveal_type(E(E(E(E(E([])))))) # revealed: E
reveal_type(E([E([E([]), E([E([])])]), E([])])) # revealed: E
E(["foo"]) # error: [invalid-argument-type]
E(E(E(["foo"]))) # error: [invalid-argument-type]

```

## `NewType` wrapping preserves singleton-ness and single-valued-ness

```py
from typing_extensions import NewType
from ty_extensions import is_singleton, is_single_valued, static_assert
from types import EllipsisType

A = NewType("A", EllipsisType)
static_assert(is_singleton(A))
static_assert(is_single_valued(A))
Comment on lines +217 to +219
Copy link
Member

Choose a reason for hiding this comment

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

and these also reveal the correct result, which is pretty cool!

Suggested change
A = NewType("A", EllipsisType)
static_assert(is_singleton(A))
static_assert(is_single_valued(A))
A = NewType("A", EllipsisType)
static_assert(is_singleton(A))
static_assert(is_single_valued(A))
reveal_type(type(A(...)) is EllipsisType) # revealed: Literal[True]
reveal_type(A(...) is ...) # revealed: Literal[True]


B = NewType("B", int)
static_assert(not is_singleton(B))
static_assert(not is_single_valued(B))
```

## `NewType` tuples can be iterated/unpacked
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
## `NewType` tuples can be iterated/unpacked
## `NewType`s of tuples can be iterated/unpacked


```py
from typing import NewType

N = NewType("N", tuple[int, str])

a, b = N((1, "foo"))

reveal_type(a) # revealed: int
reveal_type(b) # revealed: str
```

## `isinstance` of a `NewType` instance and its base class is inferred `True`
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
## `isinstance` of a `NewType` instance and its base class is inferred `True`
## `isinstance` of a `NewType` instance and its base class is inferred as `Literal[True]`


```py
from typing import NewType

N = NewType("N", int)

def f(x: N):
reveal_type(isinstance(x, int)) # revealed: Literal[True]
```

However, a `NewType` isn't a real class, so it isn't a valid second argument to `isinstance`:

```py
def f(x: N):
# error: [invalid-argument-type] "Argument to function `isinstance` is incorrect"
reveal_type(isinstance(x, N)) # revealed: bool
```

Because of that, we don't generate any narrowing constraints for it:

```py
def f(x: N | str):
if isinstance(x, N): # error: [invalid-argument-type]
reveal_type(x) # revealed: N | str
else:
reveal_type(x) # revealed: N | str
```

## Trying to subclass a `NewType` produces an error matching CPython

<!-- snapshot-diagnostics -->

```py
from typing import NewType

X = NewType("X", int)

class Foo(X): ... # error: [invalid-base]
```
6 changes: 5 additions & 1 deletion crates/ty_python_semantic/resources/mdtest/class/super.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ synthesized `Protocol`s that cannot be upcast to, or interpreted as, a non-`obje

```py
import types
from typing_extensions import Callable, TypeIs, Literal, TypedDict
from typing_extensions import Callable, TypeIs, Literal, NewType, TypedDict

def f(): ...

Expand All @@ -81,6 +81,8 @@ class SomeTypedDict(TypedDict):
x: int
y: bytes

N = NewType("N", int)

# revealed: <super: <class 'object'>, FunctionType>
reveal_type(super(object, f))
# revealed: <super: <class 'object'>, WrapperDescriptorType>
Expand All @@ -95,6 +97,8 @@ reveal_type(super(object, Alias))
reveal_type(super(object, Foo().method))
# revealed: <super: <class 'object'>, property>
reveal_type(super(object, Foo.some_property))
# revealed: <super: <class 'object'>, int>
reveal_type(super(object, N(42)))

def g(x: object) -> TypeIs[list[object]]:
return isinstance(x, list)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
source: crates/ty_test/src/lib.rs
expression: snapshot
---
---
mdtest name: new_types.md - NewType - Trying to subclass a `NewType` produces an error matching CPython
mdtest path: crates/ty_python_semantic/resources/mdtest/annotations/new_types.md
---

# Python source files

## mdtest_snippet.py

```
1 | from typing import NewType
2 |
3 | X = NewType("X", int)
4 |
5 | class Foo(X): ... # error: [invalid-base]
```

# Diagnostics

```
error[invalid-base]: Cannot subclass an instance of NewType
--> src/mdtest_snippet.py:5:11
|
3 | X = NewType("X", int)
4 |
5 | class Foo(X): ... # error: [invalid-base]
| ^
|
info: Perhaps you were looking for: `Foo = NewType('Foo', X)`
info: Definition of class `Foo` will raise `TypeError` at runtime
info: rule `invalid-base` is enabled by default

```
Loading
Loading