Skip to content

Commit 7e716c5

Browse files
committed
Merge branch 'main' into charlie/metaclass
* main: [ty] Take myself out of the reviewer pool for the next few days (#23618) [ty] Fix bug where ty would think that a `Callable` with a variadic positional parameter could be a subtype of a `Callable` with a positional-or-keyword parameter (#23610) [`ruff`] Add fix for `none-not-at-end-of-union` (`RUF036`) (#22829) Bump cargo dist to 0.31 (#23614) [`pyflakes`] Fix false positive for names shadowing re-exports (`F811`) (#23356) [`fastapi`] Handle callable class dependencies with `__call__` method (`FAST003`) (#23553) [ty] Recurse into tuples and nested tuples when applying special-cased validation of `isinstance()` and `issubclass()` (#23607) Update typing conformance suite commit (#23606) [ty] Detect invalid uses of `@final` on non-methods (#23604) [ty] Move the type hierarchy request handlers to individual modules [ty] Wire up the type hierarchy implementation with the LSP [ty] Add routine for mapping from system path to vendored path [ty] Implement internal routines for providing the LSP "type hierarchy" feature [ty] Add some helper methods on `ClassLiteral` [ty] Move some module name helper routines to methods on `ModuleName` [ty] Bump version of `lsp-types` [ty] Refactor to support building constraint sets differently (#23600) [ty] Dataclass transform: neither frozen nor non-frozen (#23366) [ty] Add snapshot tests for advanced `invalid-assignment` scenarios (#23581) [ty] disallow negative narrowing on SubclassOf types (#23598)
2 parents 8799e1d + dcab6f2 commit 7e716c5

88 files changed

Lines changed: 6713 additions & 1700 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/pr-assignee-pools.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ reviewers = ["amyreese", "ntBre"]
99
[[pools]]
1010
name = "ty-semantic"
1111
paths = ["/crates/ty_python_semantic/**"]
12-
reviewers = ["carljm", "sharkdp", "dcreager", "ibraheemdev", "oconnor663"]
12+
reviewers = ["carljm", "dcreager", "ibraheemdev", "oconnor663"]
1313

1414
[[pools]]
1515
name = "ty-module-resolver"

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
# we specify bash to get pipefail; it guards against the `curl` command
6969
# failing. otherwise `sh` won't catch that `curl` returned non-0
7070
shell: bash
71-
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.2/cargo-dist-installer.sh | sh"
71+
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
7272
- name: Cache dist
7373
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
7474
with:

.github/workflows/typing_conformance.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ env:
3434
CARGO_TERM_COLOR: always
3535
RUSTUP_MAX_RETRIES: 10
3636
RUST_BACKTRACE: 1
37-
CONFORMANCE_SUITE_COMMIT: 21b07859158d2d10ed7fe8d9b365412518ed9888
37+
CONFORMANCE_SUITE_COMMIT: e9fccc9dbbd8f1e8b24b4f88911c3d3155059e2a
3838
PYTHON_VERSION: 3.12
3939

4040
jobs:

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ libc = { version = "0.2.153" }
127127
libcst = { version = "1.8.4", default-features = false }
128128
log = { version = "0.4.17" }
129129
lsp-server = { version = "0.7.6" }
130-
lsp-types = { git = "https://github.com/astral-sh/lsp-types.git", rev = "3512a9f", features = [
130+
lsp-types = { git = "https://github.com/astral-sh/lsp-types.git", rev = "e15db0593f0ecbbd80599c3f5880e4bf5da1ca0c", features = [
131131
"proposed",
132132
] }
133133
matchit = { version = "0.9.0" }

crates/ruff_linter/resources/test/fixtures/fastapi/FAST003.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,73 @@ async def read_thing_posonly_default_trailing(query: str = "", /,): ...
299299

300300
@app.get("/things/{thing_id}")
301301
async def read_thing_posonly_with_regular(query: str = "", /, x=None): ...
302+
303+
304+
# https://github.com/astral-sh/ruff/issues/23526
305+
306+
# Error: `Depends(CallableQuery)` passes the class itself, so FastAPI uses
307+
# `__init__` (which has no params here), not `__call__`. The path parameter
308+
# `thing_id` is unused.
309+
class CallableQuery:
310+
def __call__(self, thing_id: int):
311+
pass
312+
313+
314+
@app.get("/things/{thing_id}")
315+
async def read_thing_callable_dep(query: Annotated[str, Depends(CallableQuery)]): ...
316+
317+
318+
# OK: `Depends(CallableQuery())` passes an instance, so FastAPI uses `__call__`,
319+
# which declares `thing_id`.
320+
@app.get("/things/{thing_id}")
321+
async def read_thing_callable_dep_instance(query: Annotated[str, Depends(CallableQuery())]): ...
322+
323+
324+
# OK: class with both __init__ and __call__, passed as class reference.
325+
# FastAPI uses `__init__`, which declares `thing_id`.
326+
class InitAndCallQuery:
327+
def __init__(self, thing_id: int):
328+
pass
329+
330+
def __call__(self, other: str):
331+
pass
332+
333+
334+
@app.get("/things/{thing_id}")
335+
async def read_thing_init_and_call_dep(query: Annotated[str, Depends(InitAndCallQuery)]): ...
336+
337+
338+
# Error: `Depends(CallableQueryOther)` — class reference, uses `__init__` (no
339+
# params). `thing_id` is unused.
340+
class CallableQueryOther:
341+
def __call__(self, other: str):
342+
pass
343+
344+
345+
@app.get("/things/{thing_id}")
346+
async def read_thing_callable_dep_missing(query: Annotated[str, Depends(CallableQueryOther)]): ...
347+
348+
349+
# Error: `Depends(InitAndCallQuery())` passes an instance, so FastAPI uses
350+
# `__call__`, which has `other` — not `thing_id`.
351+
@app.get("/things/{thing_id}")
352+
async def read_thing_init_and_call_instance(query: Annotated[str, Depends(InitAndCallQuery())]): ...
353+
354+
355+
# Error: class with no __init__ and no __call__; FastAPI calls __init__ which
356+
# has no parameters, so `thing_id` is not covered by the dependency.
357+
class EmptyClass:
358+
pass
359+
360+
361+
@app.get("/things/{thing_id}")
362+
async def read_thing_empty_class_dep(query: Annotated[str, Depends(EmptyClass)]): ...
363+
364+
365+
# Same instance patterns as default values (not Annotated).
366+
# OK: `__call__` declares `thing_id`.
367+
@app.get("/things/{thing_id}")
368+
async def read_thing_callable_dep_instance_default(query: str = Depends(CallableQuery())): ...
369+
# Error: `__call__` has `other`, not `thing_id`.
370+
@app.get("/things/{thing_id}")
371+
async def read_thing_init_and_call_instance_default(query: str = Depends(InitAndCallQuery())): ...
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Regression test for https://github.com/astral-sh/ruff/issues/10874
2+
# Explicit re-exports at module scope should not be flagged as redefined
3+
# by class-scoped attributes with the same name.
4+
from x import y as y
5+
6+
class Foo:
7+
y = 42 # OK — class attribute, different scope from module-level re-export

crates/ruff_linter/resources/test/fixtures/ruff/RUF036.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,53 @@ def func5() -> U[None, int]:
2121
...
2222

2323

24-
def func6(arg: U[None, None, int]):
24+
def func6(arg: U[None, None, int]):
25+
...
26+
27+
28+
# Comments in annotation (unsafe fix)
29+
def func7() -> U[
30+
None,
31+
# comment
32+
int
33+
]:
34+
...
35+
36+
37+
# Nested unions - no fix should be provided
38+
def func8(x: None | U[None, int]):
39+
...
40+
41+
42+
def func9(x: int | (str | None) | list):
43+
...
44+
45+
46+
def func10(x: U[int, U[None, list | set]]):
47+
...
48+
49+
50+
# Multiple annotations in the same function
51+
def func11(x: None | int) -> None | int:
52+
...
53+
54+
55+
# With default argument (from poetry ecosystem check)
56+
def func12(io: None | int = None) -> int | None:
57+
...
58+
59+
60+
# 3+ member PEP 604 chains
61+
def func13(arg: None | int | str):
62+
...
63+
64+
65+
def func14(arg: None | int | str | bytes):
2566
...
2667

2768

2869
# Ok
29-
def good_func1(arg: int | None):
70+
def good_func1(arg: int | None):
3071
...
3172

3273

crates/ruff_linter/resources/test/fixtures/ruff/RUF036.pyi

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ def func5() -> U[None, int]: ...
1313

1414
def func6(arg: U[None, None, int]): ...
1515

16+
# Nested unions - no fix should be provided
17+
def func7(x: None | U[None, int]): ...
18+
19+
def func8(x: U[int, U[None, list | set]]): ...
20+
21+
# Multiple annotations in the same function
22+
def func9(x: None | int) -> None | int: ...
23+
24+
# 3+ member PEP 604 chains
25+
def func10(arg: None | int | str): ...
26+
27+
def func11(arg: None | int | str | bytes): ...
28+
1629
# Ok
1730
def good_func1(arg: int | None): ...
1831

crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs

Lines changed: 81 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -307,13 +307,27 @@ impl<'a> Dependency<'a> {
307307
}
308308

309309
fn from_depends_call(arguments: &'a Arguments, semantic: &SemanticModel<'a>) -> Option<Self> {
310-
let Some(Expr::Name(name)) = arguments.find_argument_value("dependency", 0) else {
311-
return None;
312-
};
313-
314-
Self::from_dependency_name(name, semantic)
310+
let dep_arg = arguments.find_argument_value("dependency", 0)?;
311+
312+
match dep_arg {
313+
// `Depends(some_callable)` — a name reference (function or class).
314+
Expr::Name(name) => Self::from_dependency_name(name, semantic),
315+
// `Depends(SomeClass(...))` — a call expression. If the callee is a
316+
// class, FastAPI will invoke `__call__` on the resulting instance.
317+
Expr::Call(call) => {
318+
let Expr::Name(name) = call.func.as_ref() else {
319+
return None;
320+
};
321+
Self::from_dependency_instance(name, semantic)
322+
}
323+
_ => None,
324+
}
315325
}
316326

327+
/// Resolve a dependency that is a name reference (e.g. `Depends(Query)`).
328+
///
329+
/// For classes, FastAPI calls the class constructor, so the parameters come
330+
/// from `__init__`.
317331
fn from_dependency_name(name: &'a ast::ExprName, semantic: &SemanticModel<'a>) -> Option<Self> {
318332
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
319333
return Some(Self::Unknown);
@@ -334,46 +348,74 @@ impl<'a> Dependency<'a> {
334348
Some(Self::Function(parameter_names))
335349
}
336350
BindingKind::ClassDefinition(scope_id) => {
337-
let scope = &semantic.scopes[scope_id];
338-
339-
let ScopeKind::Class(class_def) = scope.kind else {
340-
return Some(Self::Unknown);
341-
};
351+
Self::class_params_from_method(semantic, scope_id, "__init__")
352+
}
353+
_ => Some(Self::Unknown),
354+
}
355+
}
342356

343-
let parameter_names = if class_def
344-
.bases()
345-
.iter()
346-
.any(|expr| is_pydantic_base_model(expr, semantic))
347-
{
348-
class_def
349-
.body
350-
.iter()
351-
.filter_map(|stmt| {
352-
stmt.as_ann_assign_stmt()
353-
.and_then(|ann_assign| ann_assign.target.as_name_expr())
354-
.map(|name| name.id.as_str())
355-
})
356-
.collect()
357-
} else if let Some(init_def) = class_def
358-
.body
359-
.iter()
360-
.filter_map(|stmt| stmt.as_function_def_stmt())
361-
.find(|func_def| func_def.name.as_str() == "__init__")
362-
{
363-
// Skip `self` parameter
364-
non_posonly_non_variadic_parameters(init_def)
365-
.skip(1)
366-
.map(|param| param.name().as_str())
367-
.collect()
368-
} else {
369-
return None;
370-
};
357+
/// Resolve a dependency that is a class instance (e.g. `Depends(Query())`).
358+
///
359+
/// FastAPI calls the instance, so the parameters come from `__call__`.
360+
fn from_dependency_instance(
361+
name: &'a ast::ExprName,
362+
semantic: &SemanticModel<'a>,
363+
) -> Option<Self> {
364+
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
365+
return Some(Self::Unknown);
366+
};
371367

372-
Some(Self::Class(parameter_names))
368+
match binding.kind {
369+
BindingKind::ClassDefinition(scope_id) => {
370+
Self::class_params_from_method(semantic, scope_id, "__call__")
373371
}
374372
_ => Some(Self::Unknown),
375373
}
376374
}
375+
376+
/// Extract parameters from a specific method (`__init__` or `__call__`) of a class.
377+
fn class_params_from_method(
378+
semantic: &SemanticModel<'a>,
379+
scope_id: ruff_python_semantic::ScopeId,
380+
method_name: &str,
381+
) -> Option<Self> {
382+
let scope = &semantic.scopes[scope_id];
383+
384+
let ScopeKind::Class(class_def) = scope.kind else {
385+
return Some(Self::Unknown);
386+
};
387+
388+
let parameter_names = if class_def
389+
.bases()
390+
.iter()
391+
.any(|expr| is_pydantic_base_model(expr, semantic))
392+
{
393+
class_def
394+
.body
395+
.iter()
396+
.filter_map(|stmt| {
397+
stmt.as_ann_assign_stmt()
398+
.and_then(|ann_assign| ann_assign.target.as_name_expr())
399+
.map(|name| name.id.as_str())
400+
})
401+
.collect()
402+
} else if let Some(method_def) = class_def
403+
.body
404+
.iter()
405+
.filter_map(|stmt| stmt.as_function_def_stmt())
406+
.find(|func_def| func_def.name.as_str() == method_name)
407+
{
408+
// Skip `self` parameter
409+
non_posonly_non_variadic_parameters(method_def)
410+
.skip(1)
411+
.map(|param| param.name().as_str())
412+
.collect()
413+
} else {
414+
return None;
415+
};
416+
417+
Some(Self::Class(parameter_names))
418+
}
377419
}
378420

379421
fn depends_arguments<'a>(expr: &'a Expr, semantic: &SemanticModel) -> Option<&'a Arguments> {

0 commit comments

Comments
 (0)