6
6
7
7
from __future__ import annotations
8
8
9
+ import ast
9
10
import collections
10
11
import re
11
12
import sys
12
13
import tokenize
13
14
from collections import Counter
14
- from collections .abc import Iterable , Sequence
15
+ from collections .abc import Iterable , Sequence , Union
15
16
from typing import TYPE_CHECKING , Literal
16
17
17
18
import astroid
193
194
"Used when we detect a string that does not have any interpolation variables, "
194
195
"in which case it can be either a normal string without formatting or a bug in the code." ,
195
196
),
197
+ "W1311" : (
198
+ "The '%s' syntax implies an f-string but the leading 'f' is missing" ,
199
+ "possible-forgotten-f-prefix" ,
200
+ "Used when we detect a string that uses '{}' with a local variable or valid "
201
+ "expression inside. This string is probably meant to be an f-string." ,
202
+ ),
196
203
}
197
204
)
198
205
@@ -996,12 +1003,103 @@ def process_non_raw_string_token(
996
1003
# character can never be the start of a new backslash escape.
997
1004
index += 2
998
1005
999
- @only_required_for_messages ("redundant-u-string-prefix" )
1000
- def visit_const (self , node : nodes .Const ) -> None :
1006
+ @check_messages ("possible-forgotten-f-prefix" )
1007
+ @check_messages ("redundant-u-string-prefix" )
1008
+ def visit_const (self , node : nodes .Const ):
1001
1009
if node .pytype () == "builtins.str" and not isinstance (
1002
1010
node .parent , nodes .JoinedStr
1003
1011
):
1004
- self ._detect_u_string_prefix (node )
1012
+ self ._detect_possible_f_string (node )
1013
+ self ._detect_u_string_prefix (node )
1014
+
1015
+ def _detect_possible_f_string (self , node : nodes .Const ):
1016
+ """Check whether strings include local/global variables in '{}'
1017
+ Those should probably be f-strings's
1018
+ """
1019
+
1020
+ def detect_if_used_in_format (node : nodes .Const ) -> bool :
1021
+ """A helper function that checks if the node is used
1022
+ in a call to format() if so returns True"""
1023
+
1024
+ def get_all_format_calls (node : nodes .Const ) -> set :
1025
+ """Return a set of all calls to format()"""
1026
+ calls = set ()
1027
+ # The skip_class is to make sure we don't go into inner scopes
1028
+ for call in node .scope ().nodes_of_class (
1029
+ nodes .Attribute , skip_klass = (nodes .FunctionDef ,)
1030
+ ):
1031
+ if call .attrname == "format" :
1032
+ if isinstance (call .expr , nodes .Name ):
1033
+ calls .add (call .expr .name )
1034
+ elif isinstance (call .expr , nodes .Subscript ):
1035
+ slice_repr = [call .expr .value , call .expr .slice .value ]
1036
+ while not isinstance (slice_repr [0 ], nodes .Name ):
1037
+ slice_repr = [
1038
+ slice_repr [0 ].value ,
1039
+ slice_repr [0 ].slice .value ,
1040
+ ] slice_repr [1 :]
1041
+ calls .add (tuple (slice_repr [0 ].name ) tuple (slice_repr [1 :]))
1042
+ return calls
1043
+
1044
+ def check_match_in_calls (
1045
+ assignment : Union [nodes .AssignName , nodes .Subscript ]
1046
+ ) -> bool :
1047
+ """Check if the node to which is being assigned is used in a call to format()"""
1048
+ format_calls = get_all_format_calls (node )
1049
+ if isinstance (assignment , nodes .AssignName ):
1050
+ if assignment .name in format_calls :
1051
+ return True
1052
+ elif isinstance (assignment , nodes .Subscript ):
1053
+ slice_repr = [assignment .value , assignment .slice .value ]
1054
+ while not isinstance (slice_repr [0 ], nodes .Name ):
1055
+ slice_repr = [
1056
+ slice_repr [0 ].value ,
1057
+ slice_repr [0 ].slice .value ,
1058
+ ] slice_repr [1 :]
1059
+ if (
1060
+ tuple (slice_repr [0 ].name ) tuple (slice_repr [1 :])
1061
+ in format_calls
1062
+ ):
1063
+ return True
1064
+ return False
1065
+
1066
+ if isinstance (node .parent , nodes .Assign ):
1067
+ return check_match_in_calls (node .parent .targets [0 ])
1068
+ if isinstance (node .parent , nodes .Tuple ):
1069
+ node_index = node .parent .elts .index (node )
1070
+ return check_match_in_calls (
1071
+ node .parent .parent .targets [0 ].elts [node_index ]
1072
+ )
1073
+ return False
1074
+
1075
+ # Find all pairs of '{}' within a string
1076
+ inner_matches = re .findall (r"(?<=\{).*?(?=\})" , node .value )
1077
+
1078
+ # If a variable is used twice it is probably used for formatting later on
1079
+ if len (inner_matches ) != len (set (inner_matches )):
1080
+ return
1081
+
1082
+ if (
1083
+ isinstance (node .parent , nodes .Attribute )
1084
+ and node .parent .attrname == "format"
1085
+ ):
1086
+ return
1087
+
1088
+ if inner_matches :
1089
+ for match in inner_matches :
1090
+ try :
1091
+ ast .parse (match , "<fstring>" , "eval" )
1092
+ except SyntaxError :
1093
+ # Not valid python
1094
+ continue
1095
+
1096
+ if not detect_if_used_in_format (node ):
1097
+ self .add_message (
1098
+ "possible-forgotten-f-prefix" ,
1099
+ line = node .lineno ,
1100
+ node = node ,
1101
+ args = (f"{{{ match } }}" ,),
1102
+ )
1005
1103
1006
1104
def _detect_u_string_prefix (self , node : nodes .Const ) -> None :
1007
1105
"""Check whether strings include a 'u' prefix like u'String'."""
@@ -1013,6 +1111,8 @@ def _detect_u_string_prefix(self, node: nodes.Const) -> None:
1013
1111
)
1014
1112
1015
1113
1114
+
1115
+
1016
1116
def register (linter : PyLinter ) -> None :
1017
1117
linter .register_checker (StringFormatChecker (linter ))
1018
1118
linter .register_checker (StringConstantChecker (linter ))
0 commit comments