diff --git a/python/ql/src/Functions/MethodArgNames.qll b/python/ql/src/Functions/MethodArgNames.qll new file mode 100644 index 000000000000..0d775b9dbf03 --- /dev/null +++ b/python/ql/src/Functions/MethodArgNames.qll @@ -0,0 +1,108 @@ +/** Definitions for reasoning about the expected first argument names for methods. */ + +import python +import semmle.python.ApiGraphs +import semmle.python.dataflow.new.internal.DataFlowDispatch +import DataFlow + +/** Holds if `f` is a method of the class `c`. */ +private predicate methodOfClass(Function f, Class c) { + exists(FunctionDef d | d.getDefinedFunction() = f and d.getScope() = c) +} + +/** Holds if `c` is a metaclass. */ +private predicate isMetaclass(Class c) { + c = API::builtin("type").getASubclass*().asSource().asExpr().(ClassExpr).getInnerScope() +} + +/** Holds if `c` is a Zope interface. */ +private predicate isZopeInterface(Class c) { + c = + API::moduleImport("zope") + .getMember("interface") + .getMember("Interface") + .getASubclass*() + .asSource() + .asExpr() + .(ClassExpr) + .getInnerScope() +} + +/** + * Holds if `f` is used in the initialisation of `c`. + * This means `f` isn't being used as a normal method. + * Ideally it should be a `@staticmethod`; however this wasn't possible prior to Python 3.10. + * We exclude this case from the `not-named-self` query. + * However there is potential for a new query that specifically covers and alerts for this case. + */ +private predicate usedInInit(Function f, Class c) { + methodOfClass(f, c) and + exists(Call call | + call.getScope() = c and + DataFlow::localFlow(DataFlow::exprNode(f.getDefinition()), DataFlow::exprNode(call.getFunc())) + ) +} + +/** + * Holds if `f` has no arguments, and also has a decorator. + * We assume that the decorator affect the method in such a way that a `self` parameter is unneeded. + */ +private predicate noArgsWithDecorator(Function f) { + not exists(f.getAnArg()) and + exists(f.getADecorator()) +} + +/** Holds if the first parameter of `f` should be named `self`. */ +predicate shouldBeSelf(Function f, Class c) { + methodOfClass(f, c) and + not isStaticmethod(f) and + not isClassmethod(f) and + not isMetaclass(c) and + not isZopeInterface(c) and + not usedInInit(f, c) and + not noArgsWithDecorator(f) +} + +/** Holds if the first parameter of `f` should be named `cls`. */ +predicate shouldBeCls(Function f, Class c) { + methodOfClass(f, c) and + not isStaticmethod(f) and + ( + isClassmethod(f) and not isMetaclass(c) + or + isMetaclass(c) and not isClassmethod(f) + ) +} + +/** Holds if the first parameter of `f` is named `self`. */ +predicate firstArgNamedSelf(Function f) { f.getArgName(0) = "self" } + +/** Holds if the first parameter of `f` refers to the class - it is either named `cls`, or it is named `self` and is a method of a metaclass. */ +predicate firstArgRefersToCls(Function f, Class c) { + methodOfClass(f, c) and + exists(string argname | argname = f.getArgName(0) | + argname = "cls" + or + /* Not PEP8, but relatively common */ + argname = "mcls" + or + /* If c is a metaclass, allow arguments named `self`. */ + argname = "self" and + isMetaclass(c) + ) +} + +/** Holds if the first parameter of `f` should be named `self`, but isn't. */ +predicate firstArgShouldBeNamedSelfAndIsnt(Function f) { + shouldBeSelf(f, _) and + not firstArgNamedSelf(f) +} + +/** Holds if the first parameter of `f` should be named `cls`, but isn't. */ +predicate firstArgShouldReferToClsAndDoesnt(Function f) { + exists(Class c | + methodOfClass(f, c) and + shouldBeCls(f, c) and + not firstArgRefersToCls(f, c) + ) +} diff --git a/python/ql/src/Functions/NonCls.py b/python/ql/src/Functions/NonCls.py index f4959d89ebd3..3fc118253891 100644 --- a/python/ql/src/Functions/NonCls.py +++ b/python/ql/src/Functions/NonCls.py @@ -1,4 +1,4 @@ class Entry(object): @classmethod - def make(klass): + def make(self): return Entry() diff --git a/python/ql/src/Functions/NonCls.qhelp b/python/ql/src/Functions/NonCls.qhelp index 6ac9170a9915..cf064fc50566 100644 --- a/python/ql/src/Functions/NonCls.qhelp +++ b/python/ql/src/Functions/NonCls.qhelp @@ -5,20 +5,19 @@ -

The first parameter of a class method, a new method or any metaclass method -should be called cls. This makes the purpose of the parameter clear to other developers. +

The first parameter of a class method (including certain special methods such as __new__), or a method of a metaclass, +should be named cls.

-

Change the name of the first parameter to cls as recommended by the style guidelines +

Ensure that the first parameter of class methods is named cls, as recommended by the style guidelines in PEP 8.

-

In the example, the first parameter to make() is klass which should be changed to cls -for ease of comprehension. +

In the following example, the first parameter of the class method make is named self instead of cls.

@@ -29,6 +28,7 @@ for ease of comprehension.
  • Python PEP 8: Function and method arguments.
  • Python Tutorial: Classes.
  • +
  • Python Docs: classmethod.
  • diff --git a/python/ql/src/Functions/NonCls.ql b/python/ql/src/Functions/NonCls.ql index 5cb9fafab895..d36eeb9a6ec4 100644 --- a/python/ql/src/Functions/NonCls.ql +++ b/python/ql/src/Functions/NonCls.ql @@ -1,7 +1,6 @@ /** * @name First parameter of a class method is not named 'cls' - * @description Using an alternative name for the first parameter of a class method makes code more - * difficult to read; PEP8 states that the first parameter to class methods should be 'cls'. + * @description By the PEP8 style guide, the first parameter of a class method should be named `cls`. * @kind problem * @tags maintainability * readability @@ -13,30 +12,11 @@ */ import python - -predicate first_arg_cls(Function f) { - exists(string argname | argname = f.getArgName(0) | - argname = "cls" - or - /* Not PEP8, but relatively common */ - argname = "mcls" - ) -} - -predicate is_type_method(Function f) { - exists(ClassValue c | c.getScope() = f.getScope() and c.getASuperType() = ClassValue::type()) -} - -predicate classmethod_decorators_only(Function f) { - forall(Expr decorator | decorator = f.getADecorator() | decorator.(Name).getId() = "classmethod") -} +import MethodArgNames from Function f, string message where - (f.getADecorator().(Name).getId() = "classmethod" or is_type_method(f)) and - not first_arg_cls(f) and - classmethod_decorators_only(f) and - not f.getName() = "__new__" and + firstArgShouldReferToClsAndDoesnt(f) and ( if exists(f.getArgName(0)) then diff --git a/python/ql/src/Functions/NonSelf.py b/python/ql/src/Functions/NonSelf.py index 00d3c2ff6f5d..69e2dde34a9a 100644 --- a/python/ql/src/Functions/NonSelf.py +++ b/python/ql/src/Functions/NonSelf.py @@ -1,9 +1,9 @@ class Point: - def __init__(val, x, y): # first parameter is mis-named 'val' + def __init__(val, x, y): # BAD: first parameter is mis-named 'val' val._x = x val._y = y class Point2: - def __init__(self, x, y): # first parameter is correctly named 'self' + def __init__(self, x, y): # GOOD: first parameter is correctly named 'self' self._x = x self._y = y \ No newline at end of file diff --git a/python/ql/src/Functions/NonSelf.qhelp b/python/ql/src/Functions/NonSelf.qhelp index c4cef70e731b..ac1639ba625a 100644 --- a/python/ql/src/Functions/NonSelf.qhelp +++ b/python/ql/src/Functions/NonSelf.qhelp @@ -6,22 +6,18 @@

    Normal methods should have at least one parameter and the first parameter should be called self. -This makes the purpose of the parameter clear to other developers.

    -

    If there is at least one parameter, then change the name of the first parameter to self as recommended by the style guidelines +

    Ensure that the first parameter of a normal method is named self, as recommended by the style guidelines in PEP 8.

    -

    If there are no parameters, then it cannot be a normal method. It may need to be marked as a staticmethod -or it could be moved out of the class as a normal function. +

    If a self parameter is unneeded, the method should be decorated with staticmethod, or moved out of the class as a regular function.

    -

    The following methods can both be used to assign values to variables in a point -object. The second method makes the association clearer because the self parameter is -used.

    +

    In the following cases, the first argument of Point.__init__ is named val instead; whereas in Point2.__init__ it is correctly named self.

    @@ -31,7 +27,7 @@ used.

  • Python PEP 8: Function and method arguments.
  • Python Tutorial: Classes.
  • - +
  • Python Docs: staticmethod.
  • diff --git a/python/ql/src/Functions/NonSelf.ql b/python/ql/src/Functions/NonSelf.ql index cb8924c071ae..cea15d3661a8 100644 --- a/python/ql/src/Functions/NonSelf.ql +++ b/python/ql/src/Functions/NonSelf.ql @@ -1,8 +1,6 @@ /** * @name First parameter of a method is not named 'self' - * @description Using an alternative name for the first parameter of an instance method makes - * code more difficult to read; PEP8 states that the first parameter to instance - * methods should be 'self'. + * @description By the PEP8 style guide, the first parameter of a normal method should be named `self`. * @kind problem * @tags maintainability * readability @@ -14,45 +12,19 @@ */ import python -import semmle.python.libraries.Zope +import MethodArgNames -predicate is_type_method(FunctionValue fv) { - exists(ClassValue c | c.declaredAttribute(_) = fv and c.getASuperType() = ClassValue::type()) -} - -predicate used_in_defining_scope(FunctionValue fv) { - exists(Call c | c.getScope() = fv.getScope().getScope() and c.getFunc().pointsTo(fv)) -} - -from Function f, FunctionValue fv, string message +from Function f, string message where - exists(ClassValue cls, string name | - cls.declaredAttribute(name) = fv and - cls.isNewStyle() and - not name = "__new__" and - not name = "__metaclass__" and - not name = "__init_subclass__" and - not name = "__class_getitem__" and - /* declared in scope */ - f.getScope() = cls.getScope() - ) and - not f.getArgName(0) = "self" and - not is_type_method(fv) and - fv.getScope() = f and - not f.getName() = "lambda" and - not used_in_defining_scope(fv) and + firstArgShouldBeNamedSelfAndIsnt(f) and ( - ( - if exists(f.getArgName(0)) - then - message = - "Normal methods should have 'self', rather than '" + f.getArgName(0) + - "', as their first parameter." - else - message = - "Normal methods should have at least one parameter (the first of which should be 'self')." - ) and - not f.hasVarArg() - ) and - not fv instanceof ZopeInterfaceMethodValue + if exists(f.getArgName(0)) + then + message = + "Normal methods should have 'self', rather than '" + f.getArgName(0) + + "', as their first parameter." + else + message = + "Normal methods should have at least one parameter (the first of which should be 'self')." + ) select f, message diff --git a/python/ql/test/query-tests/Functions/general/NonCls.expected b/python/ql/test/query-tests/Functions/general/NonCls.expected deleted file mode 100644 index 991c344c9d7f..000000000000 --- a/python/ql/test/query-tests/Functions/general/NonCls.expected +++ /dev/null @@ -1,3 +0,0 @@ -| parameter_names.py:17:5:17:24 | Function n_cmethod | Class methods or methods of a type deriving from type should have 'cls', rather than 'self', as their first parameter. | -| parameter_names.py:22:5:22:21 | Function n_cmethod2 | Class methods or methods of a type deriving from type should have 'cls' as their first parameter. | -| parameter_names.py:37:5:37:20 | Function c_method | Class methods or methods of a type deriving from type should have 'cls', rather than 'y', as their first parameter. | diff --git a/python/ql/test/query-tests/Functions/general/NonCls.qlref b/python/ql/test/query-tests/Functions/general/NonCls.qlref deleted file mode 100644 index b3525b101df2..000000000000 --- a/python/ql/test/query-tests/Functions/general/NonCls.qlref +++ /dev/null @@ -1 +0,0 @@ -Functions/NonCls.ql diff --git a/python/ql/test/query-tests/Functions/general/NonSelf.expected b/python/ql/test/query-tests/Functions/general/NonSelf.expected deleted file mode 100644 index 17d5f9818dd7..000000000000 --- a/python/ql/test/query-tests/Functions/general/NonSelf.expected +++ /dev/null @@ -1,4 +0,0 @@ -| om_test.py:71:5:71:19 | Function __repr__ | Normal methods should have at least one parameter (the first of which should be 'self'). | -| parameter_names.py:50:5:50:20 | Function __init__ | Normal methods should have 'self', rather than 'x', as their first parameter. | -| parameter_names.py:53:5:53:20 | Function s_method | Normal methods should have 'self', rather than 'y', as their first parameter. | -| parameter_names.py:56:5:56:20 | Function s_method2 | Normal methods should have at least one parameter (the first of which should be 'self'). | diff --git a/python/ql/test/query-tests/Functions/general/NonSelf.qlref b/python/ql/test/query-tests/Functions/general/NonSelf.qlref deleted file mode 100644 index 2629570c2a17..000000000000 --- a/python/ql/test/query-tests/Functions/general/NonSelf.qlref +++ /dev/null @@ -1 +0,0 @@ -Functions/NonSelf.ql diff --git a/python/ql/test/query-tests/Functions/general/parameter_names.py b/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py similarity index 71% rename from python/ql/test/query-tests/Functions/general/parameter_names.py rename to python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py index b42aaaebb1de..3a2fddc9f02c 100644 --- a/python/ql/test/query-tests/Functions/general/parameter_names.py +++ b/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py @@ -14,46 +14,47 @@ def n_smethod(ok): # not ok @classmethod - def n_cmethod(self): + def n_cmethod(self): # $shouldBeCls pass # not ok @classmethod - def n_cmethod2(): + def n_cmethod2(): # $shouldBeCls pass - # this is allowed because it has a decorator other than @classmethod @classmethod @id - def n_suppress(any_name): + def n_dec(any_name): # $shouldBeCls pass +# Metaclass - normal methods should be named cls, though self is also accepted class Class(type): def __init__(cls): pass - def c_method(y): + def c_method(y): # $shouldBeCls pass def c_ok(cls): pass - @id - def c_suppress(any_name): + # technically we could alert on mixing self for metaclasses with cls for metaclasses in the same codebase, + # but it's probably not too significant. + def c_self_ok(self): pass class NonSelf(object): - def __init__(x): + def __init__(x): # $shouldBeSelf pass - def s_method(y): + def s_method(y): # $shouldBeSelf pass - def s_method2(): + def s_method2(): # $shouldBeSelf pass def s_ok(self): @@ -67,11 +68,12 @@ def s_smethod(ok): def s_cmethod(cls): pass - def s_smethod2(ok): + # we allow methods that are used in class initialization, but only detect this case when they are called. + def s_smethod2(ok): # $ SPURIOUS: shouldBeSelf pass s_smethod2 = staticmethod(s_smethod2) - def s_cmethod2(cls): + def s_cmethod2(cls): # $ SPURIOUS: shouldBeSelf pass s_cmethod2 = classmethod(s_cmethod2) @@ -118,7 +120,15 @@ def meth(arg): Z().meth(0) +def weird_decorator(f): + def g(self): + return f() + return g +class B: + @weird_decorator + def f(): # allow no-arg functions with a decorator + pass # The `__init_subclass__` (introduced in Python 3.6) # and `__class_getitem__` (introduced in Python 3.7) methods are methods @@ -139,3 +149,10 @@ def __init_subclass__(cls): def __class_getitem__(cls): pass + +from dataclasses import dataclass, field + +@dataclass +class A: + # Lambdas used in initilisation aren't methods. + x: int = field(default_factory = lambda: 2) \ No newline at end of file diff --git a/python/ql/test/query-tests/Functions/methodArgNames/test.expected b/python/ql/test/query-tests/Functions/methodArgNames/test.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/query-tests/Functions/methodArgNames/test.ql b/python/ql/test/query-tests/Functions/methodArgNames/test.ql new file mode 100644 index 000000000000..526a648b8da3 --- /dev/null +++ b/python/ql/test/query-tests/Functions/methodArgNames/test.ql @@ -0,0 +1,24 @@ +import python +import Functions.MethodArgNames +import utils.test.InlineExpectationsTest + +module MethodArgTest implements TestSig { + string getARelevantTag() { result = ["shouldBeSelf", "shouldBeCls"] } + + predicate hasActualResult(Location location, string element, string tag, string value) { + exists(Function f | + element = f.toString() and + location = f.getLocation() and + value = "" and + ( + firstArgShouldBeNamedSelfAndIsnt(f) and + tag = "shouldBeSelf" + or + firstArgShouldReferToClsAndDoesnt(f) and + tag = "shouldBeCls" + ) + ) + } +} + +import MakeTest