diff --git a/crates/pyrefly_config/src/error_kind.rs b/crates/pyrefly_config/src/error_kind.rs index 3dea6f55f3..6e90012eb9 100644 --- a/crates/pyrefly_config/src/error_kind.rs +++ b/crates/pyrefly_config/src/error_kind.rs @@ -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. diff --git a/crates/pyrefly_python/src/sys_info.rs b/crates/pyrefly_python/src/sys_info.rs index 8499559b2b..dc019b8977 100644 --- a/crates/pyrefly_python/src/sys_info.rs +++ b/crates/pyrefly_python/src/sys_info.rs @@ -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" } diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index bb519fd536..7b06cef218 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -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; @@ -3393,10 +3394,32 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { expr: &Expr, legacy_tparams: &Option]>>, 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 { @@ -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) => { diff --git a/pyrefly/lib/binding/binding.rs b/pyrefly/lib/binding/binding.rs index f179639d42..3e568d48bf 100644 --- a/pyrefly/lib/binding/binding.rs +++ b/pyrefly/lib/binding/binding.rs @@ -2080,6 +2080,8 @@ pub struct NameAssign { pub expr: Box, pub legacy_tparams: Option]>>, 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. diff --git a/pyrefly/lib/binding/target.rs b/pyrefly/lib/binding/target.rs index 99a78d4488..914d3bf9fc 100644 --- a/pyrefly/lib/binding/target.rs +++ b/pyrefly/lib/binding/target.rs @@ -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, diff --git a/pyrefly/lib/test/sys_info.rs b/pyrefly/lib/test/sys_info.rs index 0e011fb158..fb9d087b84 100644 --- a/pyrefly/lib/test/sys_info.rs +++ b/pyrefly/lib/test/sys_info.rs @@ -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 +"#, +); diff --git a/website/docs/error-kinds.mdx b/website/docs/error-kinds.mdx index 8da9026fc0..10d8851d07 100644 --- a/website/docs/error-kinds.mdx +++ b/website/docs/error-kinds.mdx @@ -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: