Merge pull request #20217 from joefarebrother/python-qual-signature-mismatch

Python: Modernize the Signature Mismatch query
This commit is contained in:
Joe Farebrother
2025-09-17 13:29:33 +01:00
committed by GitHub
10 changed files with 366 additions and 48 deletions

View File

@@ -1,4 +1,5 @@
/**
* @deprecated
* @name Mismatch between signature and use of an overriding method
* @description Method has a different signature from the overridden method and, if it were called, would be likely to cause an error.
* @kind problem

View File

@@ -1,9 +1,12 @@
# Base class method
def runsource(self, source, filename="<input>", symbol="single"):
... # Definition
class Base:
def runsource(self, source, filename="<input>"):
...
# Extend base class method
def runsource(self, source):
... # Definition
class Sub(Base):
def runsource(self, source): # BAD: Does not match the signature of overridden method.
...
def run(obj: Base):
obj.runsource("source", filename="foo.txt")

View File

@@ -5,32 +5,25 @@
<overview>
<p> There are one (or more) legal parameters for an overridden method that are
not legal for an overriding method. This will cause an error when the overriding
method is called with a number of parameters that is legal for the overridden method.
This violates the Liskov substitution principle.
<p> When the signature of a method of a base class and a method of a subclass that overrides it don't match, a call to the base class method
may not be a valid call to the subclass method, and thus raise an exception if an instance of the subclass is passed instead.
If following the Liskov Substitution Principle, in which an instance of a subclass should be usable in every context as though it were an
instance of the base class, this behavior breaks the principle.
</p>
</overview>
<recommendation>
<p>Ensure that the overriding method accepts all the parameters that are legal for
overridden method.</p>
<p>Ensure that the overriding method in the subclass accepts the same parameters as the base method. </p>
</recommendation>
<example>
<p>In this example there is a mismatch between the legal parameters for the base
class method <code>(self, source, filename, symbol)</code> and the extension method
<code>(self, source)</code>. The extension method can be used to override the base
method as long as values are not specified for the <code>filename</code> and
<code>symbol</code> parameters. If the extension method was passed the additional
parameters accepted by the base method then an error would occur.</p>
<p>In the following example, <code>Base.runsource</code> takes an optional <code>filename</code> argument. However, the overriding method
<code>Sub.runsource</code> does not. This means the <code>run</code> function will fail if passed an instance of <code>Sub</code>.
</p>
<sample src="SignatureOverriddenMethod.py" />
<p>The extension method should be updated to support the <code>filename</code> and
<code>symbol</code> parameters supported by the overridden method.</p>
</example>
<references>

View File

@@ -13,23 +13,254 @@
*/
import python
import Expressions.CallArgs
import semmle.python.dataflow.new.DataFlow
import semmle.python.dataflow.new.internal.DataFlowDispatch
import codeql.util.Option
from FunctionValue base, PythonFunctionValue derived
where
not exists(base.getACall()) and
not exists(FunctionValue a_derived |
a_derived.overrides(base) and
exists(a_derived.getACall())
) and
not derived.getScope().isSpecialMethod() and
derived.getName() != "__init__" and
derived.isNormalMethod() and
// call to overrides distributed for efficiency
(
derived.overrides(base) and derived.minParameters() > base.maxParameters()
or
derived.overrides(base) and derived.maxParameters() < base.minParameters()
/** Holds if `base` is overridden by `sub` */
predicate overrides(Function base, Function sub) {
base.getName() = sub.getName() and
base.getScope() = getADirectSuperclass+(sub.getScope())
}
/** Constructs a string to pluralize `str` depending on `num`. */
bindingset[num, str]
string plural(int num, string str) {
num = 1 and result = "1 " + str
or
num != 1 and result = num.toString() + " " + str + "s"
}
/** Describes the minimum number of arguments `func` can accept, using "at least" if it may accept more. */
string describeMin(Function func) {
exists(string descr | descr = plural(func.getMinPositionalArguments(), "positional argument") |
if func.getMinPositionalArguments() = func.getMaxPositionalArguments()
then result = descr
else result = "at least " + descr
)
select derived, "Overriding method '" + derived.getName() + "' has signature mismatch with $@.",
base, "overridden method"
}
/** Described the maximum number of arguments `func` can accept, using "at most" if it may accept fewer, and "arbitrarily many" if it has a vararg. */
string describeMax(Function func) {
if func.hasVarArg()
then result = "arbitrarily many positional arguments"
else
exists(string descr | descr = plural(func.getMaxPositionalArguments(), "positional argument") |
if func.getMinPositionalArguments() = func.getMaxPositionalArguments()
then result = descr
else result = "at most " + descr
)
}
/** Describes the minimum number of arguments `func` can accept, without repeating "positional arguments". */
string describeMinShort(Function func) {
exists(string descr | descr = func.getMinPositionalArguments().toString() |
if func.getMinPositionalArguments() = func.getMaxPositionalArguments()
then result = descr
else result = "at least " + descr
)
}
/** Describes the maximum number of arguments `func` can accept, without repeating "positional arguments". */
string describeMaxShort(Function func) {
if func.hasVarArg()
then result = "arbitrarily many"
else
exists(string descr | descr = func.getMaxPositionalArguments().toString() |
if func.getMinPositionalArguments() = func.getMaxPositionalArguments()
then result = descr
else result = "at most " + descr
)
}
/** Describe an upper bound on the number of arguments `func` may accept, without specifying "at most". */
string describeMaxBound(Function func) {
if func.hasVarArg()
then result = "arbitrarily many"
else result = func.getMaxPositionalArguments().toString()
}
/** Holds if no way to call `base` would be valid for `sub`. The `msg` applies to the `sub method. */
predicate strongSignatureMismatch(Function base, Function sub, string msg) {
overrides(base, sub) and
(
sub.getMinPositionalArguments() > base.getMaxPositionalArguments() and
msg =
"requires " + describeMin(sub) + ", whereas overridden $@ requires " + describeMaxShort(base) +
"."
or
sub.getMaxPositionalArguments() < base.getMinPositionalArguments() and
msg =
"requires " + describeMax(sub) + ", whereas overridden $@ requires " + describeMinShort(base) +
"."
)
}
/** Holds if there may be some ways to call `base` that would not be valid for `sub`. The `msg` applies to the `sub` method. */
predicate weakSignatureMismatch(Function base, Function sub, string msg) {
overrides(base, sub) and
(
sub.getMinPositionalArguments() > base.getMinPositionalArguments() and
msg =
"requires " + describeMin(sub) + ", whereas overridden $@ may be called with " +
base.getMinPositionalArguments().toString() + "."
or
sub.getMaxPositionalArguments() < base.getMaxPositionalArguments() and
msg =
"requires " + describeMax(sub) + ", whereas overridden $@ may be called with " +
describeMaxBound(base) + "."
or
sub.getMinPositionalArguments() <= base.getMinPositionalArguments() and
sub.getMaxPositionalArguments() >= base.getMaxPositionalArguments() and
exists(string arg |
// TODO: positional-only args not considered
// e.g. `def foo(x, y, /, z):` has x,y as positional only args, should not be considered as possible kw args
// However, this likely does not create FPs, as we require a 'witness' call to generate an alert.
arg = base.getAnArg().getName() and
not arg = sub.getAnArg().getName() and
not exists(sub.getKwarg()) and
msg = "does not accept keyword argument `" + arg + "`, which overridden $@ does."
)
or
exists(base.getKwarg()) and
not exists(sub.getKwarg()) and
msg = "does not accept arbitrary keyword arguments, which overridden $@ does."
)
}
/** Holds if `f` should be ignored for considering signature mismatches. */
predicate ignore(Function f) {
isClassmethod(f)
or
exists(
Function g // other functions with the same name, e.g. @property getters/setters.
|
g.getScope() = f.getScope() and
g.getName() = f.getName() and
g != f
)
}
/** Gets a function that `call` may resolve to. */
Function resolveCall(Call call) {
exists(DataFlowCall dfc | call = dfc.getNode().(CallNode).getNode() |
result = viableCallable(dfc).(DataFlowFunction).getScope()
)
}
/** Holds if `call` may resolve to either `base` or `sub`, and `base` is overridden by `sub`. */
predicate callViableForEitherOverride(Function base, Function sub, Call call) {
overrides(base, sub) and
base = resolveCall(call) and
sub = resolveCall(call)
}
/** Holds if either both `base` and `sub` are static methods, or both are not static methods, and `base` is overridden by `sub`. */
predicate matchingStatic(Function base, Function sub) {
overrides(base, sub) and
(
isStaticmethod(base) and
isStaticmethod(sub)
or
not isStaticmethod(base) and
not isStaticmethod(sub)
)
}
int extraSelfArg(Function func) { if isStaticmethod(func) then result = 0 else result = 1 }
/** Holds if the call `call` matches the signature for `func`. */
predicate callMatchesSignature(Function func, Call call) {
func = resolveCall(call) and
(
// Each parameter of the function is accounted for in the call
forall(Parameter param, int i | param = func.getArg(i) |
// self arg
i = 0 and not isStaticmethod(func)
or
// positional arg
i - extraSelfArg(func) < call.getPositionalArgumentCount()
or
// has default
exists(param.getDefault())
or
// keyword arg
call.getANamedArgumentName() = param.getName()
)
or
// arbitrary varargs or kwargs
exists(call.getStarArg())
or
exists(call.getKwargs())
) and
// No excess parameters
call.getPositionalArgumentCount() + extraSelfArg(func) <= func.getMaxPositionalArguments() and
(
exists(func.getKwarg())
or
forall(string name | name = call.getANamedArgumentName() | exists(func.getArgByName(name)))
)
}
pragma[nomagic]
private File getFunctionFile(Function f) { result = f.getLocation().getFile() }
/** Gets a call which matches the signature of `base`, but not of overridden `sub`. */
Call getASignatureMismatchWitness(Function base, Function sub) {
callViableForEitherOverride(base, sub, result) and
callMatchesSignature(base, result) and
not callMatchesSignature(sub, result)
}
pragma[inline]
string preferredFile(File callFile, Function base, Function sub) {
if callFile = getFunctionFile(base)
then result = " A"
else
if callFile = getFunctionFile(sub)
then result = " B"
else result = callFile.getAbsolutePath()
}
/** Choose a 'witnessing' call that matches the signature of `base` but not of overridden `sub`. */
Call chooseASignatureMismatchWitness(Function base, Function sub) {
exists(getASignatureMismatchWitness(base, sub)) and
result =
min(Call c |
c = getASignatureMismatchWitness(base, sub)
|
c
order by
preferredFile(c.getLocation().getFile(), base, sub), c.getLocation().getStartLine(),
c.getLocation().getStartColumn()
)
}
module CallOption = LocatableOption<Location, Call>;
from Function base, Function sub, string msg, string extraMsg, CallOption::Option call
where
not sub.isSpecialMethod() and
sub.getName() != "__init__" and
not ignore(sub) and
not ignore(base) and
matchingStatic(base, sub) and
(
// If we have a witness, alert for a 'weak' mismatch, but prefer the message for a 'strong' mismatch if that holds.
call.asSome() = chooseASignatureMismatchWitness(base, sub) and
extraMsg =
" $@ correctly calls the base method, but does not match the signature of the overriding method." and
(
strongSignatureMismatch(base, sub, msg)
or
not strongSignatureMismatch(base, sub, _) and
weakSignatureMismatch(base, sub, msg)
)
or
// With no witness, only alert for 'strong' mismatches.
not exists(getASignatureMismatchWitness(base, sub)) and
call.isNone() and
strongSignatureMismatch(base, sub, msg) and
extraMsg = ""
)
select sub, "This method " + msg + extraMsg, base, base.getQualifiedName(), call, "This call"

View File

@@ -0,0 +1,5 @@
---
category: minorAnalysis
---
* The `py/inheritance/signature-mismatch` query has been modernized. It produces more precise results and more descriptive alert messages.
* The `py/inheritance/incorrect-overriding-signature` query has been deprecated. Its results have been consolidated into the `py/inheritance/signature-mismatch` query.

View File

@@ -1,2 +1,2 @@
| om_test.py:32:5:32:35 | Function Derived.grossly_wrong1 | Overriding method 'grossly_wrong1' has signature mismatch with $@. | om_test.py:12:5:12:41 | Function Base.grossly_wrong1 | overridden method |
| om_test.py:35:5:35:47 | Function Derived.grossly_wrong2 | Overriding method 'grossly_wrong2' has signature mismatch with $@. | om_test.py:15:5:15:41 | Function Base.grossly_wrong2 | overridden method |
| om_test.py:32:5:32:35 | Function grossly_wrong1 | This method requires 2 positional arguments, whereas overridden $@ requires 3. | om_test.py:12:5:12:41 | Function grossly_wrong1 | Base.grossly_wrong1 | file://:0:0:0:0 | (none) | This call |
| om_test.py:35:5:35:47 | Function grossly_wrong2 | This method requires 4 positional arguments, whereas overridden $@ requires 3. | om_test.py:15:5:15:41 | Function grossly_wrong2 | Base.grossly_wrong2 | file://:0:0:0:0 | (none) | This call |

View File

@@ -1 +1,13 @@
| test.py:30:5:30:26 | Function Derived.meth3 | Overriding method 'meth3' has signature mismatch with $@. | test.py:11:5:11:20 | Function Base.meth3 | overridden method |
| test.py:24:5:24:26 | Function meth1 | This method requires 2 positional arguments, whereas overridden $@ requires 1. $@ correctly calls the base method, but does not match the signature of the overriding method. | test.py:5:5:5:20 | Function meth1 | Base.meth1 | test.py:15:9:15:20 | Attribute() | This call |
| test.py:27:5:27:20 | Function meth2 | This method requires 1 positional argument, whereas overridden $@ requires 2. $@ correctly calls the base method, but does not match the signature of the overriding method. | test.py:8:5:8:26 | Function meth2 | Base.meth2 | test.py:18:9:18:21 | Attribute() | This call |
| test.py:30:5:30:26 | Function meth3 | This method requires 2 positional arguments, whereas overridden $@ requires 1. | test.py:11:5:11:20 | Function meth3 | Base.meth3 | file://:0:0:0:0 | (none) | This call |
| test.py:69:5:69:24 | Function meth | This method requires 2 positional arguments, whereas overridden $@ requires 1. | test.py:64:5:64:19 | Function meth | BlameBase.meth | file://:0:0:0:0 | (none) | This call |
| test.py:74:5:74:24 | Function meth | This method requires 2 positional arguments, whereas overridden $@ requires 1. | test.py:64:5:64:19 | Function meth | BlameBase.meth | file://:0:0:0:0 | (none) | This call |
| test.py:125:5:125:20 | Function meth1 | This method requires 1 positional argument, whereas overridden $@ may be called with 2. $@ correctly calls the base method, but does not match the signature of the overriding method. | test.py:82:5:82:25 | Function meth1 | Base2.meth1 | test.py:110:9:110:23 | Attribute() | This call |
| test.py:131:5:131:31 | Function meth4 | This method requires at least 3 positional arguments, whereas overridden $@ requires at most 2. | test.py:88:5:88:25 | Function meth4 | Base2.meth4 | file://:0:0:0:0 | (none) | This call |
| test.py:133:5:133:28 | Function meth5 | This method requires at most 3 positional arguments, whereas overridden $@ requires at least 4. | test.py:90:5:90:34 | Function meth5 | Base2.meth5 | file://:0:0:0:0 | (none) | This call |
| test.py:135:5:135:23 | Function meth6 | This method requires 2 positional arguments, whereas overridden $@ may be called with arbitrarily many. $@ correctly calls the base method, but does not match the signature of the overriding method. | test.py:92:5:92:28 | Function meth6 | Base2.meth6 | test.py:113:9:113:27 | Attribute() | This call |
| test.py:137:5:137:28 | Function meth7 | This method requires at least 2 positional arguments, whereas overridden $@ may be called with 1. $@ correctly calls the base method, but does not match the signature of the overriding method. | test.py:94:5:94:25 | Function meth7 | Base2.meth7 | test.py:114:9:114:20 | Attribute() | This call |
| test.py:139:5:139:26 | Function meth8 | This method does not accept keyword argument `y`, which overridden $@ does. $@ correctly calls the base method, but does not match the signature of the overriding method. | test.py:96:5:96:26 | Function meth8 | Base2.meth8 | test.py:115:9:115:25 | Attribute() | This call |
| test.py:147:5:147:21 | Function meth12 | This method does not accept arbitrary keyword arguments, which overridden $@ does. $@ correctly calls the base method, but does not match the signature of the overriding method. | test.py:104:5:104:31 | Function meth12 | Base2.meth12 | test.py:119:9:119:24 | Attribute() | This call |
| test.py:149:5:149:27 | Function meth13 | This method does not accept keyword argument `x`, which overridden $@ does. $@ correctly calls the base method, but does not match the signature of the overriding method. | test.py:106:5:106:27 | Function meth13 | Base2.meth13 | test.py:120:9:120:24 | Attribute() | This call |

View File

@@ -1 +1,2 @@
Functions/SignatureOverriddenMethod.ql
query: Functions/SignatureOverriddenMethod.ql
postprocess: utils/test/InlineExpectationsTestQuery.ql

View File

@@ -1 +1,2 @@
| test.py:19:9:19:31 | Attribute() | Keyword argument 'spam' is not a supported parameter name of $@. | test.py:5:5:5:20 | Function meth1 | method Base.meth1 |
| test.py:112:9:112:23 | Attribute() | Keyword argument 'x' is not a supported parameter name of $@. | test.py:86:5:86:20 | Function meth3 | method Base2.meth3 |

View File

@@ -21,13 +21,13 @@ class Base(object):
class Derived(Base):
def meth1(self, spam):
def meth1(self, spam): # $Alert[py/inheritance/signature-mismatch] # Has 1 more arg, base called in Base.foo
pass
def meth2(self):
def meth2(self): # $Alert[py/inheritance/signature-mismatch] # Has 1 fewer arg, base called in Base.foo
pass
def meth3(self, eggs): #Incorrectly overridden and not called.
def meth3(self, eggs): # $Alert[py/inheritance/signature-mismatch] # Has 1 more arg. Method is not called.
pass
def bar(self):
@@ -66,13 +66,84 @@ class BlameBase(object):
class Correct1(BlameBase):
def meth(self, arg):
def meth(self, arg): # $Alert[py/inheritance/signature-mismatch] # Has 1 more arg. The incorrect-overridden-method query would alert for the base method in this case.
pass
class Correct2(BlameBase):
def meth(self, arg):
def meth(self, arg): # $Alert[py/inheritance/signature-mismatch] # Has 1 more arg
pass
c = Correct2()
c.meth("hi")
class Base2:
def meth1(self, x=1): pass
def meth2(self, x=1): pass
def meth3(self): pass
def meth4(self, x=1): pass
def meth5(self, x, y, z, w=1): pass
def meth6(self, x, *ys): pass
def meth7(self, *ys): pass
def meth8(self, x, y): pass
def meth9(self, x, y): pass
def meth10(self, x, *, y=3): pass
def meth11(self, x, y): pass
def meth12(self, **kwargs): pass
def meth13(self, /, x): pass
def call_some(self):
self.meth1()
self.meth1(x=2)
self.meth3()
self.meth3(x=2)
self.meth6(2, 3, 4)
self.meth7()
self.meth8(1,y=3)
self.meth9(1,2)
self.meth10(1,y=3)
self.meth11(1,y=3)
self.meth12(x=2)
self.meth13(x=2)
class Derrived2(Base2):
def meth1(self): pass # $Alert[py/inheritance/signature-mismatch] # Weak mismatch (base may be called with 2 args. only alert if mismatching call exists)
def meth2(self): pass # No alert (weak mismatch, but not called)
def meth3(self, x=1): pass # No alert (no mismatch - all base calls are valid for sub)
def meth4(self, x, y, z=1): pass # $Alert[py/inheritance/signature-mismatch] # sub min > base max (strong mismatch)
def meth5(self, x, y=1): pass # $Alert[py/inheritance/signature-mismatch]
def meth6(self, x): pass # $Alert[py/inheritance/signature-mismatch] # weak mismatch (base may be called with 3+ args)
def meth7(self, x, *ys): pass # $Alert[py/inheritance/signature-mismatch] # weak mismatch (base may be called with 1 arg only)
def meth8(self, x, z): pass # $Alert[py/inheritance/signature-mismatch] # weak mismatch (base may be called with arg named y)
def meth9(self, x, z): pass # No alert (never called with wrong keyword arg)
def meth10(self, x, **kwargs): pass # No alert (y is kw-only arg in base, calls that use it are valid for sub)
def meth11(self, x, z, **kwargs): pass # $MISSING:Alert[py/inheritance/signature-mismatch] # call using y kw-arg is invalid due to not specifying z, but this is not detected. Likely a fairly niche situation.
def meth12(self): pass # $Alert[py/inheritance/signature-mismatch] # call including extra kwarg invalid
def meth13(self, /, y): pass # $Alert[py/inheritance/signature-mismatch] # weak mismatch (base may be called with arg named x), however meth13 is incorrectly detected as having 2 minimum positional arguments, whereas x is kw-only; resulting in the witness call not being detected as a valid call to Base2.meth13.