Compare commits

...

12 Commits

Author SHA1 Message Date
Aria Desires
a9526fe0a5 serde_json is no longer optional 2025-12-08 15:17:28 -05:00
Aria Desires
e733a87bd7 Teach ty check to ask uv to sync the venv of a PEP-723 script 2025-12-08 15:00:37 -05:00
Micha Reiser
0ab8521171 [ty] Remove legacy concise_message fallback behavior (#21847) 2025-12-08 16:19:01 +00:00
Alex Waygood
0ccd84136a [ty] Make Python-version subdiagnostics less verbose (#21849) 2025-12-08 15:58:23 +00:00
Aria Desires
3981a23ee9 [ty] Supress inlay hints when assigning a trivial initializer call (#21848)
## Summary

By taking a purely syntactic approach to the problem of trivial
initializer calls we can supress `x: T = T()`, `x: T = x.y.T()` and `x:
MyNewType = MyNewType(0)` but still display `x: T[U] = T()`.

The place where we drop a ball is this does not compose with our
analysis for supressing `x = (0, "hello")` as `x = (0, T())` and `x =
(T(), T())` will still get inlay hints (I don't think this is a huge
deal).

* fixes https://github.com/astral-sh/ty/issues/1516

## Test Plan

Existing snapshots cover this well.
2025-12-08 10:54:30 -05:00
Charlie Marsh
385dd2770b [ty] Avoid double-inference on non-tuple argument to Annotated (#21837)
## Summary

If you pass a non-tuple to `Annotated`, we end up running inference on
it twice. I _think_ the only case here is `Annotated[]`, where we insert
a (fake) empty `Name` node in the slice.

Closes https://github.com/astral-sh/ty/issues/1801.
2025-12-08 10:24:05 -05:00
Alex Waygood
7519f6c27b Print Python version and Python platform in the fuzzer output when fuzzing fails (#21844) 2025-12-08 14:35:36 +00:00
David Peter
4686111681 [ty] More SQLAlchemy test updates (#21846)
Minor updates to the SQLAlchemy test suite. I verified all expected
results using pyright.
2025-12-08 15:22:55 +01:00
Micha Reiser
4364ffbdd3 [ty] Don't create a related diagnostic for the primary annotation of sub-diagnostics (#21845) 2025-12-08 14:22:11 +00:00
Charlie Marsh
b845e81c4a Use memchr for computing line indexes (#21838)
## Summary

Some benchmarks with Claude's help:

| File | Size | Baseline | Optimized | Speedup |

|---------------------|-------|----------------------|----------------------|---------|
| numpy/globals.py | 3 KB | 1.48 µs (1.95 GiB/s) | 740 ns (3.89 GiB/s) |
2.0x |
| unicode/pypinyin.py | 4 KB | 2.04 µs (2.01 GiB/s) | 1.18 µs (3.49
GiB/s) | 1.7x |
| pydantic/types.py | 26 KB | 13.1 µs (1.90 GiB/s) | 5.88 µs (4.23
GiB/s) | 2.2x |
| numpy/ctypeslib.py | 17 KB | 8.45 µs (1.92 GiB/s) | 3.94 µs (4.13
GiB/s) | 2.1x |
| large/dataset.py | 41 KB | 21.6 µs (1.84 GiB/s) | 11.2 µs (3.55 GiB/s)
| 1.9x |

I think that I originally thought we _had_ to iterate
character-by-character here because we needed to do the ASCII check, but
the ASCII check can be vectorized by LLVM (and the "search for newlines"
can be done with `memchr`).
2025-12-08 08:50:51 -05:00
David Peter
c99e10eedc [ty] Increase SQLAlchemy test coverage (#21843)
## Summary

Increase our SQLAlchemy test coverage to make sure we understand
`Session.scalar`, `Session.scalars`, `Session.execute` (and their async
equivalents), as well as `Result.tuples`, `Result.one_or_none`,
`Row._tuple`.
2025-12-08 14:36:13 +01:00
Dhruv Manilawala
a364195335 [ty] Avoid diagnostic when typing_extensions.ParamSpec uses default parameter (#21839)
## Summary

fixes: https://github.com/astral-sh/ty/issues/1798

## Test Plan

Add mdtest.
2025-12-08 12:34:30 +00:00
22 changed files with 673 additions and 479 deletions

View File

@@ -166,28 +166,8 @@ 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 {
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()
self.inner.message.as_str()
}
/// Introspects this diagnostic and returns what kind of "primary" message
@@ -199,18 +179,6 @@ 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.
@@ -224,11 +192,10 @@ impl Diagnostic {
.primary_annotation()
.and_then(|ann| ann.get_message())
.unwrap_or_default();
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,
if annotation.is_empty() {
ConciseMessage::MainDiagnostic(main)
} else {
ConciseMessage::Both { main, annotation }
}
}
@@ -693,18 +660,6 @@ 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.
@@ -714,11 +669,10 @@ impl SubDiagnostic {
.primary_annotation()
.and_then(|ann| ann.get_message())
.unwrap_or_default();
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,
if annotation.is_empty() {
ConciseMessage::MainDiagnostic(main)
} else {
ConciseMessage::Both { main, annotation }
}
}
}
@@ -888,6 +842,10 @@ 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.
@@ -1508,28 +1466,10 @@ 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),
}
@@ -1540,13 +1480,9 @@ 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.
prelude: String,
pub prelude: String,
/// The metadata block.
metadata: String,
pub metadata: String,
/// The content of the script after the metadata block.
postlude: String,
pub postlude: String,
}
impl ScriptTag {

View File

@@ -33,26 +33,29 @@ 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, 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));
}
_ => {}
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;
}
// SAFETY: Assertion above guarantees `i <= u32::MAX`
#[expect(clippy::cast_possible_truncation)]
line_starts.push(TextSize::from(i as u32) + TextSize::from(1));
}
let kind = if utf8 {
// 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 {
IndexKind::Utf8
} else {
IndexKind::Ascii

View File

@@ -90,6 +90,22 @@ 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()
@@ -111,7 +127,6 @@ 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
@@ -121,7 +136,13 @@ 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 => ProjectMetadata::discover(&project_path, &system)?,
None => {
if let Some(project) = script_project {
project
} else {
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 3.11 assumed due to this configuration setting
| ^^^^^^ Python version configuration
|
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 3.8 assumed due to this configuration setting
| ^^^^^ Python version configuration
|
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
| ^^^ Python version inferred from virtual environment metadata file
| ^^^ Virtual environment metadata
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
| ^^^ Python version inferred from virtual environment metadata file
| ^^^ Virtual environment metadata
|
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 3.8 assumed due to this configuration setting
| ^^^^^^^ Python version configuration
|
Found 1 diagnostic
@@ -1206,7 +1206,7 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> {
|
2 | [environment]
3 | python-version = "3.10"
| ^^^^^^ Python 3.10 assumed due to this configuration setting
| ^^^^^^ Python version configuration
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 3.10 assumed due to this configuration setting
| ^^^^^^ Python version configuration
4 | python-platform = "linux"
|
info: rule `unresolved-import` is enabled by default

View File

@@ -19,11 +19,22 @@ pub struct InlayHint {
}
impl InlayHint {
fn variable_type(expr: &Expr, ty: Type, db: &dyn Db, allow_edits: bool) -> Self {
fn variable_type(
expr: &Expr,
rhs: &Expr,
ty: Type,
db: &dyn Db,
allow_edits: bool,
) -> Option<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
@@ -73,12 +84,12 @@ impl InlayHint {
vec![]
};
Self {
Some(Self {
position,
kind: InlayHintKind::Type,
label: InlayHintLabel { parts: label_parts },
text_edits,
}
})
}
fn call_argument_name(
@@ -250,7 +261,7 @@ struct InlayHintVisitor<'a, 'db> {
db: &'db dyn Db,
model: SemanticModel<'db>,
hints: Vec<InlayHint>,
in_assignment: bool,
assignment_rhs: Option<&'a Expr>,
range: TextRange,
settings: &'a InlayHintSettings,
in_no_edits_allowed: bool,
@@ -262,21 +273,21 @@ impl<'a, 'db> InlayHintVisitor<'a, 'db> {
db,
model: SemanticModel::new(db, file),
hints: Vec::new(),
in_assignment: false,
assignment_rhs: None,
range,
settings,
in_no_edits_allowed: false,
}
}
fn add_type_hint(&mut self, expr: &Expr, ty: Type<'db>, allow_edits: bool) {
fn add_type_hint(&mut self, expr: &Expr, rhs: &Expr, ty: Type<'db>, allow_edits: bool) {
if !self.settings.variable_types {
return;
}
let inlay_hint = InlayHint::variable_type(expr, ty, self.db, allow_edits);
self.hints.push(inlay_hint);
if let Some(inlay_hint) = InlayHint::variable_type(expr, rhs, ty, self.db, allow_edits) {
self.hints.push(inlay_hint);
}
}
fn add_call_argument_name(
@@ -299,8 +310,8 @@ impl<'a, 'db> InlayHintVisitor<'a, 'db> {
}
}
impl SourceOrderVisitor<'_> for InlayHintVisitor<'_, '_> {
fn enter_node(&mut self, node: AnyNodeRef<'_>) -> TraversalSignal {
impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> {
fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal {
if self.range.intersect(node.range()).is_some() {
TraversalSignal::Traverse
} else {
@@ -308,7 +319,7 @@ impl SourceOrderVisitor<'_> for InlayHintVisitor<'_, '_> {
}
}
fn visit_stmt(&mut self, stmt: &Stmt) {
fn visit_stmt(&mut self, stmt: &'a Stmt) {
let node = AnyNodeRef::from(stmt);
if !self.enter_node(node).is_traverse() {
@@ -317,7 +328,9 @@ impl SourceOrderVisitor<'_> for InlayHintVisitor<'_, '_> {
match stmt {
Stmt::Assign(assign) => {
self.in_assignment = !type_hint_is_excessive_for_expr(&assign.value);
if !type_hint_is_excessive_for_expr(&assign.value) {
self.assignment_rhs = Some(&*assign.value);
}
if !annotations_are_valid_syntax(assign) {
self.in_no_edits_allowed = true;
}
@@ -325,7 +338,7 @@ impl SourceOrderVisitor<'_> for InlayHintVisitor<'_, '_> {
self.visit_expr(target);
}
self.in_no_edits_allowed = false;
self.in_assignment = false;
self.assignment_rhs = None;
self.visit_expr(&assign.value);
@@ -344,22 +357,22 @@ impl SourceOrderVisitor<'_> for InlayHintVisitor<'_, '_> {
source_order::walk_stmt(self, stmt);
}
fn visit_expr(&mut self, expr: &'_ Expr) {
fn visit_expr(&mut self, expr: &'a Expr) {
match expr {
Expr::Name(name) => {
if self.in_assignment {
if let Some(rhs) = self.assignment_rhs {
if name.ctx.is_store() {
let ty = expr.inferred_type(&self.model);
self.add_type_hint(expr, ty, !self.in_no_edits_allowed);
self.add_type_hint(expr, rhs, ty, !self.in_no_edits_allowed);
}
}
source_order::walk_expr(self, expr);
}
Expr::Attribute(attribute) => {
if self.in_assignment {
if let Some(rhs) = self.assignment_rhs {
if attribute.ctx.is_store() {
let ty = expr.inferred_type(&self.model);
self.add_type_hint(expr, ty, !self.in_no_edits_allowed);
self.add_type_hint(expr, rhs, ty, !self.in_no_edits_allowed);
}
}
source_order::walk_expr(self, expr);
@@ -416,6 +429,26 @@ 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?
///
@@ -1829,35 +1862,16 @@ mod tests {
",
);
assert_snapshot!(test.inlay_hints(), @r#"
assert_snapshot!(test.inlay_hints(), @r"
class A:
def __init__(self, y):
self.x[: int] = int(1)
self.x = int(1)
self.y[: Unknown] = y
a[: A] = A([y=]2)
a.y[: int] = int(3)
a = A([y=]2)
a.y = 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
|
@@ -1871,29 +1885,11 @@ mod tests {
--> main2.py:5:18
|
3 | def __init__(self, y):
4 | self.x[: int] = int(1)
4 | self.x = int(1)
5 | self.y[: Unknown] = y
| ^^^^^^^
6 |
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)
7 | a = A([y=]2)
|
info[inlay-hint-location]: Inlay Hint Target
@@ -1906,30 +1902,13 @@ mod tests {
5 | self.y = y
|
info: Source
--> main2.py:7:13
--> main2.py:7:8
|
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
--> 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)
| ^^^
7 | a = A([y=]2)
| ^
8 | a.y = int(3)
|
---------------------------------------------
@@ -1938,12 +1917,12 @@ mod tests {
class A:
def __init__(self, y):
self.x: int = int(1)
self.x = int(1)
self.y: Unknown = y
a: A = A(2)
a.y: int = int(3)
"#);
a = A(2)
a.y = int(3)
");
}
#[test]
@@ -2937,31 +2916,12 @@ mod tests {
def __init__(self):
self.x: int = 1
x[: MyClass] = MyClass()
x = 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
|
@@ -2973,7 +2933,7 @@ mod tests {
info: Source
--> main2.py:7:5
|
6 | x[: MyClass] = MyClass()
6 | x = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
| ^^^^^
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
@@ -2991,7 +2951,7 @@ mod tests {
info: Source
--> main2.py:7:11
|
6 | x[: MyClass] = MyClass()
6 | x = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
| ^^^^^^^
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
@@ -3009,7 +2969,7 @@ mod tests {
info: Source
--> main2.py:7:20
|
6 | x[: MyClass] = MyClass()
6 | x = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
| ^^^^^^^
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
@@ -3027,7 +2987,7 @@ mod tests {
info: Source
--> main2.py:8:5
|
6 | x[: MyClass] = MyClass()
6 | x = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
| ^^^^^^^
@@ -3045,7 +3005,7 @@ mod tests {
info: Source
--> main2.py:8:19
|
6 | x[: MyClass] = MyClass()
6 | x = MyClass()
7 | y[: tuple[MyClass, MyClass]] = (MyClass(), MyClass())
8 | a[: MyClass], b[: MyClass] = MyClass(), MyClass()
| ^^^^^^^
@@ -3094,7 +3054,7 @@ mod tests {
def __init__(self):
self.x: int = 1
x: MyClass = MyClass()
x = MyClass()
y: tuple[MyClass, MyClass] = (MyClass(), MyClass())
a, b = MyClass(), MyClass()
c, d = (MyClass(), MyClass())
@@ -4097,31 +4057,11 @@ mod tests {
def __init__(self):
self.x: int = 1
self.y: int = 2
val[: MyClass] = MyClass()
val = 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
|
@@ -4137,20 +4077,6 @@ 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)
");
}
@@ -4176,31 +4102,11 @@ mod tests {
def __init__(self):
self.x: int = 1
self.y: int = 2
x[: MyClass] = MyClass()
x = 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
|
@@ -4216,20 +4122,6 @@ 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)
");
}
@@ -4258,31 +4150,11 @@ mod tests {
return 1
def y() -> int:
return 2
val[: MyClass] = MyClass()
val = 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
|
@@ -4298,22 +4170,6 @@ 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())
");
}
@@ -4346,31 +4202,11 @@ mod tests {
return 1
def y() -> List[int]:
return 2
val[: MyClass] = MyClass()
val = 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
|
@@ -4388,24 +4224,6 @@ 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])
");
}
@@ -4697,7 +4515,7 @@ mod tests {
class Foo:
def __init__(self, x: int): pass
Foo([x=]1)
f[: Foo] = Foo([x=]1)
f = Foo([x=]1)
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> main.py:3:24
@@ -4715,24 +4533,7 @@ mod tests {
3 | def __init__(self, x: int): pass
4 | 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)
| ^^^
5 | f = Foo([x=]1)
|
info[inlay-hint-location]: Inlay Hint Target
@@ -4745,22 +4546,13 @@ mod tests {
5 | f = Foo(1)
|
info: Source
--> main2.py:5:17
--> main2.py:5:10
|
3 | def __init__(self, x: int): pass
4 | Foo([x=]1)
5 | f[: Foo] = Foo([x=]1)
| ^
5 | f = 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)
");
}
@@ -4778,7 +4570,7 @@ mod tests {
class Foo:
def __new__(cls, x: int): pass
Foo([x=]1)
f[: Foo] = Foo([x=]1)
f = Foo([x=]1)
---------------------------------------------
info[inlay-hint-location]: Inlay Hint Target
--> main.py:3:22
@@ -4796,24 +4588,7 @@ mod tests {
3 | def __new__(cls, x: int): pass
4 | 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)
| ^^^
5 | f = Foo([x=]1)
|
info[inlay-hint-location]: Inlay Hint Target
@@ -4826,22 +4601,13 @@ mod tests {
5 | f = Foo(1)
|
info: Source
--> main2.py:5:17
--> main2.py:5:10
|
3 | def __new__(cls, x: int): pass
4 | Foo([x=]1)
5 | f[: Foo] = Foo([x=]1)
| ^
5 | f = 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, optional = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
@@ -55,7 +55,6 @@ 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,13 +9,15 @@ use ty_python_semantic::ProgramSettings;
use crate::metadata::options::ProjectOptionsOverrides;
use crate::metadata::pyproject::{Project, PyProject, PyProjectError, ResolveRequiresPythonError};
use crate::metadata::value::ValueSource;
use crate::metadata::script::{Pep723Error, Pep723Metadata};
use crate::metadata::value::{RelativePathBuf, 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;
@@ -85,6 +87,32 @@ 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,
@@ -120,6 +148,46 @@ 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
@@ -319,12 +387,21 @@ 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

@@ -0,0 +1,157 @@
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

@@ -9,9 +9,9 @@ python-platform = "linux"
dependencies = ["SQLAlchemy==2.0.44"]
```
## Basic model
## ORM Model
Here, we mostly make sure that ty understands SQLAlchemy's dataclass-transformer setup:
This test makes sure that ty understands SQLAlchemy's `dataclass_transform` setup:
```py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -40,14 +40,12 @@ reveal_type(User.__init__) # revealed: def __init__(self, **kw: Any) -> Unknown
invalid_user = User(invalid_arg=42)
```
## Queries
## Basic query example
First, the basic setup:
First, set up a `Session`:
```py
from datetime import datetime
from sqlalchemy import select, Integer, Text, Boolean, DateTime
from sqlalchemy import select, Integer, Text, Boolean
from sqlalchemy.orm import Session
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped, mapped_column
@@ -57,7 +55,7 @@ engine = create_engine("sqlite://example.db")
session = Session(engine)
```
Now we can declare a simple model:
And define a simple model:
```py
class Base(DeclarativeBase):
@@ -71,7 +69,7 @@ class User(Base):
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
```
And perform simple queries:
Finally, we can execute queries:
```py
stmt = select(User)
@@ -84,21 +82,27 @@ for row in session.execute(stmt):
reveal_type(row) # revealed: Row[tuple[User]]
stmt = select(User).where(User.name == "Alice")
alice = session.scalars(stmt).first()
reveal_type(alice) # revealed: User | None
alice1 = session.scalars(stmt).first()
reveal_type(alice1) # 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
@@ -106,19 +110,101 @@ 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
```
And similarly with the legacy `query` API:
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:
```py
query = session.query(User.id, User.name)
# TODO: should be `RowReturningQuery[tuple[int, str]]`
reveal_type(query) # revealed: RowReturningQuery[tuple[Unknown, Unknown]]
for row in query.all():
# TODO: should be `Row[Tuple[int, str]]`
# 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]]`
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,6 +102,38 @@ 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,3 +128,16 @@ 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

@@ -0,0 +1,19 @@
# `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,9 +58,8 @@ 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(format_args!(
"Python {version} assumed due to this configuration setting"
)));
sub_diagnostic
.annotate(Annotation::primary(span).message("Python version configuration"));
diagnostic.sub(sub_diagnostic);
} else {
diagnostic.info(format_args!(
@@ -76,10 +75,8 @@ 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("Python version inferred from virtual environment metadata file"),
);
sub_diagnostic
.annotate(Annotation::primary(span).message("Virtual environment metadata"));
// TODO: it would also be nice to tell them how we resolved their virtual environment...
diagnostic.sub(sub_diagnostic);
} else {

View File

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

View File

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

View File

@@ -1172,7 +1172,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
)
.in_type_expression(db, self.scope(), None)
.unwrap_or_else(|err| err.into_fallback_type(&self.context, subscript, true));
self.store_expression_type(arguments_slice, ty);
// 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);
}
ty
}
SpecialFormType::Literal => match self.infer_literal_parameter_type(arguments_slice) {

View File

@@ -349,6 +349,7 @@ 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,10 +427,16 @@ 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, "")
Diagnostic::new(self.id, Severity::Error, self.message)
};
let span = Span::from(file).with_range(self.range);
diag.annotate(Annotation::primary(span).message(self.message));
let mut annotation = Annotation::primary(span);
if self.id == DiagnosticId::RevealedType {
annotation = annotation.message(self.message);
}
diag.annotate(annotation);
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 NewType, NoReturn, assert_never
from typing import Final, NewType, NoReturn, assert_never
from pysource_codegen import generate as generate_random_code
from pysource_minimize import CouldNotMinimize, minimize as minimize_repro
@@ -44,6 +44,12 @@ 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."""
@@ -51,7 +57,17 @@ 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], capture_output=True, text=True
[
ty_executable,
"check",
input_file,
"--python-version",
OLDEST_SUPPORTED_PYTHON,
"--python-platform",
TY_TARGET_PLATFORM,
],
capture_output=True,
text=True,
)
return completed_process.returncode not in {0, 1, 2}
@@ -137,7 +153,10 @@ 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:"
panic_message = (
f"The following code triggers a {new}ty panic with "
f"`--python-version={OLDEST_SUPPORTED_PYTHON} --python-platform={TY_TARGET_PLATFORM}`:"
)
case _ as unreachable:
assert_never(unreachable)

View File

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

View File

@@ -89,6 +89,7 @@ dependencies = [
dev = [
{ name = "mypy" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata]
@@ -104,6 +105,7 @@ requires-dist = [
dev = [
{ name = "mypy" },
{ name = "ruff" },
{ name = "ty" },
]
[[package]]
@@ -196,6 +198,31 @@ 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"