diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index eb54a96b..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: asottile diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3c7c2c6b..d3fcedbe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: check-docstring-first - id: check-yaml @@ -11,11 +11,11 @@ repos: - id: requirements-txt-fixer - id: trailing-whitespace - repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.17.0 + rev: v1.20.0 hooks: - id: setup-cfg-fmt - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + rev: 4.0.1 hooks: - id: flake8 additional_dependencies: [flake8-typing-imports==1.7.0] @@ -29,16 +29,16 @@ repos: - id: reorder-python-imports args: [--py3-plus] - repo: https://github.com/asottile/add-trailing-comma - rev: v2.1.0 + rev: v2.2.1 hooks: - id: add-trailing-comma args: [--py36-plus] - repo: https://github.com/asottile/pyupgrade - rev: v2.23.1 + rev: v2.31.0 hooks: - id: pyupgrade args: [--py36-plus] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.910 + rev: v0.930 hooks: - id: mypy diff --git a/README.md b/README.md index 93f5fa51..b6f56ad9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Sample `.pre-commit-config.yaml`: ```yaml - repo: https://github.com/asottile/pyupgrade - rev: v2.23.1 + rev: v2.31.0 hooks: - id: pyupgrade ``` @@ -29,38 +29,53 @@ Sample `.pre-commit-config.yaml`: ### Set literals -```python -set(()) # set() -set([]) # set() -set((1,)) # {1} -set((1, 2)) # {1, 2} -set([1, 2]) # {1, 2} -set(x for x in y) # {x for x in y} -set([x for x in y]) # {x for x in y} +```diff +-set(()) ++set() +-set([]) ++set() +-set((1,)) ++{1} +-set((1, 2)) ++{1, 2} +-set([1, 2]) ++{1, 2} +-set(x for x in y) ++{x for x in y} +-set([x for x in y]) ++{x for x in y} ``` ### Dictionary comprehensions -```python -dict((a, b) for a, b in y) # {a: b for a, b in y} -dict([(a, b) for a, b in y]) # {a: b for a, b in y} +```diff +-dict((a, b) for a, b in y) ++{a: b for a, b in y} +-dict([(a, b) for a, b in y]) ++{a: b for a, b in y} ``` ### Generator expressions for some built-in functions (pep 289) -```python -min([i for i in range(3)]) # min(i for i in range(3)) -max([i for i in range(3)]) # max(i for i in range(3)) -sum([i for i in range(3)]) # sum(i for i in range(3)) -''.join([str(i) for i in range(3)]) # ''.join(str(i) for i in range(3)) +```diff +-min([i for i in range(3)]) ++min(i for i in range(3)) +-max([i for i in range(3)]) ++max(i for i in range(3)) +-sum([i for i in range(3)]) ++sum(i for i in range(3)) +-''.join([str(i) for i in range(3)]) ++''.join(str(i) for i in range(3)) ``` ### Python2.7+ Format Specifiers -```python -'{0} {1}'.format(1, 2) # '{} {}'.format(1, 2) -'{0}' '{1}'.format(1, 2) # '{}' '{}'.format(1, 2) +```diff +-'{0} {1}'.format(1, 2) ++'{} {}'.format(1, 2) +-'{0}' '{1}'.format(1, 2) ++'{}' '{}'.format(1, 2) ``` ### printf-style string formatting @@ -68,10 +83,13 @@ sum([i for i in range(3)]) # sum(i for i in range(3)) Availability: - Unless `--keep-percent-format` is passed. -```python -'%s %s' % (a, b) # '{} {}'.format(a, b) -'%r %2f' % (a, b) # '{!r} {:2f}'.format(a, b) -'%(a)s %(b)s' % {'a': 1, 'b': 2} # '{a} {b}'.format(a=1, b=2) +```diff +-'%s %s' % (a, b) ++'{} {}'.format(a, b) +-'%r %2f' % (a, b) ++'{!r} {:2f}'.format(a, b) +-'%(a)s %(b)s' % {'a': 1, 'b': 2} ++'{a} {b}'.format(a=1, b=2) ``` ### Unicode literals @@ -80,24 +98,30 @@ Availability: - File imports `from __future__ import unicode_literals` - `--py3-plus` is passed on the commandline. -```python -u'foo' # 'foo' -u"foo" # 'foo' -u'''foo''' # '''foo''' +```diff +-u'foo' ++'foo' +-u"foo" ++'foo' +-u'''foo''' ++'''foo''' ``` ### Invalid escape sequences -```python -# strings with only invalid sequences become raw strings -'\d' # r'\d' -# strings with mixed valid / invalid sequences get escaped -'\n\d' # '\n\\d' -# `ur` is not a valid string prefix in python3 -u'\d' # u'\\d' - -# this fixes a syntax error in python3.3+ -'\N' # r'\N' +```diff + # strings with only invalid sequences become raw strings +-'\d' ++r'\d' + # strings with mixed valid / invalid sequences get escaped +-'\n\d' ++'\n\\d' + # `ur` is not a valid string prefix in python3 +-u'\d' ++u'\\d' + # this fixes a syntax error in python3.3+ +-'\N' ++r'\N' # note: pyupgrade is timid in one case (that's usually a mistake) # in python2.x `'\u2603'` is the same as `'\\u2603'` without `unicode_literals` @@ -109,71 +133,113 @@ u'\d' # u'\\d' In python3.8+, comparison to literals becomes a `SyntaxWarning` as the success of those comparisons is implementation specific (due to common object caching). -```python -x is 5 # x == 5 -x is not 5 # x != 5 -x is 'foo' # x == 'foo' +```diff +-x is 5 ++x == 5 +-x is not 5 ++x != 5 +-x is 'foo' ++x == 'foo' ``` ### `ur` string literals `ur'...'` literals are not valid in python 3.x -```python -ur'foo' # u'foo' -ur'\s' # u'\\s' -# unicode escapes are left alone -ur'\u2603' # u'\u2603' -ur'\U0001f643' # u'\U0001f643' +```diff +-ur'foo' ++u'foo' +-ur'\s' ++u'\\s' + # unicode escapes are left alone +-ur'\u2603' ++u'\u2603' +-ur'\U0001f643' ++u'\U0001f643' ``` ### `.encode()` to bytes literals -```python -'foo'.encode() # b'foo' -'foo'.encode('ascii') # b'foo' -'foo'.encode('utf-8') # b'foo' -u'foo'.encode() # b'foo' -'\xa0'.encode('latin1') # b'\xa0' +```diff +-'foo'.encode() ++b'foo' +-'foo'.encode('ascii') ++b'foo' +-'foo'.encode('utf-8') ++b'foo' +-u'foo'.encode() ++b'foo' +-'\xa0'.encode('latin1') ++b'\xa0' ``` ### Long literals -```python -5L # 5 -5l # 5 -123456789123456789123456789L # 123456789123456789123456789 +```diff +-5L ++5 +-5l ++5 +-123456789123456789123456789L ++123456789123456789123456789 ``` ### Octal literals -``` -0755 # 0o755 -05 # 5 +```diff +-0755 ++0o755 +-05 ++5 ``` ### extraneous parens in `print(...)` A fix for [python-modernize/python-modernize#178] -```python -print(()) # ok: printing an empty tuple -print((1,)) # ok: printing a tuple -sum((i for i in range(3)), []) # ok: parenthesized generator argument -print(("foo")) # print("foo") +```diff + # ok: printing an empty tuple + print(()) + # ok: printing a tuple + print((1,)) + # ok: parenthesized generator argument + sum((i for i in range(3)), []) + # fixed: +-print(("foo")) ++print("foo") ``` [python-modernize/python-modernize#178]: https://github.com/python-modernize/python-modernize/issues/178 +### unittest deprecated aliases + +Rewrites [deprecated unittest method aliases](https://docs.python.org/3/library/unittest.html#deprecated-aliases) to their non-deprecated forms. + +Availability: +- More deprecated aliases are rewritten with `--py3-plus` + +```diff + from unittest import TestCase + + + class MyTests(TestCase): + def test_something(self): +- self.failUnlessEqual(1, 1) ++ self.assertEqual(1, 1) +- self.assertEquals(1, 1) ++ self.assertEqual(1, 1) +``` + ### `super()` calls Availability: - `--py3-plus` is passed on the commandline. -```python -class C(Base): - def f(self): - super(C, self).f() # super().f() +```diff + class C(Base): + def f(self): +- super(C, self).f() ++ super().f() ``` ### "new style" classes @@ -183,15 +249,18 @@ Availability: #### rewrites class declaration -```python -class C(object): pass # class C: pass -class C(B, object): pass # class C(B): pass +```diff +-class C(object): pass ++class C: pass +-class C(B, object): pass ++class C(B): pass ``` #### removes `__metaclass__ = type` declaration ```diff --__metaclass__ = type + class C: +- __metaclass__ = type ``` ### forced `str("native")` literals @@ -199,9 +268,11 @@ class C(B, object): pass # class C(B): pass Availability: - `--py3-plus` is passed on the commandline. -```python -str() # "''" -str("foo") # "foo" +```diff +-str() ++'' +-str("foo") ++"foo" ``` ### `.encode("utf-8")` @@ -209,8 +280,9 @@ str("foo") # "foo" Availability: - `--py3-plus` is passed on the commandline. -```python -"foo".encode("utf-8") # "foo".encode() +```diff +-"foo".encode("utf-8") ++"foo".encode() ``` ### `# coding: ...` comment @@ -266,103 +338,194 @@ Availability: Availability: - `--py3-plus` is passed on the commandline. -```python -def f(): - for x in y: # yield from y - yield x - - for a, b in c: # yield from c - yield (a, b) +```diff + def f(): +- for x in y: +- yield x ++ yield from y +- for a, b in c: +- yield (a, b) ++ yield from c ``` -### `if PY2` blocks +### Python2 and old Python3.x blocks Availability: - `--py3-plus` is passed on the commandline. ```python # input -if six.PY2: # also understands `six.PY3` and `not` and `sys.version_info` +import sys +if sys.version_info < (3,): # also understands `six.PY2` (and `not`), `six.PY3` (and `not`) print('py2') else: print('py3') # output +import sys print('py3') ``` +Availability: +- `--py36-plus` will remove Python <= 3.5 only blocks +- `--py37-plus` will remove Python <= 3.6 only blocks +- so on and so forth + +```python +# using --py36-plus for this example +# input +import sys +if sys.version_info < (3, 6): + print('py3.5') +else: + print('py3.6+') + +if sys.version_info <= (3, 5): + print('py3.5') +else: + print('py3.6+') + +if sys.version_info >= (3, 6): + print('py3.6+') +else: + print('py3.5') + +# output +import sys +print('py3.6+') + +print('py3.6+') + +print('py3.6+') +``` + +Note that `if` blocks without an `else` will not be rewriten as it could introduce a syntax error. + ### remove `six` compatibility code Availability: - `--py3-plus` is passed on the commandline. -```python -six.text_type # str -six.binary_type # bytes -six.class_types # (type,) -six.string_types # (str,) -six.integer_types # (int,) -six.unichr # chr -six.iterbytes # iter -six.print_(...) # print(...) -six.exec_(c, g, l) # exec(c, g, l) -six.advance_iterator(it) # next(it) -six.next(it) # next(it) -six.callable(x) # callable(x) -six.moves.range(x) # range(x) -six.moves.xrange(x) # range(x) - -from six import text_type -text_type # str - -@six.python_2_unicode_compatible # decorator is removed -class C: - def __str__(self): - return u'C()' - -class C(six.Iterator): pass # class C: pass - -class C(six.with_metaclass(M, B)): pass # class C(B, metaclass=M): pass - -@six.add_metaclass(M) # class C(B, metaclass=M): pass -class C(B): pass - -isinstance(..., six.class_types) # isinstance(..., type) -issubclass(..., six.integer_types) # issubclass(..., int) -isinstance(..., six.string_types) # isinstance(..., str) - -six.b('...') # b'...' -six.u('...') # '...' -six.byte2int(bs) # bs[0] -six.indexbytes(bs, i) # bs[i] -six.int2byte(i) # bytes((i,)) -six.iteritems(dct) # dct.items() -six.iterkeys(dct) # dct.keys() -six.itervalues(dct) # dct.values() -next(six.iteritems(dct)) # next(iter(dct.items())) -next(six.iterkeys(dct)) # next(iter(dct.keys())) -next(six.itervalues(dct)) # next(iter(dct.values())) -six.viewitems(dct) # dct.items() -six.viewkeys(dct) # dct.keys() -six.viewvalues(dct) # dct.values() -six.create_unbound_method(fn, cls) # fn -six.get_unbound_function(meth) # meth -six.get_method_function(meth) # meth.__func__ -six.get_method_self(meth) # meth.__self__ -six.get_function_closure(fn) # fn.__closure__ -six.get_function_code(fn) # fn.__code__ -six.get_function_defaults(fn) # fn.__defaults__ -six.get_function_globals(fn) # fn.__globals__ -six.raise_from(exc, exc_from) # raise exc from exc_from -six.reraise(tp, exc, tb) # raise exc.with_traceback(tb) -six.reraise(*sys.exc_info()) # raise -six.assertCountEqual(self, a1, a2) # self.assertCountEqual(a1, a2) -six.assertRaisesRegex(self, e, r, fn) # self.assertRaisesRegex(e, r, fn) -six.assertRegex(self, s, r) # self.assertRegex(s, r) - -# note: only for *literals* -six.ensure_binary('...') # b'...' -six.ensure_str('...') # '...' -six.ensure_text('...') # '...' +```diff +-six.text_type ++str +-six.binary_type ++bytes +-six.class_types ++(type,) +-six.string_types ++(str,) +-six.integer_types ++(int,) +-six.unichr ++chr +-six.iterbytes ++iter +-six.print_(...) ++print(...) +-six.exec_(c, g, l) ++exec(c, g, l) +-six.advance_iterator(it) ++next(it) +-six.next(it) ++next(it) +-six.callable(x) ++callable(x) +-six.moves.range(x) ++range(x) +-six.moves.xrange(x) ++range(x) + + +-from six import text_type +-text_type ++str + +-@six.python_2_unicode_compatible + class C: + def __str__(self): + return u'C()' + +-class C(six.Iterator): pass ++class C: pass + +-class C(six.with_metaclass(M, B)): pass ++class C(B, metaclass=M): pass + +-@six.add_metaclass(M) +-class C(B): pass ++class C(B, metaclass=M): pass + +-isinstance(..., six.class_types) ++isinstance(..., type) +-issubclass(..., six.integer_types) ++issubclass(..., int) +-isinstance(..., six.string_types) ++isinstance(..., str) + +-six.b('...') ++b'...' +-six.u('...') ++'...' +-six.byte2int(bs) ++bs[0] +-six.indexbytes(bs, i) ++bs[i] +-six.int2byte(i) ++bytes((i,)) +-six.iteritems(dct) ++dct.items() +-six.iterkeys(dct) ++dct.keys() +-six.itervalues(dct) ++dct.values() +-next(six.iteritems(dct)) ++next(iter(dct.items())) +-next(six.iterkeys(dct)) ++next(iter(dct.keys())) +-next(six.itervalues(dct)) ++next(iter(dct.values())) +-six.viewitems(dct) ++dct.items() +-six.viewkeys(dct) ++dct.keys() +-six.viewvalues(dct) ++dct.values() +-six.create_unbound_method(fn, cls) ++fn +-six.get_unbound_function(meth) ++meth +-six.get_method_function(meth) ++meth.__func__ +-six.get_method_self(meth) ++meth.__self__ +-six.get_function_closure(fn) ++fn.__closure__ +-six.get_function_code(fn) ++fn.__code__ +-six.get_function_defaults(fn) ++fn.__defaults__ +-six.get_function_globals(fn) ++fn.__globals__ +-six.raise_from(exc, exc_from) ++raise exc from exc_from +-six.reraise(tp, exc, tb) ++raise exc.with_traceback(tb) +-six.reraise(*sys.exc_info()) ++raise +-six.assertCountEqual(self, a1, a2) ++self.assertCountEqual(a1, a2) +-six.assertRaisesRegex(self, e, r, fn) ++self.assertRaisesRegex(e, r, fn) +-six.assertRegex(self, s, r) ++self.assertRegex(s, r) + + # note: only for *literals* +-six.ensure_binary('...') ++b'...' +-six.ensure_str('...') ++'...' +-six.ensure_text('...') ++'...' ``` ### `open` alias @@ -382,14 +545,21 @@ Availability: Availability: - `--py3-plus` is passed on the commandline. -```python -open("foo", "U") # open("foo") -open("foo", "Ur") # open("foo") -open("foo", "Ub") # open("foo", "rb") -open("foo", "rUb") # open("foo", "rb") -open("foo", "r") # open("foo") -open("foo", "rt") # open("foo") -open("f", "r", encoding="UTF-8") # open("f", encoding="UTF-8") +```diff +-open("foo", "U") ++open("foo") +-open("foo", "Ur") ++open("foo") +-open("foo", "Ub") ++open("foo", "rb") +-open("foo", "rUb") ++open("foo", "rb") +-open("foo", "r") ++open("foo") +-open("foo", "rt") ++open("foo") +-open("f", "r", encoding="UTF-8") ++open("f", encoding="UTF-8") ``` @@ -442,17 +612,6 @@ Availability: ``` -### Unpacking argument list comprehensions - -Availability: -- `--py3-plus` is passed on the commandline. - -```diff --foo(*[i for i in bar]) -+foo(*(i for i in bar)) -``` - - ### Rewrite `xml.etree.cElementTree` to `xml.etree.ElementTree` Availability: @@ -482,7 +641,6 @@ Availability: +float ``` - ### `typing.NamedTuple` / `typing.TypedDict` py36+ syntax Availability: @@ -516,16 +674,22 @@ class D2(typing.TypedDict): Availability: - `--py36-plus` is passed on the commandline. -```python -'{foo} {bar}'.format(foo=foo, bar=bar) # f'{foo} {bar}' -'{} {}'.format(foo, bar) # f'{foo} {bar}' -'{} {}'.format(foo.bar, baz.womp) # f'{foo.bar} {baz.womp}' -'{} {}'.format(f(), g()) # f'{f()} {g()}' +```diff +-'{foo} {bar}'.format(foo=foo, bar=bar) ++f'{foo} {bar}' +-'{} {}'.format(foo, bar) ++f'{foo} {bar}' +-'{} {}'.format(foo.bar, baz.womp) ++f'{foo.bar} {baz.womp}' +-'{} {}'.format(f(), g()) ++f'{f()} {g()}' +-'{x}'.format(**locals()) ++f'{x}' ``` _note_: `pyupgrade` is intentionally timid and will not create an f-string if it would make the expression longer or if the substitution parameters are -anything but simple names or dotted names (as this can decrease readability). +sufficiently complicated (as this can decrease readability). ### `subprocess.run`: replace `universal_newlines` with `text` @@ -580,30 +744,30 @@ Availability: ``` -### merge dicts using union operator (pep 584) +### pep 585 typing rewrites Availability: +- File imports `from __future__ import annotations` + - Unless `--keep-runtime-typing` is passed on the commandline. - `--py39-plus` is passed on the commandline. ```diff - x = {"a": 1} - y = {"b": 2} --z = {**x, **y} -+z = x | y +-def f(x: List[str]) -> None: ++def f(x: list[str]) -> None: + ... ``` -### pep 585 typing rewrites +### remove unnecessary abspath Availability: -- File imports `from __future__ import annotations` - - Unless `--keep-runtime-typing` is passed on the commandline. - `--py39-plus` is passed on the commandline. ```diff --def f(x: List[str]) -> None: -+def f(x: list[str]) -> None: - ... + from os.path import abspath + +-abspath(__file__) ++__file__ ``` diff --git a/pyupgrade/__main__.py b/pyupgrade/__main__.py index 93592ffa..593a0b9f 100644 --- a/pyupgrade/__main__.py +++ b/pyupgrade/__main__.py @@ -1,4 +1,4 @@ from pyupgrade._main import main if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pyupgrade/_ast_helpers.py b/pyupgrade/_ast_helpers.py index 6a556c5a..eb4ba066 100644 --- a/pyupgrade/_ast_helpers.py +++ b/pyupgrade/_ast_helpers.py @@ -50,3 +50,10 @@ def contains_await(node: ast.AST) -> bool: return True else: return False + + +def is_async_listcomp(node: ast.ListComp) -> bool: + return ( + any(gen.is_async for gen in node.generators) or + contains_await(node) + ) diff --git a/pyupgrade/_data.py b/pyupgrade/_data.py index e82fa464..c2633301 100644 --- a/pyupgrade/_data.py +++ b/pyupgrade/_data.py @@ -44,6 +44,7 @@ class State(NamedTuple): RECORD_FROM_IMPORTS = frozenset(( '__future__', + 'os.path', 'functools', 'mmap', 'select', @@ -116,8 +117,7 @@ def visit( def _import_plugins() -> None: - # https://github.com/python/mypy/issues/1422 - plugins_path: str = _plugins.__path__ # type: ignore + plugins_path = _plugins.__path__ mod_infos = pkgutil.walk_packages(plugins_path, f'{_plugins.__name__}.') for _, name, _ in mod_infos: __import__(name, fromlist=['_trash']) diff --git a/pyupgrade/_main.py b/pyupgrade/_main.py index f27807a9..5c92be4b 100644 --- a/pyupgrade/_main.py +++ b/pyupgrade/_main.py @@ -31,8 +31,10 @@ from pyupgrade._data import Settings from pyupgrade._data import Version from pyupgrade._data import visit +from pyupgrade._string_helpers import curly_escape from pyupgrade._string_helpers import is_ascii from pyupgrade._string_helpers import is_codec +from pyupgrade._string_helpers import NAMED_UNICODE_RE from pyupgrade._token_helpers import CLOSING from pyupgrade._token_helpers import KEYWORDS from pyupgrade._token_helpers import OPENING @@ -47,21 +49,34 @@ def parse_format(s: str) -> Tuple[DotFormatPart, ...]: - """Makes the empty string not a special case. In the stdlib, there's - loss of information (the type) on the empty string. - """ - parsed = tuple(_stdlib_parse_format(s)) - if not parsed: - return ((s, None, None, None),) - else: - return parsed + """handle named escape sequences""" + ret: List[DotFormatPart] = [] + + for part in NAMED_UNICODE_RE.split(s): + if NAMED_UNICODE_RE.fullmatch(part): + if not ret: + ret.append((part, None, None, None)) + else: + ret[-1] = (ret[-1][0] + part, None, None, None) + else: + first = True + for tup in _stdlib_parse_format(part): + if not first or not ret: + ret.append(tup) + else: + ret[-1] = (ret[-1][0] + tup[0], *tup[1:]) + first = False + + if not ret: + ret.append((s, None, None, None)) + + return tuple(ret) def unparse_parsed_string(parsed: Sequence[DotFormatPart]) -> str: def _convert_tup(tup: DotFormatPart) -> str: ret, field_name, format_spec, conversion = tup - ret = ret.replace('{', '{{') - ret = ret.replace('}', '}}') + ret = curly_escape(ret) if field_name is not None: ret += '{' + field_name if conversion: @@ -679,9 +694,9 @@ def _unparse(node: ast.expr) -> str: elif isinstance(node, ast.Attribute): return ''.join((_unparse(node.value), '.', node.attr)) elif isinstance(node, ast.Subscript): - if sys.version_info >= (3, 9): # pragma: no cover (py39+) + if sys.version_info >= (3, 9): # pragma: >=3.9 cover node_slice: ast.expr = node.slice - elif isinstance(node.slice, ast.Index): # pragma: no cover ( Tuple[int, str]: + while i > 0 and tokens[i - 1].name == 'DEDENT': + i -= 1 if i > 0 and tokens[i - 1].name in {'INDENT', UNIMPORTANT_WS}: indent = f'{tokens[i - 1].src}{" " * 4}' else: @@ -784,10 +801,6 @@ def _fix_py36_plus(contents_text: str, *, min_version: Version) -> str: return contents_text for i, token in reversed_enumerate(tokens): if token.offset in visitor.fstrings: - # TODO: handle \N escape sequences - if r'\N' in token.src: - continue - paren = i + 3 if tokens_to_src(tokens[i + 1:paren + 1]) != '.format(': continue @@ -935,4 +948,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/pyupgrade/_plugins/abspath_file.py b/pyupgrade/_plugins/abspath_file.py new file mode 100644 index 00000000..b70b2dbf --- /dev/null +++ b/pyupgrade/_plugins/abspath_file.py @@ -0,0 +1,53 @@ +import ast +from typing import Iterable +from typing import List +from typing import Tuple + +from tokenize_rt import Offset +from tokenize_rt import Token + +from pyupgrade._ast_helpers import ast_to_offset +from pyupgrade._data import register +from pyupgrade._data import State +from pyupgrade._data import TokenFunc +from pyupgrade._token_helpers import find_closing_bracket +from pyupgrade._token_helpers import find_open_paren + + +def _remove_abspath(i: int, tokens: List[Token]) -> None: + paren_start = find_open_paren(tokens, i + 1) + paren_end = find_closing_bracket(tokens, paren_start) + while i <= paren_start: + tokens[i] = Token('PLACEHOLDER', '') + i += 1 + tokens[paren_end] = Token('PLACEHOLDER', '') + + +@register(ast.Call) +def visit_Call( + state: State, + node: ast.Call, + parent: ast.AST, +) -> Iterable[Tuple[Offset, TokenFunc]]: + if ( + state.settings.min_version >= (3, 9) and + ( + ( + isinstance(node.func, ast.Name) and + node.func.id == 'abspath' and + node.func.id in state.from_imports['os.path'] + ) or + ( + isinstance(node.func, ast.Attribute) and + isinstance(node.func.value, ast.Attribute) and + isinstance(node.func.value.value, ast.Name) and + node.func.value.value.id == 'os' and + node.func.value.attr == 'path' and + node.func.attr == 'abspath' + ) + ) and + len(node.args) == 1 and + isinstance(node.args[0], ast.Name) and + node.args[0].id == '__file__' + ): + yield ast_to_offset(node), _remove_abspath diff --git a/pyupgrade/_plugins/format_locals.py b/pyupgrade/_plugins/format_locals.py new file mode 100644 index 00000000..8c736ec6 --- /dev/null +++ b/pyupgrade/_plugins/format_locals.py @@ -0,0 +1,49 @@ +import ast +from typing import Iterable +from typing import List +from typing import Tuple + +from tokenize_rt import Offset +from tokenize_rt import rfind_string_parts +from tokenize_rt import Token + +from pyupgrade._ast_helpers import ast_to_offset +from pyupgrade._data import register +from pyupgrade._data import State +from pyupgrade._data import TokenFunc +from pyupgrade._token_helpers import find_closing_bracket +from pyupgrade._token_helpers import find_open_paren +from pyupgrade._token_helpers import find_token + + +def _fix(i: int, tokens: List[Token]) -> None: + dot_pos = find_token(tokens, i, '.') + open_pos = find_open_paren(tokens, dot_pos) + close_pos = find_closing_bracket(tokens, open_pos) + for string_idx in rfind_string_parts(tokens, dot_pos - 1): + tok = tokens[string_idx] + tokens[string_idx] = tok._replace(src=f'f{tok.src}') + del tokens[dot_pos:close_pos + 1] + + +@register(ast.Call) +def visit_Call( + state: State, + node: ast.Call, + parent: ast.AST, +) -> Iterable[Tuple[Offset, TokenFunc]]: + if ( + state.settings.min_version >= (3, 6) and + isinstance(node.func, ast.Attribute) and + isinstance(node.func.value, ast.Str) and + node.func.attr == 'format' and + len(node.args) == 0 and + len(node.keywords) == 1 and + node.keywords[0].arg is None and + isinstance(node.keywords[0].value, ast.Call) and + isinstance(node.keywords[0].value.func, ast.Name) and + node.keywords[0].value.func.id == 'locals' and + len(node.keywords[0].value.args) == 0 and + len(node.keywords[0].value.keywords) == 0 + ): + yield ast_to_offset(node), _fix diff --git a/pyupgrade/_plugins/generator_expressions_pep289.py b/pyupgrade/_plugins/generator_expressions_pep289.py index f90a5913..16eb2629 100644 --- a/pyupgrade/_plugins/generator_expressions_pep289.py +++ b/pyupgrade/_plugins/generator_expressions_pep289.py @@ -8,7 +8,7 @@ from tokenize_rt import Token from pyupgrade._ast_helpers import ast_to_offset -from pyupgrade._ast_helpers import contains_await +from pyupgrade._ast_helpers import is_async_listcomp from pyupgrade._data import register from pyupgrade._data import State from pyupgrade._data import TokenFunc @@ -53,11 +53,7 @@ def visit_Call( node.func.id in ALLOWED_FUNCS and node.args and isinstance(node.args[0], ast.ListComp) and - not any( - generator.is_async - for generator in node.args[0].generators - ) and - not contains_await(node.args[0]) + not is_async_listcomp(node.args[0]) ): if len(node.args) == 1 and not node.keywords: yield ast_to_offset(node.args[0]), _delete_list_comp_brackets diff --git a/pyupgrade/_plugins/pep584.py b/pyupgrade/_plugins/pep584.py deleted file mode 100644 index db2a16a6..00000000 --- a/pyupgrade/_plugins/pep584.py +++ /dev/null @@ -1,61 +0,0 @@ -import ast -from typing import Iterable -from typing import Tuple - -from tokenize_rt import List -from tokenize_rt import NON_CODING_TOKENS -from tokenize_rt import Offset -from tokenize_rt import Token - -from pyupgrade._ast_helpers import ast_to_offset -from pyupgrade._data import register -from pyupgrade._data import State -from pyupgrade._data import TokenFunc -from pyupgrade._token_helpers import find_closing_bracket - - -def _replace_dict_brackets(i: int, tokens: List[Token]) -> None: - closing = find_closing_bracket(tokens, i) - j = closing - 1 - while tokens[j].name in NON_CODING_TOKENS and j > i: - j -= 1 - if tokens[j].name == 'OP' and tokens[j].src == ',': - tokens[j] = Token('PLACEHOLDER', '') - - if tokens[i].line == tokens[closing].line: - tokens[i] = Token('PLACEHOLDER', '') - tokens[closing] = Token('PLACEHOLDER', '') - else: - tokens[i] = Token('CODE', '(') - tokens[closing] = Token('CODE', ')') - - -def _remove_double_star(i: int, tokens: List[Token]) -> None: - j = i - while not (tokens[j].name == 'OP' and tokens[j].src == '**'): - j -= 1 - tokens[j] = Token('PLACEHOLDER', '') - - -def _replace_comma_with_pipe(i: int, tokens: List[Token]) -> None: - j = i - 1 - while not (tokens[j].name == 'OP' and tokens[j].src == ','): - j -= 1 - tokens[j] = Token('CODE', ' |') - - -@register(ast.Dict) -def visit_Dict( - state: State, - node: ast.Dict, - parent: ast.AST, -) -> Iterable[Tuple[Offset, TokenFunc]]: - if state.settings.min_version < (3, 9): - return - - if all(key is None for key in node.keys) and len(node.values) > 1: - yield ast_to_offset(node), _replace_dict_brackets - for idx, arg in enumerate(node.values): - yield ast_to_offset(arg), _remove_double_star - if idx > 0: - yield ast_to_offset(arg), _replace_comma_with_pipe diff --git a/pyupgrade/_plugins/percent_format.py b/pyupgrade/_plugins/percent_format.py index 8dc2c762..c75d745a 100644 --- a/pyupgrade/_plugins/percent_format.py +++ b/pyupgrade/_plugins/percent_format.py @@ -18,6 +18,7 @@ from pyupgrade._data import register from pyupgrade._data import State from pyupgrade._data import TokenFunc +from pyupgrade._string_helpers import curly_escape from pyupgrade._token_helpers import KEYWORDS from pyupgrade._token_helpers import remove_brace from pyupgrade._token_helpers import victims @@ -120,7 +121,8 @@ def _simplify_conversion_flag(flag: str) -> str: def _percent_to_format(s: str) -> str: def _handle_part(part: PercentFormat) -> str: s, fmt = part - s = s.replace('{', '{{').replace('}', '}}') + s = curly_escape(s) + if fmt is None: return s else: @@ -128,8 +130,6 @@ def _handle_part(part: PercentFormat) -> str: if conversion == '%': return s + '%' parts = [s, '{'] - if width and conversion == 's' and not conversion_flag: - conversion_flag = '>' if conversion == 's': conversion = '' if key: @@ -157,10 +157,6 @@ def _fix_percent_format_tuple( *, node_right: ast.Tuple, ) -> None: - # TODO: handle \N escape sequences - if r'\N' in tokens[i].src: - return - # TODO: this is overly timid paren = i + 4 if tokens_to_src(tokens[i + 1:paren + 1]) != ' % (': @@ -183,10 +179,6 @@ def _fix_percent_format_dict( *, node_right: ast.Dict, ) -> None: - # TODO: handle \N escape sequences - if r'\N' in tokens[i].src: - return - seen_keys: Set[str] = set() keys = {} @@ -276,6 +268,9 @@ def visit_BinOp( # no equivalent in format if conversion in {'a', 'r'} and nontrivial_fmt: break + # %s with None and width is not supported + if width and conversion == 's': + break # all dict substitutions must be named if isinstance(node.right, ast.Dict) and not key: break diff --git a/pyupgrade/_plugins/six_calls.py b/pyupgrade/_plugins/six_calls.py index 3fd30cd7..ac58fd57 100644 --- a/pyupgrade/_plugins/six_calls.py +++ b/pyupgrade/_plugins/six_calls.py @@ -25,7 +25,7 @@ ast.Await, ast.BinOp, ast.BoolOp, ast.Compare, ast.GeneratorExp, ast.IfExp, ast.Lambda, ast.UnaryOp, ) -if sys.version_info >= (3, 8): # pragma: no cover (py38+) +if sys.version_info >= (3, 8): # pragma: >=3.8 cover _EXPR_NEEDS_PARENS += (ast.NamedExpr,) SIX_CALLS = { @@ -51,7 +51,7 @@ 'assertRegex': '{args[0]}.assertRegex({rest})', } SIX_INT2BYTE_TMPL = 'bytes(({args[0]},))' -RAISE_FROM_TMPL = 'raise {args[0]} from {rest}' +RAISE_FROM_TMPL = 'raise {args[0]} from {args[1]}' RERAISE_TMPL = 'raise' RERAISE_2_TMPL = 'raise {args[1]}.with_traceback(None)' RERAISE_3_TMPL = 'raise {args[1]}.with_traceback({args[2]})' @@ -172,13 +172,21 @@ def visit_Call( ('reraise',), ) ): - if len(node.args) == 2 and not has_starargs(node): + if ( + len(node.args) == 2 and + not node.keywords and + not has_starargs(node) + ): func = functools.partial( find_and_replace_call, template=RERAISE_2_TMPL, ) yield ast_to_offset(node), func - elif len(node.args) == 3 and not has_starargs(node): + elif ( + len(node.args) == 3 and + not node.keywords and + not has_starargs(node) + ): func = functools.partial( find_and_replace_call, template=RERAISE_3_TMPL, @@ -186,6 +194,7 @@ def visit_Call( yield ast_to_offset(node), func elif ( len(node.args) == 1 and + not node.keywords and isinstance(node.args[0], ast.Starred) and isinstance(node.args[0].value, ast.Call) and is_name_attr( diff --git a/pyupgrade/_plugins/typing_pep563.py b/pyupgrade/_plugins/typing_pep563.py index e16da37a..2cf7eba6 100644 --- a/pyupgrade/_plugins/typing_pep563.py +++ b/pyupgrade/_plugins/typing_pep563.py @@ -94,18 +94,15 @@ def _process_call(node: ast.Call) -> Iterable[ast.AST]: def _process_subscript(node: ast.Subscript) -> Iterable[ast.AST]: name = _get_name(node.value) if name == 'Annotated': - if sys.version_info >= (3, 9): # pragma: no cover (py39+) - node_slice: ast.expr = node.slice - elif isinstance(node.slice, ast.Index): # pragma: no cover (= (3, 9): # pragma: >=3.9 cover + node_slice = node.slice + elif isinstance(node.slice, ast.Index): # pragma: <3.9 cover + node_slice: ast.AST = node.slice.value + else: # pragma: <3.9 cover + node_slice = node.slice + + if isinstance(node_slice, ast.Tuple) and node_slice.elts: + yield node_slice.elts[0] elif name != 'Literal': yield node.slice diff --git a/pyupgrade/_plugins/typing_pep604.py b/pyupgrade/_plugins/typing_pep604.py index 6ffcc205..fdf803fd 100644 --- a/pyupgrade/_plugins/typing_pep604.py +++ b/pyupgrade/_plugins/typing_pep604.py @@ -126,6 +126,15 @@ def _supported_version(state: State) -> bool: ) +def _any_arg_is_str(node_slice: ast.expr) -> bool: + return ( + isinstance(node_slice, ast.Str) or ( + isinstance(node_slice, ast.Tuple) and + any(isinstance(elt, ast.Str) for elt in node_slice.elts) + ) + ) + + @register(ast.Subscript) def visit_Subscript( state: State, @@ -135,15 +144,29 @@ def visit_Subscript( if not _supported_version(state): return + # prevent rewriting forward annotations + if ( + (sys.version_info >= (3, 9) and _any_arg_is_str(node.slice)) or + ( + sys.version_info < (3, 9) and + isinstance(node.slice, ast.Index) and + _any_arg_is_str(node.slice.value) + ) + ): + return + if is_name_attr(node.value, state.from_imports, 'typing', ('Optional',)): yield ast_to_offset(node), _fix_optional elif is_name_attr(node.value, state.from_imports, 'typing', ('Union',)): - if sys.version_info >= (3, 9): # pragma: no cover (py39+) - node_slice: ast.expr = node.slice - elif isinstance(node.slice, ast.Index): # pragma: no cover (= (3, 9): # pragma: >=3.9 cover + node_slice = node.slice + elif isinstance(node.slice, ast.Index): # pragma: <3.9 cover + node_slice: ast.AST = node.slice.value + else: # pragma: <3.9 cover + node_slice = node.slice # unexpected slice type + + if isinstance(node_slice, ast.Slice): # not a valid annotation + return if isinstance(node_slice, ast.Tuple): if node_slice.elts: diff --git a/pyupgrade/_plugins/unittest_aliases.py b/pyupgrade/_plugins/unittest_aliases.py new file mode 100644 index 00000000..3030ee8a --- /dev/null +++ b/pyupgrade/_plugins/unittest_aliases.py @@ -0,0 +1,60 @@ +import ast +import functools +from typing import Iterable +from typing import Tuple + +from tokenize_rt import Offset + +from pyupgrade._ast_helpers import ast_to_offset +from pyupgrade._data import register +from pyupgrade._data import State +from pyupgrade._data import TokenFunc +from pyupgrade._token_helpers import replace_name + + +METHOD_MAPPING_PY27 = { + 'assertEquals': 'assertEqual', + 'failUnlessEqual': 'assertEqual', + 'failIfEqual': 'assertNotEqual', + 'failUnless': 'assertTrue', + 'assert_': 'assertTrue', + 'failIf': 'assertFalse', + 'failUnlessRaises': 'assertRaises', + 'failUnlessAlmostEqual': 'assertAlmostEqual', + 'failIfAlmostEqual': 'assertNotAlmostEqual', +} + +METHOD_MAPPING_PY35_PLUS = { + **METHOD_MAPPING_PY27, + 'assertNotEquals': 'assertNotEqual', + 'assertAlmostEquals': 'assertAlmostEqual', + 'assertNotAlmostEquals': 'assertNotAlmostEqual', + 'assertRegexpMatches': 'assertRegex', + 'assertNotRegexpMatches': 'assertNotRegex', + 'assertRaisesRegexp': 'assertRaisesRegex', +} + + +@register(ast.Call) +def visit_Call( + state: State, + node: ast.Call, + parent: ast.AST, +) -> Iterable[Tuple[Offset, TokenFunc]]: + if state.settings.min_version >= (3,): + method_mapping = METHOD_MAPPING_PY35_PLUS + else: + method_mapping = METHOD_MAPPING_PY27 + + if ( + isinstance(node.func, ast.Attribute) and + isinstance(node.func.value, ast.Name) and + node.func.value.id == 'self' and + node.func.attr in method_mapping + ): + func = functools.partial( + replace_name, + name=node.func.attr, + new=f'self.{method_mapping[node.func.attr]}', + ) + yield ast_to_offset(node.func), func diff --git a/pyupgrade/_plugins/unpack_list_comprehension.py b/pyupgrade/_plugins/unpack_list_comprehension.py index b372813a..f25d1625 100644 --- a/pyupgrade/_plugins/unpack_list_comprehension.py +++ b/pyupgrade/_plugins/unpack_list_comprehension.py @@ -7,6 +7,7 @@ from tokenize_rt import Token from pyupgrade._ast_helpers import ast_to_offset +from pyupgrade._ast_helpers import is_async_listcomp from pyupgrade._data import register from pyupgrade._data import State from pyupgrade._data import TokenFunc @@ -31,6 +32,7 @@ def visit_Assign( state.settings.min_version >= (3,) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Tuple) and - isinstance(node.value, ast.ListComp) + isinstance(node.value, ast.ListComp) and + not is_async_listcomp(node.value) ): yield ast_to_offset(node.value), _replace_list_comprehension diff --git a/pyupgrade/_plugins/unpacking_argument_list_comprehensions.py b/pyupgrade/_plugins/unpacking_argument_list_comprehensions.py deleted file mode 100644 index 7b94032f..00000000 --- a/pyupgrade/_plugins/unpacking_argument_list_comprehensions.py +++ /dev/null @@ -1,24 +0,0 @@ -import ast -from typing import Iterable -from typing import Tuple - -from tokenize_rt import Offset - -from pyupgrade._ast_helpers import ast_to_offset -from pyupgrade._data import register -from pyupgrade._data import State -from pyupgrade._data import TokenFunc -from pyupgrade._token_helpers import replace_list_comp_brackets - - -@register(ast.Starred) -def visit_Starred( - state: State, - node: ast.Starred, - parent: ast.AST, -) -> Iterable[Tuple[Offset, TokenFunc]]: - if ( - state.settings.min_version >= (3,) and - isinstance(node.value, ast.ListComp) - ): - yield ast_to_offset(node.value), replace_list_comp_brackets diff --git a/pyupgrade/_plugins/versioned_branches.py b/pyupgrade/_plugins/versioned_branches.py index df66b744..6cc7a1c4 100644 --- a/pyupgrade/_plugins/versioned_branches.py +++ b/pyupgrade/_plugins/versioned_branches.py @@ -14,6 +14,7 @@ from pyupgrade._data import register from pyupgrade._data import State from pyupgrade._data import TokenFunc +from pyupgrade._data import Version from pyupgrade._token_helpers import Block @@ -77,6 +78,7 @@ def _eq(test: ast.Compare, n: int) -> bool: def _compare_to_3( test: ast.Compare, op: Union[Type[ast.cmpop], Tuple[Type[ast.cmpop], ...]], + minor: int = 0, ) -> bool: if not ( isinstance(test.ops[0], op) and @@ -87,9 +89,11 @@ def _compare_to_3( return False # checked above but mypy needs help - elts = cast('List[ast.Num]', test.comparators[0].elts) + ast_elts = cast('List[ast.Num]', test.comparators[0].elts) + # padding a 0 for compatibility with (3,) used as a spec + elts = tuple(e.n for e in ast_elts) + (0,) - return elts[0].n == 3 and all(n.n == 0 for n in elts[1:]) + return elts[:2] == (3, minor) and all(n == 0 for n in elts[2:]) @register(ast.If) @@ -98,8 +102,16 @@ def visit_If( node: ast.If, parent: ast.AST, ) -> Iterable[Tuple[Offset, TokenFunc]]: + + min_version: Version + if state.settings.min_version == (3,): + min_version = (3, 0) + else: + min_version = state.settings.min_version + assert len(min_version) >= 2 + if ( - state.settings.min_version >= (3,) and ( + min_version >= (3,) and ( # if six.PY2: is_name_attr(node.test, state.from_imports, 'six', ('PY2',)) or # if not six.PY3: @@ -114,6 +126,7 @@ def visit_If( ) ) or # sys.version_info == 2 or < (3,) + # or < (3, n) or <= (3, n) (with n= (3,) and ( + min_version >= (3,) and ( # if six.PY3: is_name_attr(node.test, state.from_imports, 'six', ('PY3',)) or # if not six.PY2: @@ -147,6 +164,8 @@ def visit_If( ) ) or # sys.version_info == 3 or >= (3,) or > (3,) + # sys.version_info >= (3, n) (with n<=m) + # or sys.version_info > (3, n) (with n= (3, 7): # pragma: no cover (py37+) +if sys.version_info >= (3, 7): # pragma: >=3.7 cover is_ascii = str.isascii -else: # pragma: no cover ( bool: return all(c in string.printable for c in s) +NAMED_UNICODE_RE = re.compile(r'(? str: + parts = NAMED_UNICODE_RE.split(s) + return ''.join( + part.replace('{', '{{').replace('}', '}}') + if not NAMED_UNICODE_RE.fullmatch(part) + else part + for part in parts + ) + def is_codec(encoding: str, name: str) -> bool: try: diff --git a/pyupgrade/_token_helpers.py b/pyupgrade/_token_helpers.py index 66232bda..a076a356 100644 --- a/pyupgrade/_token_helpers.py +++ b/pyupgrade/_token_helpers.py @@ -65,14 +65,14 @@ def find_end(tokens: List[Token], i: int) -> int: return i -if sys.version_info >= (3, 8): # pragma: no cover (py38+) +if sys.version_info >= (3, 8): # pragma: >=3.8 cover # python 3.8 fixed the offsets of generators / tuples def _arg_token_index(tokens: List[Token], i: int, arg: ast.expr) -> int: idx = _search_until(tokens, i, arg) + 1 while idx < len(tokens) and tokens[idx].name in NON_CODING_TOKENS: idx += 1 return idx -else: # pragma: no cover ( int: # lists containing non-tuples report the first element correctly if isinstance(arg, ast.List): @@ -195,7 +195,9 @@ def _minimum_indent(self, tokens: List[Token]) -> int: for i in range(self.block, self.end): if ( tokens[i - 1].name in ('NL', 'NEWLINE') and - tokens[i].name in ('INDENT', UNIMPORTANT_WS) + tokens[i].name in ('INDENT', UNIMPORTANT_WS) and + # comments can have arbitrary indentation so ignore them + tokens[i + 1].name != 'COMMENT' ): token_indent = len(tokens[i].src) if block_indent is None: @@ -209,13 +211,17 @@ def _minimum_indent(self, tokens: List[Token]) -> int: def dedent(self, tokens: List[Token]) -> None: if self.line: return - diff = self._minimum_indent(tokens) - self._initial_indent(tokens) + initial_indent = self._initial_indent(tokens) + diff = self._minimum_indent(tokens) - initial_indent for i in range(self.block, self.end): if ( tokens[i - 1].name in ('DEDENT', 'NL', 'NEWLINE') and tokens[i].name in ('INDENT', UNIMPORTANT_WS) ): - tokens[i] = tokens[i]._replace(src=tokens[i].src[diff:]) + # make sure we preserve *at least* the initial indent + s = tokens[i].src + s = s[:initial_indent] + s[initial_indent + diff:] + tokens[i] = tokens[i]._replace(src=s) def replace_condition(self, tokens: List[Token], new: List[Token]) -> None: start = self.start @@ -390,6 +396,16 @@ def arg_str(tokens: List[Token], start: int, end: int) -> str: return tokens_to_src(tokens[start:end]).strip() +def _arg_contains_newline(tokens: List[Token], start: int, end: int) -> bool: + while tokens[start].name in {'NL', 'NEWLINE', UNIMPORTANT_WS}: + start += 1 + for i in range(start, end): + if tokens[i].name in {'NL', 'NEWLINE'}: + return True + else: + return False + + def replace_call( tokens: List[Token], start: int, @@ -403,6 +419,14 @@ def replace_call( for paren in parens: arg_strs[paren] = f'({arg_strs[paren]})' + # there are a few edge cases which cause syntax errors when the first + # argument contains newlines (especially when moved outside of a natural + # contiunuation context) + if _arg_contains_newline(tokens, *args[0]) and 0 not in parens: + # this attempts to preserve more of the whitespace by using the + # original non-stripped argument string + arg_strs[0] = f'({tokens_to_src(tokens[slice(*args[0])])})' + start_rest = args[0][1] + 1 while ( start_rest < end and @@ -478,12 +502,12 @@ def replace_argument( def find_comprehension_opening_bracket(i: int, tokens: List[Token]) -> int: """Find opening bracket of comprehension given first argument.""" - if sys.version_info < (3, 8): # pragma: no cover (py38+) + if sys.version_info < (3, 8): # pragma: <3.8 cover i -= 1 while not (tokens[i].name == 'OP' and tokens[i].src == '[') and i: i -= 1 return i - else: # pragma: no cover (=3.8 cover return i diff --git a/requirements-dev.txt b/requirements-dev.txt index 0c5a37eb..2698f6cf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,3 @@ -covdefaults +covdefaults>=2.1.0 coverage pytest diff --git a/setup.cfg b/setup.cfg index 00ae4265..4253699b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pyupgrade -version = 2.23.1 +version = 2.31.0 description = A tool to automatically upgrade syntax for newer versions. long_description = file: README.md long_description_content_type = text/markdown @@ -17,6 +17,7 @@ classifiers = Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy diff --git a/tests/features/abspath_file_test.py b/tests/features/abspath_file_test.py new file mode 100644 index 00000000..dabbaa2a --- /dev/null +++ b/tests/features/abspath_file_test.py @@ -0,0 +1,52 @@ +import pytest + +from pyupgrade._data import Settings +from pyupgrade._main import _fix_plugins + + +@pytest.mark.parametrize( + ('s', 'expected'), + ( + pytest.param( + 'from os.path import abspath\n' + 'abspath(__file__)', + 'from os.path import abspath\n' + '__file__', + id='abspath', + ), + pytest.param( + 'import os\n' + 'os.path.abspath(__file__)', + 'import os\n' + '__file__', + id='os.path.abspath', + ), + ), +) +def test_fix_abspath_file(s, expected): + ret = _fix_plugins(s, settings=Settings(min_version=(3, 9))) + assert ret == expected + + +@pytest.mark.parametrize( + 's, min_version', + ( + pytest.param( + 'abspath(__file__)', + (3, 8), + id='Not Python3.9+', + ), + pytest.param( + 'os.path.abspath(file)', + (3, 9), + id='Abspath of not-__file__', + ), + pytest.param( + 'os.path.abspath(file, foo)', + (3, 9), + id='Garbage (don\'t rewrite)', + ), + ), +) +def test_fix_abspath_file_noop(s, min_version): + assert _fix_plugins(s, settings=Settings(min_version=min_version)) == s diff --git a/tests/features/format_literals_test.py b/tests/features/format_literals_test.py index bc00bd17..391fb40d 100644 --- a/tests/features/format_literals_test.py +++ b/tests/features/format_literals_test.py @@ -16,6 +16,14 @@ def test_roundtrip_text(s): assert unparse_parsed_string(parse_format(s)) == s +def test_parse_format_starts_with_named(): + # technically not possible since our string always starts with quotes + assert parse_format(r'\N{snowman} hi {0} hello') == ( + (r'\N{snowman} hi ', '0', '', None), + (' hello', None, None, None), + ) + + @pytest.mark.parametrize( ('s', 'expected'), ( @@ -49,8 +57,6 @@ def test_intentionally_not_round_trip(s, expected): "'{' '0}'.format(1)", # comment looks like placeholder but is not! '("{0}" # {1}\n"{2}").format(1, 2, 3)', - # TODO: this works by accident (extended escape treated as placeholder) - r'"\N{snowman} {}".format(1)', # don't touch f-strings (these are wrong but don't make it worse) 'f"{0}".format(a)', ), @@ -101,6 +107,11 @@ def test_format_literals_noop(s): ), # parenthesized string literals ('("{0}").format(1)', '("{}").format(1)'), + pytest.param( + r'"\N{snowman} {0}".format(1)', + r'"\N{snowman} {}".format(1)', + id='named escape sequence', + ), ), ) def test_format_literals(s, expected): diff --git a/tests/features/format_locals_test.py b/tests/features/format_locals_test.py new file mode 100644 index 00000000..b761841b --- /dev/null +++ b/tests/features/format_locals_test.py @@ -0,0 +1,53 @@ +import pytest + +from pyupgrade._data import Settings +from pyupgrade._main import _fix_plugins + + +@pytest.mark.parametrize( + ('s', 'version'), + ( + pytest.param( + '"{x}".format(**locals())', + (3,), + id='not 3.6+', + ), + pytest.param( + '"{x} {y}".format(x, **locals())', + (3, 6), + id='mixed locals() and params', + ), + ), +) +def test_fix_format_locals_noop(s, version): + assert _fix_plugins(s, settings=Settings(min_version=version)) == s + + +@pytest.mark.parametrize( + ('s', 'expected'), + ( + pytest.param( + '"{x}".format(**locals())', + 'f"{x}"', + id='normal case', + ), + pytest.param( + '"{x}" "{y}".format(**locals())', + 'f"{x}" f"{y}"', + id='joined strings', + ), + pytest.param( + '(\n' + ' "{x}"\n' + ' "{y}"\n' + ').format(**locals())\n', + '(\n' + ' f"{x}"\n' + ' f"{y}"\n' + ')\n', + id='joined strings with parens', + ), + ), +) +def test_fix_format_locals(s, expected): + assert _fix_plugins(s, settings=Settings(min_version=(3, 6))) == expected diff --git a/tests/features/fstrings_test.py b/tests/features/fstrings_test.py index 3fd54c37..425b84e2 100644 --- a/tests/features/fstrings_test.py +++ b/tests/features/fstrings_test.py @@ -26,8 +26,6 @@ '"{:{}}".format(x, y)', '"{a[b]}".format(a=a)', '"{a.a[b]}".format(a=a)', - # TODO: handle \N escape sequences - r'"\N{snowman} {}".format(a)', # not enough placeholders / placeholders missing '"{}{}".format(a)', '"{a}{b}".format(a=a)', # backslashes and quotes cannot nest @@ -58,8 +56,11 @@ def test_fix_fstrings_noop(s): ('"{}{{}}{}".format(escaped, y)', 'f"{escaped}{{}}{y}"'), ('"{}{b}{}".format(a, c, b=b)', 'f"{a}{b}{c}"'), ('"{}".format(0x0)', 'f"{0x0}"'), - # TODO: poor man's f-strings? - # '"{foo}".format(**locals())' + pytest.param( + r'"\N{snowman} {}".format(a)', + r'f"\N{snowman} {a}"', + id='named escape sequences', + ), ), ) def test_fix_fstrings(s, expected): diff --git a/tests/features/pep584_test.py b/tests/features/pep584_test.py deleted file mode 100644 index 357b915a..00000000 --- a/tests/features/pep584_test.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest - -from pyupgrade._data import Settings -from pyupgrade._main import _fix_plugins - - -@pytest.mark.parametrize( - ('s', 'version'), - ( - pytest.param( - '{**a, **b}\n', - (3, 8), - id='<3.9', - ), - pytest.param( - '{"a": 0}\n', - (3, 9), - id='Dict without merge', - ), - pytest.param( - 'x = {**a}\n', - (3, 9), - id='Merge of only one dict', - ), - ), -) -def test_fix_pep584_noop(s, version): - assert _fix_plugins(s, settings=Settings(min_version=version)) == s - - -@pytest.mark.parametrize( - ('s', 'expected'), - ( - pytest.param( - 'x = {**a, **b}\n', - - 'x = a | b\n', - - id='Simple dict rewrite', - ), - pytest.param( - 'x = {**{**a, **b}, **c}\n', - - 'x = a | b | c\n', - - id='Nested merge of dicts', - ), - pytest.param( - 'x = {**a, **b,}\n', - - 'x = a | b\n', - - id='Trailing comma', - ), - pytest.param( - 'x = {\n' - ' **a, # foo\n' - ' **b # bar\n' - '}\n', - - 'x = (\n' - ' a | # foo\n' - ' b # bar\n' - ')\n', - - id='Multiple lines with comment', - ), - pytest.param( - 'x = {\n' - ' **a,\n' - ' **b\n' - '}\n', - - 'x = (\n' - ' a |\n' - ' b\n' - ')\n', - - id='Multiple lines', - ), - pytest.param( - 'x = {\n' - ' **a,\n' - ' **b,\n' - '}\n', - - 'x = (\n' - ' a |\n' - ' b\n' - ')\n', - - id='Multiple lines, trailing comma', - ), - pytest.param( - 'x = {\n' - ' **{a: a for a in range(3)},\n' - ' **b,\n' - '}\n', - - 'x = (\n' - ' {a: a for a in range(3)} |\n' - ' b\n' - ')\n', - - id='Dict comprehension within merge of dicts', - ), - ), -) -def test_fix_pep584(s, expected): - assert _fix_plugins(s, settings=Settings(min_version=(3, 9))) == expected diff --git a/tests/features/percent_format_test.py b/tests/features/percent_format_test.py index 57ca3c65..536435e9 100644 --- a/tests/features/percent_format_test.py +++ b/tests/features/percent_format_test.py @@ -158,6 +158,7 @@ def test_simplify_conversion_flag(s, expected): '"%4%" % ()', # no equivalent in format specifier '"%.2r" % (1.25)', '"%.2a" % (1.25)', + pytest.param('"%8s" % (None,)', id='unsafe width-string conversion'), # non-string mod 'i % 3', # dict format but not keyed arguments @@ -177,9 +178,6 @@ def test_simplify_conversion_flag(s, expected): '"%(and)s" % {"and": 2}', # invalid string formats '"%" % {}', '"%(hi)" % {}', '"%2" % {}', - # TODO: handle \N escape sequences - r'"%s \N{snowman}" % (a,)', - r'"%(foo)s \N{snowman}" % {"foo": 1}', ), ) def test_percent_format_noop(s): @@ -208,8 +206,8 @@ def test_percent_format_noop_if_bug_16806(): ('"%s" % ("%s" % ("nested",),)', '"{}".format("{}".format("nested"))'), ('"%s%% percent" % (15,)', '"{}% percent".format(15)'), ('"%3f" % (15,)', '"{:3f}".format(15)'), - ('"%-5s" % ("hi",)', '"{:<5}".format("hi")'), - ('"%9s" % (5,)', '"{:>9}".format(5)'), + ('"%-5f" % (5,)', '"{:<5f}".format(5)'), + ('"%9f" % (5,)', '"{:9f}".format(5)'), ('"brace {} %s" % (1,)', '"brace {{}} {}".format(1)'), ( '"%s" % (\n' @@ -222,6 +220,15 @@ def test_percent_format_noop_if_bug_16806(): # dict ('"%(k)s" % {"k": "v"}', '"{k}".format(k="v")'), ('"%(to_list)s" % {"to_list": []}', '"{to_list}".format(to_list=[])'), + # \N escapes + ( + r'"%s \N{snowman}" % (a,)', + r'"{} \N{snowman}".format(a)', + ), + ( + r'"%(foo)s \N{snowman}" % {"foo": 1}', + r'"{foo} \N{snowman}".format(foo=1)', + ), ), ) def test_percent_format(s, expected): diff --git a/tests/features/six_test.py b/tests/features/six_test.py index 71d41c8b..4ea3f5b5 100644 --- a/tests/features/six_test.py +++ b/tests/features/six_test.py @@ -24,6 +24,7 @@ 'class C(six.with_metaclass(Meta, B), D): pass', # cannot determine args to rewrite them 'six.reraise(*err)', 'six.u(*a)', + 'six.reraise(a, b, tb=c)', 'class C(six.with_metaclass(*a)): pass', '@six.add_metaclass(*a)\n' 'class C: pass\n', @@ -126,6 +127,16 @@ def test_fix_six_noop(s): 'six.raise_from(exc, exc_from)\n', 'raise exc from exc_from\n', ), + pytest.param( + 'six.raise_from(\n' + ' e,\n' + ' f,\n' + ')', + + 'raise e from f', + + id='six raise_from across multiple lines', + ), ( 'six.reraise(tp, exc, tb)\n', 'raise exc.with_traceback(tb)\n', @@ -347,6 +358,28 @@ def test_fix_six_noop(s): '(x < y).values()', id='needs parentehsizing for Compare', ), + pytest.param( + 'x = six.itervalues(\n' + ' # comment\n' + ' x\n' + ')', + 'x = (\n' + ' # comment\n' + ' x\n' + ').values()', + id='multiline first argument with comment', + ), + pytest.param( + 'x = six.itervalues(\n' + ' # comment\n' + ' x,\n' + ')', + # TODO: ideally this would preserve whitespace better + 'x = (\n' + ' # comment\n' + ' x).values()', + id='multiline first argument with comment, trailing comma', + ), ), ) def test_fix_six(s, expected): diff --git a/tests/features/typing_pep563_test.py b/tests/features/typing_pep563_test.py index e7b82d93..f2ee80c8 100644 --- a/tests/features/typing_pep563_test.py +++ b/tests/features/typing_pep563_test.py @@ -57,6 +57,12 @@ (3,), id='Kwonly, untyped', ), + pytest.param( + 'from __future__ import annotations\n' + 'x: Annotated[1:2] = ...\n', + (3,), + id='Annotated with invalid slice', + ), ), ) def test_fix_typing_pep563_noop(s, version): diff --git a/tests/features/typing_pep604_test.py b/tests/features/typing_pep604_test.py index 503bc2af..d27c7951 100644 --- a/tests/features/typing_pep604_test.py +++ b/tests/features/typing_pep604_test.py @@ -41,6 +41,25 @@ (3, 10), id='3.10+ empty Union', ), + # https://github.com/asottile/pyupgrade/issues/567 + pytest.param( + 'from typing import Optional\n' + 'def f() -> Optional["str"]: ...\n', + (3, 10), + id='3.10+ Optional of forward reference', + ), + pytest.param( + 'from typing import Union\n' + 'def f() -> Union[int, "str"]: ...\n', + (3, 10), + id='3.10+ Union of forward reference', + ), + pytest.param( + 'from typing import Union\n' + 'def f() -> Union[1:2]: ...\n', + (3, 10), + id='invalid Union slicing', + ), ), ) def test_fix_pep604_types_noop(s, version): diff --git a/tests/features/typing_typed_dict_test.py b/tests/features/typing_typed_dict_test.py index 0ead5842..b3a4bf1c 100644 --- a/tests/features/typing_typed_dict_test.py +++ b/tests/features/typing_typed_dict_test.py @@ -134,6 +134,22 @@ def test_typing_typed_dict_noop(s): id='index unparse error', ), + pytest.param( + 'import typing\n' + 'if True:\n' + ' if False:\n' + ' pass\n' + ' D = typing.TypedDict("D", a=int)\n', + + 'import typing\n' + 'if True:\n' + ' if False:\n' + ' pass\n' + ' class D(typing.TypedDict):\n' + ' a: int\n', + + id='right after a dedent', + ), ), ) def test_typing_typed_dict(s, expected): diff --git a/tests/features/unittest_aliases_test.py b/tests/features/unittest_aliases_test.py new file mode 100644 index 00000000..1dc6dfd0 --- /dev/null +++ b/tests/features/unittest_aliases_test.py @@ -0,0 +1,63 @@ +import pytest + +from pyupgrade._data import Settings +from pyupgrade._main import _fix_plugins + + +@pytest.mark.parametrize( + ('s',), + ( + pytest.param( + 'class ExampleTests:\n' + ' def test_something(self):\n' + ' self.assertEqual(1, 1)\n', + id='not a deprecated alias', + ), + pytest.param( + 'class ExampleTests:\n' + ' def test_something(self):\n' + ' self.assertNotEquals(1, 2)\n', + id='not python 3+', + ), + ), +) +def test_fix_unittest_aliases_noop(s): + assert _fix_plugins(s, settings=Settings(min_version=(2, 7))) == s + + +@pytest.mark.parametrize( + ('s', 'expected'), + ( + ( + 'class ExampleTests:\n' + ' def test_something(self):\n' + ' self.assertEquals(1, 1)\n', + + 'class ExampleTests:\n' + ' def test_something(self):\n' + ' self.assertEqual(1, 1)\n', + ), + ), +) +def test_fix_unittest_aliases_py27(s, expected): + ret = _fix_plugins(s, settings=Settings(min_version=(2, 7))) + assert ret == expected + + +@pytest.mark.parametrize( + ('s', 'expected'), + ( + ( + 'class ExampleTests:\n' + ' def test_something(self):\n' + ' self.assertNotEquals(1, 2)\n', + + 'class ExampleTests:\n' + ' def test_something(self):\n' + ' self.assertNotEqual(1, 2)\n', + ), + ), +) +def test_fix_unittest_aliases_py3(s, expected): + ret = _fix_plugins(s, settings=Settings(min_version=(3,))) + assert ret == expected diff --git a/tests/features/unpack_list_comprehension_test.py b/tests/features/unpack_list_comprehension_test.py index c264d35c..75797e77 100644 --- a/tests/features/unpack_list_comprehension_test.py +++ b/tests/features/unpack_list_comprehension_test.py @@ -17,6 +17,11 @@ (3,), id='assignment to single variable', ), + pytest.param( + 'x, = [await foo for foo in bar]', + (3,), + id='async comprehension', + ), ), ) def test_fix_typing_text_noop(s, version): diff --git a/tests/features/unpacking_argument_list_comprehensions_test.py b/tests/features/unpacking_argument_list_comprehensions_test.py deleted file mode 100644 index ce4487c9..00000000 --- a/tests/features/unpacking_argument_list_comprehensions_test.py +++ /dev/null @@ -1,75 +0,0 @@ -import pytest - -from pyupgrade._data import Settings -from pyupgrade._main import _fix_plugins - - -@pytest.mark.parametrize( - ('s', 'version'), - ( - pytest.param( - 'foo(*[i for i in bar])\n', - (2, 7), - id='Not Python3+', - ), - pytest.param( - '2*3', - (3,), - id='Multiplication star', - ), - pytest.param( - '2**3', - (3,), - id='Power star', - ), - pytest.param( - 'foo([i for i in bar])', - (3,), - id='List comp, no star', - ), - pytest.param( - 'foo(*bar)', - (3,), - id='Starred, no list comp', - ), - ), -) -def test_fix_unpack_argument_list_comp_noop(s, version): - assert _fix_plugins(s, settings=Settings(min_version=version)) == s - - -@pytest.mark.parametrize( - ('s', 'expected'), - ( - pytest.param( - 'foo(*[i for i in bar])\n', - - 'foo(*(i for i in bar))\n', - - id='Starred list comprehension', - ), - pytest.param( - 'foo(\n' - ' *\n' - ' [i for i in bar]\n' - ' )\n', - - 'foo(\n' - ' *\n' - ' (i for i in bar)\n' - ' )\n', - - id='Multiline starred list comprehension', - ), - pytest.param( - 'foo(*[i for i in bar], qux, quox=None)\n', - - 'foo(*(i for i in bar), qux, quox=None)\n', - - id='Single line, including other args', - ), - ), -) -def test_fix_unpack_argument_list_comp(s, expected): - ret = _fix_plugins(s, settings=Settings((3,))) - assert ret == expected diff --git a/tests/features/versioned_branches_test.py b/tests/features/versioned_branches_test.py index b135fd90..2cd269e0 100644 --- a/tests/features/versioned_branches_test.py +++ b/tests/features/versioned_branches_test.py @@ -430,6 +430,20 @@ def test_fix_py2_block_noop(s): id='elif six.PY3 no else, indented', ), + pytest.param( + 'if True:\n' + ' if sys.version_info > (3,):\n' + ' print(3)\n' + ' # comment\n' + ' print(2+3)\n', + + 'if True:\n' + ' print(3)\n' + ' # comment\n' + ' print(2+3)\n', + + id='comment after dedented block', + ), ), ) def test_fix_py2_blocks(s, expected): @@ -452,3 +466,155 @@ def test_fix_py2_blocks(s, expected): def test_fix_py3_only_code(s, expected): ret = _fix_plugins(s, settings=Settings(min_version=(3,))) assert ret == expected + + +@pytest.mark.parametrize( + ('s', 'expected'), + ( + pytest.param( + 'import sys\n' + 'if sys.version_info > (3, 5):\n' + ' 3+6\n' + 'else:\n' + ' 3-5\n', + + 'import sys\n' + '3+6\n', + id='sys.version_info > (3, 5)', + ), + pytest.param( + 'from sys import version_info\n' + 'if version_info > (3, 5):\n' + ' 3+6\n' + 'else:\n' + ' 3-5\n', + + 'from sys import version_info\n' + '3+6\n', + id='from sys import version_info, > (3, 5)', + ), + pytest.param( + 'import sys\n' + 'if sys.version_info >= (3, 6):\n' + ' 3+6\n' + 'else:\n' + ' 3-5\n', + + 'import sys\n' + '3+6\n', + id='sys.version_info >= (3, 6)', + ), + pytest.param( + 'from sys import version_info\n' + 'if version_info >= (3, 6):\n' + ' 3+6\n' + 'else:\n' + ' 3-5\n', + + 'from sys import version_info\n' + '3+6\n', + id='from sys import version_info, >= (3, 6)', + ), + pytest.param( + 'import sys\n' + 'if sys.version_info < (3, 6):\n' + ' 3-5\n' + 'else:\n' + ' 3+6\n', + + 'import sys\n' + '3+6\n', + id='sys.version_info < (3, 6)', + ), + pytest.param( + 'from sys import version_info\n' + 'if version_info < (3, 6):\n' + ' 3-5\n' + 'else:\n' + ' 3+6\n', + + 'from sys import version_info\n' + '3+6\n', + id='from sys import version_info, < (3, 6)', + ), + pytest.param( + 'import sys\n' + 'if sys.version_info <= (3, 5):\n' + ' 3-5\n' + 'else:\n' + ' 3+6\n', + + 'import sys\n' + '3+6\n', + id='sys.version_info <= (3, 5)', + ), + pytest.param( + 'from sys import version_info\n' + 'if version_info <= (3, 5):\n' + ' 3-5\n' + 'else:\n' + ' 3+6\n', + + 'from sys import version_info\n' + '3+6\n', + id='from sys import version_info, <= (3, 5)', + ), + pytest.param( + 'import sys\n' + 'if sys.version_info >= (3, 6):\n' + ' pass', + + 'import sys\n' + 'pass', + id='sys.version_info >= (3, 6), noelse', + ), + ), +) +def test_fix_py3x_only_code(s, expected): + ret = _fix_plugins(s, settings=Settings(min_version=(3, 6))) + assert ret == expected + + +@pytest.mark.parametrize( + 's', + ( + # both branches are still relevant in the following cases + 'import sys\n' + 'if sys.version_info > (3, 7):\n' + ' 3-6\n' + 'else:\n' + ' 3+7\n', + + 'import sys\n' + 'if sys.version_info < (3, 7):\n' + ' 3-6\n' + 'else:\n' + ' 3+7\n', + + 'import sys\n' + 'if sys.version_info >= (3, 7):\n' + ' 3+7\n' + 'else:\n' + ' 3-6\n', + + 'import sys\n' + 'if sys.version_info <= (3, 7):\n' + ' 3-7\n' + 'else:\n' + ' 3+8\n', + + 'import sys\n' + 'if sys.version_info <= (3, 6):\n' + ' 3-6\n' + 'else:\n' + ' 3+7\n', + + 'import sys\n' + 'if sys.version_info > (3, 6):\n' + ' 3+7\n' + 'else:\n' + ' 3-6\n', + ), +) +def test_fix_py3x_only_noop(s): + assert _fix_plugins(s, settings=Settings(min_version=(3, 6))) == s