Add fixers to YAPF.
diff --git a/CHANGELOG b/CHANGELOG
index d0c0b80..59ae1fa 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -2,7 +2,12 @@
# All notable changes to this project will be documented in this file.
# This project adheres to [Semantic Versioning](http://semver.org/).
-## [0.29.1] UNRELEASED
+## [0.30.0] UNRELEASED
+### Added
+- Added a way to run lib2to3 fixers via the `--fixers` flag. The fixers live in
+ the `contrib/` directory, which isn't part of "official" yapf release.
+ Specifically, the code in `contrib/` may violate one of the core tenets of
+ yapf, and change the code itself and not just the whitespacing.
### Fixed
- Honor a disable directive at the end of a multiline comment.
- Don't require splitting before comments in a list when
diff --git a/README.rst b/README.rst
index 31f6d28..ece17ae 100644
--- a/README.rst
+++ b/README.rst
@@ -92,21 +92,22 @@
Options::
- usage: yapf [-h] [-v] [-d | -i] [-r | -l START-END] [-e PATTERN]
+ usage: yapf [-h] [-v] [-d | -i | -q] [-r | -l START-END] [-e PATTERN]
[--style STYLE] [--style-help] [--no-local-style] [-p]
- [-vv]
+ [--fixers {quotes}] [--force-quote-type {single,double}] [-vv]
[files [files ...]]
Formatter for Python code.
positional arguments:
- files
+ files reads from stdin when no files are specified.
optional arguments:
-h, --help show this help message and exit
-v, --version show version number and exit
-d, --diff print the diff for the fixed source
-i, --in-place make changes to files in place
+ -q, --quiet output nothing and set return value
-r, --recursive run recursively over directories
-l START-END, --lines START-END
range of lines to reformat, one-based
@@ -122,9 +123,12 @@
--style-help show style settings and exit; this output can be saved
to .style.yapf to make your settings permanent
--no-local-style don't search for local style definition
- -p, --parallel Run yapf in parallel when formatting multiple files.
+ -p, --parallel run yapf in parallel when formatting multiple files.
Requires concurrent.futures in Python 2.X
- -vv, --verbose Print out file names while processing
+ --fixers {quotes} comma-separated list of fixers to run on code
+ --force-quote-type {single,double}
+ type of quotes to use - ', ", or decide heuristically
+ -vv, --verbose print out file names while processing
------------
@@ -322,6 +326,48 @@
a == b
+lib2to3 Style Fixers
+====================
+
+YAPF allows you run `lib2to3 style fixers
+<http://python3porting.com/fixers.html>` before or after reformatting. The
+fixers live in the `contrib/fixers/` directory.
+
+Fixers modify the source code and not just whitespace, which is against YAPF's
+policy of changing only whitespace. Therefore, caution needs to be taken when
+using them so that the program's semantics don't change.
+
+------
+Quotes
+------
+
+The "quotes" fixer converts the quotes used to be consistent throughout the
+file. You can have YAPF heuristically choose the quote type (similar to how
+pylint chooses) or force a specific style.
+
+Heuristically choosing quote type:t
+
+.. code-block:: python
+
+ $ python -m yapf --fixers quotes < quotes.py
+ print("These quotes are double")
+
+ print("while these quotes are single")
+
+ print("We really want them to be the same!")
+
+Forcing single quote type:
+
+.. code-block:: python
+
+ $ python -m yapf --fixers quotes --force-quote-type single < quotes.py
+ print('These quotes are now single')
+
+ print('while these quotes are single')
+
+ print(We really want them to be the same!)
+
+
Knobs
=====
diff --git a/contrib/README.md b/contrib/README.md
new file mode 100644
index 0000000..c611f64
--- /dev/null
+++ b/contrib/README.md
@@ -0,0 +1,12 @@
+# YAPF contrib
+
+Any code in this directory is not officially supported, and may change or be
+removed at any time without notice.
+
+The `contrib/` directory contains external contributions to YAPF, which aren't
+part of its official code base. In particular, `lib2to3` "fixers" are added
+here. They aren't part of YAPF, because they modify non-whitespace in source
+code, which is against the YAPF philosophy.
+
+The code in this directory may not be tested on a regular basis, and thus
+should be used with caution.
diff --git a/contrib/fixers/__init__.py b/contrib/fixers/__init__.py
new file mode 100644
index 0000000..bafb0c9
--- /dev/null
+++ b/contrib/fixers/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2020 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/contrib/fixers/fix_quotes.py b/contrib/fixers/fix_quotes.py
new file mode 100644
index 0000000..a181c4b
--- /dev/null
+++ b/contrib/fixers/fix_quotes.py
@@ -0,0 +1,94 @@
+# Copyright 2020 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Fixer that enforces consistent use of quotes for strings.
+
+All strings should use the same quotes. The exception being when the opposite
+quote is used in the string itself --- i.e., '"' or "'". This also changes a
+docstring that uses ''' into one that uses double quotes, which is the required
+format.
+"""
+
+import re
+
+from contrib.fixers import line_conditional_fix
+
+
+class FixQuotes(line_conditional_fix.LineConditionalFix):
+ """Fixer for consistent use of quotes in strings."""
+
+ explicit = True # The user must ask for this fixer.
+
+ # Assorted regex patterns.
+ _delim_pattern = re.compile(r'^[uUbB]?[rR]?(?P<delim>"""|\'\'\'|"|\')')
+ _dbl_quote_pattern = re.compile(r'(^[uUbB]?[rR]?)"([^"]*)"$')
+ _sgl_quote_pattern = re.compile(r"(^[uUbB]?[rR]?)'([^']*)'$")
+ _multiline_string_quote_pattern = re.compile(
+ r"(?s)(^[uUbB]?[rR]?)'''(.*?)'''$")
+
+ PATTERN = """STRING"""
+
+ def __init__(self, options, log):
+ super(FixQuotes, self).__init__(options, log)
+
+ # The option force_quote_type permits forcing the quote style this fixer
+ # will enforce, regardless of what currently exists in the file.
+ # When it's 'none' or doesn't exist, there's no forcing and a heuristic
+ # is used to determine which quote style to use.
+ forced_quote_type = options.get('force_quote_type', 'none')
+ if forced_quote_type == 'single':
+ self._string_delim = "'"
+ elif forced_quote_type == 'double':
+ self._string_delim = '"'
+ else:
+ assert forced_quote_type == 'none'
+ self._string_delim = None
+
+ def transform(self, node, results):
+ if self.should_skip(node) or not node.parent:
+ return
+
+ first_delim = self._GetDelimiter(node.value)
+ if first_delim == "'''" and '"""' not in node.value[3:-3]:
+ # Always use """ for docstrings and multiline strings.
+ if node.value[-4] != '"':
+ new = node.clone()
+ new.parent = node.parent
+ new.value = re.sub(self._multiline_string_quote_pattern, r'\1"""\2"""',
+ node.value)
+ return new
+ return
+
+ if ((first_delim == '"' and "'" in node.value[1:-1]) or
+ (first_delim == "'" and '"' in node.value[1:-1])):
+ return
+
+ if self._string_delim is None:
+ self._string_delim = first_delim
+
+ elif first_delim != self._string_delim:
+ # The quote isn't consistent.
+ new = node.clone()
+ new.parent = node.parent
+ if self._string_delim == '"':
+ new.value = re.sub(self._sgl_quote_pattern, r'\1"\2"', node.value)
+ else:
+ new.value = re.sub(self._dbl_quote_pattern, r"\1'\2'", node.value)
+ return new
+
+ def _GetDelimiter(self, string):
+ """Return the delimiter for the given string repr, omitting prefix."""
+ match = re.search(self._delim_pattern, string)
+ if not match:
+ return None
+ return match.group('delim')
diff --git a/contrib/fixers/fixers_api.py b/contrib/fixers/fixers_api.py
new file mode 100644
index 0000000..742546d
--- /dev/null
+++ b/contrib/fixers/fixers_api.py
@@ -0,0 +1,79 @@
+# Copyright 2020 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Entry point for refactoring via the lib2to3 fixer."""
+
+from lib2to3 import refactor as lib2to3_refactor
+from lib2to3.pgen2 import parse as pgen2_parse
+from lib2to3.pgen2 import tokenize as pgen2_tokenize
+
+# Our path in the source tree.
+MODULE_NAME_PREFIX = 'contrib.fixers.fix_'
+
+# A list of available fixers.
+AVAILABLE_FIXERS = ['quotes']
+
+
+def ValidateCommandLineArguments(args):
+ if args.fixers:
+ for fixer in args.fixers:
+ if fixer not in AVAILABLE_FIXERS:
+ return 'invalid fixer specified: ' + fixer
+
+ if ((not args.fixers or 'quotes' not in args.fixers) and
+ args.force_quote_type != 'none'):
+ return 'cannot use --force-quote-type without --fixers=quotes'
+
+ return None
+
+
+def Pre2to3FixerRun(original_source, options):
+ """2to3 fixers to run before reformatting the file."""
+ if options and options['fixers']:
+ return _Run2to3Fixers(original_source, options=options)
+ return original_source
+
+
+def Post2to3FixerRun(original_source, options):
+ """2to3 fixers to run after reformatting the file."""
+ if options and options['fixers']:
+ return _Run2to3Fixers(original_source, options)
+ return original_source
+
+
+def _Run2to3Fixers(source, options):
+ """Use lib2to3 to reformat the source.
+
+ Args:
+ source: (unicode) The source to reformat.
+ options: dictionary of options to pass to lib2to3_refactor.RefactoringTool
+
+ Returns:
+ Reformatted source code.
+ """
+ fixer_names = [MODULE_NAME_PREFIX + fixer for fixer in options['fixers']]
+ options['print_function'] = True
+ try:
+ try:
+ tool = lib2to3_refactor.RefactoringTool(
+ fixer_names=fixer_names, explicit=fixer_names, options=options)
+ return '{}'.format(tool.refactor_string(source, name=''))
+ except pgen2_parse.ParseError:
+ options['print_function'] = False
+ tool = lib2to3_refactor.RefactoringTool(
+ fixer_names=fixer_names, explicit=fixer_names, options=options)
+ return '{}'.format(tool.refactor_string(source, name=''))
+ except (pgen2_tokenize.TokenError, pgen2_parse.ParseError, SyntaxError,
+ UnicodeDecodeError, UnicodeEncodeError) as err:
+ logging.error(err)
+ raise
diff --git a/contrib/fixers/line_conditional_fix.py b/contrib/fixers/line_conditional_fix.py
new file mode 100644
index 0000000..f414a22
--- /dev/null
+++ b/contrib/fixers/line_conditional_fix.py
@@ -0,0 +1,102 @@
+# Copyright 2020 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Fixer base for skipping nodes not within a line range."""
+
+import collections
+import re
+
+from lib2to3 import fixer_base
+from lib2to3 import pytree
+
+
+class LineConditionalFix(fixer_base.BaseFix):
+ """Base class for fixers which only want to execute on certain lines."""
+
+ def __init__(self, options, log):
+ super(LineConditionalFix, self).__init__(options, log)
+ # Note that for both _skip_nodes and _line_elems, we have to store the
+ # pytree node's "id" in the set, because pytree nodes aren't hashable.
+ self._skip_nodes = set()
+ self._line_elems = collections.defaultdict(set)
+
+ def start_tree(self, tree, filename):
+ """Record the initial position of nodes and determine which to skip."""
+ super(LineConditionalFix, self).start_tree(tree, filename)
+
+ disabled_region = False
+ for node in tree.pre_order():
+ if _IsYapfEnableNode(node):
+ disabled_region = False
+
+ if disabled_region:
+ self._skip_nodes.add(id(node))
+
+ start_leaf, stop_leaf = _GetLeaf(node, 0), _GetLeaf(node, -1)
+ if start_leaf is None or stop_leaf is None:
+ self._skip_nodes.add(id(node)) # cannot determine line numbers, so skip
+ elif _OutsideOfLineRanges(start_leaf.lineno, stop_leaf.lineno,
+ self.options.get('lines')):
+ self._skip_nodes.add(id(node))
+
+ if _IsYapfDisableNode(node):
+ num_newlines = node.prefix.count('\n')
+ # A tailing comment that disables yapf acts on the previous line.
+ # lib2to3 helpfully adds that comment to the first node of the next
+ # line. Therefore, when we see a trailing comment, we need to go back to
+ # the previous line and add those elements to the "self._skip_nodes"
+ # set.
+ if (not node.prefix.startswith('\n') and
+ self._line_elems[node.get_lineno() - num_newlines]):
+ for elem in self._line_elems[node.get_lineno() - num_newlines]:
+ self._skip_nodes.add(elem)
+ else:
+ disabled_region = True
+
+ self._line_elems[node.get_lineno()].add(id(node))
+
+ def should_skip(self, node):
+ """Returns true if this node isn't in the specified line range."""
+ return id(node) in self._skip_nodes
+
+
+_DISABLE_PATTERN = r'^\s*#.+yapf:\s*disable\b'
+_ENABLE_PATTERN = r'^\s*#.+yapf:\s*enable\b'
+
+
+def _OutsideOfLineRanges(start_lineno, stop_lineno, lines):
+ if not lines:
+ return False
+
+ for start, stop in reversed(lines):
+ if start_lineno >= start and stop_lineno <= stop:
+ return False
+
+ return True
+
+
+def _IsYapfDisableNode(node):
+ return re.search(_DISABLE_PATTERN, node.prefix.strip(), re.I | re.M)
+
+
+def _IsYapfEnableNode(node):
+ return re.search(_ENABLE_PATTERN, node.prefix.strip(), re.I | re.M)
+
+
+def _GetLeaf(node, index):
+ """A helper method to get the left-most or right-most child of a node."""
+ while not isinstance(node, pytree.Leaf):
+ if not node.children:
+ return None
+ node = node.children[index]
+ return node
diff --git a/yapf/__init__.py b/yapf/__init__.py
index 41b2a5d..b40f968 100644
--- a/yapf/__init__.py
+++ b/yapf/__init__.py
@@ -32,6 +32,8 @@
import os
import sys
+from contrib.fixers import fixers_api
+
from lib2to3.pgen2 import tokenize
from yapf.yapflib import errors
@@ -57,7 +59,8 @@
Raises:
YapfError: if none of the supplied files were Python files.
"""
- parser = argparse.ArgumentParser(description='Formatter for Python code.')
+ parser = argparse.ArgumentParser(
+ prog='yapf', description='Formatter for Python code.')
parser.add_argument(
'-v',
'--version',
@@ -121,19 +124,35 @@
'--no-local-style',
action='store_true',
help="don't search for local style definition")
- parser.add_argument('--verify', action='store_true', help=argparse.SUPPRESS)
parser.add_argument(
'-p',
'--parallel',
action='store_true',
help=('run yapf in parallel when formatting multiple files. Requires '
'concurrent.futures in Python 2.X'))
+
+ parser.add_argument(
+ '--fixers',
+ action='store',
+ default=None,
+ metavar='{' + ', '.join(fixers_api.AVAILABLE_FIXERS) + '}',
+ type=lambda x: [s.strip().lower() for s in x.split(',')],
+ help='comma-separated list of fixers to run on code')
+ parser.add_argument(
+ '--force-quote-type',
+ action='store',
+ default='none',
+ choices=['single', 'double'],
+ help='type of quotes to use - \', ", or decide heuristically')
+
parser.add_argument(
'-vv',
'--verbose',
action='store_true',
help='print out file names while processing')
+ parser.add_argument('--verify', action='store_true', help=argparse.SUPPRESS)
+
parser.add_argument(
'files', nargs='*', help='reads from stdin when no files are specified.')
args = parser.parse_args(argv[1:])
@@ -151,7 +170,19 @@
if args.lines and len(args.files) > 1:
parser.error('cannot use -l/--lines with more than one file')
+ err_msg = fixers_api.ValidateCommandLineArguments(args)
+ if err_msg:
+ parser.error(err_msg)
+ return 1
+
lines = _GetLines(args.lines) if args.lines is not None else None
+
+ options_for_fixers = {
+ 'fixers': args.fixers,
+ 'force_quote_type': args.force_quote_type,
+ 'lines': lines,
+ }
+
if not args.files:
# No arguments specified. Read code from stdin.
if args.in_place or args.diff:
@@ -184,6 +215,7 @@
py3compat.unicode('\n'.join(source) + '\n'),
filename='<stdin>',
style_config=style_config,
+ options=options_for_fixers,
lines=lines,
verify=args.verify)
except tokenize.TokenError as e:
@@ -211,6 +243,7 @@
print_diff=args.diff,
verify=args.verify,
parallel=args.parallel,
+ options=options_for_fixers,
quiet=args.quiet,
verbose=args.verbose)
return 1 if changed and (args.diff or args.quiet) else 0
@@ -239,9 +272,10 @@
no_local_style=False,
in_place=False,
print_diff=False,
- verify=False,
parallel=False,
+ options=None,
quiet=False,
+ verify=False,
verbose=False):
"""Format a list of files.
@@ -257,9 +291,10 @@
in_place: (bool) Modify the files in place.
print_diff: (bool) Instead of returning the reformatted source, return a
diff that turns the formatted source into reformatter source.
- verify: (bool) True if reformatted code should be verified for syntax.
parallel: (bool) True if should format multiple files in parallel.
+ options: (dict) options for running fixers.
quiet: (bool) True if should output nothing.
+ verify: (bool) True if reformatted code should be verified for syntax.
verbose: (bool) True if should print out filenames while processing.
Returns:
@@ -281,7 +316,8 @@
else:
for filename in filenames:
changed |= _FormatFile(filename, lines, style_config, no_local_style,
- in_place, print_diff, verify, quiet, verbose)
+ in_place, print_diff, options, verify, quiet,
+ verbose)
return changed
@@ -291,6 +327,7 @@
no_local_style=False,
in_place=False,
print_diff=False,
+ options=None,
verify=False,
quiet=False,
verbose=False):
@@ -309,6 +346,7 @@
style_config=style_config,
lines=lines,
print_diff=print_diff,
+ options=options,
verify=verify,
logger=logging.warning)
except tokenize.TokenError as e:
diff --git a/yapf/yapflib/yapf_api.py b/yapf/yapflib/yapf_api.py
index dde1df9..c56aced 100644
--- a/yapf/yapflib/yapf_api.py
+++ b/yapf/yapflib/yapf_api.py
@@ -36,6 +36,8 @@
import re
import sys
+from contrib.fixers import fixers_api
+
from lib2to3.pgen2 import parse
from yapf.yapflib import blank_line_calculator
@@ -56,8 +58,9 @@
style_config=None,
lines=None,
print_diff=False,
- verify=False,
+ options=None,
in_place=False,
+ verify=False,
logger=None):
"""Format a single Python file and return the formatted code.
@@ -72,8 +75,9 @@
than a whole file.
print_diff: (bool) Instead of returning the reformatted source, return a
diff that turns the formatted source into reformatter source.
- verify: (bool) True if reformatted code should be verified for syntax.
+ options: (dict) options for running fixers.
in_place: (bool) If True, write the reformatted code back to the file.
+ verify: (bool) True if reformatted code should be verified for syntax.
logger: (io streamer) A stream to output logging.
Returns:
@@ -97,6 +101,7 @@
filename=filename,
lines=lines,
print_diff=print_diff,
+ options=options,
verify=verify)
if reformatted_source.rstrip('\n'):
lines = reformatted_source.rstrip('\n').split('\n')
@@ -115,6 +120,7 @@
style_config=None,
lines=None,
print_diff=False,
+ options=None,
verify=False):
"""Format a string of Python code.
@@ -132,6 +138,7 @@
than a whole file.
print_diff: (bool) Instead of returning the reformatted source, return a
diff that turns the formatted source into reformatter source.
+ options: (dict) options for running fixers.
verify: (bool) True if reformatted code should be verified for syntax.
Returns:
@@ -143,6 +150,8 @@
if not unformatted_source.endswith('\n'):
unformatted_source += '\n'
+ unformatted_source = fixers_api.Pre2to3FixerRun(unformatted_source, options)
+
try:
tree = pytree_utils.ParseCodeToTree(unformatted_source)
except parse.ParseError as e: