-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Python: Modernize the Signature Mismatch query #20217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 12 commits
5ba5007
4212d1b
067c98d
f429b90
2bbf24b
502ea82
900a5cd
0a83c11
6587ad4
125c653
318d1cd
9fa630f
71dec0b
4fc7d84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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") |
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -13,23 +13,266 @@ | |||||||||||||
*/ | ||||||||||||||
|
||||||||||||||
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()) | ||||||||||||||
/** Holds if `base` is overriden 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 | ||||||||||||||
) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
/** 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 minumum 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. | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This TODO comment indicates incomplete handling of positional-only arguments. While the comment mentions it likely won't create false positives, this limitation should be documented more clearly or addressed, as it could affect the accuracy of signature mismatch detection for functions using positional-only parameters."
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||
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 overiden 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 overriden 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 | ||||||||||||||
joefarebrother marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||
not derived.getScope().isSpecialMethod() and | ||||||||||||||
derived.getName() != "__init__" and | ||||||||||||||
derived.isNormalMethod() and | ||||||||||||||
// call to overrides distributed for efficiency | ||||||||||||||
// No excess parameters | ||||||||||||||
call.getPositionalArgumentCount() + extraSelfArg(func) <= func.getMaxPositionalArguments() and | ||||||||||||||
( | ||||||||||||||
exists(func.getKwarg()) | ||||||||||||||
or | ||||||||||||||
forall(string name | name = call.getANamedArgumentName() | exists(func.getArgByName(name))) | ||||||||||||||
) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
/** Gets a call which matches the signature of `base`, but not of overriden `sub`. */ | ||||||||||||||
|
||||||||||||||
Call getASignatureMismatchWitness(Function base, Function sub) { | ||||||||||||||
callViableForEitherOverride(base, sub, result) and | ||||||||||||||
callMatchesSignature(base, result) and | ||||||||||||||
not callMatchesSignature(sub, result) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
/** Choose a 'witnessing' call that matches the signature of `base` but not of overridden `sub`, and is in the file `file`. */ | ||||||||||||||
Call chooseASignatureMismatchWitnessInFile(Function base, Function sub, File file) { | ||||||||||||||
result = | ||||||||||||||
min(Call c | | ||||||||||||||
c = getASignatureMismatchWitness(base, sub) and | ||||||||||||||
c.getLocation().getFile() = file | ||||||||||||||
| | ||||||||||||||
c order by c.getLocation().getStartLine(), c.getLocation().getStartColumn() | ||||||||||||||
) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
/** Choose a 'witnessing' call that matches the signature of `base` but not of overridden `sub`. */ | ||||||||||||||
Call chooseASignatureMismatchWitness(Function base, Function sub) { | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a pretty bad join in this predicate (which is why the DCA results look kinda terrible). You should be able to fix it by adding this helper predicate and referencing it in all of the appropriate places: pragma[nomagic]
private File getFunctionFile(Function f) { result = f.getLocation().getFile() } |
||||||||||||||
exists(getASignatureMismatchWitness(base, sub)) and | ||||||||||||||
( | ||||||||||||||
result = chooseASignatureMismatchWitnessInFile(base, sub, base.getLocation().getFile()) | ||||||||||||||
or | ||||||||||||||
not exists(Call c | | ||||||||||||||
c = getASignatureMismatchWitness(base, sub) and | ||||||||||||||
c.getLocation().getFile() = base.getLocation().getFile() | ||||||||||||||
) and | ||||||||||||||
result = chooseASignatureMismatchWitnessInFile(base, sub, sub.getLocation().getFile()) | ||||||||||||||
or | ||||||||||||||
not exists(Call c | | ||||||||||||||
c = getASignatureMismatchWitness(base, sub) and | ||||||||||||||
c.getLocation().getFile() = [base, sub].getLocation().getFile() | ||||||||||||||
) and | ||||||||||||||
result = | ||||||||||||||
min(Call c | | ||||||||||||||
c = getASignatureMismatchWitness(base, sub) | ||||||||||||||
| | ||||||||||||||
c | ||||||||||||||
order by | ||||||||||||||
c.getLocation().getFile().getAbsolutePath(), 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 | ||||||||||||||
( | ||||||||||||||
derived.overrides(base) and derived.minParameters() > base.maxParameters() | ||||||||||||||
// 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 | ||||||||||||||
derived.overrides(base) and derived.maxParameters() < base.minParameters() | ||||||||||||||
// With no witness, only alert for 'strong' mismatches. | ||||||||||||||
not exists(getASignatureMismatchWitness(base, sub)) and | ||||||||||||||
call.isNone() and | ||||||||||||||
strongSignatureMismatch(base, sub, msg) and | ||||||||||||||
extraMsg = "" | ||||||||||||||
) | ||||||||||||||
select derived, "Overriding method '" + derived.getName() + "' has signature mismatch with $@.", | ||||||||||||||
base, "overridden method" | ||||||||||||||
select sub, "This method " + msg + extraMsg, base, base.getQualifiedName(), call, "This call" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
Functions/SignatureOverriddenMethod.ql | ||
query: Functions/SignatureOverriddenMethod.ql | ||
postprocess: utils/test/InlineExpectationsTestQuery.ql |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | |
Uh oh!
There was an error while loading. Please reload this page.