Compare commits

...

3 Commits

Author SHA1 Message Date
Charlie Marsh
87eec9bb51 [ty] Show dynamic NamedTuple defaults in signature (#22574)
## Summary

Follow-up from https://github.com/astral-sh/ruff/pull/22327.
2026-01-14 18:28:23 +00:00
Micha Reiser
eaed0d9b5c [ty] Fix flaky completions (#22576) 2026-01-14 19:23:38 +01:00
Charlie Marsh
eb96456e1e [ty] Synthesize an empty __slots__ for named tuples (#22573)
## Summary

Closes https://github.com/astral-sh/ty/issues/2490.
2026-01-14 18:22:27 +00:00
5 changed files with 127 additions and 69 deletions

View File

@@ -469,7 +469,8 @@ reveal_type(Point2.__new__) # revealed: (cls: type, _0: Any, _1: Any) -> Point2
# `defaults` provides default values for the rightmost fields
Person = collections.namedtuple("Person", ["name", "age", "city"], defaults=["Unknown"])
reveal_type(Person) # revealed: <class 'Person'>
reveal_type(Person.__new__) # revealed: (cls: type, name: Any, age: Any, city: Any = ...) -> Person
reveal_type(Person.__new__) # revealed: (cls: type, name: Any, age: Any, city: Any = "Unknown") -> Person
reveal_mro(Person) # revealed: (<class 'Person'>, <class 'tuple[Any, Any, Any]'>, <class 'object'>)
# Can create with all fields
person1 = Person("Alice", 30, "NYC")
@@ -486,7 +487,7 @@ reveal_type(Config) # revealed: <class 'Config'>
# TODO: This should emit a diagnostic since it would fail at runtime.
TooManyDefaults = collections.namedtuple("TooManyDefaults", ["x", "y"], defaults=("a", "b", "c"))
reveal_type(TooManyDefaults) # revealed: <class 'TooManyDefaults'>
reveal_type(TooManyDefaults.__new__) # revealed: (cls: type, x: Any = ..., y: Any = ...) -> TooManyDefaults
reveal_type(TooManyDefaults.__new__) # revealed: (cls: type, x: Any = "a", y: Any = "b") -> TooManyDefaults
# Unknown keyword arguments produce an error
# error: [unknown-argument]
@@ -845,6 +846,7 @@ class Person(NamedTuple):
reveal_type(Person._field_defaults) # revealed: dict[str, Any]
reveal_type(Person._fields) # revealed: tuple[Literal["name"], Literal["age"]]
reveal_type(Person.__slots__) # revealed: tuple[()]
reveal_type(Person._make) # revealed: bound method <class 'Person'>._make(iterable: Iterable[Any]) -> Person
reveal_type(Person._asdict) # revealed: def _asdict(self) -> dict[str, Any]
reveal_type(Person._replace) # revealed: (self: Self, *, name: str = ..., age: int | None = ...) -> Self
@@ -887,6 +889,8 @@ Person = namedtuple("Person", ["id", "name", "age"], defaults=[None])
alice = Person(1, "Alice", 42)
bob = Person(2, "Bob")
reveal_type(Person.__slots__) # revealed: tuple[()]
```
## `collections.namedtuple` with tuple variable field names

View File

@@ -3244,7 +3244,10 @@ impl<'db> StaticClassLiteral<'db> {
)
})
}
(CodeGeneratorKind::NamedTuple, "__new__" | "_replace" | "__replace__" | "_fields") => {
(
CodeGeneratorKind::NamedTuple,
"__new__" | "_replace" | "__replace__" | "_fields" | "__slots__",
) => {
let fields = self.fields(db, specialization, field_policy);
let fields_iter = fields.iter().map(|(name, field)| {
let default_ty = match &field.kind {
@@ -5212,6 +5215,10 @@ fn synthesize_namedtuple_class_member<'db>(
fields.map(|(field_name, _, _)| Type::string_literal(db, &field_name));
Some(Type::heterogeneous_tuple(db, field_types))
}
"__slots__" => {
// __slots__: tuple[()] - always empty for namedtuples
Some(Type::empty_tuple(db))
}
"_replace" | "__replace__" => {
if name == "__replace__" && Program::get(db).python_version(db) < PythonVersion::PY313 {
return None;
@@ -5536,7 +5543,10 @@ impl<'db> DynamicNamedTupleLiteral<'db> {
// For fallback members from NamedTupleFallback, apply type mapping to handle
// `Self` types. The explicitly synthesized members (__new__, _fields, _replace,
// __replace__) don't need this mapping.
if matches!(name, "__new__" | "_fields" | "_replace" | "__replace__") {
if matches!(
name,
"__new__" | "_fields" | "_replace" | "__replace__" | "__slots__"
) {
result
} else {
result.map(|ty| {

View File

@@ -6564,7 +6564,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
// Infer keyword arguments.
let mut defaults_count = None;
let mut default_types: Vec<Type<'db>> = vec![];
let mut rename_type = None;
for kw in keywords {
@@ -6575,11 +6575,42 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
};
match arg.id.as_str() {
"defaults" if kind.is_collections() => {
defaults_count = kw_type
.exact_tuple_instance_spec(db)
.and_then(|spec| spec.len().maximum())
.or_else(|| kw.value.as_list_expr().map(|list| list.elts.len()));
// Extract element types from AST literals (using already-inferred types)
// or fall back to the inferred tuple spec.
match &kw.value {
ast::Expr::List(list) => {
// Elements were already inferred when we inferred kw.value above.
default_types = list
.elts
.iter()
.map(|elt| self.expression_type(elt))
.collect();
}
ast::Expr::Tuple(tuple) => {
// Elements were already inferred when we inferred kw.value above.
default_types = tuple
.elts
.iter()
.map(|elt| self.expression_type(elt))
.collect();
}
_ => {
// Fall back to using the already-inferred type.
// Try to extract element types from tuple.
if let Some(spec) = kw_type.exact_tuple_instance_spec(db)
&& let Some(fixed) = spec.as_fixed_length()
{
default_types = fixed.all_elements().to_vec();
} else {
// Can't determine individual types; use Any for each element.
let count = kw_type
.exact_tuple_instance_spec(db)
.and_then(|spec| spec.len().maximum())
.unwrap_or(0);
default_types = vec![Type::any(); count];
}
}
}
// Emit diagnostic for invalid types (not Iterable[Any] | None).
let iterable_any =
KnownClass::Iterable.to_specialized_instance(db, &[Type::any()]);
@@ -6644,8 +6675,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
}
}
let defaults_count = defaults_count.unwrap_or_default();
// Extract name.
let name = if let Type::StringLiteral(literal) = name_type {
Name::new(literal.value(db))
@@ -6778,14 +6807,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
// TODO: emit a diagnostic when `defaults_count > num_fields` (which would
// fail at runtime with `TypeError: Got more default values than field names`).
let num_fields = field_names.len();
let defaults_count = defaults_count.min(num_fields);
let defaults_count = default_types.len().min(num_fields);
let fields = field_names
.iter()
.enumerate()
.map(|(i, field_name)| {
let default =
if defaults_count > 0 && i >= num_fields - defaults_count {
Some(Type::any())
// Index into default_types: first default corresponds to first
// field that has a default.
let default_idx = i - (num_fields - defaults_count);
Some(default_types[default_idx])
} else {
None
};

View File

@@ -163,6 +163,13 @@ export default function Chrome({
[workspace, files.index, onRemoveFile],
);
const handleChange = useCallback(
(content: string) => {
onChangeFile(workspace, content);
},
[onChangeFile, workspace],
);
const { defaultLayout, onLayoutChange } = useDefaultLayout({
groupId: "editor-diagnostics",
storage: localStorage,
@@ -221,7 +228,7 @@ export default function Chrome({
diagnostics={checkResult.diagnostics}
workspace={workspace}
onMount={handleEditorMount}
onChange={(content) => onChangeFile(workspace, content)}
onChange={handleChange}
onOpenFile={onSelectFile}
onVendoredFileChange={onSelectVendoredFile}
onBackToUserFile={handleBackToUserFile}

View File

@@ -58,7 +58,7 @@ export default function Playground() {
}
}, [files]);
const handleFileAdded = (workspace: Workspace, name: string) => {
const handleFileAdded = useCallback((workspace: Workspace, name: string) => {
let handle = null;
if (name === SETTINGS_FILE_NAME) {
@@ -68,69 +68,74 @@ export default function Playground() {
}
dispatchFiles({ type: "add", name, handle, content: "" });
};
}, []);
const handleFileChanged = (workspace: Workspace, content: string) => {
if (files.selected == null) {
return;
}
const handleFileChanged = useCallback(
(workspace: Workspace, content: string) => {
if (files.selected == null) {
return;
}
dispatchFiles({
type: "change",
id: files.selected,
content,
});
const handle = files.handles[files.selected];
const handle = files.handles[files.selected];
if (handle != null) {
updateFile(workspace, handle, content, setError);
} else if (fileName === SETTINGS_FILE_NAME) {
updateOptions(workspace, content, setError);
}
if (handle != null) {
updateFile(workspace, handle, content, setError);
} else if (fileName === SETTINGS_FILE_NAME) {
updateOptions(workspace, content, setError);
}
};
dispatchFiles({
type: "change",
id: files.selected,
content,
});
},
[fileName, files.handles, files.selected],
);
const handleFileRenamed = (
workspace: Workspace,
file: FileId,
newName: string,
) => {
if (newName.startsWith("/")) {
setError("File names cannot start with '/'.");
return;
}
if (newName.startsWith("vendored:")) {
setError("File names cannot start with 'vendored:'.");
return;
}
const handleFileRenamed = useCallback(
(workspace: Workspace, file: FileId, newName: string) => {
if (newName.startsWith("/")) {
setError("File names cannot start with '/'.");
return;
}
if (newName.startsWith("vendored:")) {
setError("File names cannot start with 'vendored:'.");
return;
}
const handle = files.handles[file];
let newHandle: FileHandle | null = null;
if (handle == null) {
updateOptions(workspace, null, setError);
} else {
workspace.closeFile(handle);
}
const handle = files.handles[file];
let newHandle: FileHandle | null = null;
if (handle == null) {
updateOptions(workspace, null, setError);
} else {
workspace.closeFile(handle);
}
if (newName === SETTINGS_FILE_NAME) {
updateOptions(workspace, files.contents[file], setError);
} else {
newHandle = workspace.openFile(newName, files.contents[file]);
}
if (newName === SETTINGS_FILE_NAME) {
updateOptions(workspace, files.contents[file], setError);
} else {
newHandle = workspace.openFile(newName, files.contents[file]);
}
dispatchFiles({ type: "rename", id: file, to: newName, newHandle });
};
dispatchFiles({ type: "rename", id: file, to: newName, newHandle });
},
[files.contents, files.handles],
);
const handleFileRemoved = (workspace: Workspace, file: FileId) => {
const handle = files.handles[file];
if (handle == null) {
updateOptions(workspace, null, setError);
} else {
workspace.closeFile(handle);
}
const handleFileRemoved = useCallback(
(workspace: Workspace, file: FileId) => {
const handle = files.handles[file];
if (handle == null) {
updateOptions(workspace, null, setError);
} else {
workspace.closeFile(handle);
}
dispatchFiles({ type: "remove", id: file });
};
dispatchFiles({ type: "remove", id: file });
},
[files.handles],
);
const handleFileSelected = useCallback((file: FileId) => {
dispatchFiles({ type: "selectFile", id: file });