| #!/usr/bin/env python3 |
| |
| import argparse |
| import glob |
| import os |
| import re |
| import shlex |
| import shutil |
| import subprocess |
| import sys |
| |
| import json5 |
| |
| |
| 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.run_cmd = None |
| self.subps = None |
| self.uv_path = None |
| self.version = json5.__version__ |
| |
| 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 |
| |
| def call(self, *args, **kwargs): |
| cmd = shlex.join(*args) |
| if self.args.no_execute or self.args.verbose: |
| print(f'{cmd}') |
| if self.args.no_execute: |
| return None |
| capture_output = kwargs.get('capture_output', self.args.quiet) |
| if 'capture_output' in kwargs: |
| del kwargs['capture_output'] |
| |
| proc = subprocess.run( |
| *args, capture_output=capture_output, check=False, **kwargs |
| ) |
| if proc.returncode != 0: |
| if self.args.quiet: |
| print(proc.stdout) |
| print(proc.stderr, file=sys.stderr) |
| sys.exit(proc.returncode) |
| return proc |
| |
| def main(self, argv): |
| self.arg_parser = argparse.ArgumentParser(prog='run') |
| self.subps = self.arg_parser.add_subparsers(required=True) |
| |
| self.add_parser('build', help='Build the package.') |
| self.add_parser('check', help='Check the source code with ruff.') |
| self.add_parser('checks', help='Same as `run check`.') |
| self.add_parser('clean', help='Remove any local files.') |
| |
| 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( |
| 'devenv', |
| help='Set up a dev venv at //.venv with all the needed packages.', |
| ) |
| |
| subp = self.add_parser( |
| 'format', help='Format the source code with ruff.' |
| ) |
| subp.add_argument( |
| '--check', |
| action='store_true', |
| help='Just check to see if any files would be modified.', |
| ) |
| |
| 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('pylint', help='Lint the source code with pylint.') |
| |
| self.add_parser('mypy', help='Typecheck the code with mypy.') |
| |
| subp = self.add_parser( |
| 'presubmit', |
| help='Run all the steps that should be run prior to commiting.', |
| ) |
| subp.add_argument( |
| '-b', |
| '--branch', |
| action='store_true', |
| help='Report branch coverage.', |
| ) |
| subp.add_argument( |
| '-f', |
| '--failfast', |
| action='store_true', |
| help='Stop on first fail or error', |
| ) |
| 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') |
| |
| 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('regen', help=f'Regenerate {self.parser_file}.') |
| subp.add_argument( |
| '--check', |
| action='store_true', |
| help='Just check to see if any files would be modified.', |
| ) |
| |
| 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 fail or error', |
| ) |
| subp.add_argument( |
| '-u', '--unit', action='store_true', help='Only run unit tests.' |
| ) |
| subp.add_argument('test', nargs='*', action='store') |
| |
| self.args = self.arg_parser.parse_args(self._shuffle_argv(argv)) |
| |
| self.uv_path = shutil.which('uv') |
| if self.uv_path is None: |
| print('You need to have `uv` installed to run this script.') |
| sys.exit(2) |
| |
| if 'VIRTUAL_ENV' in os.environ: |
| self.run_cmd = ['python'] |
| elif self.args.quiet: |
| self.run_cmd = [self.uv_path, 'run', '--quiet', 'python'] |
| else: |
| self.run_cmd = [self.uv_path, 'run', 'python'] |
| |
| self.args.func(self.args) |
| |
| def run_build(self): |
| self._check_version() |
| cmd = [self.uv_path, 'build'] |
| if self.args.quiet: |
| cmd.append('--quiet') |
| self.call(cmd) |
| |
| def run_check(self): |
| self.call(self.run_cmd + ['-m', 'ruff', 'check']) |
| |
| def run_checks(self): |
| self.run_check() |
| |
| def run_clean(self): |
| path = shutil.which('git') |
| if path is None: |
| print('You must have git installed to clean out the right files.') |
| sys.exit(1) |
| |
| self.call([path, 'clean', '-fd']) |
| self.call( |
| ['rm', '-fr', '.coverage', 'build', 'dist', 'json5.egg-info'] |
| ) |
| |
| def run_coverage(self): |
| env = os.environ.copy() |
| if self.args.unit: |
| env['SKIP'] = 'integration' |
| self.args.omit = '*_test.py' |
| cmd = self.run_cmd + ['-m', '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 + ['-m', 'coverage', 'report', '--show-missing'] |
| if self.args.omit: |
| cmd.append(f'--omit={self.args.omit}') |
| self.call(cmd) |
| |
| def run_devenv(self): |
| if self.uv_path is None: |
| print('You need to have `uv` installed to set up a dev env.') |
| sys.exit(2) |
| |
| cmd = [self.uv_path, 'sync', '--extra', 'dev'] |
| if self.args.quiet: |
| cmd.append('--quiet') |
| |
| in_venv = 'VIRTUAL_ENV' in os.environ |
| self.call(cmd) |
| if not in_venv: |
| print('Run `source .venv/bin/activate` to finish devenv setup.') |
| |
| def run_format(self): |
| cmd = self.run_cmd + ['-m', 'ruff', 'format'] |
| if self.args.check: |
| cmd.append('--check') |
| self.call(cmd) |
| |
| def run_help(self): |
| if self.args.subcommand: |
| self.main([self.args.subcommand, '--help']) |
| self.main(['--help']) |
| |
| def run_pylint(self): |
| self.call(self.run_cmd[:-1] + ['pylint', 'run'] + self._files()) |
| |
| def run_mypy(self): |
| self.call(self.run_cmd + ['-m', 'mypy'] + self._files()) |
| |
| def run_presubmit(self): |
| self.args.check = True |
| self.run_regen() |
| self.run_format() |
| self.run_check() |
| self.run_pylint() |
| self.run_mypy() |
| self.run_coverage() |
| |
| # Build is way too noisy by default |
| quiet = self.args.quiet |
| self.args.quiet = True |
| self.run_build() |
| self.args.quiet = quiet |
| |
| self.run_publish() |
| |
| def run_publish(self): |
| 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.') |
| sys.exit(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' |
| if not os.path.exists(tgz) or not os.path.exists(wheel): |
| print('Run `./run build` first') |
| return |
| if self.args.test: |
| test = ['--repository', 'testpypi'] |
| else: |
| test = [] |
| if self.args.check: |
| self.call(self.run_cmd + ['-m', 'twine', 'check'] + [tgz, wheel]) |
| else: |
| self.call( |
| self.run_cmd + ['-m', 'twine', 'upload'] + test + [tgz, wheel] |
| ) |
| |
| def run_regen(self): |
| orig_file = f'{self.parser_file}.orig' |
| with open(self.parser_file, encoding='utf-8') as fp: |
| old = fp.read() |
| if self.args.no_execute or self.args.verbose: |
| print(f'mv {self.parser_file} {orig_file}') |
| else: |
| os.rename(self.parser_file, orig_file) |
| self._gen_parser() |
| if self.args.no_execute: |
| if self.args.check: |
| print(f'diff -q {orig_file} {self.parser_file}') |
| print(f'mv {orig_file} {self.parser_file}') |
| return |
| |
| with open(self.parser_file, encoding='utf-8') as fp: |
| new = fp.read() |
| if self.args.check: |
| os.remove(self.parser_file) |
| os.rename(orig_file, self.parser_file) |
| if old != new: |
| print('Need to regenerate the parser with `run regen`.') |
| sys.exit(1) |
| print(f'{self.parser_file} is up to date.') |
| elif old == new: |
| print(f'{self.parser_file} is up to date.') |
| else: |
| print(f'{self.parser_file} regenerated.') |
| |
| def run_tests(self): |
| self.call(self.run_cmd + ['-m', 'doctest', 'json5/lib.py']) |
| |
| cmd = self.run_cmd + ['-m', 'unittest', 'discover', '-p', '*_test.py'] |
| if self.args.quiet: |
| cmd.append('-q') |
| if self.args.verbose: |
| cmd.append('-v') |
| if self.args.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) |
| |
| def set_func(self, subp, method): |
| subp.set_defaults(func=lambda _: method()) |
| |
| 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 _gen_parser(self): |
| tool = '../glop/glop/tool.py' |
| if not os.path.exists(tool): |
| print('Need `glop` repo to be at ../glop.') |
| sys.exit(1) |
| |
| self.call( |
| [ |
| sys.executable, |
| tool, |
| '-o', |
| self.parser_file, |
| '--no-main', |
| '--no-memoize', |
| '-c', |
| self.grammar, |
| ] |
| ) |
| self.call(self.run_cmd + ['-m', 'ruff', 'format', self.parser_file]) |
| |
| 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}"') |
| sys.exit(1) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(Runner().main(sys.argv[1:])) |