contrib/check-py3-compat.py
author Gregory Szorc <gregory.szorc@gmail.com>
Mon, 04 Jul 2016 11:18:03 -0700
changeset 29550 1c22400db72d
parent 28584 d69172ddfdca
child 29751 8faac092bb0c
permissions -rwxr-xr-x
mercurial: implement a source transforming module loader on Python 3 The most painful part of ensuring Python code runs on both Python 2 and 3 is string encoding. Making this difficult is that string literals in Python 2 are bytes and string literals in Python 3 are unicode. So, to ensure consistent types are used, you have to use "from __future__ import unicode_literals" and/or prefix literals with their type (e.g. b'foo' or u'foo'). Nearly every string in Mercurial is bytes. So, to use the same source code on both Python 2 and 3 would require prefixing nearly every string literal with "b" to make it a byte literal. This is ugly and not something mpm is willing to do at this point in time. This patch implements a custom module loader on Python 3 that performs source transformation to convert string literals (unicode in Python 3) to byte literals. In effect, it changes Python 3's string literals to behave like Python 2's. In addition, the module loader recognizes well-known built-in functions (getattr, setattr, hasattr) and methods (encode and decode) that barf when bytes are used and prevents these from being rewritten. This prevents excessive source changes to accommodate this change (we would have to rewrite every occurrence of these functions passing string literals otherwise). The module loader is only used on Python packages belonging to Mercurial. The loader works by tokenizing the loaded source and replacing "string" tokens if necessary. The modified token stream is untokenized back to source and loaded like normal. This does add some overhead. However, this all occurs before caching: .pyc files will cache the transformed version. This means the transformation penalty is only paid on first load. As the extensive inline comments explain, the presence of a custom source transformer invalidates assumptions made by Python's built-in bytecode caching mechanism. So, we have to wrap bytecode loading and writing and add an additional header to bytecode files to facilitate additional cache validation when the source transformations change in the future. There are still a few things this code doesn't handle well, namely support for zip files as module sources and for extensions. Since Mercurial doesn't officially support Python 3 yet, I'm inclined to leave these as to-do items: getting a basic module loading mechanism in place to unblock further Python 3 porting effort is more important than comprehensive module importing support. check-py3-compat.py has been updated to ignore frames. This is necessary because CPython has built-in code to strip frames from the built-in importer. When our custom code is present, this doesn't work and the frames get all messed up. The new code is not perfect. It works for now. But once you start chasing import failures you find some edge cases where the files aren't being printed properly. This only burdens people doing future Python 3 porting work so I'm inclined to punt on the issue: the most important thing is for the source transforming module loader to land. There was a bit of churn in test-check-py3-compat.t because we now trip up on str/unicode/bytes failures as a result of source transformation. This is unfortunate but what are you going to do. It's worth noting that other approaches were investigated. We considered using a custom file encoding whose decode() would apply source transformations. This was rejected because it would require each source file to declare its custom Mercurial encoding. Furthermore, when changing the source transformation we'd need to version bump the encoding name otherwise the module caching layer wouldn't know the .pyc file was invalidated. This would mean mass updating every file when the source transformation changes. Yuck. We also considered transforming at the AST layer. However, Python's ast module is quite gnarly and doing AST transforms is quite complicated, even for trivial rewrites. There are whole Python packages that exist to make AST transformations usable. AST transforms would still require import machinery, so the choice was basically to perform source-level, token-level, or ast-level transforms. Token-level rewriting delivers the metadata we need to rewrite intelligently while being relatively easy to understand. So it won. General consensus seems to be that this approach is the best available to avoid bulk rewriting of '' to b''. However, we aren't confident that this approach will never be a future maintenance burden. This approach does unblock serious Python 3 porting efforts. So we can re-evaulate once more work is done to support Python 3.
Ignore whitespace changes - Everywhere: Within whitespace: At end of lines:
27279
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
     1
#!/usr/bin/env python
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
     2
#
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
     3
# check-py3-compat - check Python 3 compatibility of Mercurial files
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
     4
#
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
     5
# Copyright 2015 Gregory Szorc <gregory.szorc@gmail.com>
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
     6
#
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
     7
# This software may be used and distributed according to the terms of the
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
     8
# GNU General Public License version 2 or any later version.
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
     9
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    10
from __future__ import absolute_import, print_function
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    11
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    12
import ast
28584
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    13
import imp
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    14
import os
27279
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    15
import sys
28584
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    16
import traceback
27279
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    17
28583
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    18
def check_compat_py2(f):
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    19
    """Check Python 3 compatibility for a file with Python 2"""
27279
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    20
    with open(f, 'rb') as fh:
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    21
        content = fh.read()
28475
ae522fb493d4 test: make check-py3-compat.py ignore empty code more reliably
Yuya Nishihara <yuya@tcha.org>
parents: 27331
diff changeset
    22
    root = ast.parse(content)
27279
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    23
27331
35e69407b1ac contrib: ignore empty files in check-py3-compat.py
Gregory Szorc <gregory.szorc@gmail.com>
parents: 27279
diff changeset
    24
    # Ignore empty files.
28475
ae522fb493d4 test: make check-py3-compat.py ignore empty code more reliably
Yuya Nishihara <yuya@tcha.org>
parents: 27331
diff changeset
    25
    if not root.body:
27331
35e69407b1ac contrib: ignore empty files in check-py3-compat.py
Gregory Szorc <gregory.szorc@gmail.com>
parents: 27279
diff changeset
    26
        return
35e69407b1ac contrib: ignore empty files in check-py3-compat.py
Gregory Szorc <gregory.szorc@gmail.com>
parents: 27279
diff changeset
    27
27279
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    28
    futures = set()
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    29
    haveprint = False
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    30
    for node in ast.walk(root):
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    31
        if isinstance(node, ast.ImportFrom):
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    32
            if node.module == '__future__':
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    33
                futures |= set(n.name for n in node.names)
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    34
        elif isinstance(node, ast.Print):
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    35
            haveprint = True
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    36
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    37
    if 'absolute_import' not in futures:
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    38
        print('%s not using absolute_import' % f)
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    39
    if haveprint and 'print_function' not in futures:
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    40
        print('%s requires print_function' % f)
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    41
28583
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    42
def check_compat_py3(f):
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    43
    """Check Python 3 compatibility of a file with Python 3."""
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    44
    with open(f, 'rb') as fh:
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    45
        content = fh.read()
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    46
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    47
    try:
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    48
        ast.parse(content)
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    49
    except SyntaxError as e:
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    50
        print('%s: invalid syntax: %s' % (f, e))
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    51
        return
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    52
28584
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    53
    # Try to import the module.
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    54
    # For now we only support mercurial.* and hgext.* modules because figuring
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    55
    # out module paths for things not in a package can be confusing.
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    56
    if f.startswith(('hgext/', 'mercurial/')) and not f.endswith('__init__.py'):
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    57
        assert f.endswith('.py')
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    58
        name = f.replace('/', '.')[:-3]
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    59
        with open(f, 'r') as fh:
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    60
            try:
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    61
                imp.load_module(name, fh, '', ('py', 'r', imp.PY_SOURCE))
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    62
            except Exception as e:
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    63
                exc_type, exc_value, tb = sys.exc_info()
29550
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    64
                # We walk the stack and ignore frames from our custom importer,
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    65
                # import mechanisms, and stdlib modules. This kinda/sorta
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    66
                # emulates CPython behavior in import.c while also attempting
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    67
                # to pin blame on a Mercurial file.
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    68
                for frame in reversed(traceback.extract_tb(tb)):
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    69
                    if frame.name == '_call_with_frames_removed':
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    70
                        continue
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    71
                    if 'importlib' in frame.filename:
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    72
                        continue
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    73
                    if 'mercurial/__init__.py' in frame.filename:
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    74
                        continue
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    75
                    if frame.filename.startswith(sys.prefix):
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    76
                        continue
1c22400db72d mercurial: implement a source transforming module loader on Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28584
diff changeset
    77
                    break
28584
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    78
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    79
                if frame.filename:
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    80
                    filename = os.path.basename(frame.filename)
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    81
                    print('%s: error importing: <%s> %s (error at %s:%d)' % (
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    82
                          f, type(e).__name__, e, filename, frame.lineno))
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    83
                else:
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    84
                    print('%s: error importing module: <%s> %s (line %d)' % (
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    85
                          f, type(e).__name__, e, frame.lineno))
d69172ddfdca tests: try to import modules with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28583
diff changeset
    86
27279
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    87
if __name__ == '__main__':
28583
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    88
    if sys.version_info[0] == 2:
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    89
        fn = check_compat_py2
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    90
    else:
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    91
        fn = check_compat_py3
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    92
27279
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    93
    for f in sys.argv[1:]:
28583
260ce2eed951 tests: perform an ast parse with Python 3
Gregory Szorc <gregory.szorc@gmail.com>
parents: 28475
diff changeset
    94
        fn(f)
27279
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    95
40eb385f798f tests: add test for Python 3 compatibility
Gregory Szorc <gregory.szorc@gmail.com>
parents:
diff changeset
    96
    sys.exit(0)