| #!/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:])) |