astral-sh/ruff · error
string annotation offset should point to ExprStringLiteral
Error message
string annotation offset should point to ExprStringLiteral
What it means
A dynamic class created inside a string (forward-reference) annotation stores the outer string-literal node offset plus a range inside the parsed annotation text (`DynamicClassScopeOffset::StringAnnotation`). Resolution indexes the module with the stored offset and downcasts the node to `ast::ExprStringLiteral`; this expect fires when the node at that index is not a string literal.
Source
Thrown at crates/ty_python_semantic/src/types/class.rs:132
.value(&module)
.expect("dynamic class definitions should only be used for assignments")
.range(),
DynamicClassHeaderAnchor::ScopeOffset(offset) => {
let (offset, relative_range) = match offset {
DynamicClassScopeOffset::Node(offset) => (offset, None),
DynamicClassScopeOffset::StringAnnotation { offset, range } => {
(offset, Some(range))
}
};
let scope_anchor = scope.node(db).node_index().unwrap_or(NodeIndex::from(0));
let anchor_u32 = scope_anchor
.as_u32()
.expect("anchor should not be NodeIndex::NONE");
let absolute_index = NodeIndex::from(anchor_u32 + offset);
if let Some(relative_range) = relative_range {
let string: &ast::ExprStringLiteral = module
.get_by_index(absolute_index)
.try_into()
.expect("string annotation offset should point to ExprStringLiteral");
return relative_range + string.start();
}
let node: &ast::ExprCall = module
.get_by_index(absolute_index)
.try_into()
.expect("scope offset should point to ExprCall");
node.range()
}
}
}
bitflags::bitflags! {
/// Properties shared by all instances of a class.
///
/// This combines properties derived from the MRO into the existing class-classification
/// query, avoiding a separate cached query for each property.
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]View on GitHub (pinned to 15f3fe6b15)
Solutions
- Ensure the query that stores the offset reads `parsed_module` through Salsa for the same file, so edits invalidate the anchor.
- Verify the offset is always computed from `scope.node(db).node_index()` of the same scope passed to `dynamic_class_header_range`.
- Add an mdtest with the exact quoted annotation that triggered it and run `cargo nextest run -p ty_python_semantic --test mdtest`.
- Report with the reproducer if it occurs on an unmodified build.
Defensive patterns
Strategy: try-catch
Try / catch
let range = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
dynamic_class_header_range(db, scope, anchor)
}))
.unwrap_or_else(|_| file.range()); Prevention
- When reporting, include the exact quoted annotation string - reproducers hinge on it.
- Avoid exotic nesting of dynamic-class calls inside string annotations while a fix is pending (unquote the annotation).
- Re-test after every ty upgrade; string-annotation anchoring is new code.
When it happens
Trigger: Resolving a StringAnnotation anchor whose stored offset no longer lands on the string literal - the module AST changed shape (reparse, edit) while the anchor survived, or the offset was computed against a different scope's node index than the one used at resolution time.
Common situations: LSP server sessions where an edit reparses the file but the cached anchor is not invalidated (missing dependency on `parsed_module`); contributions adding new string-annotation contexts (PEP 649 deferred annotations, `__annotations__` strings, nested quoted annotations).
Related errors
- anchor should not be NodeIndex::NONE
- scope offset should point to ExprCall
- Expected `NamedTuple` definition to be an assignment
- dynamic class definitions should only be used for assignment
- DynamicClassAnchor::Definition should only be used for assig
AI-assisted analysis of astral-sh/ruff@15f3fe6b15 (2026-08-20).
Data as JSON: /api/errors/61741637010e8b37.
Report an issue: GitHub.