Enhanced truthy/falsy type narrowing pattern to handle classes that contain a __bool__ method that always returns True or False.

This commit is contained in:
Eric Traut 2021-11-28 02:52:39 -08:00
parent 3ff69a1ffc
commit cae15781a3
7 changed files with 244 additions and 176 deletions

View File

@ -200,8 +200,6 @@ import {
areTypesSame,
buildTypeVarMapFromSpecializedClass,
CanAssignFlags,
canBeFalsy,
canBeTruthy,
ClassMember,
ClassMemberLookupFlags,
combineSameSizedTuples,
@ -240,10 +238,8 @@ import {
ParameterSource,
partiallySpecializeType,
populateTypeVarMapForSelfType,
removeFalsinessFromType,
removeNoReturnFromUnion,
removeParamSpecVariadicsFromSignature,
removeTruthinessFromType,
requiresSpecialization,
requiresTypeArguments,
setTypeArgumentsRecursive,
@ -1243,6 +1239,198 @@ export function createTypeEvaluator(importLookup: ImportLookup, evaluatorOptions
return returnType;
}
function canBeFalsy(type: Type, recursionLevel = 0): boolean {
if (recursionLevel > maxTypeRecursionCount) {
return true;
}
switch (type.category) {
case TypeCategory.Unbound:
case TypeCategory.Unknown:
case TypeCategory.Any:
case TypeCategory.Never:
case TypeCategory.None: {
return true;
}
case TypeCategory.Union: {
return findSubtype(type, (subtype) => canBeFalsy(subtype, recursionLevel + 1)) !== undefined;
}
case TypeCategory.Function:
case TypeCategory.OverloadedFunction:
case TypeCategory.Module:
case TypeCategory.TypeVar: {
return false;
}
case TypeCategory.Class: {
if (TypeBase.isInstantiable(type)) {
return false;
}
// Handle tuples specially.
if (isTupleClass(type) && type.tupleTypeArguments) {
return isOpenEndedTupleClass(type) || type.tupleTypeArguments.length === 0;
}
// Check for Literal[False] and Literal[True].
if (ClassType.isBuiltIn(type, 'bool') && type.literalValue !== undefined) {
return type.literalValue === false;
}
const lenMethod = lookUpObjectMember(type, '__len__');
if (lenMethod) {
return true;
}
const boolMethod = lookUpObjectMember(type, '__bool__');
if (boolMethod) {
const boolMethodType = getTypeOfMember(boolMethod);
// If the __bool__ function unconditionally returns True, it can never be falsy.
if (isFunction(boolMethodType) && boolMethodType.details.declaredReturnType) {
const returnType = boolMethodType.details.declaredReturnType;
if (
isClassInstance(returnType) &&
ClassType.isBuiltIn(returnType, 'bool') &&
returnType.literalValue === true
) {
return false;
}
}
return true;
}
return false;
}
}
}
function canBeTruthy(type: Type, recursionLevel = 0): boolean {
if (recursionLevel > maxTypeRecursionCount) {
return true;
}
switch (type.category) {
case TypeCategory.Unknown:
case TypeCategory.Function:
case TypeCategory.OverloadedFunction:
case TypeCategory.Module:
case TypeCategory.TypeVar:
case TypeCategory.Never:
case TypeCategory.Any: {
return true;
}
case TypeCategory.Union: {
return findSubtype(type, (subtype) => canBeTruthy(subtype, recursionLevel + 1)) !== undefined;
}
case TypeCategory.Unbound:
case TypeCategory.None: {
return false;
}
case TypeCategory.Class: {
if (TypeBase.isInstantiable(type)) {
return true;
}
// Check for Tuple[()] (an empty tuple).
if (isTupleClass(type)) {
if (type.tupleTypeArguments && type.tupleTypeArguments!.length === 0) {
return false;
}
}
// Check for Literal[False], Literal[0], Literal[""].
if (type.literalValue === false || type.literalValue === 0 || type.literalValue === '') {
return false;
}
const boolMethod = lookUpObjectMember(type, '__bool__');
if (boolMethod) {
const boolMethodType = getTypeOfMember(boolMethod);
// If the __bool__ function unconditionally returns False, it can never be truthy.
if (isFunction(boolMethodType) && boolMethodType.details.declaredReturnType) {
const returnType = boolMethodType.details.declaredReturnType;
if (
isClassInstance(returnType) &&
ClassType.isBuiltIn(returnType, 'bool') &&
returnType.literalValue === false
) {
return false;
}
}
}
return true;
}
}
}
// Filters a type such that that no part of it is definitely
// truthy. For example, if a type is a union of None
// and a custom class "Foo" that has no __len__ or __nonzero__
// method, this method would strip off the "Foo"
// and return only the "None".
function removeTruthinessFromType(type: Type): Type {
return mapSubtypes(type, (subtype) => {
if (isClassInstance(subtype)) {
if (subtype.literalValue !== undefined) {
// If the object is already definitely falsy, it's fine to
// include, otherwise it should be removed.
return !subtype.literalValue ? subtype : undefined;
}
// If the object is a bool, make it "false", since
// "true" is a truthy value.
if (ClassType.isBuiltIn(subtype, 'bool')) {
return ClassType.cloneWithLiteral(subtype, /* value */ false);
}
}
// If it's possible for the type to be falsy, include it.
if (canBeFalsy(subtype)) {
return subtype;
}
return undefined;
});
}
// Filters a type such that that no part of it is definitely
// falsy. For example, if a type is a union of None
// and an "int", this method would strip off the "None"
// and return only the "int".
function removeFalsinessFromType(type: Type): Type {
return mapSubtypes(type, (subtype) => {
if (isClassInstance(subtype)) {
if (subtype.literalValue !== undefined) {
// If the object is already definitely truthy, it's fine to
// include, otherwise it should be removed.
return subtype.literalValue ? subtype : undefined;
}
// If the object is a bool, make it "true", since
// "false" is a falsy value.
if (ClassType.isBuiltIn(subtype, 'bool')) {
return ClassType.cloneWithLiteral(subtype, /* value */ true);
}
}
// If it's possible for the type to be truthy, include it.
if (canBeTruthy(subtype)) {
return subtype;
}
return undefined;
});
}
// Gets a member type from an object and if it's a function binds
// it to the object. If bindToClass is undefined, the binding is done
// using the objectType parameter. Callers can specify these separately
@ -21173,6 +21361,10 @@ export function createTypeEvaluator(importLookup: ImportLookup, evaluatorOptions
evaluateTypesForMatchNode,
evaluateTypesForCaseNode,
evaluateTypeOfParameter,
canBeTruthy,
canBeFalsy,
removeTruthinessFromType,
removeFalsinessFromType,
verifyRaiseExceptionType,
verifyDeleteExpression,
isAfterNodeReachable,

View File

@ -251,6 +251,11 @@ export interface TypeEvaluator {
evaluateTypesForCaseNode: (node: CaseNode) => void;
evaluateTypeOfParameter: (node: ParameterNode) => void;
canBeTruthy: (type: Type) => boolean;
canBeFalsy: (type: Type) => boolean;
removeTruthinessFromType: (type: Type) => Type;
removeFalsinessFromType: (type: Type) => Type;
getExpectedType: (node: ExpressionNode) => ExpectedTypeResult | undefined;
verifyRaiseExceptionType: (node: RaiseNode) => void;
verifyDeleteExpression: (node: ExpressionNode) => void;

View File

@ -73,6 +73,10 @@ export function createTypeEvaluatorWithTracker(
evaluateTypesForMatchNode: typeEvaluator.evaluateTypesForMatchNode,
evaluateTypesForCaseNode: typeEvaluator.evaluateTypesForCaseNode,
evaluateTypeOfParameter: typeEvaluator.evaluateTypeOfParameter,
canBeTruthy: typeEvaluator.canBeTruthy,
canBeFalsy: typeEvaluator.canBeFalsy,
removeTruthinessFromType: typeEvaluator.removeTruthinessFromType,
removeFalsinessFromType: typeEvaluator.removeFalsinessFromType,
getExpectedType: (n) => run('getExpectedType', () => typeEvaluator.getExpectedType(n), n),
verifyRaiseExceptionType: (n) =>
run('verifyRaiseExceptionType', () => typeEvaluator.verifyRaiseExceptionType(n), n),

View File

@ -48,8 +48,6 @@ import {
import {
addConditionToType,
applySolvedTypeVars,
canBeFalsy,
canBeTruthy,
ClassMember,
computeMroLinearization,
convertToInstance,
@ -64,8 +62,6 @@ import {
lookUpClassMember,
lookUpObjectMember,
mapSubtypes,
removeFalsinessFromType,
removeTruthinessFromType,
transformPossibleRecursiveTypeAlias,
} from './typeUtils';
import { TypeVarMap } from './typeVarMap';
@ -429,12 +425,12 @@ export function getTypeNarrowingCallback(
// Narrow the type based on whether the subtype can be true or false.
return mapSubtypes(type, (subtype) => {
if (isPositiveTest) {
if (canBeTruthy(subtype)) {
return removeFalsinessFromType(subtype);
if (evaluator.canBeTruthy(subtype)) {
return evaluator.removeFalsinessFromType(subtype);
}
} else {
if (canBeFalsy(subtype)) {
return removeTruthinessFromType(subtype);
if (evaluator.canBeFalsy(subtype)) {
return evaluator.removeTruthinessFromType(subtype);
}
}
return undefined;

View File

@ -510,111 +510,6 @@ export function transformPossibleRecursiveTypeAlias(type: Type | undefined): Typ
return type;
}
// None is always falsy. All other types are generally truthy
// unless they are objects that support the __bool__ or __len__
// methods.
export function canBeFalsy(type: Type, recursionLevel = 0): boolean {
if (recursionLevel > maxTypeRecursionCount) {
return true;
}
switch (type.category) {
case TypeCategory.Unbound:
case TypeCategory.Unknown:
case TypeCategory.Any:
case TypeCategory.Never:
case TypeCategory.None: {
return true;
}
case TypeCategory.Union: {
return findSubtype(type, (subtype) => canBeFalsy(subtype, recursionLevel + 1)) !== undefined;
}
case TypeCategory.Function:
case TypeCategory.OverloadedFunction:
case TypeCategory.Module:
case TypeCategory.TypeVar: {
return false;
}
case TypeCategory.Class: {
if (TypeBase.isInstantiable(type)) {
return false;
}
// Handle tuples specially.
if (isTupleClass(type) && type.tupleTypeArguments) {
return isOpenEndedTupleClass(type) || type.tupleTypeArguments.length === 0;
}
// Check for Literal[False] and Literal[True].
if (ClassType.isBuiltIn(type, 'bool') && type.literalValue !== undefined) {
return type.literalValue === false;
}
const lenMethod = lookUpObjectMember(type, '__len__');
if (lenMethod) {
return true;
}
const boolMethod = lookUpObjectMember(type, '__bool__');
if (boolMethod) {
return true;
}
return false;
}
}
}
export function canBeTruthy(type: Type, recursionLevel = 0): boolean {
if (recursionLevel > maxTypeRecursionCount) {
return true;
}
switch (type.category) {
case TypeCategory.Unknown:
case TypeCategory.Function:
case TypeCategory.OverloadedFunction:
case TypeCategory.Module:
case TypeCategory.TypeVar:
case TypeCategory.Never:
case TypeCategory.Any: {
return true;
}
case TypeCategory.Union: {
return findSubtype(type, (subtype) => canBeTruthy(subtype, recursionLevel + 1)) !== undefined;
}
case TypeCategory.Unbound:
case TypeCategory.None: {
return false;
}
case TypeCategory.Class: {
if (TypeBase.isInstantiable(type)) {
return true;
}
// Check for Tuple[()] (an empty tuple).
if (isTupleClass(type)) {
if (type.tupleTypeArguments && type.tupleTypeArguments!.length === 0) {
return false;
}
}
// Check for Literal[False], Literal[0], Literal[""].
if (type.literalValue === false || type.literalValue === 0 || type.literalValue === '') {
return false;
}
return true;
}
}
}
export function getTypeVarScopeId(type: Type): TypeVarScopeId | undefined {
if (isClass(type)) {
return type.details.typeVarScopeId;
@ -1419,65 +1314,6 @@ export function derivesFromClassRecursive(classType: ClassType, baseClassToFind:
return false;
}
// Filters a type such that that no part of it is definitely
// falsy. For example, if a type is a union of None
// and an "int", this method would strip off the "None"
// and return only the "int".
export function removeFalsinessFromType(type: Type): Type {
return mapSubtypes(type, (subtype) => {
if (isClassInstance(subtype)) {
if (subtype.literalValue !== undefined) {
// If the object is already definitely truthy, it's fine to
// include, otherwise it should be removed.
return subtype.literalValue ? subtype : undefined;
}
// If the object is a bool, make it "true", since
// "false" is a falsy value.
if (ClassType.isBuiltIn(subtype, 'bool')) {
return ClassType.cloneWithLiteral(subtype, /* value */ true);
}
}
// If it's possible for the type to be truthy, include it.
if (canBeTruthy(subtype)) {
return subtype;
}
return undefined;
});
}
// Filters a type such that that no part of it is definitely
// truthy. For example, if a type is a union of None
// and a custom class "Foo" that has no __len__ or __nonzero__
// method, this method would strip off the "Foo"
// and return only the "None".
export function removeTruthinessFromType(type: Type): Type {
return mapSubtypes(type, (subtype) => {
if (isClassInstance(subtype)) {
if (subtype.literalValue !== undefined) {
// If the object is already definitely falsy, it's fine to
// include, otherwise it should be removed.
return !subtype.literalValue ? subtype : undefined;
}
// If the object is a bool, make it "false", since
// "true" is a truthy value.
if (ClassType.isBuiltIn(subtype, 'bool')) {
return ClassType.cloneWithLiteral(subtype, /* value */ false);
}
}
// If it's possible for the type to be falsy, include it.
if (canBeFalsy(subtype)) {
return subtype;
}
return undefined;
});
}
export function synthesizeTypeVarForSelfCls(classType: ClassType, isClsParam: boolean): TypeVarType {
const selfType = TypeVarType.createInstance(`__type_of_self__`);
const scopeId = getTypeVarScopeId(classType) ?? '';

View File

@ -0,0 +1,29 @@
# This sample tests type narrowing for falsy and truthy values.
from typing import List, Literal, Union
class A:
...
class B:
def __bool__(self) -> bool:
...
class C:
def __bool__(self) -> Literal[False]:
...
class D:
def __bool__(self) -> Literal[True]:
...
def func1(x: Union[int, List[int], A, B, C, D, None]) -> None:
if x:
t1: Literal["int | List[int] | A | B | D"] = reveal_type(x)
else:
t2: Literal["int | List[int] | B | C | None"] = reveal_type(x)

View File

@ -403,6 +403,12 @@ test('typeNarrowingCallable1', () => {
TestUtils.validateResults(analysisResults, 2);
});
test('TypeNarrowingFalsy1', () => {
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typeNarrowingFalsy1.py']);
TestUtils.validateResults(analysisResults, 0);
});
test('ReturnTypes1', () => {
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['returnTypes1.py']);