diff --git a/stone/cli.py b/stone/cli.py index 59d0dc5f..887bb819 100644 --- a/stone/cli.py +++ b/stone/cli.py @@ -85,6 +85,11 @@ help=('Print a JSON manifest of generated output paths instead of writing ' 'generated source file contents.'), ) +_cmdline_parser.add_argument( + '--expected-output-manifest', + type=str, + help='JSON file containing the exact relative output paths Stone must produce.', +) _cmdline_parser.add_argument( '--clean-build', action='store_true', @@ -140,6 +145,42 @@ help='If set, backends will not see any routes for the specified namespaces.', ) +def _actual_outputs(output_root): + # type: (str) -> typing.List[str] + outputs = [] + for root, _, file_names in os.walk(output_root): + for file_name in file_names: + output_path = os.path.join(root, file_name) + relpath = os.path.relpath(output_path, output_root).replace(os.sep, '/') + outputs.append(relpath) + return sorted(outputs) + + +def _load_expected_output_manifest(path): + # type: (str) -> typing.List[str] + with open(path, encoding='utf-8') as manifest_file: + data = json.load(manifest_file) + if not isinstance(data, list) or not all(isinstance(item, str) for item in data): + _cmdline_parser.error( + '--expected-output-manifest must be a JSON list of strings: {}'.format(path)) + return sorted(data) + + +def _validate_expected_output_manifest(expected, actual): + # type: (typing.List[str], typing.List[str]) -> None + if actual == expected: + return + + missing = sorted(set(expected) - set(actual)) + extra = sorted(set(actual) - set(expected)) + print( + 'error: Stone output manifest mismatch.\nMissing: {}\nExtra: {}'.format( + missing, + extra), + file=sys.stderr) + sys.exit(1) + + def main(): """The entry point for the program.""" if '--' in sys.argv: @@ -361,8 +402,20 @@ def main(): file=sys.stderr) sys.exit(1) + if args.output_manifest or args.expected_output_manifest: + if args.output_manifest: + actual_manifest = c.output_manifest() + else: + actual_manifest = _actual_outputs(args.output) + else: + actual_manifest = None + + if args.expected_output_manifest: + expected_manifest = _load_expected_output_manifest(args.expected_output_manifest) + _validate_expected_output_manifest(expected_manifest, actual_manifest) + if args.output_manifest: - print(json.dumps(c.output_manifest(), indent=2)) + print(json.dumps(actual_manifest, indent=2)) if not sys.argv[0].endswith('stone'): # If we aren't running from an entry_point, then return api to make it diff --git a/test/test_cli.py b/test/test_cli.py index a2a3e751..fce1b36a 100755 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,8 +1,16 @@ #!/usr/bin/env python +import json +import os +import tempfile import unittest +from stone.cli import ( + _actual_outputs, + _load_expected_output_manifest, + _validate_expected_output_manifest, +) from stone.cli_helpers import parse_route_attr_filter @@ -126,6 +134,34 @@ def test_parse_route_attr_filter(self): self.assertFalse(expr.eval(MockRoute({'a': 1}))) self.assertFalse(expr.eval(MockRoute({'a': 1, 'b': 3}))) + def test_actual_outputs(self): + with tempfile.TemporaryDirectory() as output_root: + os.makedirs(os.path.join(output_root, 'nested')) + with open(os.path.join(output_root, 'Generated.py'), 'w', encoding='utf-8'): + pass + with open( + os.path.join(output_root, 'nested', 'Generated.swift'), + 'w', + encoding='utf-8'): + pass + + self.assertEqual( + _actual_outputs(output_root), + ['Generated.py', 'nested/Generated.swift']) + + def test_load_expected_output_manifest(self): + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8') as manifest_file: + json.dump(['b.py', 'a.py'], manifest_file) + manifest_file.flush() + + self.assertEqual(_load_expected_output_manifest(manifest_file.name), ['a.py', 'b.py']) + + def test_validate_expected_output_manifest(self): + _validate_expected_output_manifest(['a.py'], ['a.py']) + + with self.assertRaises(SystemExit): + _validate_expected_output_manifest(['a.py'], ['b.py']) + if __name__ == '__main__': unittest.main()