creeper-adventure/.venv/lib/python3.8/site-packages/pyflyby/_cmdline.py
2022-03-31 20:20:07 -05:00

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,
}