Leading whitespace in a single-quoted annotation makes ty discard the whole annotation
ty reports a syntax error and infers Unknown for a forward reference whose body starts with whitespace. mypy and pyright both accept the identical file.
from typing import List
a: " List[int]" = []
b: "List[int]" = []
reveal_type(a) # ty: `list[Unknown]`, mypy/pyright: `list[int]`
reveal_type(b) # `list[int]` everywhereerror[invalid-syntax-in-forward-annotation]: Syntax error in forward annotation
--> repro.py:3:5
|
3 | a: " List[int]" = []
| ^^---------
| |
| Expected an expression
help: Did you mean `typing.Literal[" List[int]"]`?a: " List[int]" |
|
|---|---|
| ty 0.0.81 (4edd7724a 2026-09-14) | invalid-syntax-in-forward-annotation, list[Unknown] |
| mypy 2.3.1 | Revealed type is "list[int]", no error |
| pyright 1.1.414 | Type of "a" is "List[int]", 0 errors |
Mechanism
parse_string_annotation sends triple-quoted bodies to Mode::ParenthesizedExpression and everything else to Mode::Expression (crates/ruff_python_parser/src/lib.rs):
if string.flags.is_triple_quoted() {
parse_parenthesized_expression_range(source, range)
} else {
parse_expression_range(source, range)
}Mode::Expression starts the lexer in State::AfterNewline with nesting = 0, so lex_token runs eat_indentation on the first token and the two leading spaces lex as an Indent. Parsing the exact same range in each mode:
Mode::Expression: Err(ExpectedExpression, 28..30)
Mode::ParenthesizedExpression: Ok(30..39) // true in-file range of `List[int]`The failure is total rather than degraded — parse_string_annotation returns None, so no expression inside the annotation is inferred at all, not just the outermost one.
Why this case slipped through
astral-sh/ruff#9467 asked for quoted annotations to be parsed as if parenthesized. astral-sh/ruff#15387 closed it but scoped the change to triple quotes, citing python/typing-council#9. That council thread only settled newlines inside triple-quoted strings; leading whitespace in a single-quoted body was never raised there.
PEP 484's test has already been overridden once, by this exact mechanism
PEP 484 is the only normative text here, and it is not ambiguous: the body must satisfy compile(lit, '', 'eval'), and compile(" List[int]", '', 'eval') raises IndentationError. PEP 563 restates the same test, and CPython agrees at runtime — get_type_hints raises SyntaxError: Forward reference must be an expression.
But the triple-quote rule appears in no PEP; python/typing#1578 added it to the spec in 2024. It admits bodies that fail that same test, because a leading newline raises IndentationError exactly as leading spaces do:
a: """
List[int]""" = [] # compile() -> IndentationError; ty infers list[int]
b: " List[int]" = [] # compile() -> IndentationError; ty infers list[Unknown]So the spec has already carved out one exception to PEP 484 for bodies that fail compile() on indentation. b is the same defect the carve-out was written to fix, and mypy and pyright both already read it that way.
If you want the lenient behavior
Dropping the branch is the whole change:
let source = &source[..range.end().to_usize()];
parse_parenthesized_expression_range(source, range)I measured the diagnostic delta on a spread of odd bodies; only the leading-whitespace ones change. "", " ", "int)", "(int", "int;", "int ", "int, str", "int if True else str" and " " "int" all produce byte-identical output. cargo test -p ruff_python_parser (813 tests) and cargo test -p ty_python_semantic are green with the change.
Happy to open a PR. Equally happy if the answer is that the diagnostic should stay — in that case it may still be worth parsing the whitespace-stripped body so the contents keep types, rather than dropping the annotation wholesale.
Source: astral-sh/ty