Compare commits

..

1 Commits

Author SHA1 Message Date
David Peter
07efdf8887 [ty] Test for @asynccontextmanager 2025-12-08 12:59:28 +01:00
23 changed files with 497 additions and 673 deletions

View File

@@ -166,8 +166,28 @@ impl Diagnostic {
/// Returns the primary message for this diagnostic.
///
/// A diagnostic always has a message, but it may be empty.
///
/// NOTE: At present, this routine will return the first primary
/// annotation's message as the primary message when the main diagnostic
/// message is empty. This is meant to facilitate an incremental migration
/// in ty over to the new diagnostic data model. (The old data model
/// didn't distinguish between messages on the entire diagnostic and
/// messages attached to a particular span.)
pub fn primary_message(&self) -> &str {
self.inner.message.as_str()
if !self.inner.message.as_str().is_empty() {
return self.inner.message.as_str();
}
// FIXME: As a special case, while we're migrating ty
// to the new diagnostic data model, we'll look for a primary
// message from the primary annotation. This is because most
// ty diagnostics are created with an empty diagnostic
// message and instead attach the message to the annotation.
// Fixing this will require touching basically every diagnostic
// in ty, so we do it this way for now to match the old
// semantics. ---AG
self.primary_annotation()
.and_then(|ann| ann.get_message())
.unwrap_or_default()
}
/// Introspects this diagnostic and returns what kind of "primary" message
@@ -179,6 +199,18 @@ impl Diagnostic {
/// contains *essential* information or context for understanding the
/// diagnostic.
///
/// The reason why we don't just always return both the main diagnostic
/// message and the primary annotation message is because this was written
/// in the midst of an incremental migration of ty over to the new
/// diagnostic data model. At time of writing, diagnostics were still
/// constructed in the old model where the main diagnostic message and the
/// primary annotation message were not distinguished from each other. So
/// for now, we carefully return what kind of messages this diagnostic
/// contains. In effect, if this diagnostic has a non-empty main message
/// *and* a non-empty primary annotation message, then the diagnostic is
/// 100% using the new diagnostic data model and we can format things
/// appropriately.
///
/// The type returned implements the `std::fmt::Display` trait. In most
/// cases, just converting it to a string (or printing it) will do what
/// you want.
@@ -192,10 +224,11 @@ impl Diagnostic {
.primary_annotation()
.and_then(|ann| ann.get_message())
.unwrap_or_default();
if annotation.is_empty() {
ConciseMessage::MainDiagnostic(main)
} else {
ConciseMessage::Both { main, annotation }
match (main.is_empty(), annotation.is_empty()) {
(false, true) => ConciseMessage::MainDiagnostic(main),
(true, false) => ConciseMessage::PrimaryAnnotation(annotation),
(false, false) => ConciseMessage::Both { main, annotation },
(true, true) => ConciseMessage::Empty,
}
}
@@ -660,6 +693,18 @@ impl SubDiagnostic {
/// contains *essential* information or context for understanding the
/// diagnostic.
///
/// The reason why we don't just always return both the main diagnostic
/// message and the primary annotation message is because this was written
/// in the midst of an incremental migration of ty over to the new
/// diagnostic data model. At time of writing, diagnostics were still
/// constructed in the old model where the main diagnostic message and the
/// primary annotation message were not distinguished from each other. So
/// for now, we carefully return what kind of messages this diagnostic
/// contains. In effect, if this diagnostic has a non-empty main message
/// *and* a non-empty primary annotation message, then the diagnostic is
/// 100% using the new diagnostic data model and we can format things
/// appropriately.
///
/// The type returned implements the `std::fmt::Display` trait. In most
/// cases, just converting it to a string (or printing it) will do what
/// you want.
@@ -669,10 +714,11 @@ impl SubDiagnostic {
.primary_annotation()
.and_then(|ann| ann.get_message())
.unwrap_or_default();
if annotation.is_empty() {
ConciseMessage::MainDiagnostic(main)
} else {
ConciseMessage::Both { main, annotation }
match (main.is_empty(), annotation.is_empty()) {
(false, true) => ConciseMessage::MainDiagnostic(main),
(true, false) => ConciseMessage::PrimaryAnnotation(annotation),
(false, false) => ConciseMessage::Both { main, annotation },
(true, true) => ConciseMessage::Empty,
}
}
}
@@ -842,10 +888,6 @@ impl Annotation {
pub fn hide_snippet(&mut self, yes: bool) {
self.hide_snippet = yes;
}
pub fn is_primary(&self) -> bool {
self.is_primary
}
}
/// Tags that can be associated with an annotation.
@@ -1466,10 +1508,28 @@ pub enum DiagnosticFormat {
pub enum ConciseMessage<'a> {
/// A diagnostic contains a non-empty main message and an empty
/// primary annotation message.
///
/// This strongly suggests that the diagnostic is using the
/// "new" data model.
MainDiagnostic(&'a str),
/// A diagnostic contains an empty main message and a non-empty
/// primary annotation message.
///
/// This strongly suggests that the diagnostic is using the
/// "old" data model.
PrimaryAnnotation(&'a str),
/// A diagnostic contains a non-empty main message and a non-empty
/// primary annotation message.
///
/// This strongly suggests that the diagnostic is using the
/// "new" data model.
Both { main: &'a str, annotation: &'a str },
/// A diagnostic contains an empty main message and an empty
/// primary annotation message.
///
/// This indicates that the diagnostic is probably using the old
/// model.
Empty,
/// A custom concise message has been provided.
Custom(&'a str),
}
@@ -1480,9 +1540,13 @@ impl std::fmt::Display for ConciseMessage<'_> {
ConciseMessage::MainDiagnostic(main) => {
write!(f, "{main}")
}
ConciseMessage::PrimaryAnnotation(annotation) => {
write!(f, "{annotation}")
}
ConciseMessage::Both { main, annotation } => {
write!(f, "{main}: {annotation}")
}
ConciseMessage::Empty => Ok(()),
ConciseMessage::Custom(message) => {
write!(f, "{message}")
}

View File

@@ -12,11 +12,11 @@ static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script"))
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ScriptTag {
/// The content of the script before the metadata block.
pub prelude: String,
prelude: String,
/// The metadata block.
pub metadata: String,
metadata: String,
/// The content of the script after the metadata block.
pub postlude: String,
postlude: String,
}
impl ScriptTag {

View File

@@ -33,29 +33,26 @@ impl LineIndex {
line_starts.push(TextSize::default());
let bytes = text.as_bytes();
let mut utf8 = false;
assert!(u32::try_from(bytes.len()).is_ok());
for i in memchr::memchr2_iter(b'\n', b'\r', bytes) {
// Skip `\r` in `\r\n` sequences (only count the `\n`).
if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
continue;
for (i, byte) in bytes.iter().enumerate() {
utf8 |= !byte.is_ascii();
match byte {
// Only track one line break for `\r\n`.
b'\r' if bytes.get(i + 1) == Some(&b'\n') => continue,
b'\n' | b'\r' => {
// SAFETY: Assertion above guarantees `i <= u32::MAX`
#[expect(clippy::cast_possible_truncation)]
line_starts.push(TextSize::from(i as u32) + TextSize::from(1));
}
_ => {}
}
// SAFETY: Assertion above guarantees `i <= u32::MAX`
#[expect(clippy::cast_possible_truncation)]
line_starts.push(TextSize::from(i as u32) + TextSize::from(1));
}
// Determine whether the source text is ASCII.
//
// Empirically, this simple loop is auto-vectorized by LLVM and benchmarks faster than both
// `str::is_ascii()` and hand-written SIMD.
let mut has_non_ascii = false;
for byte in bytes {
has_non_ascii |= !byte.is_ascii();
}
let kind = if has_non_ascii {
let kind = if utf8 {
IndexKind::Utf8
} else {
IndexKind::Ascii

View File

@@ -90,22 +90,6 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
})?
};
let system = OsSystem::new(&cwd);
// If we see a single path, check if it's a PEP-723 script
let mut script_project = None;
if let [path] = &*args.paths {
match ProjectMetadata::discover_script(path, &system) {
Ok(project) => {
script_project = Some(project);
}
Err(ty_project::ProjectMetadataError::NotAScript(_)) => {
// This is fine
}
Err(e) => tracing::info!("Issue reading script at `{path}`: {e}"),
}
}
let project_path = args
.project
.as_ref()
@@ -127,6 +111,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
.map(|path| SystemPath::absolute(path, &cwd))
.collect();
let system = OsSystem::new(&cwd);
let watch = args.watch;
let exit_zero = args.exit_zero;
let config_file = args
@@ -136,13 +121,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
let mut project_metadata = match &config_file {
Some(config_file) => ProjectMetadata::from_config_file(config_file.clone(), &system)?,
None => {
if let Some(project) = script_project {
project
} else {
ProjectMetadata::discover(&project_path, &system)?
}
}
None => ProjectMetadata::discover(&project_path, &system)?,
};
project_metadata.apply_configuration_files(&system)?;

View File

@@ -43,7 +43,7 @@ fn config_override_python_version() -> anyhow::Result<()> {
|
2 | [tool.ty.environment]
3 | python-version = "3.11"
| ^^^^^^ Python version configuration
| ^^^^^^ Python 3.11 assumed due to this configuration setting
|
info: rule `unresolved-attribute` is enabled by default
@@ -143,7 +143,7 @@ fn config_file_annotation_showing_where_python_version_set_typing_error() -> any
),
])?;
assert_cmd_snapshot!(case.command(), @r#"
assert_cmd_snapshot!(case.command(), @r###"
success: false
exit_code: 1
----- stdout -----
@@ -159,14 +159,14 @@ fn config_file_annotation_showing_where_python_version_set_typing_error() -> any
|
2 | [tool.ty.environment]
3 | python-version = "3.8"
| ^^^^^ Python version configuration
| ^^^^^ Python 3.8 assumed due to this configuration setting
|
info: rule `unresolved-reference` is enabled by default
Found 1 diagnostic
----- stderr -----
"#);
"###);
assert_cmd_snapshot!(case.command().arg("--python-version=3.9"), @r###"
success: false
@@ -772,7 +772,7 @@ fn pyvenv_cfg_file_annotation_showing_where_python_version_set() -> anyhow::Resu
("test.py", "aiter"),
])?;
assert_cmd_snapshot!(case.command(), @r"
assert_cmd_snapshot!(case.command(), @r###"
success: false
exit_code: 1
----- stdout -----
@@ -787,7 +787,7 @@ fn pyvenv_cfg_file_annotation_showing_where_python_version_set() -> anyhow::Resu
--> venv/pyvenv.cfg:2:11
|
2 | version = 3.8
| ^^^ Virtual environment metadata
| ^^^ Python version inferred from virtual environment metadata file
3 | home = foo/bar/bin
|
info: No Python version was specified on the command line or in a configuration file
@@ -796,7 +796,7 @@ fn pyvenv_cfg_file_annotation_showing_where_python_version_set() -> anyhow::Resu
Found 1 diagnostic
----- stderr -----
");
"###);
Ok(())
}
@@ -831,7 +831,7 @@ fn pyvenv_cfg_file_annotation_no_trailing_newline() -> anyhow::Result<()> {
("test.py", "aiter"),
])?;
assert_cmd_snapshot!(case.command(), @r"
assert_cmd_snapshot!(case.command(), @r###"
success: false
exit_code: 1
----- stdout -----
@@ -846,7 +846,7 @@ fn pyvenv_cfg_file_annotation_no_trailing_newline() -> anyhow::Result<()> {
--> venv/pyvenv.cfg:4:23
|
4 | version = 3.8
| ^^^ Virtual environment metadata
| ^^^ Python version inferred from virtual environment metadata file
|
info: No Python version was specified on the command line or in a configuration file
info: rule `unresolved-reference` is enabled by default
@@ -854,7 +854,7 @@ fn pyvenv_cfg_file_annotation_no_trailing_newline() -> anyhow::Result<()> {
Found 1 diagnostic
----- stderr -----
");
"###);
Ok(())
}
@@ -898,7 +898,7 @@ fn config_file_annotation_showing_where_python_version_set_syntax_error() -> any
|
2 | [project]
3 | requires-python = ">=3.8"
| ^^^^^^^ Python version configuration
| ^^^^^^^ Python 3.8 assumed due to this configuration setting
|
Found 1 diagnostic
@@ -1206,7 +1206,7 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
|
2 | [environment]
3 | python-version = "3.10"
| ^^^^^^ Python version configuration
| ^^^^^^ Python 3.10 assumed due to this configuration setting
4 | python-platform = "linux"
|
info: rule `unresolved-attribute` is enabled by default
@@ -1225,7 +1225,7 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
|
2 | [environment]
3 | python-version = "3.10"
| ^^^^^^ Python version configuration
| ^^^^^^ Python 3.10 assumed due to this configuration setting
4 | python-platform = "linux"
|
info: rule `unresolved-import` is enabled by default

View File

@@ -19,22 +19,11 @@ pub struct InlayHint {
}
impl InlayHint {
fn variable_type(
expr: &Expr,
rhs: &Expr,
ty: Type,
db: &dyn Db,
allow_edits: bool,
) -> Option<Self> {
fn variable_type(expr: &Expr, ty: Type, db: &dyn Db, allow_edits: bool) -> Self {
let position = expr.range().end();
// Render the type to a string, and get subspans for all the types that make it up
let details = ty.display(db).to_string_parts();
// Filter out a reptitive hints like `x: T = T()`
if call_matches_name(rhs, &details.label) {
return None;
}
// Ok so the idea here is that we potentially have a random soup of spans here,
// and each byte of the string can have at most one target associate with it.
// Thankfully, they were generally pushed in print order, with the inner smaller types
@@ -84,12 +73,12 @@ impl InlayHint {
vec![]
};
Some(Self {
Self {
position,
kind: InlayHintKind::Type,
label: InlayHintLabel { parts: label_parts },
text_edits,
})
}
}
fn call_argument_name(
@@ -261,7 +250,7 @@ struct InlayHintVisitor<'a, 'db> {
db: &'db dyn Db,
model: SemanticModel<'db>,
hints: Vec<InlayHint>,
assignment_rhs: Option<&'a Expr>,
in_assignment: bool,
range: TextRange,
settings: &'a InlayHintSettings,
in_no_edits_allowed: bool,
@@ -273,21 +262,21 @@ impl<'a, 'db> InlayHintVisitor<'a, 'db> {
db,
model: SemanticModel::new(db, file),
hints: Vec::new(),
assignment_rhs: None,
in_assignment: false,
range,
settings,
in_no_edits_allowed: false,
}
}
fn add_type_hint(&mut self, expr: &Expr, rhs: &Expr, ty: Type<'db>, allow_edits: bool) {
fn add_type_hint(&mut self, expr: &Expr, ty: Type<'db>, allow_edits: bool) {
if !self.settings.variable_types {
return;
}
if let Some(inlay_hint) = InlayHint::variable_type(expr, rhs, ty, self.db, allow_edits) {
self.hints.push(inlay_hint);
}
let inlay_hint = InlayHint::variable_type(expr, ty, self.db, allow_edits);
self.hints.push(inlay_hint);
}
fn add_call_argument_name(
@@ -310,8 +299,8 @@ impl<'a, 'db> InlayHintVisitor<'a, 'db> {
}
}
impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> {
fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal {
impl SourceOrderVisitor<'_> for InlayHintVisitor<'_, '_> {
fn enter_node(&mut self, node: AnyNodeRef<'_>) -> TraversalSignal {
if self.range.intersect(node.range()).is_some() {
TraversalSignal::Traverse
} else {
@@ -319,7 +308,7 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> {
}
}
fn visit_stmt(&mut self, stmt: &'a Stmt) {
fn visit_stmt(&mut self, stmt: &Stmt) {
let node = AnyNodeRef::from(stmt);
if !self.enter_node(node).is_traverse() {
@@ -328,9 +317,7 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> {
match stmt {
Stmt::Assign(assign) => {
if !type_hint_is_excessive_for_expr(&assign.value) {
self.assignment_rhs = Some(&*assign.value);
}
self.in_assignment = !type_hint_is_excessive_for_expr(&assign.value);
if !annotations_are_valid_syntax(assign) {
self.in_no_edits_allowed = true;
}
@@ -338,7 +325,7 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> {
self.visit_expr(target);
}
self.in_no_edits_allowed = false;
self.assignment_rhs = None;
self.in_assignment = false;
self.visit_expr(&assign.value);
@@ -357,22 +344,22 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> {
source_order::walk_stmt(self, stmt);
}
fn visit_expr(&mut self, expr: &'a Expr) {
fn visit_expr(&mut self, expr: &'_ Expr) {
match expr {
Expr::Name(name) => {
if let Some(rhs) = self.assignment_rhs {
if self.in_assignment {
if name.ctx.is_store() {
let ty = expr.inferred_type(&self.model);
self.add_type_hint(expr, rhs, ty, !self.in_no_edits_allowed);
self.add_type_hint(expr, ty, !self.in_no_edits_allowed);
}
}
source_order::walk_expr(self, expr);
}
Expr::Attribute(attribute) => {
if let Some(rhs) = self.assignment_rhs {
if self.in_assignment {
if attribute.ctx.is_store() {
let ty = expr.inferred_type(&self.model);
self.add_type_hint(expr, rhs, ty, !self.in_no_edits_allowed);
self.add_type_hint(expr, ty, !self.in_no_edits_allowed);
}
}
source_order::walk_expr(self, expr);
@@ -429,26 +416,6 @@ fn arg_matches_name(arg_or_keyword: &ArgOrKeyword, name: &str) -> bool {
}
}
/// Given a function call, check if the expression is the "same name"
/// as the function being called.
///
/// This allows us to filter out reptitive inlay hints like `x: T = T(...)`.
/// While still allowing non-trivial ones like `x: T[U] = T()`.
fn call_matches_name(expr: &Expr, name: &str) -> bool {
// Only care about function calls
let Expr::Call(call) = expr else {
return false;
};
match &*call.func {
// `x: T = T()` is a match
Expr::Name(expr_name) => expr_name.id.as_str() == name,
// `x: T = a.T()` is a match
Expr::Attribute(expr_attribute) => expr_attribute.attr.as_str() == name,
_ => false,
}
}
/// Given an expression that's the RHS of an assignment, would it be excessive to
/// emit an inlay type hint for the variable assigned to it?
///
@@ -1862,16 +1829,35 @@ mod tests {
",
);
assert_snapshot!(test.inlay_hints(), @r"
assert_snapshot!(test.inlay_hints(), @r#"
class A:
def __init__(self, y):
self.x = int(1)
self.x[: int] = int(1)
self.y[: Unknown] = y
a = A([y=]2)
a.y = int(3)
a[: A] = A([y=]2)
a.y[: int] = int(3)
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/builtins.pyi:348:7
|
347 | @disjoint_base
348 | class int:
| ^^^
349 | """int([x]) -> integer
350 | int(x, base=10) -> integer
|
info: Source
--> main2.py:4:18
|
2 | class A:
3 | def __init__(self, y):
4 | self.x[: int] = int(1)
| ^^^
5 | self.y[: Unknown] = y
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/ty_extensions.pyi:20:1
|
@@ -1885,11 +1871,29 @@ mod tests {
--> main2.py:5:18
|
3 | def __init__(self, y):
4 | self.x = int(1)
4 | self.x[: int] = int(1)
5 | self.y[: Unknown] = y
| ^^^^^^^
6 |
7 | a = A([y=]2)
7 | a[: A] = A([y=]2)
|
info[inlay-hint-location]: Inlay Hint Target
--> main.py:2:7
|
2 | class A:
| ^
3 | def __init__(self, y):
4 | self.x = int(1)
|
info: Source
--> main2.py:7:5
|
5 | self.y[: Unknown] = y
6 |
7 | a[: A] = A([y=]2)
| ^
8 | a.y[: int] = int(3)
|
info[inlay-hint-location]: Inlay Hint Target
@@ -1902,13 +1906,30 @@ mod tests {
5 | self.y = y
|
info: Source
--> main2.py:7:8
--> main2.py:7:13
|
5 | self.y[: Unknown] = y
6 |
7 | a = A([y=]2)
| ^
8 | a.y = int(3)
7 | a[: A] = A([y=]2)
| ^
8 | a.y[: int] = int(3)
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/builtins.pyi:348:7
|
347 | @disjoint_base
348 | class int:
| ^^^
349 | """int([x]) -> integer
350 | int(x, base=10) -> integer
|
info: Source
--> main2.py:8:7
|
7 | a[: A] = A([y=]2)
8 | a.y[: int] = int(3)
| ^^^
|
---------------------------------------------
@@ -1917,12 +1938,12 @@ mod tests {
class A:
def __init__(self, y):
self.x = int(1)
self.x: int = int(1)
self.y: Unknown = y
a = A(2)
a.y = int(3)
");
a: A = A(2)
a.y: int = int(3)
"#);
}
#[test]
@@ -2916,12 +2937,31 @@ mod tests {
def __init__(self):
self.x: int = 1
x = MyClass()
x[: MyClass] = MyClass()
y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
a[: MyClass], b[: MyClass] = MyClass(), MyClass()
c[: MyClass], d[: MyClass] = (MyClass(), MyClass())
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> main.py:2:7
|
2 | class MyClass:
| ^^^^^^^
3 | def __init__(self):
4 | self.x: int = 1
|
info: Source
--> main2.py:6:5
|
4 | self.x: int = 1
5 |
6 | x[: MyClass] = MyClass()
| ^^^^^^^
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
|
info[inlay-hint-location]: Inlay Hint Target
--> stdlib/builtins.pyi:2695:7
|
@@ -2933,7 +2973,7 @@ mod tests {
info: Source
--> main2.py:7:5
|
6 | x = MyClass()
6 | x[: MyClass] = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
| ^^^^^
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
@@ -2951,7 +2991,7 @@ mod tests {
info: Source
--> main2.py:7:11
|
6 | x = MyClass()
6 | x[: MyClass] = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
| ^^^^^^^
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
@@ -2969,7 +3009,7 @@ mod tests {
info: Source
--> main2.py:7:20
|
6 | x = MyClass()
6 | x[: MyClass] = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
| ^^^^^^^
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
@@ -2987,7 +3027,7 @@ mod tests {
info: Source
--> main2.py:8:5
|
6 | x = MyClass()
6 | x[: MyClass] = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
| ^^^^^^^
@@ -3005,7 +3045,7 @@ mod tests {
info: Source
--> main2.py:8:19
|
6 | x = MyClass()
6 | x[: MyClass] = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
| ^^^^^^^
@@ -3054,7 +3094,7 @@ mod tests {
def __init__(self):
self.x: int = 1
x = MyClass()
x: MyClass = MyClass()
y: tuple[MyClass, MyClass] = (MyClass(), MyClass())
a, b = MyClass(), MyClass()
c, d = (MyClass(), MyClass())
@@ -4057,11 +4097,31 @@ mod tests {
def __init__(self):
self.x: int = 1
self.y: int = 2
val = MyClass()
val[: MyClass] = MyClass()
foo(val.x)
foo([x=]val.y)
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> main.py:3:7
|
2 | def foo(x: int): pass
3 | class MyClass:
| ^^^^^^^
4 | def __init__(self):
5 | self.x: int = 1
|
info: Source
--> main2.py:7:7
|
5 | self.x: int = 1
6 | self.y: int = 2
7 | val[: MyClass] = MyClass()
| ^^^^^^^
8 |
9 | foo(val.x)
|
info[inlay-hint-location]: Inlay Hint Target
--> main.py:2:9
|
@@ -4077,6 +4137,20 @@ mod tests {
10 | foo([x=]val.y)
| ^
|
---------------------------------------------
info[inlay-hint-edit]: File after edits
info: Source
def foo(x: int): pass
class MyClass:
def __init__(self):
self.x: int = 1
self.y: int = 2
val: MyClass = MyClass()
foo(val.x)
foo(val.y)
");
}
@@ -4102,11 +4176,31 @@ mod tests {
def __init__(self):
self.x: int = 1
self.y: int = 2
x = MyClass()
x[: MyClass] = MyClass()
foo(x.x)
foo([x=]x.y)
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> main.py:3:7
|
2 | def foo(x: int): pass
3 | class MyClass:
| ^^^^^^^
4 | def __init__(self):
5 | self.x: int = 1
|
info: Source
--> main2.py:7:5
|
5 | self.x: int = 1
6 | self.y: int = 2
7 | x[: MyClass] = MyClass()
| ^^^^^^^
8 |
9 | foo(x.x)
|
info[inlay-hint-location]: Inlay Hint Target
--> main.py:2:9
|
@@ -4122,6 +4216,20 @@ mod tests {
10 | foo([x=]x.y)
| ^
|
---------------------------------------------
info[inlay-hint-edit]: File after edits
info: Source
def foo(x: int): pass
class MyClass:
def __init__(self):
self.x: int = 1
self.y: int = 2
x: MyClass = MyClass()
foo(x.x)
foo(x.y)
");
}
@@ -4150,11 +4258,31 @@ mod tests {
return 1
def y() -> int:
return 2
val = MyClass()
val[: MyClass] = MyClass()
foo(val.x())
foo([x=]val.y())
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> main.py:3:7
|
2 | def foo(x: int): pass
3 | class MyClass:
| ^^^^^^^
4 | def __init__(self):
5 | def x() -> int:
|
info: Source
--> main2.py:9:7
|
7 | def y() -> int:
8 | return 2
9 | val[: MyClass] = MyClass()
| ^^^^^^^
10 |
11 | foo(val.x())
|
info[inlay-hint-location]: Inlay Hint Target
--> main.py:2:9
|
@@ -4170,6 +4298,22 @@ mod tests {
12 | foo([x=]val.y())
| ^
|
---------------------------------------------
info[inlay-hint-edit]: File after edits
info: Source
def foo(x: int): pass
class MyClass:
def __init__(self):
def x() -> int:
return 1
def y() -> int:
return 2
val: MyClass = MyClass()
foo(val.x())
foo(val.y())
");
}
@@ -4202,11 +4346,31 @@ mod tests {
return 1
def y() -> List[int]:
return 2
val = MyClass()
val[: MyClass] = MyClass()
foo(val.x()[0])
foo([x=]val.y()[1])
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> main.py:5:7
|
4 | def foo(x: int): pass
5 | class MyClass:
| ^^^^^^^
6 | def __init__(self):
7 | def x() -> List[int]:
|
info: Source
--> main2.py:11:7
|
9 | def y() -> List[int]:
10 | return 2
11 | val[: MyClass] = MyClass()
| ^^^^^^^
12 |
13 | foo(val.x()[0])
|
info[inlay-hint-location]: Inlay Hint Target
--> main.py:4:9
|
@@ -4224,6 +4388,24 @@ mod tests {
14 | foo([x=]val.y()[1])
| ^
|
---------------------------------------------
info[inlay-hint-edit]: File after edits
info: Source
from typing import List
def foo(x: int): pass
class MyClass:
def __init__(self):
def x() -> List[int]:
return 1
def y() -> List[int]:
return 2
val: MyClass = MyClass()
foo(val.x()[0])
foo(val.y()[1])
");
}
@@ -4515,7 +4697,7 @@ mod tests {
class Foo:
def __init__(self, x: int): pass
Foo([x=]1)
f = Foo([x=]1)
f[: Foo] = Foo([x=]1)
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> main.py:3:24
@@ -4533,7 +4715,24 @@ mod tests {
3 | def __init__(self, x: int): pass
4 | Foo([x=]1)
| ^
5 | f = Foo([x=]1)
5 | f[: Foo] = Foo([x=]1)
|
info[inlay-hint-location]: Inlay Hint Target
--> main.py:2:7
|
2 | class Foo:
| ^^^
3 | def __init__(self, x: int): pass
4 | Foo(1)
|
info: Source
--> main2.py:5:5
|
3 | def __init__(self, x: int): pass
4 | Foo([x=]1)
5 | f[: Foo] = Foo([x=]1)
| ^^^
|
info[inlay-hint-location]: Inlay Hint Target
@@ -4546,13 +4745,22 @@ mod tests {
5 | f = Foo(1)
|
info: Source
--> main2.py:5:10
--> main2.py:5:17
|
3 | def __init__(self, x: int): pass
4 | Foo([x=]1)
5 | f = Foo([x=]1)
| ^
5 | f[: Foo] = Foo([x=]1)
| ^
|
---------------------------------------------
info[inlay-hint-edit]: File after edits
info: Source
class Foo:
def __init__(self, x: int): pass
Foo(1)
f: Foo = Foo(1)
");
}
@@ -4570,7 +4778,7 @@ mod tests {
class Foo:
def __new__(cls, x: int): pass
Foo([x=]1)
f = Foo([x=]1)
f[: Foo] = Foo([x=]1)
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> main.py:3:22
@@ -4588,7 +4796,24 @@ mod tests {
3 | def __new__(cls, x: int): pass
4 | Foo([x=]1)
| ^
5 | f = Foo([x=]1)
5 | f[: Foo] = Foo([x=]1)
|
info[inlay-hint-location]: Inlay Hint Target
--> main.py:2:7
|
2 | class Foo:
| ^^^
3 | def __new__(cls, x: int): pass
4 | Foo(1)
|
info: Source
--> main2.py:5:5
|
3 | def __new__(cls, x: int): pass
4 | Foo([x=]1)
5 | f[: Foo] = Foo([x=]1)
| ^^^
|
info[inlay-hint-location]: Inlay Hint Target
@@ -4601,13 +4826,22 @@ mod tests {
5 | f = Foo(1)
|
info: Source
--> main2.py:5:10
--> main2.py:5:17
|
3 | def __new__(cls, x: int): pass
4 | Foo([x=]1)
5 | f = Foo([x=]1)
| ^
5 | f[: Foo] = Foo([x=]1)
| ^
|
---------------------------------------------
info[inlay-hint-edit]: File after edits
info: Source
class Foo:
def __new__(cls, x: int): pass
Foo(1)
f: Foo = Foo(1)
");
}

View File

@@ -41,7 +41,7 @@ rustc-hash = { workspace = true }
salsa = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_json = { workspace = true, optional = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
@@ -55,6 +55,7 @@ default = ["zstd"]
deflate = ["ty_vendored/deflate"]
schemars = [
"dep:schemars",
"dep:serde_json",
"ruff_db/schemars",
"ruff_python_ast/schemars",
"ty_python_semantic/schemars",

View File

@@ -9,15 +9,13 @@ use ty_python_semantic::ProgramSettings;
use crate::metadata::options::ProjectOptionsOverrides;
use crate::metadata::pyproject::{Project, PyProject, PyProjectError, ResolveRequiresPythonError};
use crate::metadata::script::{Pep723Error, Pep723Metadata};
use crate::metadata::value::{RelativePathBuf, ValueSource};
use crate::metadata::value::ValueSource;
pub use options::Options;
use options::TyTomlError;
mod configuration_file;
pub mod options;
pub mod pyproject;
pub mod script;
pub mod settings;
pub mod value;
@@ -87,32 +85,6 @@ impl ProjectMetadata {
)
}
/// Loads a project from a `pyproject.toml` file.
pub(crate) fn from_script(
script: Pep723Metadata,
script_path: &SystemPath,
) -> Result<Self, ResolveRequiresPythonError> {
let project = Some(&script.to_project());
let parent_dir = script_path
.parent()
.map(ToOwned::to_owned)
.unwrap_or_default();
let mut metadata = Self::from_options(
script.tool.and_then(|tool| tool.ty).unwrap_or_default(),
parent_dir,
project,
)?;
// Try to get `uv sync --script` to setup the venv for us
if let Some(python) = script::uv_sync_script(script_path) {
let mut environment = metadata.options.environment.unwrap_or_default();
environment.python = Some(RelativePathBuf::new(python, ValueSource::Cli));
metadata.options.environment = Some(environment);
}
Ok(metadata)
}
/// Loads a project from a set of options with an optional pyproject-project table.
pub fn from_options(
mut options: Options,
@@ -148,46 +120,6 @@ impl ProjectMetadata {
})
}
pub fn discover_script(
path: &SystemPath,
system: &dyn System,
) -> Result<ProjectMetadata, ProjectMetadataError> {
tracing::debug!("Searching for a PEP-723 Script in '{path}'");
if !system.is_file(path) {
return Err(ProjectMetadataError::NotAScript(path.to_path_buf()));
}
let script_metadata = if let Ok(script_str) = system.read_to_string(path) {
match Pep723Metadata::from_script_str(
script_str.as_bytes(),
ValueSource::File(Arc::new(path.to_owned())),
) {
Ok(Some(pyproject)) => Some(pyproject),
Ok(None) => None,
Err(error) => {
return Err(ProjectMetadataError::InvalidScript {
path: path.to_owned(),
source: Box::new(error),
});
}
}
} else {
None
};
let Some(script_metadata) = script_metadata else {
return Err(ProjectMetadataError::NotAScript(path.to_path_buf()));
};
let metadata = ProjectMetadata::from_script(script_metadata, path).map_err(|err| {
ProjectMetadataError::InvalidRequiresPythonConstraint {
source: err,
path: path.to_owned(),
}
})?;
Ok(metadata)
}
/// Discovers the closest project at `path` and returns its metadata.
///
/// The algorithm traverses upwards in the `path`'s ancestor chain and uses the following precedence
@@ -387,21 +319,12 @@ pub enum ProjectMetadataError {
#[error("project path '{0}' is not a directory")]
NotADirectory(SystemPathBuf),
#[error("project path '{0}' is not a PEP-723 script")]
NotAScript(SystemPathBuf),
#[error("{path} is not a valid `pyproject.toml`: {source}")]
InvalidPyProject {
source: Box<PyProjectError>,
path: SystemPathBuf,
},
#[error("{path} is not a valid PEP-723 script: {source}")]
InvalidScript {
source: Box<Pep723Error>,
path: SystemPathBuf,
},
#[error("{path} is not a valid `ty.toml`: {source}")]
InvalidTyToml {
source: Box<TyTomlError>,

View File

@@ -1,157 +0,0 @@
use std::{io, process::Command, str::FromStr};
use camino::Utf8PathBuf;
use pep440_rs::VersionSpecifiers;
use ruff_db::system::{SystemPath, SystemPathBuf};
use ruff_python_ast::script::ScriptTag;
use serde::Deserialize;
use thiserror::Error;
use crate::metadata::{
pyproject::{Project, Tool},
value::{RangedValue, ValueSource, ValueSourceGuard},
};
/// PEP 723 metadata as parsed from a `script` comment block.
///
/// See: <https://peps.python.org/pep-0723/>
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct Pep723Metadata {
pub dependencies: Option<RangedValue<Vec<toml::Value>>>,
pub requires_python: Option<RangedValue<VersionSpecifiers>>,
pub tool: Option<Tool>,
/// The raw unserialized document.
#[serde(skip)]
pub raw: String,
}
#[derive(Debug, Error)]
pub enum Pep723Error {
#[error(
"An opening tag (`# /// script`) was found without a closing tag (`# ///`). Ensure that every line between the opening and closing tags (including empty lines) starts with a leading `#`."
)]
UnclosedBlock,
#[error("The PEP 723 metadata block is missing from the script.")]
MissingTag,
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Utf8(#[from] std::str::Utf8Error),
#[error(transparent)]
Toml(#[from] toml::de::Error),
#[error("Invalid filename `{0}` supplied")]
InvalidFilename(String),
}
impl Pep723Metadata {
/// Parse the PEP 723 metadata from `stdin`.
pub fn from_script_str(
contents: &[u8],
source: ValueSource,
) -> Result<Option<Self>, Pep723Error> {
let _guard = ValueSourceGuard::new(source, true);
// Extract the `script` tag.
let Some(ScriptTag { metadata, .. }) = ScriptTag::parse(contents) else {
return Ok(None);
};
// Parse the metadata.
Ok(Some(Self::from_str(&metadata)?))
}
pub fn to_project(&self) -> Project {
Project {
name: None,
version: None,
requires_python: self.requires_python.clone(),
}
}
}
/*
{
"schema": {
"version": "preview"
},
"target": "script",
"script": {
"path": "/Users/myuser/code/myproj/scripts/load-test.py"
},
"sync": {
"environment": {
"path": "/Users/myuser/.cache/uv/environments-v2/load-test-d6edaf5bfab110a8",
"python": {
"path": "/Users/myuser/.cache/uv/environments-v2/load-test-d6edaf5bfab110a8/bin/python3",
"version": "3.14.0",
"implementation": "cpython"
}
},
"action": "check"
},
"lock": null,
"dry_run": false
}
*/
/// The output of `uv sync --output-format=json --script ...`
#[derive(Debug, Clone, Deserialize)]
struct UvMetadata {
sync: Option<UvSync>,
}
#[derive(Debug, Clone, Deserialize)]
struct UvSync {
environment: Option<UvEnvironment>,
}
#[derive(Debug, Clone, Deserialize)]
struct UvEnvironment {
path: Option<String>,
}
/// Ask `uv` to sync the script's venv to some temp dir so we can analyze dependencies properly
///
/// Returns the path to the venv on success
pub fn uv_sync_script(script_path: &SystemPath) -> Option<SystemPathBuf> {
tracing::info!("Asking uv to sync the script's venv");
let mut command = Command::new("uv");
command
.arg("sync")
.arg("--output-format=json")
.arg("--script")
.arg(script_path.as_str());
let output = command
.output()
.inspect_err(|e| {
tracing::info!(
"failed to run `uv sync --output-format=json --script {script_path}`: {e}"
);
})
.ok()?;
let metadata: UvMetadata = serde_json::from_slice(&output.stdout)
.inspect_err(|e| {
tracing::info!(
"failed to parse `uv sync --output-format=json --script {script_path}`: {e}"
);
})
.ok()?;
let env_path = metadata.sync?.environment?.path?;
let utf8_path = Utf8PathBuf::from(env_path);
Some(SystemPathBuf::from_utf8_path_buf(utf8_path))
}
impl FromStr for Pep723Metadata {
type Err = toml::de::Error;
/// Parse `Pep723Metadata` from a raw TOML string.
fn from_str(raw: &str) -> Result<Self, Self::Err> {
let metadata = toml::from_str(raw)?;
Ok(Self {
raw: raw.to_string(),
..metadata
})
}
}

View File

@@ -82,6 +82,24 @@ async def main():
reveal_type(b) # revealed: int
```
### `asynccontextmanager`
```py
from contextlib import asynccontextmanager
from typing import AsyncGenerator
class Session: ...
@asynccontextmanager
async def connect() -> AsyncGenerator[Session]:
yield Session()
async def main():
async with connect() as session:
# TODO: should be `Session`
reveal_type(session) # revealed: Unknown
```
## Under the hood
```toml

View File

@@ -9,9 +9,9 @@ python-platform = "linux"
dependencies = ["SQLAlchemy==2.0.44"]
```
## ORM Model
## Basic model
This test makes sure that ty understands SQLAlchemy's `dataclass_transform` setup:
Here, we mostly make sure that ty understands SQLAlchemy's dataclass-transformer setup:
```py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -40,12 +40,14 @@ reveal_type(User.__init__) # revealed: def __init__(self, **kw: Any) -> Unknown
invalid_user = User(invalid_arg=42)
```
## Basic query example
## Queries
First, set up a `Session`:
First, the basic setup:
```py
from sqlalchemy import select, Integer, Text, Boolean
from datetime import datetime
from sqlalchemy import select, Integer, Text, Boolean, DateTime
from sqlalchemy.orm import Session
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped, mapped_column
@@ -55,7 +57,7 @@ engine = create_engine("sqlite://example.db")
session = Session(engine)
```
And define a simple model:
Now we can declare a simple model:
```py
class Base(DeclarativeBase):
@@ -69,7 +71,7 @@ class User(Base):
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
```
Finally, we can execute queries:
And perform simple queries:
```py
stmt = select(User)
@@ -82,27 +84,21 @@ for row in session.execute(stmt):
reveal_type(row) # revealed: Row[tuple[User]]
stmt = select(User).where(User.name == "Alice")
alice1 = session.scalars(stmt).first()
reveal_type(alice1) # revealed: User | None
alice = session.scalars(stmt).first()
reveal_type(alice) # revealed: User | None
alice2 = session.scalar(stmt)
reveal_type(alice2) # revealed: User | None
result = session.execute(stmt)
row = result.one_or_none()
assert row is not None
(alice3,) = row._tuple()
reveal_type(alice3) # revealed: User
```
This also works with more complex queries:
```py
stmt = select(User).where(User.is_admin == True).order_by(User.name).limit(10)
admin_users = session.scalars(stmt).all()
reveal_type(admin_users) # revealed: Sequence[User]
```
This also works with the legacy `query` API:
```py
users_legacy = session.query(User).all()
reveal_type(users_legacy) # revealed: list[User]
```
We can also specify particular columns to select:
```py
@@ -110,101 +106,19 @@ stmt = select(User.id, User.name)
# TODO: should be `Select[tuple[int, str]]`
reveal_type(stmt) # revealed: Select[tuple[Unknown, Unknown]]
ids_and_names = session.execute(stmt).all()
# TODO: should be `Sequence[Row[tuple[int, str]]]`
reveal_type(ids_and_names) # revealed: Sequence[Row[tuple[Unknown, Unknown]]]
for row in session.execute(stmt):
# TODO: should be `Row[tuple[int, str]]`
# TODO: should be `Row[Tuple[int, str]]`
reveal_type(row) # revealed: Row[tuple[Unknown, Unknown]]
for user_id, name in session.execute(stmt).tuples():
# TODO: should be `int`
reveal_type(user_id) # revealed: Unknown
# TODO: should be `str`
reveal_type(name) # revealed: Unknown
result = session.execute(stmt)
row = result.one_or_none()
assert row is not None
(user_id, name) = row._tuple()
# TODO: should be `int`
reveal_type(user_id) # revealed: Unknown
# TODO: should be `str`
reveal_type(name) # revealed: Unknown
stmt = select(User.id).where(User.name == "Alice")
# TODO: should be `Select[tuple[int]]`
reveal_type(stmt) # revealed: Select[tuple[Unknown]]
alice_id = session.scalars(stmt).first()
# TODO: should be `int | None`
reveal_type(alice_id) # revealed: Unknown | None
alice_id = session.scalar(stmt)
# TODO: should be `int | None`
reveal_type(alice_id) # revealed: Unknown | None
```
Using the legacy `query` API also works:
```py
users_legacy = session.query(User).all()
reveal_type(users_legacy) # revealed: list[User]
query = session.query(User)
reveal_type(query) # revealed: Query[User]
reveal_type(query.all()) # revealed: list[User]
for row in query:
reveal_type(row) # revealed: User
```
And similarly when specifying particular columns:
And similarly with the legacy `query` API:
```py
query = session.query(User.id, User.name)
# TODO: should be `RowReturningQuery[tuple[int, str]]`
reveal_type(query) # revealed: RowReturningQuery[tuple[Unknown, Unknown]]
# TODO: should be `list[Row[tuple[int, str]]]`
reveal_type(query.all()) # revealed: list[Row[tuple[Unknown, Unknown]]]
for row in query:
# TODO: should be `Row[tuple[int, str]]`
for row in query.all():
# TODO: should be `Row[Tuple[int, str]]`
reveal_type(row) # revealed: Row[tuple[Unknown, Unknown]]
```
## Async API
The async API is supported as well:
```py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, Integer, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text)
async def test_async(session: AsyncSession):
stmt = select(User).where(User.name == "Alice")
alice = await session.scalar(stmt)
reveal_type(alice) # revealed: User | None
stmt = select(User.id, User.name)
result = await session.execute(stmt)
for user_id, name in result.tuples():
# TODO: should be `int`
reveal_type(user_id) # revealed: Unknown
# TODO: should be `str`
reveal_type(name) # revealed: Unknown
```

View File

@@ -102,38 +102,6 @@ Other values are invalid.
P4 = ParamSpec("P4", default=int)
```
### `default` parameter in `typing_extensions.ParamSpec`
```toml
[environment]
python-version = "3.12"
```
The `default` parameter to `ParamSpec` is available from `typing_extensions` in Python 3.12 and
earlier.
```py
from typing import ParamSpec
from typing_extensions import ParamSpec as ExtParamSpec
# This shouldn't emit a diagnostic
P1 = ExtParamSpec("P1", default=[int, str])
# But, this should
# error: [invalid-paramspec] "The `default` parameter of `typing.ParamSpec` was added in Python 3.13"
P2 = ParamSpec("P2", default=[int, str])
```
And, it allows the same set of values as `typing.ParamSpec`.
```py
P3 = ExtParamSpec("P3", default=...)
P4 = ExtParamSpec("P4", default=P3)
# error: [invalid-paramspec]
P5 = ExtParamSpec("P5", default=int)
```
### Forward references in stub files
Stubs natively support forward references, so patterns that would raise `NameError` at runtime are

View File

@@ -128,16 +128,3 @@ InvalidEmptyUnion = Union[]
def _(u: InvalidEmptyUnion):
reveal_type(u) # revealed: Unknown
```
### `typing.Annotated`
```py
from typing import Annotated
# error: [invalid-syntax] "Expected index or slice expression"
# error: [invalid-type-form] "Special form `typing.Annotated` expected at least 2 arguments (one type and at least one metadata element)"
InvalidEmptyAnnotated = Annotated[]
def _(a: InvalidEmptyAnnotated):
reveal_type(a) # revealed: Unknown
```

View File

@@ -1,19 +0,0 @@
# `ParamSpec` regression on 3.9
```toml
[environment]
python-version = "3.9"
```
This used to panic when run on Python 3.9 because `ParamSpec` was introduced in Python 3.10 and the
diagnostic message for `invalid-exception-caught` expects to construct `typing.ParamSpec`.
```py
# error: [invalid-syntax]
def foo[**P]() -> None:
try:
pass
# error: [invalid-exception-caught] "Invalid object caught in an exception handler: Object has type `typing.ParamSpec`"
except P:
pass
```

View File

@@ -58,8 +58,9 @@ pub fn add_inferred_python_version_hint_to_diagnostic(
SubDiagnosticSeverity::Info,
format_args!("Python {version} was assumed when {action}"),
);
sub_diagnostic
.annotate(Annotation::primary(span).message("Python version configuration"));
sub_diagnostic.annotate(Annotation::primary(span).message(format_args!(
"Python {version} assumed due to this configuration setting"
)));
diagnostic.sub(sub_diagnostic);
} else {
diagnostic.info(format_args!(
@@ -75,8 +76,10 @@ pub fn add_inferred_python_version_hint_to_diagnostic(
"Python {version} was assumed when {action} because of your virtual environment"
),
);
sub_diagnostic
.annotate(Annotation::primary(span).message("Virtual environment metadata"));
sub_diagnostic.annotate(
Annotation::primary(span)
.message("Python version inferred from virtual environment metadata file"),
);
// TODO: it would also be nice to tell them how we resolved their virtual environment...
diagnostic.sub(sub_diagnostic);
} else {

View File

@@ -4168,8 +4168,6 @@ pub enum KnownClass {
SpecialForm,
TypeVar,
ParamSpec,
// typing_extensions.ParamSpec
ExtensionsParamSpec, // must be distinct from typing.ParamSpec, backports new features
ParamSpecArgs,
ParamSpecKwargs,
ProtocolMeta,
@@ -4241,7 +4239,6 @@ impl KnownClass {
| Self::TypeVar
| Self::ExtensionsTypeVar
| Self::ParamSpec
| Self::ExtensionsParamSpec
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::TypeVarTuple
@@ -4374,7 +4371,6 @@ impl KnownClass {
| KnownClass::TypeVar
| KnownClass::ExtensionsTypeVar
| KnownClass::ParamSpec
| KnownClass::ExtensionsParamSpec
| KnownClass::ParamSpecArgs
| KnownClass::ParamSpecKwargs
| KnownClass::TypeVarTuple
@@ -4461,7 +4457,6 @@ impl KnownClass {
| KnownClass::TypeVar
| KnownClass::ExtensionsTypeVar
| KnownClass::ParamSpec
| KnownClass::ExtensionsParamSpec
| KnownClass::ParamSpecArgs
| KnownClass::ParamSpecKwargs
| KnownClass::TypeVarTuple
@@ -4548,7 +4543,6 @@ impl KnownClass {
| KnownClass::TypeVar
| KnownClass::ExtensionsTypeVar
| KnownClass::ParamSpec
| KnownClass::ExtensionsParamSpec
| KnownClass::ParamSpecArgs
| KnownClass::ParamSpecKwargs
| KnownClass::TypeVarTuple
@@ -4640,7 +4634,6 @@ impl KnownClass {
| Self::TypeVar
| Self::ExtensionsTypeVar
| Self::ParamSpec
| Self::ExtensionsParamSpec
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::TypeVarTuple
@@ -4740,7 +4733,6 @@ impl KnownClass {
| KnownClass::TypeVar
| KnownClass::ExtensionsTypeVar
| KnownClass::ParamSpec
| KnownClass::ExtensionsParamSpec
| KnownClass::ParamSpecArgs
| KnownClass::ParamSpecKwargs
| KnownClass::ProtocolMeta
@@ -4814,7 +4806,6 @@ impl KnownClass {
Self::TypeVar => "TypeVar",
Self::ExtensionsTypeVar => "TypeVar",
Self::ParamSpec => "ParamSpec",
Self::ExtensionsParamSpec => "ParamSpec",
Self::ParamSpecArgs => "ParamSpecArgs",
Self::ParamSpecKwargs => "ParamSpecKwargs",
Self::TypeVarTuple => "TypeVarTuple",
@@ -5148,18 +5139,11 @@ impl KnownClass {
Self::TypeAliasType
| Self::ExtensionsTypeVar
| Self::TypeVarTuple
| Self::ExtensionsParamSpec
| Self::ParamSpec
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::Deprecated
| Self::NewType => KnownModule::TypingExtensions,
Self::ParamSpec => {
if Program::get(db).python_version(db) >= PythonVersion::PY310 {
KnownModule::Typing
} else {
KnownModule::TypingExtensions
}
}
Self::NoDefaultType => {
let python_version = Program::get(db).python_version(db);
@@ -5263,7 +5247,6 @@ impl KnownClass {
| Self::TypeVar
| Self::ExtensionsTypeVar
| Self::ParamSpec
| Self::ExtensionsParamSpec
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::TypeVarTuple
@@ -5354,7 +5337,6 @@ impl KnownClass {
| Self::TypeVar
| Self::ExtensionsTypeVar
| Self::ParamSpec
| Self::ExtensionsParamSpec
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::TypeVarTuple
@@ -5438,7 +5420,7 @@ impl KnownClass {
"Iterable" => &[Self::Iterable],
"Iterator" => &[Self::Iterator],
"Mapping" => &[Self::Mapping],
"ParamSpec" => &[Self::ParamSpec, Self::ExtensionsParamSpec],
"ParamSpec" => &[Self::ParamSpec],
"ParamSpecArgs" => &[Self::ParamSpecArgs],
"ParamSpecKwargs" => &[Self::ParamSpecKwargs],
"TypeVarTuple" => &[Self::TypeVarTuple],
@@ -5560,8 +5542,6 @@ impl KnownClass {
| Self::TypedDictFallback
| Self::TypeVar
| Self::ExtensionsTypeVar
| Self::ParamSpec
| Self::ExtensionsParamSpec
| Self::NamedTupleLike
| Self::ConstraintSet
| Self::GenericContext
@@ -5575,6 +5555,7 @@ impl KnownClass {
| Self::TypeAliasType
| Self::NoDefaultType
| Self::SupportsIndex
| Self::ParamSpec
| Self::ParamSpecArgs
| Self::ParamSpecKwargs
| Self::TypeVarTuple
@@ -5989,7 +5970,6 @@ mod tests {
KnownClass::Member | KnownClass::Nonmember | KnownClass::StrEnum => {
PythonVersion::PY311
}
KnownClass::ParamSpec => PythonVersion::PY310,
_ => PythonVersion::PY37,
};
(class, version_added)

View File

@@ -5033,15 +5033,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
) => {
self.infer_legacy_typevar(target, call_expr, definition, typevar_class)
}
Some(
paramspec_class @ (KnownClass::ParamSpec
| KnownClass::ExtensionsParamSpec),
) => self.infer_legacy_paramspec(
target,
call_expr,
definition,
paramspec_class,
),
Some(KnownClass::ParamSpec) => {
self.infer_paramspec(target, call_expr, definition)
}
Some(KnownClass::NewType) => {
self.infer_newtype_expression(target, call_expr, definition)
}
@@ -5086,12 +5080,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
target_ty
}
fn infer_legacy_paramspec(
fn infer_paramspec(
&mut self,
target: &ast::Expr,
call_expr: &ast::ExprCall,
definition: Definition<'db>,
known_class: KnownClass,
) -> Type<'db> {
fn error<'db>(
context: &InferContext<'db, '_>,
@@ -5108,8 +5101,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let db = self.db();
let arguments = &call_expr.arguments;
let is_typing_extensions = known_class == KnownClass::ExtensionsParamSpec;
let assume_all_features = self.in_stub() || is_typing_extensions;
let assume_all_features = self.in_stub();
let python_version = Program::get(db).python_version(db);
let have_features_from =
|version: PythonVersion| assume_all_features || python_version >= version;
@@ -5602,10 +5594,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
self.infer_type_expression(&bound.value);
}
if let Some(default) = arguments.find_keyword("default") {
if matches!(
known_class,
Some(KnownClass::ParamSpec | KnownClass::ExtensionsParamSpec)
) {
if let Some(KnownClass::ParamSpec) = known_class {
self.infer_paramspec_default(&default.value);
} else {
self.infer_type_expression(&default.value);
@@ -8451,7 +8440,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
);
}
}
Some(KnownClass::ParamSpec | KnownClass::ExtensionsParamSpec) => {
Some(KnownClass::ParamSpec) => {
if let Some(builder) = self
.context
.report_lint(&INVALID_PARAMSPEC, call_expression)

View File

@@ -1172,11 +1172,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
)
.in_type_expression(db, self.scope(), None)
.unwrap_or_else(|err| err.into_fallback_type(&self.context, subscript, true));
// Only store on the tuple slice; non-tuple cases are handled by
// `infer_subscript_load_impl` via `infer_expression`.
if arguments_slice.is_tuple_expr() {
self.store_expression_type(arguments_slice, ty);
}
self.store_expression_type(arguments_slice, ty);
ty
}
SpecialFormType::Literal => match self.infer_literal_parameter_type(arguments_slice) {

View File

@@ -349,7 +349,6 @@ pub(super) fn to_lsp_diagnostic(
sub_diagnostic
.annotations()
.iter()
.filter(|annotation| !annotation.is_primary())
.filter_map(|annotation| {
annotation_to_related_information(db, annotation, encoding)
}),

View File

@@ -427,16 +427,10 @@ mod tests {
let mut diag = if self.id == DiagnosticId::RevealedType {
Diagnostic::new(self.id, Severity::Error, "Revealed type")
} else {
Diagnostic::new(self.id, Severity::Error, self.message)
Diagnostic::new(self.id, Severity::Error, "")
};
let span = Span::from(file).with_range(self.range);
let mut annotation = Annotation::primary(span);
if self.id == DiagnosticId::RevealedType {
annotation = annotation.message(self.message);
}
diag.annotate(annotation);
diag.annotate(Annotation::primary(span).message(self.message));
diag
}
}

View File

@@ -33,7 +33,7 @@ from collections.abc import Callable
from dataclasses import KW_ONLY, dataclass
from functools import partial
from pathlib import Path
from typing import Final, NewType, NoReturn, assert_never
from typing import NewType, NoReturn, assert_never
from pysource_codegen import generate as generate_random_code
from pysource_minimize import CouldNotMinimize, minimize as minimize_repro
@@ -44,12 +44,6 @@ MinimizedSourceCode = NewType("MinimizedSourceCode", str)
Seed = NewType("Seed", int)
ExitCode = NewType("ExitCode", int)
TY_TARGET_PLATFORM: Final = "linux"
# ty supports `--python-version=3.8`, but typeshed only supports 3.9+,
# so that's probably the oldest version we can usefully test with.
OLDEST_SUPPORTED_PYTHON: Final = "3.9"
def ty_contains_bug(code: str, *, ty_executable: Path) -> bool:
"""Return `True` if the code triggers a panic in type-checking code."""
@@ -57,17 +51,7 @@ def ty_contains_bug(code: str, *, ty_executable: Path) -> bool:
input_file = Path(tempdir, "input.py")
input_file.write_text(code)
completed_process = subprocess.run(
[
ty_executable,
"check",
input_file,
"--python-version",
OLDEST_SUPPORTED_PYTHON,
"--python-platform",
TY_TARGET_PLATFORM,
],
capture_output=True,
text=True,
[ty_executable, "check", input_file], capture_output=True, text=True
)
return completed_process.returncode not in {0, 1, 2}
@@ -153,10 +137,7 @@ class FuzzResult:
case Executable.RUFF:
panic_message = f"The following code triggers a {new}parser bug:"
case Executable.TY:
panic_message = (
f"The following code triggers a {new}ty panic with "
f"`--python-version={OLDEST_SUPPORTED_PYTHON} --python-platform={TY_TARGET_PLATFORM}`:"
)
panic_message = f"The following code triggers a {new}ty panic:"
case _ as unreachable:
assert_never(unreachable)

View File

@@ -19,7 +19,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = ["mypy", "ruff", "ty"]
dev = ["mypy", "ruff"]
[tool.hatch.build.targets.wheel]
include = ["fuzz.py"]

View File

@@ -89,7 +89,6 @@ dependencies = [
dev = [
{ name = "mypy" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata]
@@ -105,7 +104,6 @@ requires-dist = [
dev = [
{ name = "mypy" },
{ name = "ruff" },
{ name = "ty" },
]
[[package]]
@@ -198,31 +196,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" },
]
[[package]]
name = "ty"
version = "0.0.1a32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/92/8da015685fb83734a2a83de02080e64d182509de77fa9bcf3eed12eeab4b/ty-0.0.1a32.tar.gz", hash = "sha256:12f62e8a3dd0eaeb9557d74b1c32f0616ae40eae10a4f411e1e2a73225f67ff2", size = 4689151, upload-time = "2025-12-05T21:04:26.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/e6/fdc35c9ba047f16afdfedf36fb51c221e0190ccde9f70ee28e77084d6612/ty-0.0.1a32-py3-none-linux_armv6l.whl", hash = "sha256:ffe595eaf616f06f58f951766477830a55c2502d2c9f77dde8f60d9a836e0645", size = 9673128, upload-time = "2025-12-05T21:04:17.702Z" },
{ url = "https://files.pythonhosted.org/packages/19/20/eaff31048e2f309f37478f7d715c8de9f9bab03cba4758da27b9311147af/ty-0.0.1a32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:07f1dce88ad6028fb14665aefe4e6697012c34bd48edd37d02b7eb6a833dbf62", size = 9434094, upload-time = "2025-12-05T21:04:03.383Z" },
{ url = "https://files.pythonhosted.org/packages/67/d4/ea8ed57d11b81c459f23561fd6bfb0f54a8d4120cf72541e3bdf71d46202/ty-0.0.1a32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8fab7ed12528c77ddd600a9638ca859156a53c20f1e381353fa87a255bd397eb", size = 8980296, upload-time = "2025-12-05T21:04:28.912Z" },
{ url = "https://files.pythonhosted.org/packages/49/02/3ce98bbfbb3916678d717ee69358d38a404ca9a39391dda8874b66dd5ee7/ty-0.0.1a32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ace395280fc21e25eff0a53cfbd68170f90a4b8ef2f85dfabe1ecbca2ced456b", size = 9263054, upload-time = "2025-12-05T21:04:05.619Z" },
{ url = "https://files.pythonhosted.org/packages/b7/be/a639638bcd1664de2d70a87da6c4fe0e3272a60b7fa3f0c108a956a456bd/ty-0.0.1a32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2bcbeed7f5ed8e3c1c7e525fce541e7b943ac04ee7fe369a926551b5e50ea4a8", size = 9451396, upload-time = "2025-12-05T21:04:01.265Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a4/2bcf54e842a3d10dc14b369f28a3bab530c5d7ddba624e910b212bda93ee/ty-0.0.1a32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60ff2e4493f90f81a260205d87719bb1d3420928a1e4a2a7454af7cbdfed2047", size = 9862726, upload-time = "2025-12-05T21:04:08.806Z" },
{ url = "https://files.pythonhosted.org/packages/5f/c7/19e6719496e59f2f082f34bcac312698366cf50879fdcc3ef76298bfe6a0/ty-0.0.1a32-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:53cad50a59a0d943b06872e0b10f9f2b564805c2ea93f64c7798852bc1901954", size = 10475051, upload-time = "2025-12-05T21:04:31.059Z" },
{ url = "https://files.pythonhosted.org/packages/88/77/bdf0ddb066d2b62f141d058f8a33bb7c8628cdbb8bfa75b20e296b79fb4e/ty-0.0.1a32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:343d43cdc1d7f649ea2baa64ac2b479da3d679239b94509f1df12f7211561ea9", size = 10232712, upload-time = "2025-12-05T21:04:19.849Z" },
{ url = "https://files.pythonhosted.org/packages/ed/07/f73260a461762a581a007015c1019d40658828ce41576f8c1db88dee574d/ty-0.0.1a32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f45483e4a84bcf622413712164ea687ce323a9f7013b9e7977c5d623ed937ca9", size = 10237705, upload-time = "2025-12-05T21:04:35.366Z" },
{ url = "https://files.pythonhosted.org/packages/2c/57/dbb92206cf2f798d8c51ea16504e8afb90a139d0ff105c31cec9a1db29f9/ty-0.0.1a32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d452f30d47002a6bafc36d1b6aee42c321e9ec9f7f43a04a2ee7d48c208b86c", size = 9766469, upload-time = "2025-12-05T21:04:22.236Z" },
{ url = "https://files.pythonhosted.org/packages/c3/5e/143d93bd143abcebcbaa98c8aeec78898553d62d0a5a432cd79e0cf5bd6d/ty-0.0.1a32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:86c4e31737fe954637890cef1f3e1b479ffb20e836cac3b76050bdbe80005010", size = 9238592, upload-time = "2025-12-05T21:04:11.33Z" },
{ url = "https://files.pythonhosted.org/packages/21/b8/225230ae097ed88f3c92ad974dd77f8e4f86f2594d9cd0c729da39769878/ty-0.0.1a32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:daf15fa03bc39a76a0fbc9c2d81d79d528f584e3fbe08d71981e3f7912db91d6", size = 9502161, upload-time = "2025-12-05T21:04:37.642Z" },
{ url = "https://files.pythonhosted.org/packages/85/13/cc89955c9637f25f3aca2dd7749c6008639ef036f0b9bea3e9d89e892ff9/ty-0.0.1a32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6128f6bab5c6dab3d08689fed1d529dc34f50f221f89c8e16064ed0c549dad7a", size = 9603058, upload-time = "2025-12-05T21:04:39.532Z" },
{ url = "https://files.pythonhosted.org/packages/46/77/1fe2793c8065a02d1f70ca7da1b87db49ca621bcbbdb79a18ad79d5d0ab2/ty-0.0.1a32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:55aab688be1b46776a5a458a1993cae0da7725932c45393399c479c2fa979337", size = 9879903, upload-time = "2025-12-05T21:04:13.567Z" },
{ url = "https://files.pythonhosted.org/packages/fc/47/fd58e80a3e42310b4b649340d5d97403fe796146cae8678b3a031a414b8e/ty-0.0.1a32-py3-none-win32.whl", hash = "sha256:f55ec25088a09236ad1578b656a07fa009c3a353f5923486905ba48175d142a6", size = 9077703, upload-time = "2025-12-05T21:04:15.849Z" },
{ url = "https://files.pythonhosted.org/packages/8d/96/209c417c69317339ea8e9b3277fd98364a0e97dd1ffd3585e143ec7b4e57/ty-0.0.1a32-py3-none-win_amd64.whl", hash = "sha256:ed8d5cbd4e47dfed86aaa27e243008aa4e82b6a5434f3ab95c26d3ee5874d9d7", size = 9922426, upload-time = "2025-12-05T21:04:33.289Z" },
{ url = "https://files.pythonhosted.org/packages/e0/1c/350fd851fb91244f8c80cec218009cbee7564d76c14e2f423b47e69a5cbc/ty-0.0.1a32-py3-none-win_arm64.whl", hash = "sha256:dbb25f9b513d34cee8ce419514eaef03313f45c3f7ab4eb6e6d427ea1f6854af", size = 9453761, upload-time = "2025-12-05T21:04:24.502Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"