Skip to content

Commit cbe94b0

Browse files
[ty] Support empty function bodies in if TYPE_CHECKING blocks (#19372)
## Summary Resolves astral-sh/ty#339 Supports having a blank function body inside `if TYPE_CHECKING` block or in the elif or else of a `if not TYPE_CHECKING` block. ```py if TYPE_CHECKING: def foo() -> int: ... if not TYPE_CHECKING: ... else: def bar() -> int: ... ``` ## Test Plan Update `function/return_type.md` --------- Co-authored-by: Carl Meyer <[email protected]>
1 parent 029de78 commit cbe94b0

File tree

4 files changed

+147
-1
lines changed

4 files changed

+147
-1
lines changed

crates/ty_python_semantic/resources/mdtest/function/return_type.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,80 @@ def f(x: int | str):
127127
return x
128128
```
129129

130+
### In `if TYPE_CHECKING` block
131+
132+
Inside an `if TYPE_CHECKING` block, we allow "stub" style function definitions with empty bodies,
133+
since these functions will never actually be called.
134+
135+
```py
136+
from typing import TYPE_CHECKING
137+
138+
if TYPE_CHECKING:
139+
def f() -> int: ...
140+
141+
else:
142+
def f() -> str:
143+
return "hello"
144+
145+
reveal_type(f) # revealed: def f() -> int
146+
147+
if not TYPE_CHECKING:
148+
...
149+
elif True:
150+
def g() -> str: ...
151+
152+
else:
153+
def h() -> str: ...
154+
155+
if not TYPE_CHECKING:
156+
def i() -> int:
157+
return 1
158+
159+
else:
160+
def i() -> str: ...
161+
162+
reveal_type(i) # revealed: def i() -> str
163+
164+
if False:
165+
...
166+
elif TYPE_CHECKING:
167+
def j() -> str: ...
168+
169+
else:
170+
def j_() -> str: ... # error: [invalid-return-type]
171+
172+
if False:
173+
...
174+
elif not TYPE_CHECKING:
175+
def k_() -> str: ... # error: [invalid-return-type]
176+
177+
else:
178+
def k() -> str: ...
179+
180+
class Foo:
181+
if TYPE_CHECKING:
182+
def f(self) -> int: ...
183+
184+
if TYPE_CHECKING:
185+
class Bar:
186+
def f(self) -> int: ...
187+
188+
def get_bool() -> bool:
189+
return True
190+
191+
if TYPE_CHECKING:
192+
if get_bool():
193+
def l() -> str: ...
194+
195+
if get_bool():
196+
if TYPE_CHECKING:
197+
def m() -> str: ...
198+
199+
if TYPE_CHECKING:
200+
if not TYPE_CHECKING:
201+
def n() -> str: ...
202+
```
203+
130204
## Conditional return type
131205

132206
```py

crates/ty_python_semantic/src/semantic_index/builder.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ pub(super) struct SemanticIndexBuilder<'db, 'ast> {
9090

9191
/// Flags about the file's global scope
9292
has_future_annotations: bool,
93+
/// Whether we are currently visiting an `if TYPE_CHECKING` block.
94+
in_type_checking_block: bool,
9395

9496
// Used for checking semantic syntax errors
9597
python_version: PythonVersion,
@@ -130,6 +132,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
130132
try_node_context_stack_manager: TryNodeContextStackManager::default(),
131133

132134
has_future_annotations: false,
135+
in_type_checking_block: false,
133136

134137
scopes: IndexVec::new(),
135138
place_tables: IndexVec::new(),
@@ -248,6 +251,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
248251
node_with_kind,
249252
children_start..children_start,
250253
reachability,
254+
self.in_type_checking_block,
251255
);
252256
let is_class_scope = scope.kind().is_class();
253257
self.try_node_context_stack_manager.enter_nested_scope();
@@ -719,7 +723,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
719723
// since its the pattern that introduces any constraints, not the body.) Ideally, that
720724
// standalone expression would wrap the match arm's pattern as a whole. But a standalone
721725
// expression can currently only wrap an ast::Expr, which patterns are not. So, we need to
722-
// choose an Expr that can stand in for the pattern, which we can wrap in a standalone
726+
// choose an Expr that can "stand in" for the pattern, which we can wrap in a standalone
723727
// expression.
724728
//
725729
// See the comment in TypeInferenceBuilder::infer_match_pattern for more details.
@@ -1498,6 +1502,17 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
14981502
let mut last_predicate = self.record_expression_narrowing_constraint(&node.test);
14991503
let mut last_reachability_constraint =
15001504
self.record_reachability_constraint(last_predicate);
1505+
1506+
let is_outer_block_in_type_checking = self.in_type_checking_block;
1507+
1508+
let if_block_in_type_checking = is_if_type_checking(&node.test);
1509+
1510+
// Track if we're in a chain that started with "not TYPE_CHECKING"
1511+
let mut is_in_not_type_checking_chain = is_if_not_type_checking(&node.test);
1512+
1513+
self.in_type_checking_block =
1514+
if_block_in_type_checking || is_outer_block_in_type_checking;
1515+
15011516
self.visit_body(&node.body);
15021517

15031518
let mut post_clauses: Vec<FlowSnapshot> = vec![];
@@ -1516,6 +1531,7 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
15161531
// if there's no `else` branch, we should add a no-op `else` branch
15171532
Some((None, Default::default()))
15181533
});
1534+
15191535
for (clause_test, clause_body) in elif_else_clauses {
15201536
// snapshot after every block except the last; the last one will just become
15211537
// the state that we merge the other snapshots into
@@ -1538,12 +1554,34 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
15381554
self.record_reachability_constraint(last_predicate);
15391555
}
15401556

1557+
// Determine if this clause is in type checking context
1558+
let clause_in_type_checking = if let Some(elif_test) = clause_test {
1559+
if is_if_type_checking(elif_test) {
1560+
// This block has "TYPE_CHECKING" condition
1561+
true
1562+
} else if is_if_not_type_checking(elif_test) {
1563+
// This block has "not TYPE_CHECKING" condition so we update the chain state for future blocks
1564+
is_in_not_type_checking_chain = true;
1565+
false
1566+
} else {
1567+
// This block has some other condition
1568+
// It's in type checking only if we're in a "not TYPE_CHECKING" chain
1569+
is_in_not_type_checking_chain
1570+
}
1571+
} else {
1572+
is_in_not_type_checking_chain
1573+
};
1574+
1575+
self.in_type_checking_block = clause_in_type_checking;
1576+
15411577
self.visit_body(clause_body);
15421578
}
15431579

15441580
for post_clause_state in post_clauses {
15451581
self.flow_merge(post_clause_state);
15461582
}
1583+
1584+
self.in_type_checking_block = is_outer_block_in_type_checking;
15471585
}
15481586
ast::Stmt::While(ast::StmtWhile {
15491587
test,
@@ -2711,3 +2749,18 @@ impl ExpressionsScopeMapBuilder {
27112749
ExpressionsScopeMap(interval_map.into_boxed_slice())
27122750
}
27132751
}
2752+
2753+
/// Returns if the expression is a `TYPE_CHECKING` expression.
2754+
fn is_if_type_checking(expr: &ast::Expr) -> bool {
2755+
matches!(expr, ast::Expr::Name(ast::ExprName { id, .. }) if id == "TYPE_CHECKING")
2756+
}
2757+
2758+
/// Returns if the expression is a `not TYPE_CHECKING` expression.
2759+
fn is_if_not_type_checking(expr: &ast::Expr) -> bool {
2760+
matches!(expr, ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) if *op == ruff_python_ast::UnaryOp::Not
2761+
&& matches!(
2762+
&**operand,
2763+
ast::Expr::Name(ast::ExprName { id, .. }) if id == "TYPE_CHECKING"
2764+
)
2765+
)
2766+
}

crates/ty_python_semantic/src/semantic_index/place.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,10 +525,20 @@ impl FileScopeId {
525525

526526
#[derive(Debug, salsa::Update, get_size2::GetSize)]
527527
pub struct Scope {
528+
/// The parent scope, if any.
528529
parent: Option<FileScopeId>,
530+
531+
/// The node that introduces this scope.
529532
node: NodeWithScopeKind,
533+
534+
/// The range of [`FileScopeId`]s that are descendants of this scope.
530535
descendants: Range<FileScopeId>,
536+
537+
/// The constraint that determines the reachability of this scope.
531538
reachability: ScopedReachabilityConstraintId,
539+
540+
/// Whether this scope is defined inside an `if TYPE_CHECKING:` block.
541+
in_type_checking_block: bool,
532542
}
533543

534544
impl Scope {
@@ -537,12 +547,14 @@ impl Scope {
537547
node: NodeWithScopeKind,
538548
descendants: Range<FileScopeId>,
539549
reachability: ScopedReachabilityConstraintId,
550+
in_type_checking_block: bool,
540551
) -> Self {
541552
Scope {
542553
parent,
543554
node,
544555
descendants,
545556
reachability,
557+
in_type_checking_block,
546558
}
547559
}
548560

@@ -573,6 +585,10 @@ impl Scope {
573585
pub(crate) fn reachability(&self) -> ScopedReachabilityConstraintId {
574586
self.reachability
575587
}
588+
589+
pub(crate) fn in_type_checking_block(&self) -> bool {
590+
self.in_type_checking_block
591+
}
576592
}
577593

578594
#[derive(Copy, Clone, Debug, PartialEq, Eq)]

crates/ty_python_semantic/src/types/infer.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2135,6 +2135,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
21352135
if self.in_function_overload_or_abstractmethod() {
21362136
return;
21372137
}
2138+
if self.scope().scope(self.db()).in_type_checking_block() {
2139+
return;
2140+
}
21382141
if let Some(class) = self.class_context_of_current_method() {
21392142
enclosing_class_context = Some(class);
21402143
if class.is_protocol(self.db()) {

0 commit comments

Comments
 (0)