Custom Rule Condition Syntax and Methods¶
The condition
field on a Rule is used to restrict pattern matches based on boolean logic.
It supports a subset of Python syntax.
See Also
Syntax¶
Most built-in Python syntax is supported, for example:
and
,or
, andnot
expressions<
,>
,==
, and other boolean operators- strings, integers,
None
, and other literals all
,any
,list
,set
,len
, and other built-in functions
Conditions themselves use a predefined set of methods (see Conditions below). These apply to variable names defined in the pattern of the rule (see Reference: Pattern Syntax Capture Expressions for further reference).
Note
In the following examples, the tests
field is used to show examples of what the pattern matches. See
Reference: Rule Configuration tests
Field for more information.
Single-variable conditions¶
Captured variables can be conditioned using methods.
Examples
Match any integer assignments to x
(see has_type
below):
pattern: x = ${i}
condition: i.has_type("int")
tests:
- match: x = 3
- no-match: x = "y"
Match classes whose name begins with "Sourcery" (see matches_regex
below):
pattern: |
class ${cls}(...):
...
condition: cls.matches_regex("^Sourcery\w*$")
tests:
- match: |
class SourceryException(Exception):
pass
- no-match: |
class AdHocSourceryVisitor(Visitor):
function: typing.Callable[[typing.Any], typing.Any]
Complex conditions¶
Using boolean expressions, you can combine conditions captured variables.
Examples
Multiple conditions on a single variable (see is_lower_case
and starts_with
below):
pattern: ${assignee} = ${assignment}
condition: assignee.is_lower_case() and assignee.starts_with("_")
tests:
- match: _private_variable = 42 # lowercase and starts with "_"
- no-match: private_variable = 42 # lowercase, but does not start with "_"
- no-match: _PRIVATE_VARIABLE = 42 # starts with "_", but is not lowercase
Conditions involving more than one variable (see starts_with
and
statement_count
below):
pattern: |
def ${fn}(...):
${statements+}
condition: fn.starts_with("_") and statements.statement_count() > 40
description: Private functions should not exceed a certain length.
Pattern-level conditions¶
The special name pattern
is used to refer to the entire match instead of only to specific captures. In the example
below, pattern.in_module_scope()
(see in_module_scope
) means that the condition is applied
to the whole pattern (in this case the assignment) rather than any of the variables within it. Note that you can
combine pattern conditions with any other conditions, as shown.
pattern: ${var} = ${value}
condition: |
pattern.in_module_scope()
and not var.is_upper_case()
and not var.starts_with("_")
description: Don't declare `${var}` as a global variable
Conditions¶
This is a complete list of the methods you can use on captures (and patterns). If none of these conditions fit your use-case, feel free to suggest a new one on GitHub.
Condition | Description |
---|---|
capture.is_exception_type() | Check if capture represents an exception type, like ValueError . |
capture.is_conditional_expression() | Returns true for a condition expression (ternary operator). |
capture.is_expression_statement() | Returns true for a statement containing just an expression. |
capture.is_literal() | Returns whether the capture is a literal. |
capture.is_str_literal() | Returns True for string literal constants. |
capture.is_numeric_literal() | Returns True for numeric literal constants. |
capture.is_none_literal() | Returns true for a None literal. |
capture.matches_regex(pattern: str) | Returns true if the regex pattern is found anywhere in capture. |
capture.is_lower_case() | Return True if all cased characters in the name are lower case. |
capture.is_upper_case() | Return True if all cased characters in the name are upper case. |
capture.is_identifier() | Return True if the name is a valid identifier. |
capture.is_snake_case() | Returns true if the name is lower case, also allowing underscores. |
capture.is_dunder_name() | Return True if the name starts and ends with double underscores. |
capture.is_upper_camel_case() | Returns true if the name is only upper or lower case, with no underscores. |
left.equals(right: Capture | str) | Returns true if left and right names are the same. |
capture.character_count() | Counts the number of characters. |
capture.starts_with(prefix: str) | Returns true if the name starts with the specified prefix . |
capture.ends_with(suffix: str) | Returns true if the name ends with the specified suffix . |
capture.contains(substring: str) | Returns true if the name contains the specified substring . |
capture.in_module_scope() | Returns true if the capture is found in module scope. |
capture.in_class_scope(name: str = None) | Returns true if the capture is found in class scope. |
capture.in_function_scope(name: str = None) | Returns true if the capture is found in function scope. |
capture.in_except_clause() | Returns true if the capture is found in an exception clause. |
capture.in_finally_clause() | Returns true if the capture is found in a finally clause. |
capture.statement_count() | Returns the number of statements in the capture. |
capture.has_quote(quote: str) | Returns true if the string uses quote . |
capture.with_quote(quote: str) | Quotes the string using the quote . |
capture.has_type(*types: str) | Return true if the capture's inferred type is any of types . |
capture.has_exception_type() | Returns true if the inferred type of the capture is an Exception. |
Capture Conditions¶
Contains conditions about what has been captured.
capture.is_exception_type() -> bool¶
Check if capture
represents an exception type, like ValueError
.
rules:
- id: raise-created-exception
pattern: |
${var} = ${value}
raise ${var}
condition: value.is_exception_type()
replacement: |
raise ${value}
description: Directly `raise` exceptions instead of assigning them first
capture.is_conditional_expression() -> bool¶
Returns true for a condition expression (ternary operator).
Here is an example of a conditional expression:
"sky" if looking_up() else "ground"
And this is a rule that uses this condition:
rules:
- id: conditional-expression-arg
description: Do not use conditional expressions as arguments
pattern: ${call}(..., ${arg}, ...)
condition: arg.is_conditional_expression()
tests:
- match: print("hi" if hello else "bye", "world")
- no-match: print("Hello world!")
See Python docs for more details about conditional expressions.
capture.is_expression_statement() -> bool¶
Returns true for a statement containing just an expression.
Here's some examples:
- print("Hello world")
- true
- my_list.append(item)
- true
- name = "Hello world"
- false
- def func(): ...
- false
- break
- false
- pass
- false
And this is a rule that uses the condition:
rules:
- id: raise-created-exception
pattern: ${expr}
condition: |
expr.is_expression_statement()
and (expr.is_exception_type() or expr.has_exception_type())
replacement: raise ${expr}
description: Exceptions should not be created without being raised
tests:
- match: Exception
- match: ValueError()
- no-match: raise Exception
- no-match: raise ValueError()
Literal Conditions¶
Conditions related to literals.
capture.is_literal() -> bool¶
Returns whether the capture is a literal.
rules:
- id: cannot-raise-literal
pattern: raise ${a}
condition: a.is_literal()
description: Cannot raise a literal - did you mean to raise an Exception?
tests:
- match: raise 'string'
- no-match: raise
- no-match: raise Exception
- no-match: raise CustomError
capture.is_str_literal() -> bool¶
Returns True for string literal constants.
rules:
- id: no-hard-coded-file-name
description: Hard-coded file name
pattern: ${var} = ${value}
condition: |
value.is_str_literal()
and (var.ends_with("file")
or var.ends_with("file_name")
or var.ends_with("dir")
or var.ends_with("dir_name"))
explanation: |
File and directory names should be read from a parameter or the config.
tests:
- match: input_file = "test.txt"
- match: file_name = "placeholder.py"
- match: working_dir = "/home/user/test"
- no-match: file_name = f"test_{module}.py"
- no-match: other_file = input_file
- no-match: working_dir = Path()
capture.is_numeric_literal() -> bool¶
Returns True for numeric literal constants.
rules:
- id: no-magic_numbers
description: Don't assign a magic number to a variable.
pattern: ${var} = ${value}
condition: value.is_numeric_literal()
tests:
- match: nr = 42
- match: pi_nr = 3.14
- no-match: nr = other_nr
- no-match: nr = 5 + 7
- no-match: nr = a + 42
capture.is_none_literal() -> bool¶
Returns true for a None
literal.
rules:
- id: do-not-return-value-from-init
description: __init__ should not return a value
pattern: return ${value}
condition: |
not value.is_none_literal() and pattern.in_function_scope("__init__")
tests:
- match: |
class Example:
def __init__(stuff):
self.stuff = stuff
return self
- no-match: |
class Example:
def __init__(stuff):
self.stuff = stuff
return None
Name Conditions¶
Conditions related to capture names.
Note that all name conditions are themselves named in snake_case instead of
the standard Python naming for str
functions which uses no underscores.
Python str function | Sourcery condition |
---|---|
str.islower |
capture.is_lower_case |
str.isupper |
capture.is_upper_case |
str.startswith |
capture.starts_with |
str.endswith |
capture.ends_with |
in |
capture.contains |
This is so that all Sourcery conditions are consistently named, and also allows
easy addition of conditions like is_snake_case
which is much better named than
issnake
🐍!
capture.matches_regex(pattern: str) -> bool¶
Returns true if the regex pattern
is found anywhere in capture.
This search is unanchored meaning it matches anywhere in the string:
rules:
- id: dont-use-long-numbers-in-vars
description: Do not use long numbers in variable names
pattern: ${name} = ${value}
condition: name.matches_regex(r"\d\d\d") # Use raw strings to handle backslashes
tests:
- match: start123end = 123
- match: begin123 = "BAD"
- no-match: a12b34c56 = "OK"
To anchor the search:
- Use
^
to anchor at the start of the string - Use
$
to anchor at the end of the string
rules:
- id: dont-use-dunder-vars
description: Do not use dunder name `${name}` as a variable name
pattern: ${name} = ${value}
condition: name.matches_regex("^__.*__$")
tests:
- match: __strange__ = 123
- match: __eq__ = "WRONG"
- no-match: a__b__c = "OK"
capture.is_lower_case() -> bool¶
Return True if all cased characters in the name are lower case.
rules:
- id: lower-names
description: Ensure variable name is lower case
pattern: ${var} = ${value}
condition: var.is_lower_case()
tests:
- match: banana = 1
- match: banana2 = 1
- no-match: BANANA = 1
- no-match: baNana = 1
capture.is_upper_case() -> bool¶
Return True if all cased characters in the name are upper case.
rules:
- id: upper-names
description: Do not reassign the value of constants
pattern: |
${var} = ${value}
${var} = ${other_value}
condition: var.is_upper_case()
tests:
- match: |
BANANA = 1
BANANA = 2
- match: |
BANANA = 1
BANANA = 1
- match: |
BANANA2 = 1
BANANA2 = 2
- no-match: BANANA2 = 1
- no-match: |
banana = 1
banana = 2
- no-match: |
BAnANA = 1
BAnANA = 2
capture.is_identifier() -> bool¶
Return True if the name is a valid identifier.
rules:
- id: valid-identifiers
description: Ensure variable name is a valid identifier
pattern: ${var} = ${value}
condition: not var.is_identifier()
tests:
- match: banana[1] = 1
- match: a.b = 1
- no-match: BANANA = 1
- no-match: banana = 1
capture.is_snake_case() -> bool¶
Returns true if the name is lower case, also allowing underscores.
We suggest using this condition mainly in the negative form. E.g. to detect variable or function names not adhering to snake case.
rules:
- id: snake-case-functions
pattern: |
def ${function_name}(...):
...
condition: not function_name.is_snake_case()
description: Use snake case for function names
tests:
- match: |
def miXed():
pass
- match: |
def UpperCamelCase():
pass
- match: |
class Something:
def UpperCamelCase(self):
pass
- match: |
def too__many__underscores():
pass
- match: |
def double_underscore_suffix__():
pass
- match: |
def mixed_and_underScore():
pass
- no-match: |
def nice_function_name():
pass
- no-match: |
def _private():
pass
- no-match: |
def __very_private():
pass
- no-match: |
class Something:
def single(self):
pass
- no-match: |
class Something:
def __init__(self):
pass
capture.is_dunder_name() -> bool¶
Return True if the name starts and ends with double underscores.
rules:
- id: dunder-names
description: Do not call attributes of dunder variables
pattern: ${var}.${anything}
condition: var.is_dunder_name()
tests:
- match: __version__.whatever()
- match: __version__.whatever
- no-match: __version__ = "1.1"
capture.is_upper_camel_case() -> bool¶
Returns true if the name is only upper or lower case, with no underscores.
We suggest to use this condition mainly in the negative form. E.g. to detect class names not adhering to upper camel case.
rules:
- id: upper-camel-case-names
description: Use upper camel case for class names
pattern: |
class ${class_name}(...):
...
condition: not class_name.is_upper_camel_case()
tests:
- match: |
class lower:
pass
- match: |
class UPPER:
pass
- match: |
class snake_case:
pass
- match: |
class UPPER_UNDERSCORE:
pass
- match: |
class UpperCamelCase_WithUnderscore:
pass
- no-match: |
class UpperCamelCase:
pass
- no-match: |
class UpperCamelCase42:
pass
- no-match: |
class B:
pass
- no-match: |
class _PrivateUpperCamelCase123:
pass
left.equals(right: Capture | str) -> bool¶
Returns true if left
and right
names are the same.
rules:
- id: use-standard-name-for-aliases-pandas
description: Import `pandas` as `pd`
pattern: import ${left*}, pandas as ${alias}, ${right*}
condition: not alias.equals("pd")
tests:
- match: import pandas as pds
- match: import pandas as np
- match: import numpy, pandas as pds, tensorflow
- no-match: import pandas
- no-match: import pandas as pd
- no-match: import numpy, pandas as pd, tensorflow
- no-match: import modin.pandas as pds
capture.character_count() -> int¶
Counts the number of characters.
Can be used to limit the length of a name:
rules:
- id: limit-var-name-length
description: Variables longer than 20 characters are not allowed
pattern: ${var} = ${value}
condition: var.character_count() >= 20
tests:
- match: the_meaning_of_life_the_universe_and_everything = 42
- no-match: concise_name = 43
capture.starts_with(prefix: str) -> bool¶
Returns true if the name starts with the specified prefix
.
rules:
- id: avoid-global-variables
description: Do not define variables at the module level
pattern: "${var} = ${value}"
condition: |
var.in_module_scope()
and not var.is_upper_case()
and not var.starts_with("_")
tests:
- match: max_holy_handgrenade_count = 3
# The next example is wrapped in a string because `:` has a special meaning in yaml
- match: "max_holy_handgrenade_count: int = 3"
- no-match: _max_holy_handgrenade_count = 3
- no-match: MAX_HOLY_HANDGRENADE_COUNT = 3
- no-match: |
def f():
max_holy_handgrenade_count = 3
capture.ends_with(suffix: str) -> bool¶
Returns true if the name ends with the specified suffix
.
rules:
- id: name-type-suffix
description: Do not use the type of a variable as a suffix.
explanation: |
Names shouldn't needlessly include the type of the variable.
pattern: ${name} = ${value}
condition: |
name.ends_with("_dict")
or name.ends_with("_list")
or name.ends_with("_set")
or name.ends_with("_int")
or name.ends_with("_float")
or name.ends_with("_str")
tests:
- match: magic_int = 42
- match: magic_int = 42.00
- match: magic_float = 42
- match: magic_float = 42.00
- match: custom_notes_dict = {}
capture.contains(substring: str) -> bool¶
Returns true if the name contains the specified substring
.
rules:
- id: no-controller-in-models
description: Do not define controllers in the model package.
pattern: ${name} = ${value}
condition: name.contains("controller")
paths:
include:
- models
tests:
- match: custom_controller = CustomController()
- match: custom_controller = SomeObject()
- match: controller = SomeObject()
Scope Conditions¶
Contains conditions related to the scope a capture is in.
All condition only check if the innermost local scope (function, class or module) matches the condition.
See Python Scopes and Namespace for more details about scopes.
capture.in_module_scope() -> bool¶
Returns true if the capture is found in module scope.
This means it is not within a class or a function definition.
rules:
- id: avoid-global-variables
description: Do not define variables at the module level - (found variable ${var})
pattern: ${var} = ${value}
condition: pattern.in_module_scope() and var.is_lower_case() and not var.starts_with("_") and var.is_identifier()
tests:
- match: |
# This var is not in a class or function so is in module scope
x = 0
- no-match: |
class Example:
# This var is in a class so is not in module scope
x = 0
- no-match: |
def f():
# This var is in a function so is not in module scope
x = 0
capture.in_class_scope(name: str = None) -> bool¶
Returns true if the capture is found in class scope.
This means it is within a class definition but not within a method definition.
The name
argument can be used to specify the name of the class.
rules:
- id: no-private-class-attributes
description: Do not use private class attributes
pattern: ${var} = ${value}
condition: var.in_class_scope() and var.starts_with("_")
tests:
- match: |
class Example:
# This var is within a class, but not in a function, so is in class scope
_x = 0
- no-match: |
# This var is not in a class, so is not in class scope
_x = 0
- no-match: |
class Example:
def f(self):
# This var is within a function so is not in class scope
# despite being within a class
_x = 0
capture.in_function_scope(name: str = None) -> bool¶
Returns true if the capture is found in function scope.
This means it is within a function definition but not within an inner
class definition within that function.
The name
argument can be used to specify the name of the function.
Here's an example with no name
:
rules:
- id: no-constants-assigned-in-functions
description: Use upper case variables only as constants outside of functions
pattern: ${var} = ${value}
condition: var.in_function_scope() and var.is_upper_case()
tests:
- match: |
def example():
# This var is in a function, so is in function scope
VALUE = "value"
- match: |
class Example:
def example(self):
# This var is in a function inside a class, so is in function
# scope
VALUE = "value"
- no-match: |
# This var is not in a function, so is not in function scope
VALUE = "value"
- no-match: |
def factory():
class Example:
# This var is within a class within a function, so is not in
# function scope
VALUE = "value"
Here's an example using name
to check it's in the __init__
method:
rules:
- id: do-not-return-value-from-init
description: __init__ should not return a value
pattern: return ...
condition: pattern.in_function_scope("__init__")
tests:
- match: |
def __init__(stuff):
self.stuff = stuff
return
capture.in_except_clause() -> bool¶
Returns true if the capture is found in an exception clause.
This only searches for an enclosing exception clause in the local scope.
rules:
- id: only-bare-raise-in-except-handler
pattern: raise
condition: not pattern.in_except_clause()
description: Bare `raise` statements should only be used in `except` blocks
tests:
- match: raise
- no-match: raise ValueError
- match: |
try:
access_database()
raise
except DBNotConnectedError:
logger.info("DB is not connected")
- no-match: |
try:
access_database()
except DBNotConnectedError:
logger.info("DB is not connected")
raise
capture.in_finally_clause() -> bool¶
Returns true if the capture is found in a finally clause.
This only searches for an enclosing finally clause in the local scope.
rules:
- id: no-break-in-finally
pattern: break
condition: pattern.in_finally_clause()
description: Do not use `break` statements in `finally` blocks
tests:
- match: |
try:
x()
finally:
break
- no-match: |
def f():
while True:
try:
x()
except:
break
- no-match: |
def f():
for i in range(5):
x()
break
Statement Conditions¶
Conditions related to statements.
capture.statement_count() -> int¶
Returns the number of statements in the capture.
Counts the number of statement and nested statements.
This for example would give a statement_count
of 3:
if arriving(): # +1
print("Hello") # +1
else: # 0 - This is part of the `if` statement
print("Good bye") # +1
rules:
- id: no-long-functions
description: No functions with more than 40 statements
pattern: |
def ${name}(...):
${statements+}
condition: statements.statement_count() > 40
String Conditions¶
Conditions related to string literals.
capture.has_quote(quote: str) -> bool¶
Returns true if the string uses quote
.
rules:
- id: do-not-use-single-quotes
pattern: |
"${string}"
condition: string.has_quote("'") and not string.matches_regex("\"")
replacement: |
${string.with_quote("\"")}
description: Do not use single quotes
tests:
- match: a = 'reee'
expect: a = "reee"
- match: func(something, other, thing, next_arg='ree')
expect: func(something, other, thing, next_arg="ree")
- no-match: a = "bbb"
- no-match: a = '"'
capture.with_quote(quote: str) -> Capture¶
Quotes the string using the quote
.
rules:
- id: do-not-use-single-quotes
pattern: |
"${string}"
condition: string.has_quote("'") and not string.matches_regex("\"")
replacement: |
${string.with_quote("\"")}
description: Do not use single quotes
tests:
- match: a = 'reee'
expect: a = "reee"
- match: func(something, other, thing, next_arg='ree')
expect: func(something, other, thing, next_arg="ree")
- no-match: a = "bbb"
- no-match: a = '"'
Type Conditions¶
Contains conditions related to Capture
types.
capture.has_type(*types: str) -> bool¶
Return true if the capture's inferred type is any of types
.
Multiple type arguments can be passed to check whether any of them match.
rules:
- id: dont-use-str-function-on-str-type
description: Unneccesary call to `str` on value with `str` type
pattern: str(${value})
condition: value.has_type("str")
tests:
- match: |
name = "Ada"
full_name = str(name) + "Lovelace"
- no-match: |
count = 6
message = "Found " + str(count) + "nectarines"
capture.has_exception_type() -> bool¶
Returns true if the inferred type of the capture is an Exception.
Note that this only recognizes built in exceptions.
rules:
- id: raise-created-exception
pattern: ${expr}
condition: |
expr.is_expression_statement()
and (expr.is_exception_type() or expr.has_exception_type())
replacement: raise ${expr}
description: Exceptions should not be created without being raised
tests:
- match: Exception
- match: ValueError()
- no-match: raise Exception
- no-match: raise ValueError()