Blob Blame History Raw
"""Module/script to clamp the mtimes of all .py files to $SOURCE_DATE_EPOCH

When called as a script with arguments, this compiles the directories
given as arguments recursively.

If upstream is interested, this can be later integrated to the compileall module
as an additional option (e.g. --clamp-source-mtime).

License:
This has been derived from the Python's compileall module
and it follows Python licensing. For more info see: https://www.python.org/psf/license/
"""
from __future__ import print_function
import os
import sys

# Python 3.6 and higher
PY36 = sys.version_info[0:2] >= (3, 6)

__all__ = ["clamp_dir", "clamp_file"]


def _walk_dir(dir, maxlevels, quiet=0):
    if PY36 and quiet < 2 and isinstance(dir, os.PathLike):
        dir = os.fspath(dir)
    else:
        dir = str(dir)
    if not quiet:
        print('Listing {!r}...'.format(dir))
    try:
        names = os.listdir(dir)
    except OSError:
        if quiet < 2:
            print("Can't list {!r}".format(dir))
        names = []
    names.sort()
    for name in names:
        if name == '__pycache__':
            continue
        fullname = os.path.join(dir, name)
        if not os.path.isdir(fullname):
            yield fullname
        elif (maxlevels > 0 and name != os.curdir and name != os.pardir and
              os.path.isdir(fullname) and not os.path.islink(fullname)):
                for result in _walk_dir(fullname, maxlevels=maxlevels - 1,
                                        quiet=quiet):
                    yield result


def clamp_dir(dir, source_date_epoch, quiet=0):
    """Clamp the mtime of all modules in the given directory tree.

    Arguments:

    dir:       the directory to byte-compile
    source_date_epoch: integer parsed from $SOURCE_DATE_EPOCH
    quiet:     full output with False or 0, errors only with 1,
               no output with 2
    """
    maxlevels = sys.getrecursionlimit()
    files = _walk_dir(dir, quiet=quiet, maxlevels=maxlevels)
    success = True
    for file in files:
        if not clamp_file(file, source_date_epoch, quiet=quiet):
            success = False
    return success


def clamp_file(fullname, source_date_epoch, quiet=0):
    """Clamp the mtime of one file.

    Arguments:

    fullname:  the file to byte-compile
    source_date_epoch: integer parsed from $SOURCE_DATE_EPOCH
    quiet:     full output with False or 0, errors only with 1,
               no output with 2
    """
    if PY36 and quiet < 2 and isinstance(fullname, os.PathLike):
        fullname = os.fspath(fullname)
    else:
        fullname = str(fullname)
    name = os.path.basename(fullname)

    if os.path.isfile(fullname) and not os.path.islink(fullname):
        if name[-3:] == '.py':
            try:
                mtime = int(os.stat(fullname).st_mtime)
                atime = int(os.stat(fullname).st_atime)
            except OSError as e:
                if quiet >= 2:
                    return False
                elif quiet:
                    print('*** Error checking mtime of {!r}...'.format(fullname))
                else:
                    print('*** ', end='')
                print(e.__class__.__name__ + ':', e)
                return False
            if mtime > source_date_epoch:
                if not quiet:
                    print('Clamping mtime of {!r}'.format(fullname))
                try:
                    os.utime(fullname, (atime, source_date_epoch))
                except OSError as e:
                    if quiet >= 2:
                        return False
                    elif quiet:
                        print('*** Error clamping mtime of {!r}...'.format(fullname))
                    else:
                        print('*** ', end='')
                    print(e.__class__.__name__ + ':', e)
                    return False
    return True


def main():
    """Script main program."""
    import argparse

    source_date_epoch = os.getenv('SOURCE_DATE_EPOCH')
    if not source_date_epoch:
        print("Not clamping source mtimes, $SOURCE_DATE_EPOCH not set")
        return True  # This is a success, no action needed
    try:
        source_date_epoch = int(source_date_epoch)
    except ValueError:
        print("$SOURCE_DATE_EPOCH must be an integer")
        return False

    parser = argparse.ArgumentParser(
        description='Clamp .py source mtime to $SOURCE_DATE_EPOCH.')
    parser.add_argument('-q', action='count', dest='quiet', default=0,
                        help='output only error messages; -qq will suppress '
                             'the error messages as well.')
    parser.add_argument('clamp_dest', metavar='FILE|DIR', nargs='+',
                        help=('zero or more file and directory paths '
                              'to clamp'))

    args = parser.parse_args()
    clamp_dests = args.clamp_dest

    success = True
    try:
        for dest in clamp_dests:
            if os.path.isfile(dest):
                if not clamp_file(dest, quiet=args.quiet,
                                  source_date_epoch=source_date_epoch):
                    success = False
            else:
                if not clamp_dir(dest, quiet=args.quiet,
                                 source_date_epoch=source_date_epoch):
                    success = False
        return success
    except KeyboardInterrupt:
        if args.quiet < 2:
            print("\n[interrupted]")
        return False
    return True


if __name__ == '__main__':
    exit_status = int(not main())
    sys.exit(exit_status)