Skip to content

Commit 63bb27d

Browse files
committed
add W1311: possible-forgotten-f-prefix
from pylint-dev#4787
1 parent 2088414 commit 63bb27d

File tree

2 files changed

+107
-4
lines changed

2 files changed

+107
-4
lines changed

pylint/checkers/strings.py

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66

77
from __future__ import annotations
88

9+
import ast
910
import collections
1011
import re
1112
import sys
1213
import tokenize
1314
from collections import Counter
14-
from collections.abc import Iterable, Sequence
15+
from collections.abc import Iterable, Sequence, Union
1516
from typing import TYPE_CHECKING, Literal
1617

1718
import astroid
@@ -193,6 +194,12 @@
193194
"Used when we detect a string that does not have any interpolation variables, "
194195
"in which case it can be either a normal string without formatting or a bug in the code.",
195196
),
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+
),
196203
}
197204
)
198205

@@ -996,12 +1003,103 @@ def process_non_raw_string_token(
9961003
# character can never be the start of a new backslash escape.
9971004
index += 2
9981005

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):
10011009
if node.pytype() == "builtins.str" and not isinstance(
10021010
node.parent, nodes.JoinedStr
10031011
):
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+
)
10051103

10061104
def _detect_u_string_prefix(self, node: nodes.Const) -> None:
10071105
"""Check whether strings include a 'u' prefix like u'String'."""
@@ -1013,6 +1111,8 @@ def _detect_u_string_prefix(self, node: nodes.Const) -> None:
10131111
)
10141112

10151113

1114+
1115+
10161116
def register(linter: PyLinter) -> None:
10171117
linter.register_checker(StringFormatChecker(linter))
10181118
linter.register_checker(StringConstantChecker(linter))

pylint/reporters/text.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class TextReporter(BaseReporter):
111111

112112
name = "text"
113113
extension = "txt"
114+
# pylint: disable-next=possible-forgotten-f-prefix
114115
line_format = "{path}:{line}:{column}: {msg_id}: {msg} ({symbol})"
115116

116117
def __init__(self, output: TextIO | None = None) -> None:
@@ -186,6 +187,7 @@ class ParseableTextReporter(TextReporter):
186187
"""
187188

188189
name = "parseable"
190+
# pylint: disable-next=possible-forgotten-f-prefix
189191
line_format = "{path}:{line}: [{msg_id}({symbol}), {obj}] {msg}"
190192

191193
def __init__(self, output: TextIO | None = None) -> None:
@@ -201,6 +203,7 @@ class VSTextReporter(ParseableTextReporter):
201203
"""Visual studio text reporter."""
202204

203205
name = "msvs"
206+
# pylint: disable-next=possible-forgotten-f-prefix
204207
line_format = "{path}({line}): [{msg_id}({symbol}){obj}] {msg}"
205208

206209

0 commit comments

Comments
 (0)