Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/pyrefly_config/src/error_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ pub enum ErrorKind {
InvalidSyntax,
/// An error related to type alias usage or definition.
InvalidTypeAlias,
/// A user-defined `TYPE_CHECKING` constant that is not typed as `bool`. Type checkers treat
/// `TYPE_CHECKING` as `True` while the runtime sees `False`, so it must be a `bool`
/// (conventionally `TYPE_CHECKING = False`).
InvalidTypeCheckingConstant,
/// An error caused by incorrect usage or definition of a TypeVar.
InvalidTypeVar,
/// An error caused by incorrect usage or definition of a TypeVarTuple.
Expand Down
2 changes: 1 addition & 1 deletion crates/pyrefly_python/src/sys_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ impl SysInfo {
Some(self.evaluate(x)?.to_bool())
}

fn is_type_checking_constant_name(x: &str) -> bool {
pub fn is_type_checking_constant_name(x: &str) -> bool {
x == "TYPE_CHECKING" || x == "TYPE_CHECKING_WITH_PYREFLY"
}

Expand Down
24 changes: 24 additions & 0 deletions pyrefly/lib/alt/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use pyrefly_python::ast::Ast;
use pyrefly_python::dunder;
use pyrefly_python::module_name::ModuleName;
use pyrefly_python::short_identifier::ShortIdentifier;
use pyrefly_python::sys_info::SysInfo;
use pyrefly_types::dimension::SizeExpr;
use pyrefly_types::facet::FacetKind;
use pyrefly_types::shaped_array::ShapedArrayType;
Expand Down Expand Up @@ -3393,10 +3394,32 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
expr: &Expr,
legacy_tparams: &Option<Box<[Idx<KeyLegacyTypeParam>]>>,
is_in_function_scope: bool,
is_module_scope: bool,
errors: &ErrorCollector,
) -> Type {
let (annot, ty) =
self.name_assign_infer(name, annot_key.as_ref(), receiver_idx, expr, errors);
// A user-defined module-level `TYPE_CHECKING` (or pyrefly's `TYPE_CHECKING_WITH_PYREFLY`)
// constant is treated as `True` by type checkers and `False` at runtime, so it must be a
// `bool`. A class attribute that merely shares the name is not the sentinel, so restrict to
// module scope. Stub files have no runtime and conventionally initialize typing constants to
// placeholder values (e.g. `TYPE_CHECKING = 1`), so skip them.
// See https://github.com/facebook/pyrefly/issues/3756.
if is_module_scope
&& SysInfo::is_type_checking_constant_name(name.as_str())
&& !self.module().path().is_interface()
&& !self.is_subset_eq(&ty, &self.heap.mk_class_type(self.stdlib.bool().clone()))
{
self.error(
errors,
expr.range(),
ErrorKind::InvalidTypeCheckingConstant,
format!(
"`{name}` must have type `bool` (e.g. `{name} = False`), got `{}`",
self.for_display(ty.clone())
),
);
}
if let Some(annot) = &annot
&& let Some((AnnotationStyle::Forwarded, _)) = annot_key
{
Expand Down Expand Up @@ -5186,6 +5209,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
&x.expr,
&x.legacy_tparams,
x.is_in_function_scope,
x.is_module_scope,
errors,
),
Binding::TypeVar(x) => {
Expand Down
2 changes: 2 additions & 0 deletions pyrefly/lib/binding/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2080,6 +2080,8 @@ pub struct NameAssign {
pub expr: Box<Expr>,
pub legacy_tparams: Option<Box<[Idx<KeyLegacyTypeParam>]>>,
pub is_in_function_scope: bool,
/// True if this assignment is at module scope (not inside a function or class body).
pub is_module_scope: bool,
pub first_use: FirstUse,
/// The Definition idx for this NameAssign, if infer_with_first_use is enabled.
/// Used at solve time for inline first-use pinning and partial answer storage.
Expand Down
1 change: 1 addition & 0 deletions pyrefly/lib/binding/target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ impl<'a> BindingsBuilder<'a> {
expr: value,
legacy_tparams: tparams,
is_in_function_scope: self.scopes.in_function_scope(),
is_module_scope: !self.scopes.in_function_scope() && !self.scopes.in_class_body(),
first_use: FirstUse::Undetermined,
def_idx: if uses_first_use { Some(def_idx) } else { None },
receiver_idx,
Expand Down
89 changes: 89 additions & 0 deletions pyrefly/lib/test/sys_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,3 +469,92 @@ testcase!(
TestEnv::new_with_version(PythonVersion::new(3, 7, 0)),
"",
);

// https://github.com/facebook/pyrefly/issues/3756: a module-level `TYPE_CHECKING` constant is
// treated as `True` by type checkers and `False` at runtime, so it must have type `bool`.
testcase!(
test_type_checking_constant_bad_annotation,
r#"
TYPE_CHECKING: str = "" # E: TYPE_CHECKING
"#,
);

testcase!(
test_type_checking_constant_bad_value,
r#"
TYPE_CHECKING = 1 # E: `TYPE_CHECKING` must have type `bool`
"#,
);

// The canonical `from typing import TYPE_CHECKING` is an import, not an assignment, so it is fine.
testcase!(
test_type_checking_constant_import_ok,
r#"
from typing import TYPE_CHECKING
if TYPE_CHECKING:
x: int = 1
"#,
);

testcase!(
test_type_checking_constant_define_ok,
r#"
TYPE_CHECKING = False
TYPE_CHECKING_WITH_PYREFLY: bool = False
"#,
);

// A local variable that merely shares the name is not the module-level constant, so don't flag it.
testcase!(
test_type_checking_constant_local_ok,
r#"
def f() -> None:
TYPE_CHECKING: str = ""
"#,
);

// We check the type is `bool`; we do not additionally require the value to be `False`, so a
// `True` value (a runtime bug) is currently accepted.
testcase!(
bug = "TYPE_CHECKING = True is a runtime bug but is not flagged (we only check the type)",
test_type_checking_constant_true_not_flagged,
r#"
TYPE_CHECKING = True
"#,
);

// A class attribute that merely shares the name is not the module-level sentinel.
testcase!(
test_type_checking_constant_class_attr_ok,
r#"
class C:
TYPE_CHECKING: str = ""
"#,
);

// `TYPE_CHECKING_WITH_PYREFLY` is also recognized, so a bad definition is flagged too.
testcase!(
test_type_checking_with_pyrefly_bad,
r#"
TYPE_CHECKING_WITH_PYREFLY = 1 # E: must have type `bool`
"#,
);

// Annotation-only declarations route through a different binding, so we don't check them yet.
testcase!(
bug = "annotation-only `TYPE_CHECKING: str` (no value) is not yet flagged",
test_type_checking_constant_annotation_only_not_flagged,
r#"
TYPE_CHECKING: str
"#,
);

// Stub (`.pyi`) files have no runtime and conventionally initialize typing constants to placeholder
// values (e.g. `TYPE_CHECKING = 1` in mypy's test fixtures), so the check is skipped there.
testcase!(
test_type_checking_constant_stub_ok,
TestEnv::one_with_path("foo", "foo.pyi", "TYPE_CHECKING = 1"),
r#"
import foo
"#,
);
10 changes: 10 additions & 0 deletions website/docs/error-kinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,16 @@ x = 2
Bad: TypeAlias = x
```

## invalid-type-checking-constant

A module-level `TYPE_CHECKING` constant that is not typed as `bool`. Type checkers treat
`TYPE_CHECKING` as `True`, while at runtime it is `False`, so a user-defined one must be a `bool`
(conventionally `TYPE_CHECKING = False`).

```python
TYPE_CHECKING: str = "" # error: not a bool
```

## invalid-type-var

An error caused by incorrect usage or definition of a TypeVar. A few examples:
Expand Down
Loading