From 526e235fc1200241ef2e5488fc3c76c4fd417cdf Mon Sep 17 00:00:00 2001 From: Joe Farebrother Date: Mon, 27 Jan 2025 13:46:15 +0000 Subject: [PATCH 1/9] Update NonSelf and NonCls queries --- python/ql/src/Functions/MethodArgNames.qll | 79 ++++++++++++++++++++++ python/ql/src/Functions/NonCls.ql | 23 +------ python/ql/src/Functions/NonSelf.ql | 32 ++------- 3 files changed, 85 insertions(+), 49 deletions(-) create mode 100644 python/ql/src/Functions/MethodArgNames.qll diff --git a/python/ql/src/Functions/MethodArgNames.qll b/python/ql/src/Functions/MethodArgNames.qll new file mode 100644 index 000000000000..e84a59977b6b --- /dev/null +++ b/python/ql/src/Functions/MethodArgNames.qll @@ -0,0 +1,79 @@ +/** Definitions for reasoning about the expected first argument names for methods. */ + +import python +import semmle.python.ApiGraphs + +/** Holds if `f` is a method of the class `c`. */ +private predicate methodOfClass(Function f, Class c) { f.getScope() = c } + +/** Holds if `c` is a metaclass. */ +private predicate isMetaclass(Class c) { + c.getABase() = API::builtin("type").getASubclass*().asSource().asExpr() +} + +/** Holds if `f` is a class method. */ +private predicate isClassMethod(Function f) { + f.getADecorator() = API::builtin("classmethod").asSource().asExpr() +} + +/** Holds if `f` is a static method. */ +private predicate isStaticMethod(Function f) { + f.getADecorator() = API::builtin("staticmethod").asSource().asExpr() +} + +/** Holds if `c` is a Zope interface. */ +private predicate isZopeInterface(Class c) { + c.getABase() = + API::moduleImport("zone") + .getMember("interface") + .getMember("interface") + .getASubclass*() + .asSource() + .asExpr() +} + +/** 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 f.getName() in ["__new__", "__init_subclass__", "__metaclass__", "__class_getitem__"] and + isMetaclass(c) and + not isZopeInterface(c) +} + +/** 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) + or + f.getName() in ["__new__", "__init_subclass__", "__metaclass__", "__class_getitem__"] + ) +} + +/** 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` is named `cls`. */ +predicate firstArgNamedCls(Function f) { + exists(string argname | argname = f.getArgName(0) | + argname = "cls" + or + /* Not PEP8, but relatively common */ + argname = "mcls" + ) +} + +/** Holds if the first parameter of `f` should be named `self`, but isn't. */ +predicate firstArgShouldBeNamedSelfAndIsnt(Function f) { + exists(Class c | shouldBeSelf(f, c)) and + not firstArgNamedSelf(f) +} + +/** Holds if the first parameter of `f` should be named `cls`, but isn't. */ +predicate firstArgShouldBeNamedClsAndIsnt(Function f) { + exists(Class c | shouldBeCls(f, c)) and + not firstArgNamedCls(f) +} diff --git a/python/ql/src/Functions/NonCls.ql b/python/ql/src/Functions/NonCls.ql index 5cb9fafab895..ae9ab0846d6f 100644 --- a/python/ql/src/Functions/NonCls.ql +++ b/python/ql/src/Functions/NonCls.ql @@ -13,30 +13,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 + firstArgShouldBeNamedClsAndIsnt(f) and ( if exists(f.getArgName(0)) then diff --git a/python/ql/src/Functions/NonSelf.ql b/python/ql/src/Functions/NonSelf.ql index cb8924c071ae..2ea72014a0e5 100644 --- a/python/ql/src/Functions/NonSelf.ql +++ b/python/ql/src/Functions/NonSelf.ql @@ -14,33 +14,11 @@ */ 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)) @@ -52,7 +30,5 @@ where 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 + ) select f, message From fa76bf3c9ff16a8200b6e23db45b48902d65ed34 Mon Sep 17 00:00:00 2001 From: Joe Farebrother Date: Mon, 27 Jan 2025 16:58:19 +0000 Subject: [PATCH 2/9] Fix formatting and rewrite redundant exists --- python/ql/src/Functions/MethodArgNames.qll | 13 +++++++------ python/ql/src/Functions/NonSelf.ql | 20 +++++++++----------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/python/ql/src/Functions/MethodArgNames.qll b/python/ql/src/Functions/MethodArgNames.qll index e84a59977b6b..1843bcd93782 100644 --- a/python/ql/src/Functions/MethodArgNames.qll +++ b/python/ql/src/Functions/MethodArgNames.qll @@ -14,6 +14,8 @@ private predicate isMetaclass(Class c) { /** Holds if `f` is a class method. */ private predicate isClassMethod(Function f) { f.getADecorator() = API::builtin("classmethod").asSource().asExpr() + or + f.getName() in ["__new__", "__init_subclass__", "__metaclass__", "__class_getitem__"] } /** Holds if `f` is a static method. */ @@ -37,8 +39,7 @@ predicate shouldBeSelf(Function f, Class c) { methodOfClass(f, c) and not isStaticMethod(f) and not isClassMethod(f) and - not f.getName() in ["__new__", "__init_subclass__", "__metaclass__", "__class_getitem__"] and - isMetaclass(c) and + not isMetaclass(c) and not isZopeInterface(c) } @@ -47,9 +48,9 @@ predicate shouldBeCls(Function f, Class c) { methodOfClass(f, c) and not isStaticMethod(f) and ( - isClassMethod(f) + isClassMethod(f) and not isMetaclass(c) or - f.getName() in ["__new__", "__init_subclass__", "__metaclass__", "__class_getitem__"] + isMetaclass(c) and not isClassMethod(f) ) } @@ -68,12 +69,12 @@ predicate firstArgNamedCls(Function f) { /** Holds if the first parameter of `f` should be named `self`, but isn't. */ predicate firstArgShouldBeNamedSelfAndIsnt(Function f) { - exists(Class c | shouldBeSelf(f, c)) and + shouldBeSelf(f, _) and not firstArgNamedSelf(f) } /** Holds if the first parameter of `f` should be named `cls`, but isn't. */ predicate firstArgShouldBeNamedClsAndIsnt(Function f) { - exists(Class c | shouldBeCls(f, c)) and + shouldBeCls(f, _) and not firstArgNamedCls(f) } diff --git a/python/ql/src/Functions/NonSelf.ql b/python/ql/src/Functions/NonSelf.ql index 2ea72014a0e5..94bc8a2fa378 100644 --- a/python/ql/src/Functions/NonSelf.ql +++ b/python/ql/src/Functions/NonSelf.ql @@ -20,15 +20,13 @@ from Function f, string message where 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 - ) + 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 From 0bf8d4ec4b32c4234ae6d457da57309413ff96d2 Mon Sep 17 00:00:00 2001 From: Joe Farebrother Date: Thu, 30 Jan 2025 13:55:49 +0000 Subject: [PATCH 3/9] Exclude 'methods' used in initialisation, and allow self for metaclass methods --- python/ql/src/Functions/MethodArgNames.qll | 30 ++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/python/ql/src/Functions/MethodArgNames.qll b/python/ql/src/Functions/MethodArgNames.qll index 1843bcd93782..d1a609fd4112 100644 --- a/python/ql/src/Functions/MethodArgNames.qll +++ b/python/ql/src/Functions/MethodArgNames.qll @@ -2,6 +2,7 @@ import python import semmle.python.ApiGraphs +import DataFlow /** Holds if `f` is a method of the class `c`. */ private predicate methodOfClass(Function f, Class c) { f.getScope() = c } @@ -34,13 +35,29 @@ private predicate isZopeInterface(Class c) { .asExpr() } +/** + * 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 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) + not isZopeInterface(c) and + not usedInInit(f, c) } /** Holds if the first parameter of `f` should be named `cls`. */ @@ -73,8 +90,17 @@ predicate firstArgShouldBeNamedSelfAndIsnt(Function f) { not firstArgNamedSelf(f) } +/** Holds if `f` is a regular method of a metaclass, and its first argument is named `self`. */ +private predicate metaclassNamedSelf(Function f, Class c) { + methodOfClass(f, c) and + firstArgNamedSelf(f) and + isMetaclass(c) and + not isClassMethod(f) +} + /** Holds if the first parameter of `f` should be named `cls`, but isn't. */ predicate firstArgShouldBeNamedClsAndIsnt(Function f) { shouldBeCls(f, _) and - not firstArgNamedCls(f) + not firstArgNamedCls(f) and + not metaclassNamedSelf(f, _) } From aa2c84ea36f5922acf6e2053858061cf62ee351a Mon Sep 17 00:00:00 2001 From: Joe Farebrother Date: Fri, 31 Jan 2025 12:03:52 +0000 Subject: [PATCH 4/9] Move tests to separate folder --- .../Functions/{general => methodArgNames}/NonCls.expected | 0 .../Functions/{general => methodArgNames}/NonCls.qlref | 0 .../Functions/{general => methodArgNames}/NonSelf.expected | 0 .../Functions/{general => methodArgNames}/NonSelf.qlref | 0 .../Functions/{general => methodArgNames}/parameter_names.py | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename python/ql/test/query-tests/Functions/{general => methodArgNames}/NonCls.expected (100%) rename python/ql/test/query-tests/Functions/{general => methodArgNames}/NonCls.qlref (100%) rename python/ql/test/query-tests/Functions/{general => methodArgNames}/NonSelf.expected (100%) rename python/ql/test/query-tests/Functions/{general => methodArgNames}/NonSelf.qlref (100%) rename python/ql/test/query-tests/Functions/{general => methodArgNames}/parameter_names.py (100%) diff --git a/python/ql/test/query-tests/Functions/general/NonCls.expected b/python/ql/test/query-tests/Functions/methodArgNames/NonCls.expected similarity index 100% rename from python/ql/test/query-tests/Functions/general/NonCls.expected rename to python/ql/test/query-tests/Functions/methodArgNames/NonCls.expected diff --git a/python/ql/test/query-tests/Functions/general/NonCls.qlref b/python/ql/test/query-tests/Functions/methodArgNames/NonCls.qlref similarity index 100% rename from python/ql/test/query-tests/Functions/general/NonCls.qlref rename to python/ql/test/query-tests/Functions/methodArgNames/NonCls.qlref diff --git a/python/ql/test/query-tests/Functions/general/NonSelf.expected b/python/ql/test/query-tests/Functions/methodArgNames/NonSelf.expected similarity index 100% rename from python/ql/test/query-tests/Functions/general/NonSelf.expected rename to python/ql/test/query-tests/Functions/methodArgNames/NonSelf.expected diff --git a/python/ql/test/query-tests/Functions/general/NonSelf.qlref b/python/ql/test/query-tests/Functions/methodArgNames/NonSelf.qlref similarity index 100% rename from python/ql/test/query-tests/Functions/general/NonSelf.qlref rename to python/ql/test/query-tests/Functions/methodArgNames/NonSelf.qlref 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 100% rename from python/ql/test/query-tests/Functions/general/parameter_names.py rename to python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py From e8adef18a3ff27d5cbe92b5a77c2ed3521c023af Mon Sep 17 00:00:00 2001 From: Joe Farebrother Date: Mon, 3 Feb 2025 17:36:43 +0000 Subject: [PATCH 5/9] Update to inline expectations + fixes --- python/ql/src/Functions/MethodArgNames.qll | 10 ++++--- .../Functions/methodArgNames/NonCls.expected | 3 --- .../Functions/methodArgNames/NonCls.qlref | 1 - .../Functions/methodArgNames/NonSelf.expected | 4 --- .../Functions/methodArgNames/NonSelf.qlref | 1 - .../methodArgNames/parameter_names.py | 26 ++++++++++--------- .../Functions/methodArgNames/test.expected | 0 .../Functions/methodArgNames/test.ql | 24 +++++++++++++++++ 8 files changed, 44 insertions(+), 25 deletions(-) delete mode 100644 python/ql/test/query-tests/Functions/methodArgNames/NonCls.expected delete mode 100644 python/ql/test/query-tests/Functions/methodArgNames/NonCls.qlref delete mode 100644 python/ql/test/query-tests/Functions/methodArgNames/NonSelf.expected delete mode 100644 python/ql/test/query-tests/Functions/methodArgNames/NonSelf.qlref create mode 100644 python/ql/test/query-tests/Functions/methodArgNames/test.expected create mode 100644 python/ql/test/query-tests/Functions/methodArgNames/test.ql diff --git a/python/ql/src/Functions/MethodArgNames.qll b/python/ql/src/Functions/MethodArgNames.qll index d1a609fd4112..0dc4cc3c5fdf 100644 --- a/python/ql/src/Functions/MethodArgNames.qll +++ b/python/ql/src/Functions/MethodArgNames.qll @@ -9,7 +9,7 @@ private predicate methodOfClass(Function f, Class c) { f.getScope() = c } /** Holds if `c` is a metaclass. */ private predicate isMetaclass(Class c) { - c.getABase() = API::builtin("type").getASubclass*().asSource().asExpr() + c = API::builtin("type").getASubclass*().asSource().asExpr().(ClassExpr).getInnerScope() } /** Holds if `f` is a class method. */ @@ -26,13 +26,15 @@ private predicate isStaticMethod(Function f) { /** Holds if `c` is a Zope interface. */ private predicate isZopeInterface(Class c) { - c.getABase() = - API::moduleImport("zone") - .getMember("interface") + c = + API::moduleImport("zope") .getMember("interface") + .getMember("Interface") .getASubclass*() .asSource() .asExpr() + .(ClassExpr) + .getInnerScope() } /** diff --git a/python/ql/test/query-tests/Functions/methodArgNames/NonCls.expected b/python/ql/test/query-tests/Functions/methodArgNames/NonCls.expected deleted file mode 100644 index 991c344c9d7f..000000000000 --- a/python/ql/test/query-tests/Functions/methodArgNames/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/methodArgNames/NonCls.qlref b/python/ql/test/query-tests/Functions/methodArgNames/NonCls.qlref deleted file mode 100644 index b3525b101df2..000000000000 --- a/python/ql/test/query-tests/Functions/methodArgNames/NonCls.qlref +++ /dev/null @@ -1 +0,0 @@ -Functions/NonCls.ql diff --git a/python/ql/test/query-tests/Functions/methodArgNames/NonSelf.expected b/python/ql/test/query-tests/Functions/methodArgNames/NonSelf.expected deleted file mode 100644 index 17d5f9818dd7..000000000000 --- a/python/ql/test/query-tests/Functions/methodArgNames/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/methodArgNames/NonSelf.qlref b/python/ql/test/query-tests/Functions/methodArgNames/NonSelf.qlref deleted file mode 100644 index 2629570c2a17..000000000000 --- a/python/ql/test/query-tests/Functions/methodArgNames/NonSelf.qlref +++ /dev/null @@ -1 +0,0 @@ -Functions/NonSelf.ql diff --git a/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py b/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py index b42aaaebb1de..1fd87b2c0d34 100644 --- a/python/ql/test/query-tests/Functions/methodArgNames/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) 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..01c67d45b055 --- /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 + firstArgShouldBeNamedClsAndIsnt(f) and + tag = "shouldBeCls" + ) + ) + } +} + +import MakeTest From 3802a73f47766ae2fa56d8adce8ffade0d568796 Mon Sep 17 00:00:00 2001 From: Joe Farebrother Date: Tue, 4 Feb 2025 10:13:25 +0000 Subject: [PATCH 6/9] Update docs --- python/ql/src/Functions/NonCls.py | 2 +- python/ql/src/Functions/NonCls.qhelp | 10 +++++----- python/ql/src/Functions/NonCls.ql | 3 +-- python/ql/src/Functions/NonSelf.py | 4 ++-- python/ql/src/Functions/NonSelf.qhelp | 12 ++++-------- python/ql/src/Functions/NonSelf.ql | 4 +--- 6 files changed, 14 insertions(+), 21 deletions(-) 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..418218e5a1fa 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 ae9ab0846d6f..bca9a57c9080 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 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..eb796f9b8833 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 94bc8a2fa378..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 From 287cf0121df625d27fbad05233c5bb82148959a4 Mon Sep 17 00:00:00 2001 From: Joe Farebrother Date: Tue, 4 Feb 2025 10:46:05 +0000 Subject: [PATCH 7/9] Fix docs --- python/ql/src/Functions/NonCls.qhelp | 2 +- python/ql/src/Functions/NonSelf.qhelp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/ql/src/Functions/NonCls.qhelp b/python/ql/src/Functions/NonCls.qhelp index 418218e5a1fa..cf064fc50566 100644 --- a/python/ql/src/Functions/NonCls.qhelp +++ b/python/ql/src/Functions/NonCls.qhelp @@ -28,7 +28,7 @@ in PEP 8.

  • Python PEP 8: Function and method arguments.
  • Python Tutorial: Classes.
  • -
  • Python Docs: classmethod.
  • +
  • Python Docs: classmethod.
  • diff --git a/python/ql/src/Functions/NonSelf.qhelp b/python/ql/src/Functions/NonSelf.qhelp index eb796f9b8833..ac1639ba625a 100644 --- a/python/ql/src/Functions/NonSelf.qhelp +++ b/python/ql/src/Functions/NonSelf.qhelp @@ -27,7 +27,7 @@ in PEP 8.

  • Python PEP 8: Function and method arguments.
  • Python Tutorial: Classes.
  • -
  • Python Docs: staticmethod.
  • . +
  • Python Docs: staticmethod.
  • From 61d5a692fb962bc4e3c463457ad306909d5604b2 Mon Sep 17 00:00:00 2001 From: Joe Farebrother Date: Fri, 7 Feb 2025 21:46:30 +0000 Subject: [PATCH 8/9] Refactor metaclass logic a bit, ensure lambdas are excluded --- python/ql/src/Functions/MethodArgNames.qll | 54 ++++++++----------- python/ql/src/Functions/NonCls.ql | 2 +- .../methodArgNames/parameter_names.py | 7 +++ .../Functions/methodArgNames/test.ql | 2 +- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/python/ql/src/Functions/MethodArgNames.qll b/python/ql/src/Functions/MethodArgNames.qll index 0dc4cc3c5fdf..0cbe3ba21965 100644 --- a/python/ql/src/Functions/MethodArgNames.qll +++ b/python/ql/src/Functions/MethodArgNames.qll @@ -2,28 +2,19 @@ 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) { f.getScope() = 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 `f` is a class method. */ -private predicate isClassMethod(Function f) { - f.getADecorator() = API::builtin("classmethod").asSource().asExpr() - or - f.getName() in ["__new__", "__init_subclass__", "__metaclass__", "__class_getitem__"] -} - -/** Holds if `f` is a static method. */ -private predicate isStaticMethod(Function f) { - f.getADecorator() = API::builtin("staticmethod").asSource().asExpr() -} - /** Holds if `c` is a Zope interface. */ private predicate isZopeInterface(Class c) { c = @@ -55,8 +46,8 @@ private predicate usedInInit(Function f, Class c) { /** 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 isStaticmethod(f) and + not isClassmethod(f) and not isMetaclass(c) and not isZopeInterface(c) and not usedInInit(f, c) @@ -65,24 +56,29 @@ predicate shouldBeSelf(Function f, Class c) { /** 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 + not isStaticmethod(f) and ( - isClassMethod(f) and not isMetaclass(c) + isClassmethod(f) and not isMetaclass(c) or - isMetaclass(c) and not isClassMethod(f) + 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` is named `cls`. */ -predicate firstArgNamedCls(Function f) { +/** 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) ) } @@ -92,17 +88,11 @@ predicate firstArgShouldBeNamedSelfAndIsnt(Function f) { not firstArgNamedSelf(f) } -/** Holds if `f` is a regular method of a metaclass, and its first argument is named `self`. */ -private predicate metaclassNamedSelf(Function f, Class c) { - methodOfClass(f, c) and - firstArgNamedSelf(f) and - isMetaclass(c) and - not isClassMethod(f) -} - /** Holds if the first parameter of `f` should be named `cls`, but isn't. */ -predicate firstArgShouldBeNamedClsAndIsnt(Function f) { - shouldBeCls(f, _) and - not firstArgNamedCls(f) and - not metaclassNamedSelf(f, _) +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.ql b/python/ql/src/Functions/NonCls.ql index bca9a57c9080..d36eeb9a6ec4 100644 --- a/python/ql/src/Functions/NonCls.ql +++ b/python/ql/src/Functions/NonCls.ql @@ -16,7 +16,7 @@ import MethodArgNames from Function f, string message where - firstArgShouldBeNamedClsAndIsnt(f) and + firstArgShouldReferToClsAndDoesnt(f) and ( if exists(f.getArgName(0)) then diff --git a/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py b/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py index 1fd87b2c0d34..6a93c35568e3 100644 --- a/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py +++ b/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py @@ -141,3 +141,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.ql b/python/ql/test/query-tests/Functions/methodArgNames/test.ql index 01c67d45b055..526a648b8da3 100644 --- a/python/ql/test/query-tests/Functions/methodArgNames/test.ql +++ b/python/ql/test/query-tests/Functions/methodArgNames/test.ql @@ -14,7 +14,7 @@ module MethodArgTest implements TestSig { firstArgShouldBeNamedSelfAndIsnt(f) and tag = "shouldBeSelf" or - firstArgShouldBeNamedClsAndIsnt(f) and + firstArgShouldReferToClsAndDoesnt(f) and tag = "shouldBeCls" ) ) From f46a2a177384ef5c3ed63302a0e067dc2f1ab8cf Mon Sep 17 00:00:00 2001 From: Joe Farebrother Date: Wed, 12 Feb 2025 09:40:45 +0000 Subject: [PATCH 9/9] Exclude some decorators --- python/ql/src/Functions/MethodArgNames.qll | 12 +++++++++++- .../Functions/methodArgNames/parameter_names.py | 8 ++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/python/ql/src/Functions/MethodArgNames.qll b/python/ql/src/Functions/MethodArgNames.qll index 0cbe3ba21965..0d775b9dbf03 100644 --- a/python/ql/src/Functions/MethodArgNames.qll +++ b/python/ql/src/Functions/MethodArgNames.qll @@ -43,6 +43,15 @@ private predicate usedInInit(Function f, Class c) { ) } +/** + * 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 @@ -50,7 +59,8 @@ predicate shouldBeSelf(Function f, Class c) { not isClassmethod(f) and not isMetaclass(c) and not isZopeInterface(c) and - not usedInInit(f, c) + not usedInInit(f, c) and + not noArgsWithDecorator(f) } /** Holds if the first parameter of `f` should be named `cls`. */ diff --git a/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py b/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py index 6a93c35568e3..3a2fddc9f02c 100644 --- a/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py +++ b/python/ql/test/query-tests/Functions/methodArgNames/parameter_names.py @@ -120,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