202 Commits
Author SHA1 Message Date
Micha Reiser 207ec3affe [ty] Add diagnostic baseline support (#27636) 2026-10-26 13:34:01 -07:00
Brent Westbrook 8334c5c57d [ty] Add support for SARIF output (#29635)
Summary
--

This PR fixes astral-sh/ty#2484 by moving our SARIF rendering from
`ruff_linter` into `ruff_db` and
wiring that up as an output format for ty.

The first commit introduces the metadata and makes some other
preparatory changes to the SARIF
emitter in Ruff. The second commit then moves the emitter to `ruff_db`
and removes the remaining
Ruff-specific calls. The third commit then wires that up to ty's CLI.

Test Plan
--

Existing tests, adapted to `ruff_db`, as well as a new ty CLI test
2026-10-23 14:57:03 -04:00
David Peter 068e06a3c9 [ty] Add return-type-inference configuration (#29323)
## Summary

Introduce a new configuration option `analysis.return-type-inference`
which can be used to control the return type inference feature, which
we'll introduce in #29253. I will not merge this PR without the one on
top, but it seemed like a good idea to split it out for easy review.
2026-10-15 21:13:58 +02:00
Carl Meyer b24be84212 [ty] Add opt-in diagnostic for mismatched sentinel names (#27206)
Add an opt-in `mismatched-sentinel-name` diagnostic for
`builtins.sentinel` and `typing_extensions.Sentinel` definitions whose
name argument does not match their assignment target. The rule is
ignored by default because matching names are optional; enable it with
`--warn mismatched-sentinel-name` or the corresponding rule
configuration.

Accept unqualified names and names qualified by the full enclosing class
path, including nested classes. Constructor aliases and explicit `repr`
arguments are supported.

Fixes astral-sh/ty#4091.

## Test plan

Manually checked a sentinel with a 4,097-character literal name used as
a type annotation. Default settings accepted the annotation without
diagnostics; enabling `mismatched-sentinel-name` reported only the
naming mismatch.
2026-10-13 11:32:08 -07:00
Charlie Marsh dd619474e0 [ty] Enable invalid-mutable-override by default (#29335)
## Summary

Enable `invalid-mutable-override` as an error by default. We now reject
overrides that narrow a writable attribute's type without requiring
users to opt in:

```python
class Base:
    value: int

class Child(Base):
    value: bool  # invalid-mutable-override
```

Code accepting a `Base` can assign any `int` to `value`, violating the
subclass's `bool` annotation. The rule remains independently
configurable, so users can disable it while migrating.

Addresses [the default-severity discussion in
#28556](https://github.com/astral-sh/ruff/pull/28556#discussion_r4261238726).
2026-10-13 13:22:02 -04:00
David Peter e03746cbf4 [ty] Prepare mdtests for return type inference (#29295)
## Summary

This PR prepares our mdtests for return type inference. For example, we
had some definitions like
```py
def flag(): ...
```
which were supposed to be used in `if flag()` tests of ambiguous
truthiness. However, with return type inference, ty would understand
that this function returns `None`, which is falsy.

I'm pulling this out so as to reduce the diff on the upcoming return
type inference PR.

## Test Plan

Tested by running the return type inference implementation against these
changes.
2026-10-12 15:15:57 +02:00
Charlie Marsh 002095b37d [ty] Check attribute and property override types (#28556)
## Summary

We now check that attribute and property overrides preserve the types
that can be read and written through the superclass. The checks extend
`invalid-attribute-override` and introduce two separately configurable
rules, following the distinction proposed in
[ty#2158](https://github.com/astral-sh/ty/issues/2158).

### `invalid-attribute-override`

We reject ordinary attributes whose readable type is incompatible with
the inherited declaration. This rule is enabled by default:

```python
class Base:
    value: int

class Child(Base):
    value: str  # Now rejected: invalid-attribute-override.
```

The rule also rejects removing write access, such as replacing a
writable attribute with a `Final` attribute, and retains the existing
checks for incompatible `ClassVar` qualifiers.

### `invalid-property-type-override`

We reject incompatible property result types, setters that accept fewer
values than the inherited setter, and getter-only properties that
replace writable attributes. This rule is also enabled by default:

```python
class Base:
    @property
    def value(self) -> int:
        return 0

class Child(Base):
    @property
    def value(self) -> str:  # Now rejected: invalid-property-type-override.
        return ""
```

A read-only property returning `bool` would be a valid override of this
`int` property. Reads can become more specific; writes must continue to
accept every value permitted by the superclass.

### `invalid-mutable-override`

Narrowing a writable attribute's type preserves reads but restricts
writes. We report this separately, and leave the rule disabled by
default:

```python
class Base:
    value: int

class Child(Base):
    value: bool  # invalid-mutable-override, when enabled.

def reset(obj: Base) -> None:
    obj.value = 42

child = Child()
reset(child)  # Allowed through Base, but child.value is no longer a bool.
```

This distinction lets users opt into mutable invariance without changing
the other override checks.

## Case analysis

### Cached properties in Home Assistant

The [ecosystem report](https://fed09ff3.ty-ecosystem-ext.pages.dev/diff)
adds 6,986 `invalid-property-type-override` diagnostics.

Home Assistant accounts for 6,863 of those, including 6,861 cases where
a read-only property replaces a writable attribute.

A common pattern is
[BlueCurrentEntity.available](https://github.com/home-assistant/core/blob/8f4c394961405acec82bc01f24620359736617b3/homeassistant/components/blue_current/entity.py#L43),
which replaces the inherited [Entity.available cached
property](https://github.com/home-assistant/core/blob/8f4c394961405acec82bc01f24620359736617b3/homeassistant/helpers/entity.py#L870)
with a getter-only property:

```python
from functools import cached_property

class Entity:
    @cached_property
    def available(self) -> bool:
        return True

class IntegrationEntity(Entity):
    @property
    def available(self) -> bool:  # Now rejected: invalid-property-type-override.
        return True

def mark_unavailable(entity: Entity) -> None:
    entity.available = False

mark_unavailable(Entity())  # Succeeds.
mark_unavailable(IntegrationEntity())  # Raises AttributeError.
```

Both getters return `bool`, but the override removes a supported write
(a `cached_property` stores its value in the instance dictionary and
permits assignment to replace it, even before the first read, so it's
writable).

This PR retains the diagnostic (it's correct, after all): a function
accepting `Entity` can assign to `entity.available`. Replacing the
cached property with a getter-only property makes that valid operation
fail on subclass instances.

(pyright 1.1.411 in standard mode rejects this override, but mypy 2.3.1
and pyrefly 1.2.0 with default checks accept it.)

Part of https://github.com/astral-sh/ty/issues/2158.
2026-10-08 11:19:53 -04:00
David Peter 55129c13c4 [ty] Correct unused ignore rule documentation (#29040)
`unused-ignore-comment` reports unused `ty: ignore` directives, so its
documentation should not describe the setting that disables unused
`type: ignore` diagnostics.

Remove that incorrect option from the rule documentation and fix the
wording for `unused-type-ignore-comment`. Regenerate the rule reference
and schema.
2026-10-02 08:50:42 +02:00
David Peter 2d253467b5 [ty] Improve unresolved-import documentation (#29039)
## Summary

This is probably the first diagnostic that many new ty users will see,
to let's try to improve the docs here a bit.
2026-10-01 12:32:09 +00:00
David Peter f183d34dca [ty] Add opt-in truthiness-test-of-none-union rule (#28889)
## Summary

Add a new opt-in `truthiness-test-of-none-union ` rule which flags
truthiness checks on types like `int | None` or `str | None`. For
example, it will emit an error on the following (problematic) `if not
limit` check:

```py
def take(items: list[str], limit: int | None = None) -> list[str]:
    if not limit:  # error: [truthiness-test-of-none-union]
        return items
    return items[:limit]
```

Related to https://github.com/astral-sh/ruff/discussions/8757 and
inspired by Pyrefly's `implicit-bool` rule (but narrower in scope).

## Test strategy

New test suite
2026-09-25 22:06:20 +02:00
David Peter df0b49b8e7 [ty] Disable invalid-legacy-positional-parameter by default (#28834)
## Summary

Disable `invalid-legacy-positional-parameter` by default. Modern code
can use double-underscore-prefixed parameter names to avoid collisions
with `**kwargs`, without intending to use the legacy positional-only
convention.

The mdtest harness continues to enable the rule globally, and the
conformance runner already enables it with
`--error=invalid-legacy-positional-parameter`. Update the rule
documentation to explain the default, and regenerate the docs and
schema.

Follow-up to
https://github.com/astral-sh/ruff/pull/25079#issuecomment-5797494065.
2026-09-23 16:10:57 +00:00
Alex Waygood 68073852bf [ty] Add rules that detect suspicious uses of Callable, Iterable, Iterator or Generator types in a boolean context (#28554) 2026-09-21 17:42:00 +01:00
Charlie Marsh 1244656343 [ty] Preserve PEP 695 bindings across nested classes (#28723)
## Summary

We now preserve the enclosing class's binding when PEP 695 type
parameters are referenced in a nested class, instead of rebinding them
to the nested class's methods:

```python
class Outer[T]:
    class Inner:
        def method(self, value: T) -> T:
            return value

# def method(self, value: T@Outer) -> T@Outer
reveal_type(Outer.Inner.method)
```

The class-scope visibility restriction now applies only to legacy
bindings. This also preserves PEP 695 `ParamSpec` and `TypeVarTuple`
bindings across nested classes and intervening methods.

Closes https://github.com/astral-sh/ty/issues/4559.
2026-09-20 07:48:23 -04:00
Charlie Marsh 1dcb398a94 [ty] Diagnose unguarded cycles in implicit and PEP 613 aliases (#28704)
## Summary

We now report `cyclic-type-alias-definition` for implicit and PEP 613
aliases that refer to themselves directly or through unions, including
mutually recursive and generic aliases.

```python
from typing import TypeAlias, Union

IntOr = Union[int, "IntOr"]  # cyclic-type-alias-definition
x: IntOr

Explicit: TypeAlias = Union[int, "Explicit"]  # cyclic-type-alias-definition
y: Explicit
```

The shared alias-inference query reports errors at the alias definitions
and preserves cycle errors in a `Result` alongside the recovery type.
Both syntaxes use the same recovery path. Recursion through a containing
type remains valid.

This moves
[`aliases_recursive.py`](https://github.com/python/typing/blob/3a4ada0a0e6b380c6d7561b8392c6b159f5f374e/conformance/tests/aliases_recursive.py)
from Partial to Pass.
2026-09-19 23:42:20 -04:00
Charlie Marsh 58868211d8 [ty] Reject class-scoped type variables in __init__ receivers (#28706)
## Summary

We add `invalid-init-type-variable`, enabled as an error by default, to
enforce the [typing specification's
restriction](https://typing.python.org/en/latest/spec/constructors.html#init-method)
on class-scoped type variables in explicit `__init__` receiver
annotations:

```python
class Swapped[T, U]:
    # invalid-init-type-variable
    def __init__(self: "Swapped[U, T]") -> None: ...
```

Function-scoped type variables remain valid, and constructor inference
is unchanged. Mypy doesn't catch this, but Pyright and Pyrefly do.
2026-09-18 19:43:29 -04:00
Charlie Marsh 59806988c6 [ty] Improve diagnostics for async generator stubs (#28692)
## Summary

An `async def` stub without `yield` describes a coroutine function, even
when its return annotation is `AsyncIterator` or `AsyncGenerator`. For
example:

`stubs.pyi`:

```python
from collections.abc import AsyncIterator

async def values() -> AsyncIterator[int]: ...
```

`main.py`:

```python
from stubs import values

async def consume() -> None:
    async for value in values():
        pass
```

Before, the diagnostic identifies the missing iteration method but gives
no guidance on correcting the stub:

```text
error[not-iterable]: Object of type `CoroutineType[Any, Any, AsyncIterator[int]]` is not async-iterable
info: It has no `__aiter__` method
```

After, we point to the declaration and suggest how to declare an async
generator. We also suggest `await`, since a coroutine can intentionally
return an async iterator:

```text
error[not-iterable]: Object of type `CoroutineType[Any, Any, AsyncIterator[int]]` is not async-iterable
 --> main.py:4:24
  |
4 |     async for value in values():
  |                        ^^^^^^^^
  |
 ::: stubs.pyi:3:1
  |
3 | async def values() -> AsyncIterator[int]: ...
  | ---------------------------------------- Without `yield` in the function body this function returns a coroutine
info: It has no `__aiter__` method
help: `await` the coroutine before iterating over its result
help: To declare `values` as an async generator, use `def` rather than `async def` or add `yield` to the body
```

For declarations in dependencies, we suggest reporting an incorrect stub
to the library maintainers. The stub guidance also appears for
incompatible callable return types, including method overrides, and is
limited to functions with stub-like bodies. The typing rules are
unchanged.

Closes https://github.com/astral-sh/ty/issues/2875.
2026-09-18 18:43:00 +00:00
Micha Reiser af73c077aa [ty] Only skip uv workspace metadata for a single script (#28581) 2026-09-17 19:09:35 +02:00
Micha Reiser 0c40f14f9f [ty] Support dependency lints with --config-file (#28573) 2026-09-17 16:45:23 +02:00
Charlie MarshandAlex Waygood f4fd1244d8 [ty] Reject abstract class instantiation (#28167)
## Summary


Previously, we allowed constructor calls to classes with unimplemented
abstract methods. We now report `instantiate-abstract-class` in such
cases:

```python
from typing import Protocol

class Base(Protocol):
    def method(self) -> int: ...

class Abstract(Base): ...

class Concrete(Base):
    def method(self) -> int:
        return 42

Abstract()  # Before: no error. After: instantiate-abstract-class.
Concrete()  # Before and after: no error.
```

The check covers `@abstractmethod`, implicitly abstract protocol
methods, and abstract property accessors. We reuse the analysis for
`@final` classes so the two checks agree on which methods remain
abstract. Methods decorated with `@abstractmethod` are checked even when
the class does not use `ABCMeta`.

This moves
[`protocols_merging`](https://github.com/python/typing/blob/cf943ccbea5596ef969eec6e2260097ff697b7ba/conformance/tests/protocols_merging.py)
from Partial to Pass and reports three missing errors in
[`protocols_explicit`](https://github.com/python/typing/blob/cf943ccbea5596ef969eec6e2260097ff697b7ba/conformance/tests/protocols_explicit.py).
The other 143 conformance files are unchanged.

Closes https://github.com/astral-sh/ty/issues/1877.

Based on @AlexWaygood's work in #23395.

---------

Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
2026-09-15 09:20:41 -04:00
David Peter feecd77879 [ty] Document runtime limitations of experimental syntax (#28606)
## Summary

Explain that experimental type syntax can cause runtime errors in
libraries that inspect annotations.

Address [Alex's
suggestion](https://github.com/astral-sh/ruff/pull/28513#issuecomment-5678791867).
2026-09-15 11:56:49 +00:00
David Peter efb30c1f4c [ty] Align TypeIs and isinstance narrowing (#28193)
## Summary

This PR aligns `TypeIs` and `isinstance` narrowing for unspecialized
generics and gradual protocols in both strict and non-strict narrowing
modes:
- if `analysis.strict-generic-narrowing = true`, we now intersect with
the top materialization of the generic/gradual type, i.e. for a type
guard returning `TypeIs[list]`, we now narrow with the (typically
unspellable) `Top[list[Unknown]]`. Previously, we did not do this
consistently for `isinstance` narrowing either when narrowing to a
gradual protocol.
- if `analysis.strict-generic-narrowing = false` (default), we now use
the same "union filtering" approach that we also use for `isinstance`
narrowing (note that the [`TypeIs`
PEP](https://peps.python.org/pep-0742/) specifies the narrowing behavior
in terms of intersections, but allows "practical approximations of these
types"). Using the union filtering approach allows us to avoid overly
permissive gradual unions that would result from plain intersections
with a type like `list[Unknown]`. We also extend the union filtering
approach here to properly handle gradual protocols.

In both modes, the negative branch still uses the top materialization to
exclude all specializations.

The new behavior can be summarized like this:

```py
class Invariant[T]:
    value: T

def is_invariant(x: object) -> TypeIs[Invariant]: ...

@final
class Unrelated: ...
```

<table>
  <thead>
    <tr>
<th colspan="3"><code>is_invariant(x)</code> / <code>isinstance(x,
Invariant)</code></th>
    </tr>
    <tr>
      <th>Before narrowing</th>
      <th>Non-strict narrowing</th>
      <th>Strict narrowing</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>object</code></td>
      <td><code>Invariant[Unknown]</code></td>
      <td><code>Top[Invariant[Unknown]]</code></td>
    </tr>
    <tr>
      <td><code>Invariant[int] | Unrelated</code></td>
      <td><code>Invariant[int]</code></td>
      <td><code>Invariant[int]</code></td>
    </tr>
  </tbody>
</table>

```py
@runtime_checkable
class Reader(Protocol):
    def read(self) -> Any: ...

class SpecificReader:
    def read(self) -> str: ...

def is_reader(arg: object) -> TypeIs[Reader]: ...
```

<table>
  <thead>
    <tr>
<th colspan="3"><code>is_reader(x)</code> / <code>isinstance(x,
Reader)</code></th>
    </tr>
    <tr>
      <th>Before narrowing</th>
      <th>Non-strict narrowing</th>
      <th>Strict narrowing</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>object</code></td>
      <td><code>Reader</code></td>
      <td><code>Top[Reader]</code></td>
    </tr>
    <tr>
      <td><code>SpecificReader | Unrelated</code></td>
      <td><code>SpecificReader</code></td>
      <td><code>SpecificReader</code></td>
    </tr>
  </tbody>
</table>

closes https://github.com/astral-sh/ty/issues/4394 (Fixed in both
non-strict and strict mode; Before: reveals `(list[Unknown] &
~list[Unknown]) | int` and reports `invalid-return-type`, After: reveals
`int`; no errors)

## Ecosystem impact

Looks great. Lots of removed diagnostics in non-strict-mode projects,
and only very few new diagnostics in strict-mode projects, all of which
are expected with these changes here.

## Test strategy

New and updated Markdown tests
2026-09-07 15:58:57 +02:00
Alex Waygood 689a33fd36 [ty] Add rules to detect always-truthy and always-falsy conditions (#28034)
## Summary

Builds on #28032.

Add two rules for boolean tests whose truthiness is statically known:

- `redundant-condition`, enabled by default at warning severity, catches
cases such as an uncalled function or method, an unawaited coroutine, a
generator tested for emptiness, or a tuple whose length makes its
truthiness constant.
- `redundant-condition-strict`, disabled by default, covers
boolean/integer conditions, conditions whose outcome is fixed only by
short-circuit evaluation, and conditions containing walrus expressions.
These can expose real bugs, but are also much more likely to be
intentional.

The checks reuse ty's truthiness inference and apply to `if`/`elif`,
`while`, assertions, match guards, conditional expressions,
comprehension filters, and unary `not`. The operands of `and`/`or` are
checked when the expression is used as a condition. The main design
question is which statically redundant tests are likely to represent
mistakes. The implementation deliberately prefers missing some redundant
conditions to warning about common defensive or compatibility patterns.

## Avoiding false positives

### Separate likely mistakes from more opinionated checks

Types assignable to `int`, including `bool`, use the strict rule. So do
conditions whose outcome is fixed by short-circuit evaluation even
though their inferred value type has ambiguous truthiness, and tests
containing walrus expressions. Other values with fixed truthiness use
the default rule.

This keeps checks of ordinary boolean/integer flags quiet by default,
because these are extremely common and it is difficult to distinguish
deliberately constant flags from mistakes:

```python
DEBUG = False

if DEBUG:  # Only reported by redundant-condition-strict.
    print("debugging")
```

The strict rule is a sibling of the default rule: it handles a separate
set of expressions rather than reporting the same expressions more
aggressively.

### Distinguish a condition's outcome from its value type

Short-circuit evaluation can establish a fixed outcome even when the
expression's value type does not:

```python
def check(value: object):
    if value and False:  # Always false; reported by redundant-condition-strict.
        print("unreachable")
```

The condition fails whether `value` is truthy or falsy. However, saving
`value and False` to a variable and testing that variable later is
different: the saved value could be the original object, whose
`__bool__` result may change between calls. The redundant-condition
checks use short-circuit truthiness APIs introduced in
https://github.com/astral-sh/ruff/pull/28082 for direct conditions, and
value-type truthiness for tests of computed values. This also handles
chained comparisons whose intermediate results are objects with mutable
truthiness.

### Recognize explicitly constant tests

Both rules exempt direct boolean, integer and `None` literals in the
AST, such as `if False:`, `if 0:`, `while True:`, `while 1:`, and
`assert None`. These spellings are strong evidence that the constant
truthiness is deliberate. A name _inferred_ as `Literal[False]` is
different: its value may not be apparent where it is tested, so it
remains eligible for the strict rule.

### Treat walrus expressions as evidence of intentional side effects

An always-truthy operand can be useful because evaluating it assigns a
value:

```python
if should_continue() and (message := "continuing") and is_ready():
    print(message)
```

The middle operand uses the strict rule, so it does not warn by default.

We does not attempt to classify arbitrary side effects. For example, `if
items.append(value) or fallback:` can still trigger the default rule
because `append` returns `None`.

An `and`/`or` expression used to compute a value (rather than one that
is used directly in an `if` test or similar), such as `result =
items.append(value) or fallback`, is not flagged by either rule.

### Follow the origins of environment-dependent conditions

Both rules exempt compatibility checks involving `sys.version_info`,
`sys.platform`, `os.name`, and `TYPE_CHECKING`. A condition being
constant for the configured environment does not make it redundant
across the environments supported by the project.

The exemption also follows names and attributes through their
definitions, including aliases and imports across modules:

```python
import sys

IS_WINDOWS = sys.platform == "win32"
LINE_PREFIX = "\n" if IS_WINDOWS else ""

if LINE_PREFIX:  # Exempt even though its truthiness is known on this platform.
    print(LINE_PREFIX)
```

The traversal follows assignment values, augmented assignments, walrus
bindings, loop and comprehension iterables, context-manager expressions,
and the subjects of captured `match` patterns.

This lookup is deliberately not flow-sensitive: if any resolved
definition depends on one of these symbols, the use is exempt. This is
so that we err on the side of avoiding false positives.

The shared definition-resolution machinery from #28032 allows this
traversal during inference without requesting completed inference of the
same scope. Name lookups are cached per scope and name; attribute
lookups are cached per receiver type and attribute name. Definition
traversal has its own Salsa query boundary and handles cyclic aliases,
so repeated conditions can share the provenance analysis.

### Preserve defensive assertions and exhaustive dispatch

Defensive boolean/integer assertions such as `assert isinstance(value,
int)` remain useful at runtime even when `value` is annotated as `int`,
since the runtime does not enforce type annotations. These are therefore
exempt from both rules.

Non-boolean mistakes such as `assert function` (where the user meant
`assert function()`) are still reported by the default rule. A complete
assertion whose value type has ambiguous truthiness but whose outcome is
fixed by short-circuit evaluation remains eligible for the strict rule.

For `if` and `elif` statements, the strict rule also recognizes
defensive exits. An always-false condition is exempt when its body ends
in a `raise`, an assertion that might fail, a call returning `Never`, or
a return assignable to `NotImplementedType`:

```python
def increment(value: int) -> int:
    if not isinstance(value, int):
        raise TypeError("expected an int")
    return value + 1
```

For an always-true condition, the same heuristic examines an immediately
following `else` suite, or the remainder of the surrounding suite when
there are no later clauses. This accommodates exhaustive dispatch
followed by `assert_never`, and early-return dispatch followed by a
defensive exception.

These defensive-exit exemptions apply to boolean/integer conditions and
conditions requiring short-circuit analysis. An uncalled function is
still suspicious even when an otherwise unreachable branch raises.

### Check compound conditions once

In `result = flag and function`, Python does not test the truthiness of
the final operand: it simply returns that value if evaluation reaches
it. The rule therefore does not flag `function`, even though it is an
always-truthy object in a boolean expression. In `if flag and
predicate:`, the surrounding `if` does test the truthiness of
`predicate`, so the rule reports the uncalled function even though the
complete condition has ambiguous truthiness.

Unlike `and`/`or` expressions used to compute values, `not` explicitly
tests its operand's truthiness even when it used to computer a value, so
`not` expressions are checked even outside the context of
`if`/`while`/`elif`/`assert` tests.

For compound conditions, the redundant-condition checks visit operands
before deciding whether to report the complete expression. Reporting an
uncalled function in `if not function:` suppresses a second duplicate
diagnostic on the `not function` expression as a whole. Boolean/integer
operands and operands requiring short-circuit analysis are instead
suppressed so that the complete condition can be reported once. This
applies consistently to statement conditions, conditional expressions,
and comprehension filters.

Boolean tests inside value expressions, such as `if consume(not
function):`, are checked independently. Tests in nested scopes are
checked when those scopes are inferred.

Neither rule runs in stub files or files excluded from checking.
Diagnostic and provenance analysis is skipped entirely when both rules
are disabled.

## Diagnostics and fixes

Special-case diagnostics are provided to help explain some of the more
common errors we see in the ecosystem: an uncalled function or method
being always truthy, a fixed-length tuple being always truthy or always
falsy, a `TypedDict` always being truthy due to the presence of
`Required` fields, a `@final` class without `__bool__` or `__len__`
being always truthy, or an unevaluated generator being always truthy.

For simple function and method references, the diagnostic carries a call
fix. A parameterless signature gets an unsafe `()` fix; signatures with
parameters get a display-only `(...)` suggestion. Generator diagnostics
suggest `any()` and carry a display-only wrapping fix when the standard
`any` builtin is known not to be shadowed, including by a custom
`__builtins__.pyi`.

These fixes are deliberately not classified as safe: calling a function
or consuming a generator can change runtime behavior, and the intended
correction may be different.

### Default-rule diagnostics

<details>
<summary>Uncalled function</summary>

![Uncalled
function](https://github.com/user-attachments/assets/9538e4a9-b716-4fff-a4c7-24fc6deb7994)

</details>

<details>
<summary>Uncalled method</summary>

![Uncalled
method](https://github.com/user-attachments/assets/3c327ada-eec0-4bcc-9588-e5df50b3455b)

</details>

<details>
<summary>Generator: suggest <code>any()</code></summary>

![Generator: suggest
any()](https://github.com/user-attachments/assets/83d500fb-d69a-4753-94d6-4008379af8d8)

</details>

<details>
<summary>Tuple with a fixed length</summary>

![Tuple with a fixed
length](https://github.com/user-attachments/assets/e37ade52-22a6-4946-aa29-71f5328bfa79)

</details>

<details>
<summary>Tuple with a minimum length</summary>

![Tuple with a minimum
length](https://github.com/user-attachments/assets/6fc71bd5-6a52-417f-9a9b-db4e760752c3)

</details>

<details>
<summary>Empty tuple</summary>

![Empty
tuple](https://github.com/user-attachments/assets/6c8e8242-fd8b-410d-b56b-e6a49737ea0f)

</details>

<details>
<summary>Nonempty string literals</summary>

![Nonempty string
literals](https://github.com/user-attachments/assets/139f9f53-5128-4d85-8901-01937f312986)

</details>

<details>
<summary>Empty string</summary>

![Empty
string](https://github.com/user-attachments/assets/261fc16d-dc67-44b8-bf51-58f2fcb12e2f)

</details>

<details>
<summary><code>None</code></summary>


![None](https://github.com/user-attachments/assets/c04b0308-b294-4125-bba8-3603fd5bda02)

</details>

<details>
<summary><code>TypedDict</code>: required fields and their
declarations</summary>

![TypedDict: required fields and their
declarations](https://github.com/user-attachments/assets/46367c59-665f-4b40-893b-227add4ca3df)

</details>

<details>
<summary>Final class without <code>__bool__</code> or
<code>__len__</code></summary>

![Final class without __bool__ or
__len__](https://github.com/user-attachments/assets/4e8e92e7-0645-485c-ab30-4d0807943005)

</details>

<details>
<summary>Enum: implicit finality</summary>

![Enum: implicit
finality](https://github.com/user-attachments/assets/6596a69b-a122-40bb-80fc-b011c054e15d)

</details>

### Strict-rule diagnostics

These examples enable `redundant-condition-strict` explicitly.

<details>
<summary>Indexing <code>bytes</code> returns <code>int</code></summary>

![Indexing bytes returns
int](https://github.com/user-attachments/assets/e6f33bc9-c998-4be5-b1ca-36c32b073d99)

</details>

<details>
<summary>Comparing <code>str</code> with <code>bytes</code></summary>

![Comparing str with
bytes](https://github.com/user-attachments/assets/48e15215-d6ae-4779-ab44-244c21dabf55)

</details>

<details>
<summary>Length comparison: explain the fixed tuple length</summary>

![Length comparison: explain the fixed tuple
length](https://github.com/user-attachments/assets/556ef251-ac2f-4048-b7f4-b545a8b87a69)

</details>

<details>
<summary>Comparison: omit an obvious literal's type annotation</summary>

![Comparison: omit an obvious literal's type
annotation](https://github.com/user-attachments/assets/95a0e73f-38f5-457b-9726-8169016b991d)

</details>

## Implementation

The semantic index records the outermost boolean tests in each scope, so
one check owns the diagnostics for a complete condition and its
subexpressions. A shared checker separates classification, context
exemptions, and duplicate suppression from diagnostic rendering. The
main implementation is in
`types/infer/builder/redundant_conditions/mod.rs`; its `diagnostic.rs`
submodule constructs messages and fixes, and `exemptions.rs` handles
environment provenance and defensive exits.
2026-09-05 20:45:59 +01:00
Alex Waygood 7de38e7e2a [ty] Add a new disjoint-cast rule (#28129)
## Summary

`typing.cast()` leaves its argument unchanged at runtime, but accepts
casts even when the inferred source type and destination type cannot
overlap. Add a disabled-by-default `disjoint-cast` rule to flag these
cases:

```python
from typing import cast


def f(value: int) -> str:
    return cast(str, value)  # disjoint-cast
```

Casts between overlapping types remain accepted. The rule exempts stub
files and casts to or from `Never`.

The documentation explains less obvious disjoint casts involving
invariant containers and `NewType`, and discusses `TypeGuard` as a
possible alternative. Enable the rule in ecosystem analysis to assess
its impact.

## Error-tree diagnostics

Add error-tree explanations for disjointness checks involving invariant
generic arguments, unions, tuples, protocol members, final classes, and
TypedDict fields. Secondary annotations identify the inferred source
type, the destination, and available type definitions, including
`@final` decorators.

For example, casting a class whose `values()` method returns `list[str]`
to a protocol requiring `list[int]` produces:

```text
info: protocol member `values` is incompatible
info: └── return types `list[int]` and `list[str]` are disjoint
info:     └── `int` and `str` are not mutual subtypes of each other
```

## Parallels in other type checkers

| Checker | Rule | Comparison with `disjoint-cast` |
|---|---|---|
| **basedpyright** |
[`reportInvalidCast`](https://docs.basedpyright.com/latest/benefits-over-pyright/new-diagnostic-rules/#reportinvalidcast)
| Same high-level purpose: reject casts between non-overlapping types.
Its docs explicitly demonstrate `int` → `str`, and discuss rejecting
`dict` → `TypedDict` casts. |
| **Pyrefly** |
[`invalid-cast`](https://pyrefly.org/en/docs/error-kinds/#invalid-cast)
| Disabled by default, but deliberately narrower than `disjoint-cast`:
checks disjoint **runtime classes**. It accepts `list[int]` →
`list[str]`, ignores tuple-element differences, treats TypedDicts as
overlapping with `dict`, and excludes protocols. |

Mypy and upstream Pyright do not have direct equivalents. Both can
report redundant casts, but their current `cast` implementations do not
check source/destination overlap: [mypy
implementation](https://github.com/python/mypy/blob/master/mypy/checkexpr.py#L4546),
[Pyright
implementation](https://github.com/microsoft/pyright/blob/main/packages/pyright-internal/src/analyzer/typeEvaluator.ts#L10917).

### TypeScript

TypeScript has a similar restriction on type assertions: **TS2352**
rejects assertions between types that do not sufficiently overlap. For
example:

```typescript
const value = "hello" as number; // TS2352
```

Like `typing.cast()`, TypeScript's type assertions do not perform
runtime validation. An explicit two-step assertion through `unknown` or
`any`, such as `value as unknown as Target`, can bypass the restriction.
The overlap criterion differs from ty's disjointness relation, so this
is a precedent for checking suspicious casts rather than an exact
semantic equivalent. See the [TypeScript handbook on type
assertions](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions).
2026-09-01 21:06:04 +00:00
Zsolt Dollenstein 0444f4a4ab [ty] Stop passing --frozen to uv workspace metadata (#28224)
When dependency declarations change without updating `uv.lock`, ty's
frozen metadata query returns the old dependency graph. This can leave
`missing-direct-dependency` diagnostics after the dependency has been
declared.

Drop `--frozen` so uv can refresh the lockfile. Keep `--active` without
synchronizing installed packages. Add a stale-lock regression and update
the rule documentation and its generated copies.

CLI-only `uv check --frozen` and `--isolated` policies are not forwarded
to ty's metadata subprocess. Preserving those policies needs a uv-side
follow-up.
2026-09-01 18:57:11 +01:00
Alex Waygood 2c3fa3e32f [ty] Clarify the scope of unsound-assignment (#28157) 2026-08-29 19:06:44 +01:00
Charlie Marsh 917c626954 [ty] Reject class access to generic instance attributes (#28123)
## Summary

Previously, we allowed generic instance attributes to be read or written
through the generic class itself, including through a specialized
generic alias. We now report `invalid-attribute-access` for these
accesses: specializing a class does not give it separate attribute
storage for each set of type arguments.

```python
class Node[T]:
    label: T

    def __init__(self, label: T) -> None:
        self.label = label

# Before: no errors. After: invalid-attribute-access on each line.
Node[int].label = 1
Node[int].label
Node.label = 1
Node.label

node = Node(1)
node.label = 2  # Before and after: OK.
```

We check the generic declaration before applying the class's type
arguments, using the existing variance analysis to detect dependencies
through type aliases without expanding recursive specializations.
Instance access, class variables, and attributes independent of the
class's type parameters remain valid. Access to methods and descriptors
retains its existing behavior, and invalid accesses retain their
inferred types for error recovery.

Importantly, we continue to allow access through `cls`,
`type(instance)`, and parameters annotated with `type[...]`. These
receivers can refer to concrete subclasses with their own class
attributes. The typing specification [makes the analogous
`type(instance).attribute` diagnostic
optional](https://github.com/python/typing/commit/b41e5096bc9b1333355911614648616f7cfbf943)
for that reason.

This moves
[`generics_type_erasure`](https://github.com/python/typing/blob/b41e5096bc9b1333355911614648616f7cfbf943/conformance/tests/generics_type_erasure.py)
from Partial to Pass.

<details>
<summary>Case analysis</summary>

Mypy 2.1.0, pyright 1.1.410, and pyrefly 1.3.0-dev.1 all reject the four
class accesses above. The Pyrefly comparisons use its `default` preset.
Pyright and pyrefly pass the full conformance file. Mypy still fails two
separate assertions because it infers `Never` rather than `Any` for an
unspecialized constructor call.

In `__init_subclass__`, `cls` refers to the subclass being initialized.
MkDocs reads its configuration metadata through this receiver, while
steam.py assigns the converter's target type. We allow both patterns, as
do mypy, pyright, and pyrefly:

```python
from typing import Generic, TypeVar

T = TypeVar("T")

class BasePlugin(Generic[T]):
    config_class: T

    def __init_subclass__(cls):
        cls.config_class  # All four: OK.

class Converter(Generic[T]):
    converter_for: T

    def __init_subclass__(cls, converter_for):
        cls.converter_for = converter_for  # All four: OK.
```

We also allow reads through `cls` in `__new__` and classmethods.
Prefect's worker configuration, Static-frame's store configuration, and
SymPy's Gaussian domains use this pattern, with concrete subclasses
supplying the attributes. All four checkers accept these examples:

```python
from typing import Generic, TypeVar

T = TypeVar("T")

class GaussianElement(Generic[T]):
    base: T
    _parent: T

    def __new__(cls):
        cls.base  # All four: OK.
        return super().__new__(cls)

    @classmethod
    def _get_xy(cls):
        cls._parent  # All four: OK.
```

For class objects passed as parameters, we agree with mypy: the receiver
may be a concrete subclass, even when the annotation names a generic
base. Pyright and Pyrefly's `default` preset reject both examples below.
Pyrefly's `basic` preset accepts them because it disables the relevant
check. The same distinction applies whether the class parameter retains
a type variable, as in pytest, or fixes its type argument, as in
Static-frame:

```python
from typing import Generic, TypeVar

T = TypeVar("T")

class CaptureBase(Generic[T]):
    EMPTY_BUFFER: T

def capture(captureclass: type[CaptureBase[T]]):
    # ty, mypy: OK; pyright, pyrefly (default): error.
    captureclass.EMPTY_BUFFER

class Store(Generic[T]):
    _STORE_CONFIG_CLASS: T

def read(cls: type[Store[object]]):
    # ty, mypy: OK; pyright, pyrefly (default): error.
    cls._STORE_CONFIG_CLASS
```

</details>
2026-08-29 12:44:34 +00:00
Charlie Marsh 700421c2ab [ty] Reject circular type alias definitions (#28140)
## Summary

Previously, we accepted circular `TypeAliasType` definitions and aliases
that include themselves as union members. We now report
`cyclic-type-alias-definition` for these cases, while continuing to
allow recursion through containers and other concrete types.

```python
from typing import TypeAliasType

# Before: no error. After: cyclic-type-alias-definition.
Direct = TypeAliasType("Direct", "Direct")

# Before: no error. After: cyclic-type-alias-definition.
type IntOr = int | IntOr

# Before and after: no error.
type Tree = int | list[Tree]
```

We follow references through aliases and unions, stopping when recursion
occurs inside another type, such as `list[Tree]`. For generic aliases,
we follow only the type arguments exposed by the alias's definition.
This lets us distinguish an actual cycle from a finite expression like
`Identity[Identity[int]]`, or an unused recursive argument like
`Constant[Cycle]` when `Constant[T]` is defined as `int`.

The checks reuse cached summaries across alias definitions. Checking a
chain of 8,192 aliases takes 0.092 seconds, compared with 0.087 seconds
on the base branch, using the median of five runs with matching debug
builds.

This moves
[`aliases_typealiastype`](https://github.com/python/typing/blob/cf943ccbea5596ef969eec6e2260097ff697b7ba/conformance/tests/aliases_typealiastype.py)
and
[`aliases_type_statement`](https://github.com/python/typing/blob/cf943ccbea5596ef969eec6e2260097ff697b7ba/conformance/tests/aliases_type_statement.py)
from Partial to Pass, adding three missing errors in the former and one
in the latter. The other 143 conformance results are unchanged.

<details>
<summary>Case analysis</summary>

Mypy 2.1.0, pyright 1.1.410, and pyrefly 1.3.0-dev.1 (with the default
preset) already report the four required errors that we were missing.
All three reject direct, mutual, and union cycles; the differences below
concern additional generic alias cases.

Changing a recursive reference's type argument does not make a union
cycle valid. All four checkers reject this definition, while accepting
recursion through a container:

```python
# ty, mypy, pyright, pyrefly: error.
type Cycle[T] = T | Cycle[str]

# ty, mypy, pyright, pyrefly: OK.
type Tree[T] = T | list[Tree[T]]
```

Recursion through a container remains valid when the type arguments
grow. In `Repeated` below, expanding the aliases outside `list` reaches
`int`, so there is no circular definition. Ty and pyright accept it;
pyrefly reports a cyclic self-reference. Mypy rejects the underlying
`Recursive` definition because it does not support this pattern of
growing type arguments, and also reports an error on `Repeated`.

```python
# ty, pyright, pyrefly: OK.
# mypy: error; growing type arguments in recursive aliases are unsupported.
type Recursive[T] = T | list[Recursive[list[T]]]

# ty, pyright: OK. Mypy, pyrefly: error.
type Repeated = Recursive[Recursive[int]]
```

In contrast, substituting `Cycle` for `T` exposes `Cycle` itself as a
union member. Ty, pyright, and pyrefly reject this. Mypy rejects
`Recursive` as above, so it cannot provide a useful comparison for this
use of the alias:

```python
type Recursive[T] = T | list[Recursive[list[T]]]

# ty, pyright, pyrefly: error.
type Cycle = Recursive[Cycle]
```

These results also hold for equivalent definitions using
`TypeAliasType`.

A generic alias that returns its argument cannot make a circular
definition valid. Ty and pyrefly reject this cycle, but pyright accepts
it. Mypy did not finish checking the isolated example within 20 seconds:

```python
type Identity[T] = T

# ty, mypy, pyright, pyrefly: OK.
type Repeated = Identity[Identity[int]]

# ty, pyrefly: error. Pyright: OK. Mypy: timed out.
type Cycle = Identity[Cycle]
```

</details>
2026-08-29 06:57:56 -04:00
Zsolt Dollenstein 33648d47d7 [ty] Check direct dependencies in PEP 723 scripts (#28136)
`missing-direct-dependency` currently skips PEP 723 scripts. This
extends the rule to scripts using uv integration, checking imports
against the script's inline `dependencies` list and directing users
there when a declaration is missing.

Each script uses its own dependency graph, independently of the
workspace and other scripts. The dependency metadata is cached
separately from script settings so diagnostics update after
synchronization even when those settings are unchanged. The rule skips
scripts whose metadata is unavailable or out of date.
2026-08-29 09:59:52 +00:00
Alex Waygood ef08e25617 [ty] Format lint documentation the same way we format mdtests (#28135) 2026-08-28 18:18:07 +01:00
Zsolt Dollenstein f4839591d6 [ty] Add a missing-direct-dependency rule (#28001)
This PR replaces #25027. It adds an opt-in check for imports of
installed distributions that the current project does not declare
directly in `pyproject.toml`.

## Overview

The check uses ty's existing uv workspace integration (`TY_UV=1`) to
call `uv workspace metadata --frozen --active`. uv now provides module
ownership without synchronizing the environment, so ty can read the
metadata without installing packages or updating `uv.lock`.

The metadata provides:

1. Each workspace project's runtime and optional dependencies.
2. Dependencies declared in dependency groups.
3. Importable modules and their owning distributions, including source
paths for editable installs.

During import inference, ty maps each resolved import to its
distribution and compares it against the importing project's direct
declarations. If the declaration is missing, ty emits
`missing-direct-dependency`, naming the distribution to add. Each
distribution is reported once per file, at its first unsuppressed
import.

The rule is disabled by default and requires an existing, synchronized
environment. Unavailable or incompatible dependency metadata disables
the check; rejected dependency metadata produces a debug message.

## Nuances

- Editable installs may lack entries in uv's module-owner map. ty uses
their source paths to attribute resolved imports.
- Files identified as package code require runtime or optional
dependencies. Non-package files, such as tests and scripts, can also use
dependency groups, but those groups' transitive dependencies still need
direct declarations.
- For `from package import child`, a resolved child module is checked
against its own distribution rather than the parent's. Imports with
unknown or ambiguous ownership are skipped.
- Ownership metadata is used only when the explicitly selected Python
environment matches uv's environment.
- The current integration covers directory checks. Explicit file
arguments, `--config-file`, and standalone PEP 723 scripts do not use
the workspace's dependency declarations.

The schema and rule documentation are regenerated. Most of the
rule-documentation diff consists of updated source-line links.
2026-08-28 14:01:44 +00:00
Charlie Marsh f41e263298 [ty] Validate generic protocol variance (#27531)
## Summary

Follow-up to #27534.

We now validate explicitly declared generic protocol variance against
its structural interface:

```python
from typing import Protocol, TypeVar

T = TypeVar("T")


class Source(Protocol[T]):  # error: [invalid-protocol]
    def read(self) -> T: ...
```

A type variable appearing only in a return position must be covariant,
while writable protocol attributes must remain invariant. We derive both
declared and inferred variance from the same member read/write
capabilities already used for structural compatibility, including
inferred legacy and PEP 695 protocol parameters:

```python
class Writable[T](Protocol):
    _value: T


def overwrite(value: Writable[object]) -> None:
    value._value = object()


def unsound(value: Writable[int]) -> None:
    overwrite(value)  # error: [invalid-argument-type]
```

For now, I've limited this to the easier cases, so recursive interfaces,
custom descriptors with unrepresentable setter domains, generic
defaults, and inherited or malformed headers are unchanged.
2026-08-26 06:45:42 -06:00
Alex Waygood a7cf72750c [ty] Add a dedicated missing-slot diagnostic (#28039)
## Summary

Emit `missing-slot` when an instance assignment targets a declared
attribute but the instance has neither a matching slot nor an instance
dictionary. Previously, this used `unresolved-attribute`.

This lets users affected by generated stubs such as protobuf's disable
this check without disabling other `unresolved-attribute` diagnostics.

Fixes astral-sh/ty#4375.
2026-08-25 22:28:43 +01:00
Charlie Marsh 54b36c7a24 [ty] Support __slots__ (#27730)
## Summary

Previously, we did not model `__slots__` as instance storage, so slotted
attributes were not recognized consistently and classes without an
instance dictionary could accept arbitrary attribute assignments.

```python
class Counter:
    __slots__ = ("value",)

    def __init__(self) -> None:
        self.value = 0

    def increment(self) -> None:
        self.value += 1


Counter().extra = 1  # error: [unresolved-attribute]
```

We now represent slots as class descriptors and track instance layouts
through inheritance. This covers literal slot declarations, generic and
annotated slots, explicit `__dict__` and `__weakref__` storage,
dataclass-generated slots, descriptor-based writes, class-namespace
lookup, and incompatible builtin layouts.

Slots participate in attribute inference and enumeration, while invalid
class-body declarations and unsupported writes receive the appropriate
existing diagnostics.

Closes https://github.com/astral-sh/ty/issues/1268.
2026-08-21 17:48:18 -04:00
Alex Waygood da575120b8 [ty] Add an opt-in unsound-assignment rule for variables (#27874)
(Stacked on top of #27872)

## Summary

Add a disabled-by-default `unsound-assignment` rule for variable
assignments whose inferred value is assignable to a fully static
declared type but is not a subtype of it. Attribute and subscript
assignments are deliberately excluded from this initial implementation.

Share declaration lookup and diagnostic rendering with
`invalid-assignment` so the rule points to the annotation responsible
for the expected type, including parameters, variadics, global/nonlocal
bindings, and augmented assignments.

Fixes astral-sh/ty#4289.
2026-08-19 19:36:03 +01:00
Alex Waygood 8e0dbf4bd9 [ty] Add an opt-in rule flagging function decorators that transform a non-dynamic type into a dynamic type (#27829) 2026-08-18 20:41:48 +00:00
Charlie Marsh 0e8636cc47 [ty] Diagnose invalid module-level __getattr__ calls (#27507)
## Summary

Previously, an invalid module-level `__getattr__` function caused us to
report that the requested attribute was missing, even though Python
actually invokes the function and raises `TypeError`:

```python
# example.py
def __getattr__() -> str:
    return "fallback"

# main.py
import example

example.missing  # error: [invalid-attribute-access]
from example import missing  # error: [invalid-module-getattr-call]
```

We now propagate failed module-level `__getattr__` calls through member
lookup, preserve the function's return type for error recovery, and
point diagnostics at its definition. Direct attribute access uses
`invalid-attribute-access`, while failed `from` imports use the
dedicated `invalid-module-getattr-call` rule. Defined module attributes
and real submodules continue to take precedence.
2026-08-14 14:12:08 -04:00
Micha Reiser a103832c28 [ty] Check PEP 723 scripts in isolation (#27462)
## Summary
This PR extends the PEP 723 configuration support to `ty.environment`
settings. That is:

* Scripts are checked with a separate virtual environment and search
paths
* Scripts can configure a different Python version
* Scripts can configure a different Python platform

Our long-term plan is to use uv to create the script's virtual
environment. We don't do this in this PR. Instead, we:

* Respect `VIRTUAL_ENV`, LSP fallback environment (the selected
environment), global available Python installation. Unlike for projects,
ty does not search for a local `.venv` folder
* Unlike for projects, scripts default to an empty `environment.root`
because scripts are considered to work in isolation. Users can customize
`environment.root`.


Out of scope for this PR but things we should look into next:

* Report a diagnostic if a script's metadata fails to parse instead of
falling back to the project's configuration.
* Add support for the Python environment extension's file-specific
virtual env (so that you can select a virtual env for a specific file)
* Preserve source ranges for configuration options from script metadata
(shouldn't be too hard but the PR is already large as it is)
* Integrating with uv. I expect that this will require significant
changes to the implementation to provide good UX, but I like to tackle
problems one step at a time (it also makes it easier to explain why we
changed something).
* We currently don't watch for changes in script search paths. That
means, results in the LSP or when using `ty check --watch` could get
outdated

## Test Plan
Testing: Added integration and language-server coverage and verified the
relevant unit, CLI, server, and Markdown suites.
2026-08-14 13:24:27 +02:00
Alex Waygood 78cad66655 [ty] Add an opt-in unsound-yield lint (#27593) 2026-08-09 15:02:37 +01:00
Alex Waygood 02ce4ba994 [ty] Add an opt-in unsound-return-statement lint (#27561) 2026-08-07 14:26:38 +01:00
David Peter 87aa1aafcc [ty] Gradual isinstance narrowing for generic classes (#27308)
## Summary

This PR introduces a new `ty.analysis.strict-generic-narrowing` option
that controls how `isinstance` and `issubclass` narrowing works for
unspecialized generic classes. When this option is set to `true`, we use
the top materialization of the generic class (e.g.
`Top[list[Unknown]]`). This is the current behavior on `main` and the
sound way to do `isinstance` narrowing with generic classes. When the
option is set to `false` (the new default), we switch to an unsound mode
that is intended to help users migrate from other type checkers.

In this non-strict mode, we intersect with the `Unknown`-specialization
of the generic class. For example, `x: object` is narrowed to `object &
list[Unknown] = list[Unknown]` after `isinstance(x, list)`. If the type
of `x` already contains a specialization of `list`, or a generic base of
`list`, we narrow to the corresponding `list` specialization instead.
For example, `x: Sequence[int]` is narrowed to `list[int]`, and `x:
Mapping[str, int]` is narrowed to `dict[str, int]` after `isinstance(x,
dict)`.

We also add a special case for `TypedDict`s here: When starting from a
`TypedDict` type, and narrowing via `isinstance(.., dict)` (or
`Mapping`, `MutableMapping`, ..), we simply preserve the previous
`TypedDict` type.

We also apply those settings to class patterns in `match` statements,
resolving this comment:
https://github.com/astral-sh/ty/issues/3676#issuecomment-4897968972

### `strict-generic-narrowing = true` (current behavior on `main`)

```py
def f(xs: object):
    if isinstance(xs, list):
        reveal_type(xs)  # Top[list[Unknown]]
        for x in xs:
            reveal_type(x)  # object

        xs.append(1)  # error!

def g(xs: Sequence[int]):
    if isinstance(xs, list):
        reveal_type(xs)  # Sequence[int] & Top[list[Unknown]] = Top[list[int & Unknown]]
```

### `strict-generic-narrowing = false` (gradual / non-strict, new
default behavior)

```py
def f(xs: object):
    if isinstance(xs, list):
        reveal_type(xs)  # list[Unknown]
        for x in xs:
            reveal_type(x)  # Unknown

        xs.append(1)  # okay

def g(xs: Sequence[int]):
    if isinstance(xs, list):
        reveal_type(xs)  # list[int]
```

closes https://github.com/astral-sh/ty/issues/3476 (basically asks for
exactly this feature)
closes https://github.com/astral-sh/ty/issues/3890 (we support all of
these use cases as intended by the user)
closes https://github.com/astral-sh/ty/issues/3676 (in the new default
mode, the original use case is supported, in strict mode, we need to
implement the intersection simplifications which are tracked in
https://github.com/astral-sh/ty/issues/1824)
closes https://github.com/astral-sh/ty/issues/2843 (we support the
original use case now. we could keep this issue open if we really want
to support this in strict mode as well?)
closes https://github.com/astral-sh/ty/issues/3477 (already closed, but
directly asks for a mode like this)

also related: https://github.com/astral-sh/ty/issues/1130 (we have
special handling for TypedDicts here, but we probably still want to
solve this for strict mode)

## Previously considered alternatives

* https://github.com/astral-sh/ruff/pull/26472 Intersecting with the
gradual `Unknown` specialization. The problem here was that this
introduces too much graduality. When starting from a fully static type
like `Sequence[int]`, we would get `Sequence[int] & Sequence[Unknown] =
Sequence[int & Unknown]` after an `isinstance(..., Sequence)` check.
Iterating over that type would yield elements of type `int & Unknown`,
which is too permissive.
* https://github.com/astral-sh/ruff/pull/26797 Intersecting with
transient top materializations. The idea here was to still intersect
with `Top[C[Unknown]]`, but then remove that `Top` materialization after
the intersection type had been simplified. This solved the problem with
`Sequence[..]` above, because now we created the intersection type
`Sequence[int] & Top[Sequence[Unknown]] = Sequence[int] &
Sequence[object] = Sequence[int & object] = Sequence[int]` which was
still fully static. However, this still caused problems when invariant
generics were involved. For example, starting with `Sequence[int]` and
narrowing using `isinstance(.., list)` would lead to `Sequence[int] &
Top[list[Unknown]]`. We showed that this type is equivalent to
`Top[list[int & Unknown]]`, but that didn't solve the problem that
iterating over that type after removing the `Top` materialization still
yielded elements of type `int & Unknown`.
* https://github.com/astral-sh/ruff/pull/26848 isinstance narrowing
using tagged `object`/`Never` types. This approach followed the
suggestion in https://github.com/astral-sh/ty/issues/3375. The idea was
to create types that act like `object`/`Never` in intersection
simplification, but like `Unknown` elsewhere. One problem was that this
didn't really address the problem of invariant generics. For those, we
were still forced to use a special `Top*[..]` materialization that would
tell us: this type came from a non-strict isinstance narrowing and it
should produce `object*`/`Never*` types when methods/attributes are
accessed on this type. So for the `Sequence[int] / isinstance(.., list)`
example, we would still create `Sequence[int] & Top*[list[Unknown]]`,
but the advantage was that we now created the intersection `int &
object*` when iterating over that type. Since this simplified to `int`,
it seemed like the problem from above was solved. However, introducing
those two magical types didn't feel very satisfactory. And we still
didn't match the behavior of other type checkers. When narrowing from
`object` using `isinstance(..., list)`, we would create
`Top*[list[Unknown]]`, whereas other type checkers simply inferred
`list[Unknown]`. And there were also more subtle problems where merging
a type like `object*` with another type in a control-flow join would
create `object* | Other = object*` and therefore lose precision.

## Test plan

New and updated Markdown tests

## Ecosystem

Looks good. There are some cases where `TypedDict` types "survive" a
negative `isinstance(.., dict)` check, but I think that's
https://github.com/astral-sh/ty/issues/1130. Gradual mode doesn't do
anything differently in negative branches.


[PR_27308_ECOSYSTEM_SUMMARY.md](https://github.com/user-attachments/files/30749390/PR_27308_ECOSYSTEM_SUMMARY.md)
(the egglog case was resolved since then)
2026-08-06 01:40:30 -07:00
Micha Reiser 721f3104c7 [ty] Remove deprecated src.root setting in favor of environment.root (#27456) 2026-08-04 16:17:29 +02:00
BitWeaverandCharlie Marsh 2272478c06 [ty] Reject ClassVar and Final qualifiers in NamedTuple fields (#27380)
## Summary

`NamedTuple` builds each field's type by passing its annotation through
`typing._type_check`, which rejects bare type qualifiers. A field
annotated with `ClassVar` or `Final` therefore raises `TypeError` as
soon as the class statement executes:

```pycon
>>> from typing import ClassVar, NamedTuple
>>> class Foo(NamedTuple):
...     x: ClassVar[int]
TypeError: typing.ClassVar[int] is not valid as type argument
```

ty accepted both silently. This extends the existing
`invalid-named-tuple` rule to report them.

The obvious place for the check iterates `own_fields`, but that cannot
see either case: it drops `ClassVar` declarations outright, and does not
retain the `Final` qualifier for the fields it does keep. Rather than
duplicate the class-body walk, this extracts it into
`own_annotated_declarations` and adds a second consumer,
`own_annotated_qualifiers`, which reports what the class body literally
says.

Two behaviours worth calling out:

- A field carrying both qualifiers is reported once per qualifier, since
removing just one of them still leaves a class definition that raises
`TypeError`.
- A class that merely inherits from a `NamedTuple` is an ordinary class
at runtime, so it is left alone.

Separately, the two `invalid-named-tuple` reporters for malformed fields
shared their anchoring and primary-annotation logic; that is now
factored into a common `report_invalid_named_tuple_field` helper.

Closes astral-sh/ty#4131

## Test Plan

New mdtests in `named_tuple.md` cover both qualifiers, the unsubscripted
form (`x: ClassVar`), the inheritance non-case, and a snapshot of the
rendered diagnostic.

- `cargo test -p ty_python_semantic`: 297 + 14 unit tests and 479
mdtests pass
- `cargo clippy --workspace --all-targets --all-features -- -D
warnings`: clean
- `uv run --only-group dev --locked prek run --files ...`: passes
- `cargo dev generate-all` re-run for the rule documentation change

---------

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
2026-07-31 18:41:57 -04:00
Aria Desires 995db7ce3d [ty] Add --exclude-scripts and --include-scripts (#27169)
This is a 3rd pseudo-alternative to 

* https://github.com/astral-sh/uv/pull/20676
* https://github.com/astral-sh/ruff/pull/27165

which @MichaReiser suggested as a way to ask for the desired behaviour
that becomes "standard API" and not like, some random "oh are we doing
uv?" escape-hatch.

Notably here a PEP 723 script is only excluded if it's "implicitly
selected" in much the same way that this still checks `bar.py`: `ty
check --exclude foo/ -- foo/bar.py`

`--include-scripts` is the default and exists only to negate
`--exclude-scripts` in e.g. config-vs-cli-arg fights (and maybe will be
nice if we ever change the default).
2026-07-24 12:16:39 -07:00
Denys Zhak 7e6e8b7dbd [ty] Add a lint rule for combined abstract and final decorators (#26932)
Closes https://github.com/astral-sh/ty/issues/3876

## Summary

Lint rule for combined abstract and final decorators

## Test Plan

Md tests
2026-07-24 16:35:06 +05:30
Carl Meyer f774523168 [ty] Support generic manual PEP 695 type aliases (#27083)
## Summary

Add specialization support for generic aliases created with
`TypeAliasType`, including defaults, reordered parameters, `ParamSpec`,
`TypeVarTuple`, nested aliases, constructor inference, and recursive
aliases. Validate malformed `type_params` declarations.

Closes https://github.com/astral-sh/ty/issues/1737.

## Test plan

Added and updated mdtests covering manual-alias specialization in
annotations and value positions, defaults and bounds, nested and
recursive aliases, `Callable`/`ParamSpec` and variadic aliases,
constructor inference, invalid and scoped type parameters,
duplicate/default-order/variadic validation, non-generic and double
specialization errors, and preservation of native PEP 695 aliases in
`Callable` types.

Ecosystem impact is large because scipy-stubs makes extensive use of
generic `TypeAliasType`, and several other ecosystem projects depend on
it. Ecosystem changes look correct/expected for newly understanding
these aliases; some reveal existing limitations of PEP 695 type aliases
that should be fixed separately.
2026-07-23 07:58:45 -07:00
Charlie Marsh 64722a0754 [ty] Allow equality narrowing across non-final classes (#27031)
## Summary

Prior to this change, equality and membership narrowing preserved
otherwise-incompatible union members because a subclass could override
`__eq__`. By default, we now assume that subclasses of a non-final class
do not override the inherited comparison method:

```py
class Foo: ...

def f(value: Foo | None, other: Foo) -> None:
    reveal_type(None == other)  # Literal[False]

    if value == other:
        reveal_type(value)  # Foo

    if value in [other]:
        reveal_type(value)  # Foo
```

The behavior is intentionally unsound. Users who want conservative
equality behavior can enable the single `strict-equality-narrowing`
setting, which also preserves broad builtin types instead of narrowing
them to literals:

```toml
[tool.ty.analysis]
strict-equality-narrowing = true
```

For compatibility, `strict-literal-narrowing` remains supported as an
alias for `strict-equality-narrowing`.

Closes https://github.com/astral-sh/ty/issues/3419.
2026-07-22 15:23:50 +00:00
Carl Meyer 70acda33d6 [ty] Narrow TypeGuard keyword arguments, remove invalid-type-guard-call (#26809)
Closes astral-sh/ty#3991.

## Summary

Stop emitting the `invalid-type-guard-call` diagnostic; a
`TypeIs`/`TypeGuard` call without a narrowable target is valid and
should simply have no narrowing effect. There's no indication in the
typing spec or conformance suite that non-narrowing calls to type-guard
functions should be an error, and this rule unnecessarily restricts
flexible runtime use of such functions, which may not need to apply a
static-analysis narrowing in all cases.

Retain the rule for now with `LintStatus::removed` so existing
configuration references receive a friendlier `unknown-rule` warning
clarifying that the rule has been removed. (When we go to stable we
should fully remove the rule.)

Narrow explicit keyword arguments when they bind to the first
non-`self`/`cls` formal parameter, including bound methods, unbound
methods, class methods, and matching overloads. Never narrow any other
parameter.

## Test plan

mdtests cover keyword reordering, `TypeIs` and `TypeGuard`,
literals/starred calls, omitted defaulted targets, multiple matching
overloads, and bound/unbound/class-method calls.
2026-07-14 12:14:33 -07:00
David Peter 20b113c9b3 [ty] Add support for ignored snippets in mdtests (#26796)
## Summary

We now test all of our rule descriptions using mdtests. For
Pydantic-specific rules, we can't do that, since we don't want to bundle
Pydantic or add a mock-version of Pydantic to the mdtests
(https://github.com/astral-sh/ruff/pull/26762#discussion_r3573467129).
In order to still get syntax highlighting in our docs, we therefore add
support for the following `data-mdtest="ignore"` attribute that can be
used to skip mdtest validation:

````markdown
```python {data-mdtest="ignore"}
from pydantic import BaseModel

# [...]
```
````
2026-07-14 11:28:51 -07:00
David Peter 6f2f4862aa [ty] Pydantic: Warn about immediately discarded extra arguments (#26762)
## Summary

By default, Pydantic allows arbitrary extra constructor arguments, which
is simply ignored (`extra="ignore"`). This often indicates a mistake
though, so we now emit emit a new diagnostic with severity "warning":

```py
class Person(BaseModel):
    name: str
    admin: bool = Field(default=false)

# a typo: should be `admin`, not `admni`

Person(name="Alice", admni=true)  # warning: [pydantic-discarded-extra-argument]
```

closes https://github.com/astral-sh/ty/issues/3966

## Ecosystem impact

One true positive.

## Test plan

New and updated Markdown tests
2026-07-13 22:59:17 +02:00
Charlie Marsh 035ebc332a [ty] Document enum subclass literal narrowing (#26709)
## Summary

This follows up on #26414 by documenting that the standard library's
`StrEnum` and `IntEnum` types are subclasses of `str` and `int`,
respectively, and are therefore affected by unsafe builtin literal
narrowing.

The new `StrEnum` example shows how a call can be statically typed as
returning `Literal["a"] | None` while returning a `Choice` enum member
at runtime. The generated configuration documentation and schema now
include the clarification.
2026-07-10 14:54:01 -04:00