Skip to content
Draft
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
198 changes: 198 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/shadowing/function.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,204 @@ def f(): ...
f = 1 # error: [invalid-assignment]
```

## Compatible function reassignment

A function declaration constrains later assignments by its callable signature and descriptor
behavior, not by the identity of the original function.

```py
def original(value: int) -> int:
return value

def replacement(value: int) -> int:
return value

original = replacement
```

## Reassigned functions expose their current identity across modules

A function that replaces another function remains a descriptor. Imports must see the replacement
function rather than preserving the original function's identity.

`implementation.py`:

```py
def original(instance: object, value: int) -> int:
return value

def replacement(instance: object, value: int) -> int:
return value

before = original
original = replacement
```

`main.py`:

```py
from implementation import before, original, replacement

reveal_type(original is before) # revealed: Literal[False]
reveal_type(original is replacement) # revealed: Literal[True]

class Container:
method = original

reveal_type(Container().method(1)) # revealed: int
```

## Conditionally reassigned functions preserve both public identities

When reassignment depends on a condition, an imported function can still refer to either compatible
function. Its identity must not be narrowed to either possibility.

`implementation.py`:

```py
def condition() -> bool:
return True

def original(value: int) -> int:
return value

def replacement(value: int) -> int:
return value

before = original

if condition():
original = replacement
```

`main.py`:

```py
from implementation import before, original, replacement

reveal_type(original is before) # revealed: bool
reveal_type(original is replacement) # revealed: bool
```

## Imported compatible function reassignment

An imported function with the same signature can replace an existing function declaration.

`implementation.py`:

```py
def imported(value: int) -> int:
return value
```

`main.py`:

```py
def imported(value: int) -> int:
return value

from implementation import imported
```

## Callable objects do not preserve function descriptor behavior

A class object or callable instance cannot replace a function, even when its call signature matches.
Unlike ordinary functions, these objects do not bind an instance when assigned to a class attribute.

```py
class Factory:
def __init__(self, value: int) -> None: ...

class CallableObject:
def __call__(self, value: int) -> object:
return value

def factory(value: int) -> object:
return value

factory = Factory # error: [invalid-assignment]

def callback(value: int) -> object:
return value

callback = CallableObject() # error: [invalid-assignment]
```

## Type-checking-only function declarations describe callable signatures

A function defined only under `TYPE_CHECKING` does not exist at runtime, so its declaration does not
promise function descriptor behavior. A compatible callable object can provide its implementation,
but importing that object must not incorrectly turn it into a binding descriptor.

`implementation.py`:

```py
from typing import TYPE_CHECKING

class CallableObject:
def __call__(self, instance: object, value: int) -> int:
return value

if TYPE_CHECKING:
def callback(instance: object, value: int) -> int: ...

callback = CallableObject()
```

`main.py`:

```py
from implementation import callback

class Container:
method = callback

Container().method(1) # error: [missing-argument]
```

## Incompatible function reassignment

A replacement function must accept the original function's arguments and return a compatible type.

```py
def original(value: int) -> int:
return value

def different_type(value: str) -> str:
return value

original = different_type # error: [invalid-assignment]
```

Parameter names remain significant when callers may pass them by keyword.

```py
def accepts_value(value: int) -> int:
return value

def accepts_number(number: int) -> int:
return number

accepts_value = accepts_number # error: [invalid-assignment]
```

## Class methods preserve descriptor behavior

A callable instance does not bind an instance argument when accessed as a class attribute, so it
cannot replace a function-like method even when their call signatures otherwise match.

```py
class CallableObject:
def __call__(this, self: object, value: int) -> int:
return value

class Container:
def method(self, value: int) -> int:
return value

method = CallableObject() # error: [invalid-assignment]
```

## Explicit shadowing

```py
Expand Down
30 changes: 29 additions & 1 deletion crates/ty_python_semantic/src/place.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1063,7 +1063,35 @@ pub(crate) fn place_by_id<'db>(
.with_qualifiers(qualifiers),
}
}
// Place is declared, trust the declared type
// A function declaration identifies its original function object, while a compatible
// reassignment can replace that object. Once a different binding is visible, expose the
// current binding so imports and other public lookups do not retain its stale identity.
PlaceAndQualifiers {
place:
Place::Defined(
declared @ DefinedPlace {
ty: Type::FunctionLiteral(_),
definedness: Definedness::AlwaysDefined,
provenance: Provenance::SingleDefinition(declaration),
..
},
),
qualifiers,
} if place_id.as_symbol().is_some()
&& all_considered_bindings().any(|binding| {
matches!(binding.binding, DefinitionState::Defined(binding) if binding != declaration)
}) =>
{
let bindings = all_considered_bindings();
match place_from_bindings_impl(db, bindings, requires_explicit_reexport, None).place {
Place::Defined(inferred) => {
Place::Defined(inferred.with_origin(TypeOrigin::Declared))
.with_qualifiers(qualifiers)
}
Place::Undefined => Place::Defined(declared).with_qualifiers(qualifiers),
}
}
// Place is declared, trust the declared type.
place_and_quals @ PlaceAndQualifiers {
place:
Place::Defined(DefinedPlace {
Expand Down
29 changes: 27 additions & 2 deletions crates/ty_python_semantic/src/types/infer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1448,8 +1448,28 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
.or_else(|| resolved_place.ignore_possibly_undefined());

let assignment_ty = match (place, declared_ty) {
(PlaceExprRef::Symbol(_), Some(Type::FunctionLiteral(function))) => {
let callable = function.into_callable_type(db);
let callable = if function.file(db) == self.file()
&& self.is_in_type_checking_block(
self.scope(),
function.node(db, self.file(), self.module()),
) {
// A type-checking-only definition describes a callable signature, but never
// creates a function object whose descriptor behavior must be preserved.
callable.into_regular(db)
} else {
callable
};
Type::Callable(callable)
}
_ => declared_ty.unwrap_or(Type::unknown()),
};

AddBinding {
declared_ty,
assignment_ty,
binding,
node,
qualifiers,
Expand Down Expand Up @@ -4155,8 +4175,10 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}

let node = target.into();
let declared_ty = self.fallback_member_declared_type(node);
let add = AddBinding {
declared_ty: self.fallback_member_declared_type(node),
declared_ty,
assignment_ty: declared_ty.unwrap_or(Type::unknown()),
binding: definition,
node,
qualifiers: TypeQualifiers::empty(),
Expand Down Expand Up @@ -11975,7 +11997,10 @@ impl<V> IntoIterator for VecSet<V> {

#[must_use]
struct AddBinding<'db, 'ast> {
/// The declared value type, retained for inference context and diagnostics.
declared_ty: Option<Type<'db>>,
/// The contract a new binding must satisfy; function names use their callable signature.
assignment_ty: Type<'db>,
binding: Definition<'db>,
node: AnyNodeRef<'ast>,
qualifiers: TypeQualifiers,
Expand Down Expand Up @@ -12053,7 +12078,7 @@ impl<'db, 'ast> AddBinding<'db, 'ast> {
}
}

if !bound_ty.is_assignable_to(db, declared_ty) {
if !bound_ty.is_assignable_to(db, self.assignment_ty) {
builder.discard_dict_key_assignments_for(self.binding);
report_invalid_assignment(
&builder.context,
Expand Down
Loading