515 lines
20 KiB
Python
515 lines
20 KiB
Python
# pyflyby/_cmdline.py.
|
|
# Copyright (C) 2011, 2012, 2013, 2014, 2015, 2018 Karl Chen.
|
|
# License: MIT http://opensource.org/licenses/MIT
|
|
|
|
from __future__ import (absolute_import, division, print_function,
|
|
with_statement)
|
|
|
|
import optparse
|
|
import os
|
|
import signal
|
|
import six
|
|
from six import reraise
|
|
from six.moves import input
|
|
import sys
|
|
from textwrap import dedent
|
|
import traceback
|
|
|
|
|
|
from pyflyby._file import (FileText, Filename, atomic_write_file,
|
|
expand_py_files_from_args, read_file)
|
|
from pyflyby._importstmt import ImportFormatParams
|
|
from pyflyby._log import logger
|
|
from pyflyby._util import cached_attribute, indent
|
|
|
|
|
|
def hfmt(s):
|
|
return dedent(s).strip()
|
|
|
|
def maindoc():
|
|
import __main__
|
|
return (__main__.__doc__ or '').strip()
|
|
|
|
|
|
def _sigpipe_handler(*args):
|
|
# The parent process piped our stdout and closed the pipe before we
|
|
# finished writing, e.g. "tidy-imports ... | head" or "tidy-imports ... |
|
|
# less". Exit quietly - squelch the "close failed in file object
|
|
# destructor" message would otherwise be raised.
|
|
raise SystemExit(1)
|
|
|
|
|
|
def parse_args(addopts=None, import_format_params=False, modify_action_params=False):
|
|
"""
|
|
Do setup for a top-level script and parse arguments.
|
|
"""
|
|
### Setup.
|
|
# Register a SIGPIPE handler.
|
|
signal.signal(signal.SIGPIPE, _sigpipe_handler)
|
|
### Parse args.
|
|
parser = optparse.OptionParser(usage='\n'+maindoc())
|
|
|
|
def log_level_callbacker(level):
|
|
def callback(option, opt_str, value, parser):
|
|
logger.set_level(level)
|
|
return callback
|
|
|
|
def debug_callback(option, opt_str, value, parser):
|
|
logger.set_level("DEBUG")
|
|
|
|
parser.add_option("--debug", action="callback",
|
|
callback=debug_callback,
|
|
help="Debug mode (noisy and fail fast).")
|
|
|
|
parser.add_option("--verbose", action="callback",
|
|
callback=log_level_callbacker("DEBUG"),
|
|
help="Be noisy.")
|
|
|
|
parser.add_option("--quiet", action="callback",
|
|
callback=log_level_callbacker("ERROR"),
|
|
help="Be quiet.")
|
|
|
|
parser.add_option("--version", action="callback",
|
|
callback=lambda *args: print_version_and_exit(),
|
|
help="Print pyflyby version and exit.")
|
|
|
|
if modify_action_params:
|
|
group = optparse.OptionGroup(parser, "Action options")
|
|
action_diff = action_external_command('pyflyby-diff')
|
|
def parse_action(v):
|
|
V = v.strip().upper()
|
|
if V == 'PRINT':
|
|
return action_print
|
|
elif V == 'REPLACE':
|
|
return action_replace
|
|
elif V == 'QUERY':
|
|
return action_query()
|
|
elif V == "DIFF":
|
|
return action_diff
|
|
elif V.startswith("QUERY:"):
|
|
return action_query(v[6:])
|
|
elif V.startswith("EXECUTE:"):
|
|
return action_external_command(v[8:])
|
|
elif V == "IFCHANGED":
|
|
return action_ifchanged
|
|
else:
|
|
raise Exception(
|
|
"Bad argument %r to --action; "
|
|
"expected PRINT or REPLACE or QUERY or IFCHANGED "
|
|
"or EXECUTE:..." % (v,))
|
|
|
|
def set_actions(actions):
|
|
actions = tuple(actions)
|
|
parser.values.actions = actions
|
|
|
|
def action_callback(option, opt_str, value, parser):
|
|
action_args = value.split(',')
|
|
set_actions([parse_action(v) for v in action_args])
|
|
|
|
def action_callbacker(actions):
|
|
def callback(option, opt_str, value, parser):
|
|
set_actions(actions)
|
|
return callback
|
|
|
|
group.add_option(
|
|
"--actions", type='string', action='callback',
|
|
callback=action_callback,
|
|
metavar='PRINT|REPLACE|IFCHANGED|QUERY|DIFF|EXECUTE:mycommand',
|
|
help=hfmt('''
|
|
Comma-separated list of action(s) to take. If PRINT, print
|
|
the changed file to stdout. If REPLACE, then modify the
|
|
file in-place. If EXECUTE:mycommand, then execute
|
|
'mycommand oldfile tmpfile'. If DIFF, then execute
|
|
'pyflyby-diff'. If QUERY, then query user to continue.
|
|
If IFCHANGED, then continue actions only if file was
|
|
changed.'''))
|
|
group.add_option(
|
|
"--print", "-p", action='callback',
|
|
callback=action_callbacker([action_print]),
|
|
help=hfmt('''
|
|
Equivalent to --action=PRINT (default when stdin or stdout is
|
|
not a tty) '''))
|
|
group.add_option(
|
|
"--diff", "-d", action='callback',
|
|
callback=action_callbacker([action_diff]),
|
|
help=hfmt('''Equivalent to --action=DIFF'''))
|
|
group.add_option(
|
|
"--replace", "-r", action='callback',
|
|
callback=action_callbacker([action_ifchanged, action_replace]),
|
|
help=hfmt('''Equivalent to --action=IFCHANGED,REPLACE'''))
|
|
group.add_option(
|
|
"--diff-replace", "-R", action='callback',
|
|
callback=action_callbacker([action_ifchanged, action_diff, action_replace]),
|
|
help=hfmt('''Equivalent to --action=IFCHANGED,DIFF,REPLACE'''))
|
|
actions_interactive = [
|
|
action_ifchanged, action_diff,
|
|
action_query("Replace {filename}?"), action_replace]
|
|
group.add_option(
|
|
"--interactive", "-i", action='callback',
|
|
callback=action_callbacker(actions_interactive),
|
|
help=hfmt('''
|
|
Equivalent to --action=IFCHANGED,DIFF,QUERY,REPLACE (default
|
|
when stdin & stdout are ttys) '''))
|
|
if os.isatty(0) and os.isatty(1):
|
|
default_actions = actions_interactive
|
|
else:
|
|
default_actions = [action_print]
|
|
parser.set_default('actions', tuple(default_actions))
|
|
parser.add_option_group(group)
|
|
|
|
parser.add_option(
|
|
'--symlinks', action='callback', nargs=1, type=str,
|
|
dest='symlinks', callback=symlink_callback, help="--symlinks should be one of: " + symlinks_help,
|
|
)
|
|
parser.set_defaults(symlinks='error')
|
|
|
|
if import_format_params:
|
|
group = optparse.OptionGroup(parser, "Pretty-printing options")
|
|
group.add_option('--align-imports', '--align', type='str', default="32",
|
|
metavar='N',
|
|
help=hfmt('''
|
|
Whether and how to align the 'import' keyword in
|
|
'from modulename import aliases...'. If 0, then
|
|
don't align. If 1, then align within each block
|
|
of imports. If an integer > 1, then align at
|
|
that column, wrapping with a backslash if
|
|
necessary. If a comma-separated list of integers
|
|
(tab stops), then pick the column that results in
|
|
the fewest number of lines total per block.'''))
|
|
group.add_option('--from-spaces', type='int', default=3, metavar='N',
|
|
help=hfmt('''
|
|
The number of spaces after the 'from' keyword.
|
|
(Must be at least 1; default is 3.)'''))
|
|
group.add_option('--separate-from-imports', action='store_true',
|
|
default=False,
|
|
help=hfmt('''
|
|
Separate 'from ... import ...'
|
|
statements from 'import ...' statements.'''))
|
|
group.add_option('--no-separate-from-imports', action='store_false',
|
|
dest='separate_from_imports',
|
|
help=hfmt('''
|
|
(Default) Don't separate 'from ... import ...'
|
|
statements from 'import ...' statements.'''))
|
|
group.add_option('--align-future', action='store_true',
|
|
default=False,
|
|
help=hfmt('''
|
|
Align the 'from __future__ import ...' statement
|
|
like others.'''))
|
|
group.add_option('--no-align-future', action='store_false',
|
|
dest='align_future',
|
|
help=hfmt('''
|
|
(Default) Don't align the 'from __future__ import
|
|
...' statement.'''))
|
|
group.add_option('--width', type='int', default=79, metavar='N',
|
|
help=hfmt('''
|
|
Maximum line length (default: 79).'''))
|
|
group.add_option('--black', action='store_true', default=False,
|
|
help=hfmt('''
|
|
Use black to format imports. If this option is
|
|
used, all other formatting options are ignored.'''))
|
|
group.add_option('--hanging-indent', type='choice', default='never',
|
|
choices=['never','auto','always'],
|
|
metavar='never|auto|always',
|
|
dest='hanging_indent',
|
|
help=hfmt('''
|
|
How to wrap import statements that don't fit on
|
|
one line.
|
|
If --hanging-indent=always, then always indent
|
|
imported tokens at column 4 on the next line.
|
|
If --hanging-indent=never (default), then align
|
|
import tokens after "import (" (by default column
|
|
40); do so even if some symbols are so long that
|
|
this would exceed the width (by default 79)).
|
|
If --hanging-indent=auto, then use hanging indent
|
|
only if it is necessary to prevent exceeding the
|
|
width (by default 79).
|
|
'''))
|
|
def uniform_callback(option, opt_str, value, parser):
|
|
parser.values.separate_from_imports = False
|
|
parser.values.from_spaces = 3
|
|
parser.values.align_imports = '32'
|
|
group.add_option('--uniform', '-u', action="callback",
|
|
callback=uniform_callback,
|
|
help=hfmt('''
|
|
(Default) Shortcut for --no-separate-from-imports
|
|
--from-spaces=3 --align-imports=32.'''))
|
|
def unaligned_callback(option, opt_str, value, parser):
|
|
parser.values.separate_from_imports = True
|
|
parser.values.from_spaces = 1
|
|
parser.values.align_imports = '0'
|
|
group.add_option('--unaligned', '-n', action="callback",
|
|
callback=unaligned_callback,
|
|
help=hfmt('''
|
|
Shortcut for --separate-from-imports
|
|
--from-spaces=1 --align-imports=0.'''))
|
|
|
|
parser.add_option_group(group)
|
|
if addopts is not None:
|
|
addopts(parser)
|
|
# This is the only way to provide a default value for an option with a
|
|
# callback.
|
|
if modify_action_params:
|
|
args = ["--symlinks=error"] + sys.argv[1:]
|
|
else:
|
|
args = None
|
|
options, args = parser.parse_args(args=args)
|
|
if import_format_params:
|
|
align_imports_args = [int(x.strip())
|
|
for x in options.align_imports.split(",")]
|
|
if len(align_imports_args) == 1 and align_imports_args[0] == 1:
|
|
align_imports = True
|
|
elif len(align_imports_args) == 1 and align_imports_args[0] == 0:
|
|
align_imports = False
|
|
else:
|
|
align_imports = tuple(sorted(set(align_imports_args)))
|
|
options.params = ImportFormatParams(
|
|
align_imports =align_imports,
|
|
from_spaces =options.from_spaces,
|
|
separate_from_imports =options.separate_from_imports,
|
|
max_line_length =options.width,
|
|
use_black =options.black,
|
|
align_future =options.align_future,
|
|
hanging_indent =options.hanging_indent,
|
|
)
|
|
return options, args
|
|
|
|
|
|
def _default_on_error(filename):
|
|
raise SystemExit("bad filename %s" % (filename,))
|
|
|
|
def filename_args(args, on_error=_default_on_error):
|
|
"""
|
|
Return list of filenames given command-line arguments.
|
|
|
|
:rtype:
|
|
``list`` of `Filename`
|
|
"""
|
|
if args:
|
|
return expand_py_files_from_args(args, on_error)
|
|
elif not os.isatty(0):
|
|
return [Filename.STDIN]
|
|
else:
|
|
syntax()
|
|
|
|
|
|
def print_version_and_exit(extra=None):
|
|
from pyflyby._version import __version__
|
|
msg = "pyflyby %s" % (__version__,)
|
|
progname = os.path.realpath(sys.argv[0])
|
|
if os.path.exists(progname):
|
|
msg += " (%s)" % (os.path.basename(progname),)
|
|
print(msg)
|
|
if extra:
|
|
print(extra)
|
|
raise SystemExit(0)
|
|
|
|
|
|
def syntax(message=None, usage=None):
|
|
if message:
|
|
logger.error(message)
|
|
outmsg = ((usage or maindoc()) +
|
|
'\n\nFor usage, see: %s --help' % (sys.argv[0],))
|
|
print(outmsg, file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
class AbortActions(Exception):
|
|
pass
|
|
|
|
|
|
class Modifier(object):
|
|
def __init__(self, modifier, filename):
|
|
self.modifier = modifier
|
|
self.filename = filename
|
|
self._tmpfiles = []
|
|
|
|
@cached_attribute
|
|
def input_content(self):
|
|
return read_file(self.filename)
|
|
|
|
# TODO: refactor to avoid having these heavy-weight things inside a
|
|
# cached_attribute, which causes annoyance while debugging.
|
|
@cached_attribute
|
|
def output_content(self):
|
|
return FileText(self.modifier(self.input_content), filename=self.filename)
|
|
|
|
def _tempfile(self):
|
|
from tempfile import NamedTemporaryFile
|
|
f = NamedTemporaryFile()
|
|
self._tmpfiles.append(f)
|
|
return f, Filename(f.name)
|
|
|
|
|
|
@cached_attribute
|
|
def output_content_filename(self):
|
|
f, fname = self._tempfile()
|
|
if six.PY3:
|
|
f.write(bytes(self.output_content.joined, "utf-8"))
|
|
else:
|
|
f.write(self.output_content.joined.encode('utf-8'))
|
|
f.flush()
|
|
return fname
|
|
|
|
@cached_attribute
|
|
def input_content_filename(self):
|
|
if isinstance(self.filename, Filename):
|
|
return self.filename
|
|
# If the input was stdin, and the user wants a diff, then we need to
|
|
# write it to a temp file.
|
|
f, fname = self._tempfile()
|
|
if six.PY3:
|
|
f.write(bytes(self.input_content, "utf-8"))
|
|
else:
|
|
f.write(self.input_content)
|
|
f.flush()
|
|
return fname
|
|
|
|
|
|
def __del__(self):
|
|
for f in self._tmpfiles:
|
|
f.close()
|
|
|
|
|
|
def process_actions(filenames, actions, modify_function,
|
|
reraise_exceptions=()):
|
|
errors = []
|
|
def on_error_filename_arg(arg):
|
|
print("%s: bad filename %s" % (sys.argv[0], arg), file=sys.stderr)
|
|
errors.append("%s: bad filename" % (arg,))
|
|
filenames = filename_args(filenames, on_error=on_error_filename_arg)
|
|
for filename in filenames:
|
|
try:
|
|
m = Modifier(modify_function, filename)
|
|
for action in actions:
|
|
action(m)
|
|
except AbortActions:
|
|
continue
|
|
except reraise_exceptions:
|
|
raise
|
|
except Exception as e:
|
|
errors.append("%s: %s: %s" % (filename, type(e).__name__, e))
|
|
type_e = type(e)
|
|
try:
|
|
tb = sys.exc_info()[2]
|
|
if str(filename) not in str(e):
|
|
try:
|
|
e = type_e("While processing %s: %s" % (filename, e))
|
|
pass
|
|
except TypeError:
|
|
# Exception takes more than one argument
|
|
pass
|
|
if logger.debug_enabled:
|
|
reraise(type_e, e, tb)
|
|
traceback.print_exception(type(e), e, tb)
|
|
finally:
|
|
tb = None # avoid refcycles involving tb
|
|
continue
|
|
if errors:
|
|
msg = "\n%s: encountered the following problems:\n" % (sys.argv[0],)
|
|
for e in errors:
|
|
lines = e.splitlines()
|
|
msg += " " + lines[0] + '\n'.join(
|
|
(" %s"%line for line in lines[1:]))
|
|
raise SystemExit(msg)
|
|
|
|
|
|
def action_print(m):
|
|
output_content = m.output_content
|
|
sys.stdout.write(output_content.joined)
|
|
|
|
|
|
def action_ifchanged(m):
|
|
if m.output_content.joined == m.input_content.joined:
|
|
logger.debug("unmodified: %s", m.filename)
|
|
raise AbortActions
|
|
|
|
|
|
def action_replace(m):
|
|
if m.filename == Filename.STDIN:
|
|
raise Exception("Can't replace stdio in-place")
|
|
logger.info("%s: *** modified ***", m.filename)
|
|
atomic_write_file(m.filename, m.output_content)
|
|
|
|
|
|
def action_external_command(command):
|
|
import subprocess
|
|
def action(m):
|
|
bindir = os.path.dirname(os.path.realpath(sys.argv[0]))
|
|
env = os.environ
|
|
env['PATH'] = env['PATH'] + ":" + bindir
|
|
fullcmd = "%s %s %s" % (
|
|
command, m.input_content_filename, m.output_content_filename)
|
|
logger.debug("Executing external command: %s", fullcmd)
|
|
ret = subprocess.call(fullcmd, shell=True, env=env)
|
|
logger.debug("External command returned %d", ret)
|
|
return action
|
|
|
|
|
|
def action_query(prompt="Proceed?"):
|
|
def action(m):
|
|
p = prompt.format(filename=m.filename)
|
|
print()
|
|
print("%s [y/N] " % (p), end="")
|
|
try:
|
|
if input().strip().lower().startswith('y'):
|
|
return True
|
|
except KeyboardInterrupt:
|
|
print("KeyboardInterrupt", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
print("Aborted")
|
|
raise AbortActions
|
|
return action
|
|
|
|
def symlink_callback(option, opt_str, value, parser):
|
|
parser.values.actions = tuple(i for i in parser.values.actions if i not in
|
|
symlink_callbacks.values())
|
|
if value in symlink_callbacks:
|
|
parser.values.actions = (symlink_callbacks[value],) + parser.values.actions
|
|
else:
|
|
raise optparse.OptionValueError("--symlinks must be one of 'error', 'follow', 'skip', or 'replace'. Got %r" % value)
|
|
|
|
symlinks_help = """\
|
|
--symlinks=error (default; gives an error on symlinks),
|
|
--symlinks=follow (follows symlinks),
|
|
--symlinks=skip (skips symlinks),
|
|
--symlinks=replace (replaces symlinks with the target file\
|
|
"""
|
|
|
|
# Warning, the symlink actions will only work if they are run first.
|
|
# Otherwise, output_content may already be cached
|
|
def symlink_error(m):
|
|
if m.filename == Filename.STDIN:
|
|
return symlink_follow(m)
|
|
if m.filename.islink:
|
|
raise SystemExit("""\
|
|
Error: %s appears to be a symlink. Use one of the following options to allow symlinks:
|
|
%s
|
|
""" % (m.filename, indent(symlinks_help, ' ')))
|
|
|
|
def symlink_follow(m):
|
|
if m.filename == Filename.STDIN:
|
|
return
|
|
if m.filename.islink:
|
|
logger.info("Following symlink %s" % m.filename)
|
|
m.filename = m.filename.realpath
|
|
|
|
def symlink_skip(m):
|
|
if m.filename == Filename.STDIN:
|
|
return symlink_follow(m)
|
|
if m.filename.islink:
|
|
logger.info("Skipping symlink %s" % m.filename)
|
|
raise AbortActions
|
|
|
|
def symlink_replace(m):
|
|
if m.filename == Filename.STDIN:
|
|
return symlink_follow(m)
|
|
if m.filename.islink:
|
|
logger.info("Replacing symlink %s" % m.filename)
|
|
# The current behavior automatically replaces symlinks, so do nothing
|
|
|
|
symlink_callbacks = {
|
|
'error': symlink_error,
|
|
'follow': symlink_follow,
|
|
'skip': symlink_skip,
|
|
'replace': symlink_replace,
|
|
}
|