-
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
Open
joefarebrother
wants to merge
14
commits into
github:main
Choose a base branch
from
joefarebrother:python-qual-signature-mismatch
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
5ba5007
Modernize signature mismatch
joefarebrother 4212d1b
Update alert messages and choose one witness
joefarebrother 067c98d
Include conditional alert messages for various cases
joefarebrother f429b90
Update tests, update alert messages
joefarebrother 2bbf24b
Add additional test cases
joefarebrother 502ea82
Updae other test output
joefarebrother 900a5cd
Update documentation
joefarebrother 0a83c11
Add changenote.+ fix typo
joefarebrother 6587ad4
Update python/ql/src/Functions/SignatureOverriddenMethod.ql
joefarebrother 125c653
Use new option name
joefarebrother 318d1cd
Increase precision in detecting call matches signature
joefarebrother 9fa630f
Add comments documenting helper predicates, and add call resolve cond…
joefarebrother 71dec0b
Fix typos
joefarebrother 4fc7d84
Revert more precise version of call matches signature predicate for p…
joefarebrother File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,23 +13,256 @@ | |
*/ | ||
|
||
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 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 | ||
) | ||
} | ||
|
||
/** 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) { | ||
// TODO: This is not fully precise. | ||
// For example, it does not detect that a method `def foo(self,x,y)` is matched by a call `obj.foo(1,y=2)` | ||
// since y is passed in the call as a keyword argument, but still counts toward a positional argument of the method. | ||
// A more precise version was attempted, but reverted due to performance concerns. | ||
( | ||
// Enough parameters | ||
call.getPositionalArgumentCount() + extraSelfArg(func) >= func.getMinPositionalArguments() | ||
or | ||
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 overridden `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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
4 changes: 2 additions & 2 deletions
4
python/ql/test/query-tests/Functions/general/SignatureOverriddenMethod.expected
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
12 changes: 11 additions & 1 deletion
12
python/ql/test/query-tests/Functions/overriding/SignatureOverriddenMethod.expected
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,11 @@ | ||
| 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: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 | |
3 changes: 2 additions & 1 deletion
3
python/ql/test/query-tests/Functions/overriding/SignatureOverriddenMethod.qlref
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
1 change: 1 addition & 0 deletions
1
python/ql/test/query-tests/Functions/overriding/WrongNameForArgumentInCall.expected
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The 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."
Copilot uses AI. Check for mistakes.