Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Python: Eliminate some FPs
Two main variants: cases where `__eq__` is not defined (which inherit
`__hash__` automatically), and frozen dataclasses.
  • Loading branch information
tausbn committed Apr 9, 2026
commit a7ba98cff779c1a64731d99c4e391679ea425a74
48 changes: 44 additions & 4 deletions python/ql/src/Expressions/HashedButNoHash.ql
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,57 @@ predicate setsHashToNone(Class cls) {
DuckTyping::getAnAttributeValue(cls, "__hash__") instanceof None
}

/**
* Holds if `cls` has a `@dataclass` decorator with `frozen=True` or `unsafe_hash=True`,
* which generates a `__hash__` method at runtime.
*/
predicate hasDataclassHash(Class cls) {
exists(DataFlow::CallCfgNode decorator |
decorator = API::moduleImport("dataclasses").getMember("dataclass").getACall() and
decorator.asExpr() = cls.getADecorator() and
decorator.getArgByName(["frozen", "unsafe_hash"]).asExpr().(ImmutableLiteral).booleanValue() =
true
)
}

/**
* Holds if `cls` defines `__eq__` directly (not inherited from `object`),
* or has a `@dataclass` decorator that generates `__eq__` (the default).
*/
predicate definesEq(Class cls) {
DuckTyping::hasMethod(cls, "__eq__")
or
// @dataclass(...) call generates __eq__ unless eq=False
exists(DataFlow::CallCfgNode decorator |
decorator = API::moduleImport("dataclasses").getMember("dataclass").getACall() and
decorator.asExpr() = cls.getADecorator() and
not decorator.getArgByName("eq").asExpr().(ImmutableLiteral).booleanValue() = false
)
or
// bare @dataclass without arguments also generates __eq__
cls.getADecorator() =
API::moduleImport("dataclasses").getMember("dataclass").getAValueReachableFromSource().asExpr()
}

/**
* Holds if `cls` is a user-defined class whose instances are unhashable.
* A new-style class without `__hash__` is unhashable, as is one that explicitly
* sets `__hash__ = None`.
*
* In Python, a class is unhashable if:
* - It explicitly sets `__hash__ = None`, or
* - It defines `__eq__` (directly or via decorator like `@dataclass`) without
* defining `__hash__`, and doesn't have a decorator that generates `__hash__`.
*/
predicate isUnhashableUserClass(Class cls) {
setsHashToNone(cls)
or
// In Python 3, defining __eq__ without __hash__ makes a class unhashable.
// This rule does not apply in Python 2.
major_version() = 3 and
DuckTyping::isNewStyle(cls) and
definesEq(cls) and
not DuckTyping::hasMethod(cls, "__hash__") and
not hasDataclassHash(cls) and
not DuckTyping::hasUnresolvedBase(getADirectSuperclass*(cls))
or
setsHashToNone(cls)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
| test.py:17:17:17:19 | ControlFlowNode for obj | This $@ of $@ is unhashable. | test.py:16:11:16:23 | ControlFlowNode for HasEqNoHash() | instance | test.py:16:11:16:23 | ControlFlowNode for HasEqNoHash() | HasEqNoHash |
| test.py:48:17:48:17 | ControlFlowNode for p | This $@ of $@ is unhashable. | test.py:47:9:47:27 | ControlFlowNode for MutableDataclass() | instance | test.py:47:9:47:27 | ControlFlowNode for MutableDataclass() | MutableDataclass |
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expressions/HashedButNoHash.ql

Check warning

Code scanning / CodeQL

Query test without inline test expectations Warning test

Query test does not use inline test expectations.
48 changes: 48 additions & 0 deletions python/ql/test/3/query-tests/Expressions/general/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Classes without __eq__ are hashable (inherit __hash__ from object)
class NoEqClass:
def __init__(self, x):
self.x = x

def hash_no_eq():
obj = NoEqClass(1)
return hash(obj) # OK: no __eq__, so __hash__ is inherited from object

# Classes with __eq__ but no __hash__ are unhashable
class HasEqNoHash:
def __eq__(self, other):
return self.x == other.x

def hash_eq_no_hash():
obj = HasEqNoHash()
return hash(obj) # should be flagged

# @dataclass(frozen=True) generates __hash__, so instances are hashable
from dataclasses import dataclass

@dataclass(frozen=True)
class FrozenPoint:
x: int
y: int

def hash_frozen_dataclass():
p = FrozenPoint(1, 2)
return hash(p) # OK: frozen dataclass has __hash__

# @dataclass(unsafe_hash=True) also generates __hash__
@dataclass(unsafe_hash=True)
class UnsafeHashPoint:
x: int
y: int

def hash_unsafe_hash_dataclass():
p = UnsafeHashPoint(1, 2)
return hash(p) # OK: unsafe_hash=True generates __hash__

# Plain @dataclass without frozen/unsafe_hash and with __eq__ is unhashable
@dataclass
class MutableDataclass:
x: int

def hash_mutable_dataclass():
p = MutableDataclass(1)
return hash(p) # should be flagged: @dataclass generates __eq__ but not __hash__
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,12 @@ def local():
def apply(f):
pass
apply(foo)([1])

# NoEqClass is hashable (no __eq__ => inherits __hash__ from object).
class NoEqClass:
def __init__(self, x):
self.x = x

def hash_no_eq():
obj = NoEqClass(1)
return hash(obj) # OK: no __eq__, so __hash__ is inherited from object
Loading