blob: 6e883d2b56eb256f314633ecbdab36d868bffa42 [file] [edit]
#!/usr/bin/env python3
"""This is a wrapper script used for maintaining projects.
It wraps the commands and tools you need to run to develop and
maintain projects -- building, testing, packaging, etc. -- and provides
a project-neutral interface to them.
"""
import argparse
import glob
import os
import re
import shlex
import shutil
import subprocess
import sys
class FailFastException(Exception):
def __init__(self, returncode):
self.returncode = returncode
class Runner:
def __init__(self):
self.arg_parser = None
self.args = None
self.grammar = 'json5/json5.g'
self.package = 'json5'
self.parser_file = f'{self.package}/parser.py'
self.subps = None
self.failfast = False
self.returncode = 0
with open(self.package + '/version.py', encoding='utf-8') as fp:
contents = fp.read()
m = re.search("__version__ = '(.*)'", contents, re.MULTILINE)
if m:
self.version = m.group(1)
else:
print(
'Error, could not determine package version. Exiting',
file=sys.stderr,
)
sys.exit(1)
self.uv_path = shutil.which('uv')
if self.uv_path is None and self.args.command not in ('help', 'tests'):
print('You need to have `uv` installed to run this script')
sys.exit(2)
if 'VIRTUAL_ENV' in os.environ:
self.run_cmd = ['python', '-m']
else:
self.run_cmd = [self.uv_path, 'run', 'python', '-m']
def main(self, argv):
# Change stdout to be unbuffered so that output shows up in
# the right interleaving between this process and any child
# processes.
sys.stdout.reconfigure(line_buffering=True)
self.parse_args(argv)
self.failfast = getattr(self.args, 'failfast', False)
if not self.args.quiet and (
(self.args.verbose and self.args.command != 'help')
or self.args.command == 'presubmit'
):
print(
f'# Running `{self.args.command}` using Python '
f'{self._python_version()}'
)
try:
return self.args.func(self.args)
except FailFastException as e:
return e.returncode
def parse_args(self, argv):
self.arg_parser = argparse.ArgumentParser(
prog='run', description=__doc__
)
self.subps = self.arg_parser.add_subparsers(
dest='command', required=True
)
subp = self._add_parser(
'regen',
help=f'Regenerate the parser ({self.parser_file}) if the grammar '
f'({self.grammar}) has changed',
)
subp.add_argument(
'--check',
action='store_true',
help='Just check to see if the grammar needs to be regenerated',
)
subp = self._add_parser(
'format',
help='Format the source code with ruff format and pyproject-fmt',
)
subp.add_argument(
'--check',
action='store_true',
help='Just check to see if any files need to be reformatted',
)
subp = self._add_parser(
'checks', help='Run static analyzers (ruff check, pylint, mypy)'
)
subp.add_argument(
'-f',
'--failfast',
action='store_true',
help='Stop on first failure or error',
)
subp = self._add_parser('tests', help='Run the tests')
subp.add_argument(
'-d',
'--durations',
metavar='N',
action='store',
help='show the N slowed test cases (N=0 for all)',
)
subp.add_argument(
'-f',
'--failfast',
action='store_true',
help='Stop on first failure or error',
)
subp.add_argument(
'-u', '--unit', action='store_true', help='Only run unit tests'
)
subp.add_argument('test', nargs='*', action='store')
subp = self._add_parser(
'coverage', help='Run tests and report code coverage'
)
subp.add_argument(
'-b',
'--branch',
action='store_true',
help='Report branch coverage.',
)
subp.add_argument(
'--omit',
help='Omit files whose paths match one of these patterns',
)
subp.add_argument(
'-u', '--unit', action='store_true', help='Only run unit tests'
)
subp.add_argument('test', nargs='*', action='store')
self._add_parser('build', help='Build the package')
subp = self._add_parser('publish', help='Publish packages to PyPI')
subp.add_argument(
'--check',
action='store_true',
help='Check the package intead of publishing',
)
subp.add_argument(
'--test',
action='store_true',
help='Upload to the PyPI test instance',
)
subp.add_argument(
'--prod',
action='store_true',
help='Upload to the real PyPI instance',
)
subp = self._add_parser(
'presubmit', help='Check that all of the above commands pass'
)
subp.add_argument(
'-b',
'--branch',
action='store_true',
help='Report branch coverage',
)
subp.add_argument(
'-f',
'--failfast',
action='store_true',
help='Stop on first failure or error',
)
subp = self._add_parser('help', help='Get help on a subcommand')
subp.add_argument(
nargs='?',
action='store',
dest='subcommand',
help='The command to get help for',
)
self._add_parser(
'devenv',
help='Set up a Python venv at //.venv with all the needed '
'development packages',
)
subp = self._add_parser(
'clean', help='Remove any unversioned local files'
)
subp.add_argument(
'--check',
action='store_true',
help='Just check if there are any unversioned files and report them',
)
self.args = self.arg_parser.parse_args(self._shuffle_argv(argv))
def _add_parser(self, cmd, help): # pylint: disable=redefined-builtin
method = getattr(self, 'run_' + cmd.replace('-', '_'))
subp = self.subps.add_parser(cmd, help=help)
subp.add_argument(
'-n',
'--no-execute',
action='store_true',
help="Don't do anything that causes effects.",
)
subp.add_argument(
'-q',
'--quiet',
action='store_true',
help='Suppress output unless something fails.',
)
subp.add_argument(
'-v',
'--verbose',
action='store_true',
help='Echo commands as they are run.',
)
subp.set_defaults(func=lambda _: method())
return subp
#
# Commands
#
def run_build(self):
self._check_python_version()
self._check_version()
cmd = [self.uv_path, 'build']
if self.args.quiet:
cmd.append('--quiet')
proc = self._call(cmd, capture_output=True)
for line in proc.stderr.decode('utf-8').splitlines():
# Build is very verbose by default; ignore most output unless
# we're running with --verbose.
lines_to_ignore = (
'Building',
'Copying',
'Creating',
'Writing',
'adding',
'copying',
'creating',
'installing',
'removing',
'reading',
'running',
'writing',
)
if self.args.verbose or not line.startswith(lines_to_ignore):
print(line, file=sys.stderr)
return self.returncode
def run_checks(self):
self._check_python_version()
if self.args.command == 'presubmit':
print()
if not self.args.quiet:
print('### ruff check')
self._call(self.run_cmd + ['ruff', 'check'])
if not self.args.quiet:
print('\n### pylint')
proc = self._call(self.run_cmd + ['pylint'] + self._files())
if proc.returncode == 0 and not self.args.quiet:
print('Success: pylint found no issues')
if not self.args.quiet:
print('\n### mypy')
self._call(self.run_cmd + ['mypy'] + self._files())
return self.returncode
def run_clean(self):
path = shutil.which('git')
if path is None:
print('You must have git installed to clean out the right files.')
if self.failfast:
raise FailFastException(1)
self.returncode = 1
if self.args.check:
self._call([path, 'clean', '-nd'])
else:
self._call([path, 'clean', '-fd'])
self._remove('.coverage')
self._remove('build')
self._remove('dist')
self._remove('json5.egg-info')
return self.returncode
def run_coverage(self):
self._check_python_version()
env = os.environ.copy()
if self.args.unit:
env['SKIP'] = 'integration'
self.args.omit = '*_test.py'
cmd = self.run_cmd + ['coverage', 'run']
if self.args.branch:
cmd.append('--branch')
cmd.extend(
[
'-m',
'unittest',
'discover',
'-p',
'*_test.py',
],
)
if self.args.verbose:
cmd.append('-v')
for test in self.args.test:
cmd.extend(['-k', self._trim_test_glob(test)])
self._call(cmd, env=env)
cmd = self.run_cmd + ['coverage', 'report', '--show-missing']
if self.args.omit:
cmd.append(f'--omit={self.args.omit}')
self._call(cmd)
return self.returncode
def run_devenv(self):
self._check_python_version()
if self.uv_path is None:
print('You need to have `uv` installed to set up a dev env.')
return 1
cmd = [self.uv_path, 'sync']
if self.args.quiet:
cmd.append('--quiet')
in_venv = 'VIRTUAL_ENV' in os.environ
self._call(cmd)
if not in_venv and not self.returncode:
print('Run `source .venv/bin/activate` to finish devenv setup.')
return self.returncode
def run_format(self):
self._check_python_version()
cmd = self.run_cmd[:-2] + [
'pyproject-fmt',
'--column-width',
'80',
'--no-print-diff',
]
if self.args.check:
cmd.append('--check')
else:
cmd.append('--stdout')
cmd.append('pyproject.toml')
old = None
with open('pyproject.toml', 'r', encoding='utf8') as fp:
old = fp.read()
proc = self._call(cmd, capture_output=True, ignore_error=True)
if self.args.check:
if proc.returncode:
print('pyproject.toml needs formatting')
cmd.remove('--no-print-diff')
self._call(cmd, ignore_error=True)
elif not self.args.quiet:
print('pyproject.toml already formatted')
elif proc.returncode and not proc.stdout:
return proc.returncode
new = proc.stdout.decode('utf8')
if not self.args.no_execute and not self.args.check:
if new != old:
with open('pyproject.toml', 'wb') as fp:
fp.write(proc.stdout)
print('pyproject.toml reformatted')
elif not self.args.quiet:
print('pyproject.toml already formatted')
cmd = self.run_cmd + ['ruff', 'format']
if self.args.check:
cmd.append('--check')
proc = self._call(cmd, capture_output=True)
out = proc.stdout.decode('utf-8')
out = out.replace('left unchanged', 'already formatted')
if not self.args.quiet or not out.endswith('already formatted\n'):
print(out, end='')
return proc.returncode
def run_help(self):
if self.args.subcommand:
self.main([self.args.subcommand, '--help'])
return self.main(['--help'])
def run_presubmit(self):
self._check_python_version()
self.args.check = True
# Stub out args to `run tests`
self.args.durations = []
self.args.omit = []
self.args.test = []
self.args.unit = False
steps = (
# TODO: Enable this once glop is properly packaged and available.
# ('regen --check', self.run_regen),
('format --check', self.run_format),
('checks', self.run_checks),
('tests', self.run_tests),
('coverage --check', self.run_coverage),
('build', self.run_build),
('publish --check', self.run_publish),
)
for desc, func in steps:
if not self.args.quiet:
print(f'\n## Running `{desc}`')
func()
if not self.args.quiet and not self.returncode:
print('\n## Presubmit passed.')
elif self.returncode:
print('\n## Presubmit failed.')
return self.returncode
def run_publish(self):
self._check_python_version()
if not self.args.check and not self.args.test and not self.args.prod:
print('You must specify either --test or --prod to upload.')
return 2
self._check_version()
sep = os.path.sep
tgz = f'dist{sep}{self.package}-{self.version}.tar.gz'
wheel = f'dist{sep}{self.package}-{self.version}-py3-none-any.whl'
self.run_build()
if not os.path.exists(tgz) or not os.path.exists(wheel):
print('Run `./run build` first')
return 1
if self.args.test:
test = ['--repository', 'testpypi']
else:
test = []
if self.args.check:
self._call(self.run_cmd + ['twine', 'check'] + [tgz, wheel])
else:
self._call(
self.run_cmd + ['twine', 'upload'] + test + [tgz, wheel]
)
return self.returncode
def run_regen(self):
self._check_python_version()
with open(self.parser_file, encoding='utf-8') as fp:
old = fp.read()
old_fname = self.parser_file + '.old'
try:
# We wrap this in a try block to ensure that if something
# goes wrong we restore the old version.
self._rename(self.parser_file, old_fname)
self._gen_parser()
with open(self.parser_file, encoding='utf-8') as fp:
new = fp.read()
if old != new:
if self.args.check:
if self.args.verbose:
print(
'Need to regenerate the parser with `run regen`:.'
)
self._call(
['diff', old_fname, self.parser_file],
indent=' ',
capture_output=True,
ignore_error=True,
)
else:
print(
'Need to regenerate the parser with `run regen`.'
)
else:
print(f'{self.parser_file} regenerated.')
elif not self.args.quiet:
print(f'{self.parser_file} is up-to-date.')
if old != new:
if self.args.check:
self._rename(old_fname, self.parser_file)
return 1
else:
self._remove(old_fname, quiet=True)
return self.returncode
except Exception:
if not os.path.exists(self.parser_file) and os.path.exists(
old_fname
):
self._rename(old_fname, self.parser_file)
raise
def _gen_parser(self):
cmd = self.run_cmd + [
'-m',
'glop',
'-o',
self.parser_file,
'--no-main',
'--no-memoize',
'-c',
self.grammar,
]
proc = self._call(cmd)
if proc.returncode:
return proc.returncode
# ignore the output from `ruff format`; glop currently produces
# files that need reformatting.
proc = self._call(
self.run_cmd + ['ruff', 'format', self.parser_file],
capture_output=True,
ignore_error=True,
)
return proc.returncode
def run_tests(self):
_ = self._call(self.run_cmd + ['doctest', 'json5/lib.py'])
cmd = self.run_cmd + [
'unittest',
'discover',
'-p',
'*_test.py',
]
if self.args.quiet:
cmd.append('-q')
if self.args.verbose:
cmd.append('-v')
if self.failfast:
cmd.append('-f')
if self.args.durations:
cmd.extend(['--durations', self.args.durations])
for test in self.args.test:
cmd.extend(['-k', self._trim_test_glob(test)])
env = os.environ.copy()
if self.args.unit:
env['SKIP'] = 'integration'
_ = self._call(cmd, env=env)
return self.returncode
def _call(self, args, indent='', ignore_error=False, **kwargs):
cmd = shlex.join(args)
verbose = kwargs.pop('verbose', self.args.verbose)
if self.args.no_execute or verbose:
print(f'{indent}{cmd}')
if self.args.no_execute:
return None
capture_output = kwargs.get('capture_output', self.args.quiet)
kwargs.pop('capture_output', None)
proc = subprocess.run(
args, capture_output=capture_output, check=False, **kwargs
)
if (proc.returncode or verbose) and not capture_output:
if proc.stdout:
print(self._indent(indent, proc.stdout.decode('utf8')))
if proc.stderr:
print(
self._indent(indent, proc.stderr.decode('utf8')),
file=sys.stderr,
)
if proc.returncode and not ignore_error:
if self.failfast:
raise FailFastException(proc.returncode)
self.returncode = proc.returncode
return proc
def _indent(self, indent, output):
line_sep = '\n' + indent
out = indent
out += line_sep.join(output.splitlines())
return out
def _rename(self, old, new):
if self.args.no_execute or self.args.verbose:
print(f'mv {old} {new}')
if not self.args.no_execute:
os.rename(old, new)
def _remove(self, fname, quiet=False):
if not os.path.exists(fname):
return
is_dir = os.path.isdir(fname)
dchar = '/' if is_dir else ''
try:
if self.args.check and not quiet:
print(f'Would remove {fname}{dchar}')
else:
if (
not self.args.quiet
and not quiet
and not self.args.no_execute
):
print(f'Removing {fname}{dchar}')
if os.path.isdir(fname):
if self.args.verbose or self.args.no_execute:
print(f'rm -fr {fname}{dchar}')
shutil.rmtree(fname)
else:
if self.args.verbose or self.args.no_execute:
print(f'rm -f {fname}{dchar}')
if not self.args.no_execute:
os.remove(fname)
except OSError as e:
print(e, file=sys.stderr)
raise FailFastException(1) from e
def _shuffle_argv(self, argv):
# Take any flags that appear before the subcommand and append
# them after the subcommand but before any flags following the
# subcommand.
leading_args = []
argc = len(argv)
i = 0
while i < argc:
if argv[i][0] != '-':
break
leading_args.append(argv[i])
i += 1
return argv[i : i + 1] + leading_args + argv[i + 1 :]
def _files(self):
return (
['./run']
+ glob.glob(f'{self.package}/*.py')
+ glob.glob('tests/*.py')
)
def _trim_test_glob(self, test):
if test.startswith('^'):
test = test[1:]
else:
test = '*' + test
if test.endswith('$'):
test = test[:-1]
else:
test = test + '*'
return test
def _check_version(self):
m = re.match(r'\d+\.\d+\.\d+(\.dev\d+)?', self.version)
if not m:
print(f'Unexpected version format: "{self.version}"')
raise FailFastException(1)
def _check_python_version(self):
if self.args.command not in ('tests', 'help', 'clean') and (
sys.version_info < (3, 11)
):
print(
f'Only `tests`, `help`, and `clean` are supported '
f"on Python versions older than 3.10. You're running "
f'{self._python_version()}.'
)
raise FailFastException(1)
def _python_version(self):
return sys.version.split()[0]
if __name__ == '__main__':
sys.exit(Runner().main(sys.argv[1:]))