Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
365 changes: 365 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/assignment/augmented.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,371 @@ def f(flag: bool, flag2: bool):
reveal_type(f) # revealed: float | str
```

## Declared attributes with in-place operators

`+=` assigns the value returned by `__iadd__` back to its target. That value must be compatible with
the attribute's declared type.

```py
class Value:
def __iadd__(self, other: int) -> str:
return "updated"

class Holder:
value: Value

holder = Holder()
# error: [invalid-assignment]
holder.value += 1
reveal_type(holder.value) # revealed: Value
```

## Declared attributes without in-place operators

When an object does not define `__iadd__`, `+=` falls back to `__add__`. Its result must still be
compatible with the attribute's declared type.

```py
class Value:
def __add__(self, other: int) -> str:
return "updated"

class Holder:
value: Value

holder = Holder()
# error: [invalid-assignment]
holder.value += 1
```

## Inferred attributes in loops

An unannotated instance attribute may change type. After its initial `None` value is replaced, an
augmented assignment inside a loop must also contribute its result to the inferred attribute type.

```py
class Counter:
def update(self) -> None:
self.value = None
self.value = 0
for _ in range(1):
self.value += 1.0

reveal_type(Counter().value) # revealed: None | float
```

## Inferred class attributes

An unannotated class attribute still has an inferred type that restricts assignments through an
instance.

```py
class Holder:
value = 1

holder = Holder()
# error: [invalid-assignment]
holder.value += 0.5
```

## Read-only properties

`+=` writes its result back to the attribute. A property without a setter therefore cannot be the
target of an augmented assignment.

```py
class ReadOnly:
@property
def value(self) -> int:
return 1

read_only = ReadOnly()
# error: [invalid-assignment]
read_only.value += 1
```

## Properties with different getter and setter types

A property can accept a wider type in its setter than it returns from its getter. The result of `/=`
is checked against the setter, while subsequent reads still use the getter's return type.

```py
class Counter:
@property
def value(self) -> int:
return 1

@value.setter
def value(self, value: float) -> None:
pass

counter = Counter()
counter.value /= 2
reveal_type(counter.value) # revealed: int
```

## Attributes defined by descriptors

When an unannotated class attribute is a data descriptor, its `__set__` method determines which
values may be assigned.

```py
class Descriptor:
def __get__(self, instance: object, owner: type[object] | None = None) -> int:
return 1

def __set__(self, instance: object, value: str) -> None:
pass

class Holder:
value = Descriptor()

holder = Holder()
# error: [invalid-assignment]
holder.value += 1
```

## Custom subscript assignments

`/=` first reads an item, then writes the result back through `__setitem__`. The assigned value is
the result of the operation, not the right-hand operand.

```py
class Container:
def __getitem__(self, key: int) -> int:
return 1

def __setitem__(self, key: int, value: int) -> None:
pass

container = Container()
# error: [invalid-assignment]
container[0] /= 2
reveal_type(container[0]) # revealed: int
```

## Subscript setters with different value types

A collection can accept a wider type in `__setitem__` than `__getitem__` returns. After a valid
assignment, subsequent reads still use the return type of `__getitem__`.

```py
class Container:
def __getitem__(self, key: int) -> int:
return 1

def __setitem__(self, key: int, value: float) -> None:
pass

container = Container()
container[0] /= 2
reveal_type(container[0]) # revealed: int
```

## Annotated collection entries

An annotation fixes the element type of a list, so `/=` cannot write a `float` into a `list[int]`.

```py
values: list[int] = [1]
# error: [invalid-assignment]
values[0] /= 2
```

The same rule applies to the value type of an annotated dictionary.

```py
mapping: dict[str, int] = {"value": 1}
# error: [invalid-assignment]
mapping["value"] /= 2
```

An annotated collection remains constrained when it is accessed through an attribute.

```py
class Holder:
values: list[int]

holder = Holder()
# error: [invalid-assignment]
holder.values[0] /= 2
```

## Typed dictionary entries

A `TypedDict` field can only be assigned a value compatible with its declared type.

```py
from typing import TypedDict

class Payload(TypedDict):
value: int

payload: Payload = {"value": 1}
# error: [invalid-assignment]
payload["value"] /= 2
```

## Read-only subscripts

A readable item cannot be reassigned when its container does not implement `__setitem__`.

```py
values: tuple[int] = (1,)
# error: [invalid-assignment]
values[0] += 1
```

## Missing attributes

If an augmented assignment cannot read its target, it must report that failure only once; no
assignment is attempted.

```py
class Missing: ...

missing = Missing()
# error: [unresolved-attribute]
missing.value += 1
```

The same applies when an attribute is missing from one member of a union.

```py
class Counter:
count: int

def update(counter: Counter | None) -> None:
# error: [unresolved-attribute]
counter.count += 1
```

## Invalid subscript reads

An invalid key prevents an item from being read, so the failed assignment must not produce a second
error.

```py
mapping: dict[str, int] = {}
# error: [invalid-argument-type]
mapping[1] += 1
```

A value without `__getitem__` also fails before assignment can be attempted.

```py
value = 1
# error: [not-subscriptable]
value[0] += 1
```

## Right-hand-side errors after failed reads

Even when an attribute cannot be read, the right-hand side must still be checked for unrelated
errors.

```py
class Missing: ...

missing = Missing()
# error: [unresolved-attribute]
# error: [unresolved-reference]
missing.value += missing_attribute_operand
```

The same rule applies when a subscript cannot be read.

```py
mapping: dict[str, int] = {}
# error: [invalid-argument-type]
# error: [unresolved-reference]
mapping[1] += missing_subscript_operand
```

## Failed in-place operations

If `__iadd__` rejects its operand, its return type must not be treated as a value to assign.

```py
class Value:
def __iadd__(self, other: int) -> str:
return "updated"

class Holder:
value: Value

holder = Holder()
# error: [unsupported-operator]
holder.value += "invalid"
```

## Union attribute assignments

When objects in a union have different attribute types, each operator result should be checked
against the attribute from the same object. Ordinary assignments already lose this relationship, so
augmented assignments currently report the same false positive.

```py
class AValue:
def __iadd__(self, other: int) -> "AValue":
return self

class BValue:
def __iadd__(self, other: int) -> "BValue":
return self

class A:
value: AValue

class B:
value: BValue

def update(value: A | B) -> None:
# TODO: Check each result against the attribute it came from.
# error: [invalid-assignment]
value.value += 1
```

## Collections that may be read-only

When a collection could be a writable list or a read-only tuple, an item assignment is invalid
because it cannot be performed on every possible value.

```py
def update(value: list[int] | tuple[int, ...]) -> None:
# error: [invalid-assignment]
value[0] += 1
```

## Typed dictionary assignments with multiple possible keys

A key that can select fields with different value types must only be assigned a value accepted by
every possible field.

```py
from typing import Literal, TypedDict

class Payload(TypedDict):
whole: int
fractional: float

def update(value: Payload, key: Literal["whole", "fractional"]) -> None:
# error: [invalid-assignment]
value[key] /= 2
```

## Inferred collection entries

Augmented assignments are not yet included when inferring the element type of an unannotated
collection.

```py
values = [1]
# TODO: Infer `list[float]` instead of rejecting the assignment.
# error: [invalid-assignment]
values[0] /= 2
```

## Implicit dunder calls on class objects

```py
Expand Down
8 changes: 5 additions & 3 deletions crates/ty_python_semantic/resources/mdtest/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,9 @@ reveal_type(c_instance.b) # revealed: int

#### Augmented assignments

An augmented assignment contributes its result to the inferred type of an unannotated instance
attribute.

```py
class Weird:
def __iadd__(self, other: None) -> str:
Expand All @@ -269,9 +272,8 @@ class C:
self.w = Weird()
self.w += None

# TODO: Mypy and pyright do not support this, but it would be great if we could
# infer `str` here (`Weird` is not a possible type for the `w` attribute).
reveal_type(C().w) # revealed: Weird
# TODO: Infer only `str`, since the initial `Weird` value has been overwritten.
reveal_type(C().w) # revealed: Weird | str
```

#### Nested augmented assignments after narrowing
Expand Down
Loading