diff --git a/test/lint/lint-python-format-tests.txt b/test/lint/lint-python-format-tests.txt new file mode 100644 --- /dev/null +++ b/test/lint/lint-python-format-tests.txt @@ -0,0 +1,43 @@ +# This file contains python code with % string formatters to test the +# lint-python-format.py script. + +# Single line +"test %s" % "string" +"pi %.2f" % 3.1415 + +# Multiple lines +"test %s" % + "string" +"test %s" % \ + "string" +"test %s" \ + % "string" +"test %s %s %s" \ + % ("1", "2", "3") +"test %s %s %s" % \ + ("1", "2", "3") +"test %s %s %s" \ + % ("1", + "2", "3") +"test %s %s %s" \ + % ("0" \ + + "1", + "2", "3") + +# In a comment +# "test %s" % "string" + +# Nested comment +"test %s %s %s" \ + % ("1", + #"4", + "2", "3") + +# Inlined comments are not supported +# "test %s %s %s" \ +# % ("1", #4, +# "2", "3") + +# Nested format inside a list or dict is not supported +# ["test %s" % "string"] +# will replace with => ["test %s".format("string"]) \ No newline at end of file diff --git a/test/lint/lint-python-format.py b/test/lint/lint-python-format.py new file mode 100755 --- /dev/null +++ b/test/lint/lint-python-format.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2019 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +# +# Lint python format : This program checks that the old python fomatting method +# is not being used (formatting with "string %s" % content). +# The new "{}".format(content) or f"{} content" method should be used instead. +# Usage of the % formatter is expected to be deprecated by python in the future. + +import re +import sys +from doctest import testmod + + +def is_complete(snippet): + r"""Check if a code snippet is complete. + + >>> is_complete("a = [1, 2, 3]") + True + >>> is_complete("a = [1,") + False + >>> is_complete("a = [1, (") + False + >>> is_complete("a = [") + False + >>> is_complete("a = [1, {") + False + >>> is_complete('a = [1, 2, "%d" % \\') + False + >>> is_complete('a = [1, 2, \"\"\"') + False + >>> is_complete('"%s" %') + False + """ + can_continue = [',', '(', '[', '{', '\\', '"""', '%'] + return not any(snippet.strip().endswith(end) for end in can_continue) + + +def build_replacement(error): + r"""Replace a snippet using the % formatter with a version using .format(). + + >>> build_replacement('"test %s" % "1"') + '"test {:s}".format("1")' + >>> build_replacement('"test %.2f" % 3.1415') + '"test {:.2f}".format(3.1415)' + >>> build_replacement('"test %s" \\\n% "1"') + '"test {:s}".format("1")' + >>> build_replacement('"test %s" %\\\n"1"') + '"test {:s}".format("1")' + >>> build_replacement('"test %s %s %s" % ("1", "2", "3")') + '"test {:s} {:s} {:s}".format("1", "2", "3")' + >>> build_replacement('"test %s %s %s" % \\\n("1", "2", "3")') + '"test {:s} {:s} {:s}".format("1", "2", "3")' + >>> build_replacement('"test %s %s %s" % \\\n("1",\n"2", "3")') + '"test {:s} {:s} {:s}".format("1", "2", "3")' + """ + # Inline the error snippet. + # Replace line continuation ('\'), line breaks and their surrounding + # spaces and indentation to a single space character + replacement = re.sub(r"\s*\\\s+", " ", error, re.MULTILINE) + replacement = re.sub(r"\s*(?:\r?\n|\r(?!\n))\s*", + " ", replacement, re.MULTILINE) + + # Replace the specifiers, retaining their content. + # E.g. %.2f => {:.2f} + def specifier_sub(match): + return "{:" + match.group(1) + "}" + + (replacement, count) = re.subn( + r"%([a-zA-Z0-9.]+)", specifier_sub, replacement, flags=re.MULTILINE) + + # Replace the qualifier. + # E.g % 42 => .format(42) + # E.g. % (42, "my_string") => .format(42, "my_string") + def qualifier_sub(match): + qualifier_sub.qualifier += match.group(1).strip() + return qualifier_sub.qualifier + + qualifier_sub.qualifier = ".format(" + if count > 1: + # If more that 1 specifier, % expects a tuple starting with '(' + replacement = re.sub(r"\s+%\s+\((.+)", qualifier_sub, replacement, + flags=re.MULTILINE) + else: + # Otherwise there is no parenthesis + replacement = re.sub(r"\s+%\s+(.+)", qualifier_sub, replacement, + flags=re.MULTILINE) + ")" + + return replacement + + +def find_snippets(file): + """Find code snippets in the source file that contains the percent ('%') + character""" + with open(file, 'r') as f: + snippet_line = "" + snippets = {} + + for line_number, line in enumerate(f): + # Skip comments + if not line.strip().startswith('#'): + # If we are not already in a snippet and the line contains a % + # character, start saving the snippet + if not snippet_line and '%' in line: + snippet_line = str(line_number + 1) + snippets[snippet_line] = "" + + # In a snippet ? + # - save the line + # - check if the snippet is complete + if snippet_line: + snippets[snippet_line] += line + if is_complete(line): + snippet_line = "" + + return snippets + + +def find_errors(file): + """Extract snippets using the % symbol as a formatter with their line + number""" + pattern = re.compile(r"(?:\"|')\s*\\?\s+%\s+(?:\\\s+)?.+$", re.MULTILINE) + snippets = find_snippets(file) + return dict( + [(l, s) for l, s in snippets.items() if pattern.search(s) is not None]) + + +def main(file): + r"""Print line number and code snippets using the % formatter from the file, + and suggest a replacement using the .format() method. + Output format is : + () + => + + >>> main("test/lint/lint-python-format-tests.txt") + (5) "test %s" % "string" + => "test {:s}".format("string") + (6) "pi %.2f" % 3.1415 + => "pi {:.2f}".format(3.1415) + (9) "test %s" % + "string" + => "test {:s}".format("string") + (11) "test %s" % \ + "string" + => "test {:s}".format("string") + (13) "test %s" \ + % "string" + => "test {:s}".format("string") + (15) "test %s %s %s" \ + % ("1", "2", "3") + => "test {:s} {:s} {:s}".format("1", "2", "3") + (17) "test %s %s %s" % \ + ("1", "2", "3") + => "test {:s} {:s} {:s}".format("1", "2", "3") + (19) "test %s %s %s" \ + % ("1", + "2", "3") + => "test {:s} {:s} {:s}".format("1", "2", "3") + (22) "test %s %s %s" \ + % ("0" \ + + "1", + "2", "3") + => "test {:s} {:s} {:s}".format("0" + "1", "2", "3") + (31) "test %s %s %s" \ + % ("1", + "2", "3") + => "test {:s} {:s} {:s}".format("1", "2", "3") + """ + errors = find_errors(file) + # Python dictionnaries do not guarantee ordering, sort by line number + for line_number, error in sorted(errors.items(), + key=lambda pair: int(pair[0])): + replacement = build_replacement(error) + print("({}) {}".format(line_number, error.rstrip())) + print("=> " + replacement) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(testmod()[1]) + else: + main(sys.argv[1])