|
4 | 4 | import logging
|
5 | 5 | import math
|
6 | 6 | import re
|
| 7 | +import sys |
7 | 8 | from collections import namedtuple
|
8 | 9 | from contextlib import suppress
|
9 | 10 | from functools import lru_cache, partial
|
@@ -448,6 +449,10 @@ def visit_With(self, node):
|
448 | 449 | self.check_for_b022(node)
|
449 | 450 | self.generic_visit(node)
|
450 | 451 |
|
| 452 | + def visit_JoinedStr(self, node): |
| 453 | + self.check_for_b028(node) |
| 454 | + self.generic_visit(node) |
| 455 | + |
451 | 456 | def check_for_b005(self, node):
|
452 | 457 | if node.func.attr not in B005.methods:
|
453 | 458 | return # method name doesn't match
|
@@ -1009,6 +1014,116 @@ def check_for_b906(self, node: ast.FunctionDef):
|
1009 | 1014 | else:
|
1010 | 1015 | self.errors.append(B906(node.lineno, node.col_offset))
|
1011 | 1016 |
|
| 1017 | + def check_for_b028(self, node: ast.JoinedStr): # noqa: C901 |
| 1018 | + # AST structure of strings in f-strings in 3.7 is different enough this |
| 1019 | + # implementation doesn't work |
| 1020 | + if sys.version_info <= (3, 7): |
| 1021 | + return # pragma: no cover |
| 1022 | + |
| 1023 | + def myunparse(node: ast.AST) -> str: # pragma: no cover |
| 1024 | + if sys.version_info >= (3, 9): |
| 1025 | + return ast.unparse(node) |
| 1026 | + if isinstance(node, ast.Name): |
| 1027 | + return node.id |
| 1028 | + if isinstance(node, ast.Attribute): |
| 1029 | + return myunparse(node.value) + "." + node.attr |
| 1030 | + if isinstance(node, ast.Constant): |
| 1031 | + return repr(node.value) |
| 1032 | + if isinstance(node, ast.Call): |
| 1033 | + return myunparse(node.func) + "()" # don't bother with arguments |
| 1034 | + |
| 1035 | + # as a last resort, just give the type name |
| 1036 | + return type(node).__name__ |
| 1037 | + |
| 1038 | + quote_marks = "'\"" |
| 1039 | + current_mark = None |
| 1040 | + variable = None |
| 1041 | + for value in node.values: |
| 1042 | + # check for quote mark after pre-marked variable |
| 1043 | + if ( |
| 1044 | + current_mark is not None |
| 1045 | + and variable is not None |
| 1046 | + and isinstance(value, ast.Constant) |
| 1047 | + and isinstance(value.value, str) |
| 1048 | + and value.value[0] == current_mark |
| 1049 | + ): |
| 1050 | + self.errors.append( |
| 1051 | + B028( |
| 1052 | + variable.lineno, |
| 1053 | + variable.col_offset, |
| 1054 | + vars=(myunparse(variable.value),), |
| 1055 | + ) |
| 1056 | + ) |
| 1057 | + current_mark = variable = None |
| 1058 | + # don't continue with length>1, so we can detect a new pre-mark |
| 1059 | + # in the same string as a post-mark, e.g. `"{foo}" "{bar}"` |
| 1060 | + if len(value.value) == 1: |
| 1061 | + continue |
| 1062 | + |
| 1063 | + # detect pre-mark |
| 1064 | + if ( |
| 1065 | + isinstance(value, ast.Constant) |
| 1066 | + and isinstance(value.value, str) |
| 1067 | + and value.value[-1] in quote_marks |
| 1068 | + ): |
| 1069 | + current_mark = value.value[-1] |
| 1070 | + variable = None |
| 1071 | + continue |
| 1072 | + |
| 1073 | + # detect variable, if there's a pre-mark |
| 1074 | + if ( |
| 1075 | + current_mark is not None |
| 1076 | + and variable is None |
| 1077 | + and isinstance(value, ast.FormattedValue) |
| 1078 | + and value.conversion != ord("r") |
| 1079 | + ): |
| 1080 | + # check if the format spec shows that this is numeric |
| 1081 | + # or otherwise hard/impossible to convert to `!r` |
| 1082 | + if ( |
| 1083 | + isinstance(value.format_spec, ast.JoinedStr) |
| 1084 | + and value.format_spec.values # empty format spec is fine |
| 1085 | + ): |
| 1086 | + if ( |
| 1087 | + # if there's variables in the format_spec, skip |
| 1088 | + len(value.format_spec.values) > 1 |
| 1089 | + or not isinstance(value.format_spec.values[0], ast.Constant) |
| 1090 | + ): |
| 1091 | + current_mark = variable = None |
| 1092 | + continue |
| 1093 | + format_specifier = value.format_spec.values[0].value |
| 1094 | + |
| 1095 | + # if second character is an align, first character is a fill |
| 1096 | + # char - strip it |
| 1097 | + if len(format_specifier) > 1 and format_specifier[1] in "<>=^": |
| 1098 | + format_specifier = format_specifier[1:] |
| 1099 | + |
| 1100 | + # strip out precision digits, so the only remaining ones are |
| 1101 | + # width digits, which will misplace the quotes |
| 1102 | + format_specifier = re.sub(r"\.\d*", "", format_specifier) |
| 1103 | + |
| 1104 | + # skip if any invalid characters in format spec |
| 1105 | + invalid_characters = "".join( |
| 1106 | + ( |
| 1107 | + "=", # align character only valid for numerics |
| 1108 | + "+- ", # sign |
| 1109 | + "0123456789", # width digits |
| 1110 | + "z", # coerce negative zero floating point to positive |
| 1111 | + "#", # alternate form |
| 1112 | + "_,", # thousands grouping |
| 1113 | + "bcdeEfFgGnoxX%", # various number specifiers |
| 1114 | + ) |
| 1115 | + ) |
| 1116 | + if set(format_specifier).intersection(invalid_characters): |
| 1117 | + current_mark = variable = None |
| 1118 | + continue |
| 1119 | + |
| 1120 | + # otherwise save value as variable |
| 1121 | + variable = value |
| 1122 | + continue |
| 1123 | + |
| 1124 | + # if no pre-mark or variable detected, reset state |
| 1125 | + current_mark = variable = None |
| 1126 | + |
1012 | 1127 |
|
1013 | 1128 | def compose_call_path(node):
|
1014 | 1129 | if isinstance(node, ast.Attribute):
|
@@ -1370,6 +1485,12 @@ def visit_Lambda(self, node):
|
1370 | 1485 | " decorator. Consider adding @abstractmethod."
|
1371 | 1486 | )
|
1372 | 1487 | )
|
| 1488 | +B028 = Error( |
| 1489 | + message=( |
| 1490 | + "B028 {!r} is manually surrounded by quotes, consider using the `!r` conversion" |
| 1491 | + " flag." |
| 1492 | + ) |
| 1493 | +) |
1373 | 1494 |
|
1374 | 1495 | # Warnings disabled by default.
|
1375 | 1496 | B901 = Error(
|
|
0 commit comments