OLD | NEW |
---|---|
1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
2 # Copyright 2015 The LUCI Authors. All rights reserved. | 2 # Copyright 2015 The LUCI Authors. All rights reserved. |
3 # Use of this source code is governed under the Apache License, Version 2.0 | 3 # Use of this source code is governed under the Apache License, Version 2.0 |
4 # that can be found in the LICENSE file. | 4 # that can be found in the LICENSE file. |
5 | 5 |
6 """Tool to interact with recipe repositories. | 6 """Tool to interact with recipe repositories. |
7 | 7 |
8 This tool operates on the nearest ancestor directory containing an | 8 This tool operates on the nearest ancestor directory containing an |
9 infra/config/recipes.cfg. | 9 infra/config/recipes.cfg. |
10 """ | 10 """ |
(...skipping 41 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
52 universe_view = loader.UniverseView(universe, package_deps.root_package) | 52 universe_view = loader.UniverseView(universe, package_deps.root_package) |
53 | 53 |
54 # Prevent flakiness caused by stale pyc files. | 54 # Prevent flakiness caused by stale pyc files. |
55 package.cleanup_pyc(package_deps.root_package.recipes_dir) | 55 package.cleanup_pyc(package_deps.root_package.recipes_dir) |
56 | 56 |
57 return test.main( | 57 return test.main( |
58 universe_view, raw_args=args.args, | 58 universe_view, raw_args=args.args, |
59 engine_flags=args.operational_args.engine_flags) | 59 engine_flags=args.operational_args.engine_flags) |
60 | 60 |
61 | 61 |
62 def lint(config_file, package_deps, args): | |
63 from recipe_engine import lint_test | |
64 from recipe_engine import loader | |
65 | |
66 universe = loader.RecipeUniverse(package_deps, config_file) | |
67 universe_view = loader.UniverseView(universe, package_deps.root_package) | |
68 | |
69 lint_test.main(universe_view, args.whitelist or []) | |
70 | |
71 | |
72 def bundle(config_file, package_deps, args): | |
73 from recipe_engine import bundle | |
74 from recipe_engine import loader | |
75 | |
76 universe = loader.RecipeUniverse(package_deps, config_file) | |
77 | |
78 bundle.main(package_deps.root_package, universe, args.destination) | |
79 | |
80 | |
81 def handle_recipe_return(recipe_result, result_filename, stream_engine, | 62 def handle_recipe_return(recipe_result, result_filename, stream_engine, |
82 engine_flags): | 63 engine_flags): |
83 if engine_flags and engine_flags.use_result_proto: | 64 if engine_flags and engine_flags.use_result_proto: |
84 return new_handle_recipe_return( | 65 return new_handle_recipe_return( |
85 recipe_result, result_filename, stream_engine) | 66 recipe_result, result_filename, stream_engine) |
86 | 67 |
87 if 'recipe_result' in recipe_result.result: | 68 if 'recipe_result' in recipe_result.result: |
88 result_string = json.dumps( | 69 result_string = json.dumps( |
89 recipe_result.result['recipe_result'], indent=2) | 70 recipe_result.result['recipe_result'], indent=2) |
90 if result_filename: | 71 if result_filename: |
(...skipping 71 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
162 | 143 |
163 def get_properties_from_operational_args(op_args): | 144 def get_properties_from_operational_args(op_args): |
164 if not op_args.properties.property: | 145 if not op_args.properties.property: |
165 return None | 146 return None |
166 return _op_properties_to_dict(op_args.properties.property) | 147 return _op_properties_to_dict(op_args.properties.property) |
167 | 148 |
168 op_args = args.operational_args | 149 op_args = args.operational_args |
169 op_properties = get_properties_from_operational_args(op_args) | 150 op_properties = get_properties_from_operational_args(op_args) |
170 if args.properties and op_properties: | 151 if args.properties and op_properties: |
171 raise ValueError( | 152 raise ValueError( |
172 "Got operational args properties as well as CLI properties.") | 153 'Got operational args properties as well as CLI properties.') |
173 | 154 |
174 properties = op_properties | 155 properties = op_properties |
175 if not properties: | 156 if not properties: |
176 properties = args.properties | 157 properties = args.properties |
177 | 158 |
178 properties['recipe'] = args.recipe | 159 properties['recipe'] = args.recipe |
179 | 160 |
180 properties = recipe_util.strip_unicode(properties) | 161 properties = recipe_util.strip_unicode(properties) |
181 | 162 |
182 os.environ['PYTHONUNBUFFERED'] = '1' | 163 os.environ['PYTHONUNBUFFERED'] = '1' |
(...skipping 69 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
252 print >> sys.stderr, '--verbose-json passed without --output-json' | 233 print >> sys.stderr, '--verbose-json passed without --output-json' |
253 return 1 | 234 return 1 |
254 | 235 |
255 return autoroll.main(args, repo_root, config_file) | 236 return autoroll.main(args, repo_root, config_file) |
256 | 237 |
257 | 238 |
258 class ProjectOverrideAction(argparse.Action): | 239 class ProjectOverrideAction(argparse.Action): |
259 def __call__(self, parser, namespace, values, option_string=None): | 240 def __call__(self, parser, namespace, values, option_string=None): |
260 p = values.split('=', 2) | 241 p = values.split('=', 2) |
261 if len(p) != 2: | 242 if len(p) != 2: |
262 raise ValueError("Override must have the form: repo=path") | 243 raise ValueError('Override must have the form: repo=path') |
263 project_id, path = p | 244 project_id, path = p |
264 | 245 |
265 v = getattr(namespace, self.dest, None) | 246 v = getattr(namespace, self.dest, None) |
266 if v is None: | 247 if v is None: |
267 v = {} | 248 v = {} |
268 setattr(namespace, self.dest, v) | 249 setattr(namespace, self.dest, v) |
269 | 250 |
270 if v.get(project_id): | 251 if v.get(project_id): |
271 raise ValueError("An override is already defined for [%s] (%s)" % ( | 252 raise ValueError('An override is already defined for [%s] (%s)' % ( |
272 project_id, v[project_id])) | 253 project_id, v[project_id])) |
273 path = os.path.abspath(os.path.expanduser(path)) | 254 path = os.path.abspath(os.path.expanduser(path)) |
274 if not os.path.isdir(path): | 255 if not os.path.isdir(path): |
275 raise ValueError("Override path [%s] is not a directory" % (path,)) | 256 raise ValueError('Override path [%s] is not a directory' % (path,)) |
276 v[project_id] = path | 257 v[project_id] = path |
277 | 258 |
278 | 259 |
279 def depgraph(config_file, package_deps, args): | 260 def depgraph(config_file, package_deps, args): |
280 from recipe_engine import depgraph | 261 from recipe_engine import depgraph |
281 from recipe_engine import loader | 262 from recipe_engine import loader |
282 | 263 |
283 universe = loader.RecipeUniverse(package_deps, config_file) | 264 universe = loader.RecipeUniverse(package_deps, config_file) |
284 | 265 |
285 depgraph.main(universe, package_deps.root_package, | 266 depgraph.main(universe, package_deps.root_package, |
(...skipping 34 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
320 'list': lambda prop: [_op_property_value(v) for v in prop.list.property], | 301 'list': lambda prop: [_op_property_value(v) for v in prop.list.property], |
321 } | 302 } |
322 | 303 |
323 def _op_property_value(prop): | 304 def _op_property_value(prop): |
324 """Returns the Python-converted value of an arguments_pb2.Property. | 305 """Returns the Python-converted value of an arguments_pb2.Property. |
325 | 306 |
326 Args: | 307 Args: |
327 prop (arguments_pb2.Property): property to convert. | 308 prop (arguments_pb2.Property): property to convert. |
328 Returns: The converted value. | 309 Returns: The converted value. |
329 Raises: | 310 Raises: |
330 ValueError: If "prop" is incomplete or invalid. | 311 ValueError: If 'prop' is incomplete or invalid. |
331 """ | 312 """ |
332 typ = prop.WhichOneof('value') | 313 typ = prop.WhichOneof('value') |
333 conv = _OP_PROPERTY_CONV.get(typ) | 314 conv = _OP_PROPERTY_CONV.get(typ) |
334 if not conv: | 315 if not conv: |
335 raise ValueError('Unknown property field [%s]' % (typ,)) | 316 raise ValueError('Unknown property field [%s]' % (typ,)) |
336 return conv(prop) | 317 return conv(prop) |
337 | 318 |
338 | 319 |
339 def _op_properties_to_dict(pmap): | 320 def _op_properties_to_dict(pmap): |
340 """Creates a properties dictionary from an arguments_pb2.PropertyMap entry. | 321 """Creates a properties dictionary from an arguments_pb2.PropertyMap entry. |
341 | 322 |
342 Args: | 323 Args: |
343 pmap (arguments_pb2.PropertyMap): Map to convert to dictionary form. | 324 pmap (arguments_pb2.PropertyMap): Map to convert to dictionary form. |
344 Returns (dict): A dictionary derived from the properties in "pmap". | 325 Returns (dict): A dictionary derived from the properties in 'pmap'. |
345 """ | 326 """ |
346 return dict((k, _op_property_value(pmap[k])) for k in pmap) | 327 return dict((k, _op_property_value(pmap[k])) for k in pmap) |
347 | 328 |
348 | 329 |
349 def add_common_args(parser): | 330 def add_common_args(parser): |
350 from recipe_engine import package_io | 331 from recipe_engine import package_io |
351 | 332 |
352 def package_type(value): | 333 def package_type(value): |
353 if not os.path.isfile(value): | 334 if not os.path.isfile(value): |
354 raise argparse.ArgumentTypeError( | 335 raise argparse.ArgumentTypeError( |
(...skipping 15 matching lines...) Expand all Loading... | |
370 '--verbose', '-v', action='count', | 351 '--verbose', '-v', action='count', |
371 help='Increase logging verboisty') | 352 help='Increase logging verboisty') |
372 # TODO(phajdan.jr): Figure out if we need --no-fetch; remove if not. | 353 # TODO(phajdan.jr): Figure out if we need --no-fetch; remove if not. |
373 parser.add_argument( | 354 parser.add_argument( |
374 '--no-fetch', action='store_true', | 355 '--no-fetch', action='store_true', |
375 help='Disable automatic fetching') | 356 help='Disable automatic fetching') |
376 parser.add_argument('-O', '--project-override', metavar='ID=PATH', | 357 parser.add_argument('-O', '--project-override', metavar='ID=PATH', |
377 action=ProjectOverrideAction, | 358 action=ProjectOverrideAction, |
378 help='Override a project repository path with a local one.') | 359 help='Override a project repository path with a local one.') |
379 parser.add_argument( | 360 parser.add_argument( |
380 # Use "None" as default so that we can recognize when none of the | 361 # Use 'None' as default so that we can recognize when none of the |
381 # bootstrap options were passed. | 362 # bootstrap options were passed. |
382 '--use-bootstrap', action='store_true', default=None, | 363 '--use-bootstrap', action='store_true', default=None, |
383 help='Use bootstrap/bootstrap.py to create a isolated python virtualenv' | 364 help='Use bootstrap/bootstrap.py to create a isolated python virtualenv' |
384 ' with required python dependencies.') | 365 ' with required python dependencies.') |
385 parser.add_argument( | 366 parser.add_argument( |
386 '--disable-bootstrap', action='store_false', dest='use_bootstrap', | 367 '--disable-bootstrap', action='store_false', dest='use_bootstrap', |
387 help='Disables bootstrap (see --use-bootstrap)') | 368 help='Disables bootstrap (see --use-bootstrap)') |
388 | 369 |
389 def operational_args_type(value): | 370 def operational_args_type(value): |
390 with open(value) as fd: | 371 with open(value) as fd: |
391 return jsonpb.ParseDict(json.load(fd), arguments_pb2.Arguments()) | 372 return jsonpb.ParseDict(json.load(fd), arguments_pb2.Arguments()) |
392 | 373 |
393 parser.set_defaults(operational_args=arguments_pb2.Arguments()) | 374 parser.set_defaults(operational_args=arguments_pb2.Arguments()) |
394 | 375 |
395 parser.add_argument( | 376 parser.add_argument( |
396 '--operational-args-path', | 377 '--operational-args-path', |
397 dest='operational_args', | 378 dest='operational_args', |
398 type=operational_args_type, | 379 type=operational_args_type, |
399 help='The path to an operational Arguments file. If provided, this file ' | 380 help='The path to an operational Arguments file. If provided, this file ' |
400 'must contain a JSONPB-encoded Arguments protobuf message, and will ' | 381 'must contain a JSONPB-encoded Arguments protobuf message, and will ' |
401 'be integrated into the runtime parameters.') | 382 'be integrated into the runtime parameters.') |
402 | 383 |
384 def post_process_args(parser, args): | |
385 if args.command == 'remote': | |
386 # TODO(iannucci): this is a hack; remote doesn't behave like ANY other | |
387 # commands. A way to solve this will be to allow --package to take | |
388 # a remote repo and then simply remove the remote subcommand entirely. | |
389 return | |
403 | 390 |
404 def post_process_common_args(parser, args): | 391 if not args.package: |
405 if args.command == "remote": | 392 parser.error('%s requires --package' % args.command) |
406 # TODO(iannucci): this is a hack; remote doesn't behave like ANY other | |
407 # commands. A way to solve this will be to allow --package to take a remote | |
408 # repo and then simply remove the remote subcommand entirely. | |
409 return | |
410 | 393 |
411 if not args.package: | 394 return post_process_args |
412 parser.error('%s requires --package' % args.command) | |
413 | 395 |
414 | 396 |
415 def main(): | 397 def main(): |
416 parser = argparse.ArgumentParser( | 398 parser = argparse.ArgumentParser( |
417 description='Interact with the recipe system.') | 399 description='Interact with the recipe system.') |
418 | 400 |
419 add_common_args(parser) | 401 common_postprocess_func = add_common_args(parser) |
402 | |
403 from recipe_engine import fetch, lint_test, bundle | |
404 to_add = [fetch, lint_test, bundle] | |
420 | 405 |
421 subp = parser.add_subparsers() | 406 subp = parser.add_subparsers() |
422 | 407 for module in to_add: |
423 fetch_p = subp.add_parser( | 408 module.add_subparser(subp) |
424 'fetch', | |
425 description='Fetch and update dependencies.') | |
426 fetch_p.set_defaults(command='fetch') | |
427 | 409 |
428 test_p = subp.add_parser( | 410 test_p = subp.add_parser( |
429 'test', | 411 'test', |
430 description='Generate or check expectations by simulation') | 412 description='Generate or check expectations by simulation') |
431 test_p.set_defaults(command='test') | 413 test_p.set_defaults(command='test') |
432 test_p.add_argument('args', nargs=argparse.REMAINDER) | 414 test_p.add_argument('args', nargs=argparse.REMAINDER) |
433 | 415 |
434 lint_p = subp.add_parser( | |
435 'lint', | |
436 description='Check recipes for stylistic and hygenic issues') | |
437 lint_p.set_defaults(command='lint') | |
438 | |
439 lint_p.add_argument( | |
440 '--whitelist', '-w', action='append', | |
441 help='A regexp matching module names to add to the default whitelist. ' | |
442 'Use multiple times to add multiple patterns,') | |
443 | |
444 bundle_p = subp.add_parser( | |
445 'bundle', | |
446 description=( | |
447 'Create a hermetically runnable recipe bundle. This captures the result' | |
448 ' of all network operations the recipe_engine might normally do to' | |
449 ' bootstrap itself.')) | |
450 bundle_p.set_defaults(command='bundle') | |
451 bundle_p.add_argument( | |
452 '--destination', default='./bundle', | |
453 type=os.path.abspath, | |
454 help='The directory of where to put the bundle (default: %(default)r).') | |
455 | 416 |
456 def properties_file_type(filename): | 417 def properties_file_type(filename): |
457 with (sys.stdin if filename == '-' else open(filename)) as f: | 418 with (sys.stdin if filename == '-' else open(filename)) as f: |
458 obj = json.load(f) | 419 obj = json.load(f) |
459 if not isinstance(obj, dict): | 420 if not isinstance(obj, dict): |
460 raise argparse.ArgumentTypeError( | 421 raise argparse.ArgumentTypeError( |
461 "must contain a JSON object, i.e. `{}`.") | 422 'must contain a JSON object, i.e. `{}`.') |
462 return obj | 423 return obj |
463 | 424 |
464 def parse_prop(prop): | 425 def parse_prop(prop): |
465 key, val = prop.split('=', 1) | 426 key, val = prop.split('=', 1) |
466 try: | 427 try: |
467 val = json.loads(val) | 428 val = json.loads(val) |
468 except (ValueError, SyntaxError): | 429 except (ValueError, SyntaxError): |
469 pass # If a value couldn't be evaluated, keep the string version | 430 pass # If a value couldn't be evaluated, keep the string version |
470 return {key: val} | 431 return {key: val} |
471 | 432 |
472 def properties_type(value): | 433 def properties_type(value): |
473 obj = json.loads(value) | 434 obj = json.loads(value) |
474 if not isinstance(obj, dict): | 435 if not isinstance(obj, dict): |
475 raise argparse.ArgumentTypeError("must contain a JSON object, i.e. `{}`.") | 436 raise argparse.ArgumentTypeError('must contain a JSON object, i.e. `{}`.') |
476 return obj | 437 return obj |
477 | 438 |
478 run_p = subp.add_parser( | 439 run_p = subp.add_parser( |
479 'run', | 440 'run', |
480 description='Run a recipe locally') | 441 description='Run a recipe locally') |
481 run_p.set_defaults(command='run', properties={}) | 442 run_p.set_defaults(command='run', properties={}) |
482 | 443 |
483 run_p.add_argument( | 444 run_p.add_argument( |
484 '--workdir', | 445 '--workdir', |
485 type=os.path.abspath, | 446 type=os.path.abspath, |
(...skipping 30 matching lines...) Expand all Loading... | |
516 'recipe', | 477 'recipe', |
517 help='The recipe to execute') | 478 help='The recipe to execute') |
518 run_p.add_argument( | 479 run_p.add_argument( |
519 'props', | 480 'props', |
520 nargs=argparse.REMAINDER, | 481 nargs=argparse.REMAINDER, |
521 type=parse_prop, | 482 type=parse_prop, |
522 help='A list of property pairs; e.g. mastername=chromium.linux ' | 483 help='A list of property pairs; e.g. mastername=chromium.linux ' |
523 'issue=12345. The property value will be decoded as JSON, but if ' | 484 'issue=12345. The property value will be decoded as JSON, but if ' |
524 'this decoding fails the value will be interpreted as a string.') | 485 'this decoding fails the value will be interpreted as a string.') |
525 | 486 |
487 | |
526 remote_p = subp.add_parser( | 488 remote_p = subp.add_parser( |
527 'remote', | 489 'remote', |
528 description='Invoke a recipe command from specified repo and revision') | 490 description='Invoke a recipe command from specified repo and revision') |
529 remote_p.set_defaults(command='remote') | 491 remote_p.set_defaults(command='remote') |
530 remote_p.add_argument( | 492 remote_p.add_argument( |
531 '--repository', required=True, | 493 '--repository', required=True, |
532 help='URL of a git repository to fetch') | 494 help='URL of a git repository to fetch') |
533 remote_p.add_argument( | 495 remote_p.add_argument( |
534 '--revision', | 496 '--revision', |
535 help=( | 497 help=( |
(...skipping 61 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
597 'doc', | 559 'doc', |
598 description='List all known modules reachable from the current package, ' | 560 description='List all known modules reachable from the current package, ' |
599 'with their documentation') | 561 'with their documentation') |
600 doc_p.add_argument('recipe', nargs='?', | 562 doc_p.add_argument('recipe', nargs='?', |
601 help='Restrict documentation to this recipe') | 563 help='Restrict documentation to this recipe') |
602 doc_p.add_argument('--kind', default='jsonpb', choices=doc_kinds, | 564 doc_p.add_argument('--kind', default='jsonpb', choices=doc_kinds, |
603 help='Output this kind of documentation') | 565 help='Output this kind of documentation') |
604 doc_p.set_defaults(command='doc') | 566 doc_p.set_defaults(command='doc') |
605 | 567 |
606 args = parser.parse_args() | 568 args = parser.parse_args() |
607 post_process_common_args(parser, args) | 569 common_postprocess_func(parser, args) |
570 if hasattr(args, 'postprocess_func'): | |
dnj
2017/04/27 16:33:53
ew ... maybe ABC / classes are actually the way to
| |
571 args.postprocess_func(parser, args) | |
608 | 572 |
609 # TODO(iannucci): We should always do logging.basicConfig() (probably with | 573 # TODO(iannucci): We should always do logging.basicConfig() (probably with |
610 # logging.WARNING), even if no verbose is passed. However we need to be | 574 # logging.WARNING), even if no verbose is passed. However we need to be |
611 # careful as this could cause issues with spurious/unexpected output. I think | 575 # careful as this could cause issues with spurious/unexpected output. I think |
612 # it's risky enough to do in a different CL. | 576 # it's risky enough to do in a different CL. |
613 | 577 |
614 if args.verbose > 0: | 578 if args.verbose > 0: |
615 logging.basicConfig() | 579 logging.basicConfig() |
616 logging.getLogger().setLevel(logging.INFO) | 580 logging.getLogger().setLevel(logging.INFO) |
617 if args.verbose > 1: | 581 if args.verbose > 1: |
(...skipping 93 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
711 # to automatically find a consistent state, rather than bailing out. | 675 # to automatically find a consistent state, rather than bailing out. |
712 # Especially that only some subcommands refer to package_deps. | 676 # Especially that only some subcommands refer to package_deps. |
713 package_deps = package.PackageDeps.create( | 677 package_deps = package.PackageDeps.create( |
714 repo_root, config_file, allow_fetch=not args.no_fetch, | 678 repo_root, config_file, allow_fetch=not args.no_fetch, |
715 deps_path=args.deps_path, overrides=args.project_override) | 679 deps_path=args.deps_path, overrides=args.project_override) |
716 except subprocess.CalledProcessError: | 680 except subprocess.CalledProcessError: |
717 # A git checkout failed somewhere. Return 2, which is the sign that this is | 681 # A git checkout failed somewhere. Return 2, which is the sign that this is |
718 # an infra failure, rather than a test failure. | 682 # an infra failure, rather than a test failure. |
719 return 2 | 683 return 2 |
720 | 684 |
721 if args.command == 'fetch': | 685 if hasattr(args, 'func'): |
722 # We already did everything in the create() call above. | 686 return args.func(package_deps, args) |
723 assert not args.no_fetch, 'Fetch? No-fetch? Make up your mind!' | 687 |
724 return 0 | 688 if args.command == 'test': |
725 elif args.command == 'test': | |
726 return test(config_file, package_deps, args) | 689 return test(config_file, package_deps, args) |
727 elif args.command == 'bundle': | |
728 return bundle(config_file, package_deps, args) | |
729 elif args.command == 'lint': | |
730 return lint(config_file, package_deps, args) | |
731 elif args.command == 'run': | 690 elif args.command == 'run': |
732 return run(config_file, package_deps, args) | 691 return run(config_file, package_deps, args) |
733 elif args.command == 'autoroll': | 692 elif args.command == 'autoroll': |
734 return autoroll(repo_root, config_file, args) | 693 return autoroll(repo_root, config_file, args) |
735 elif args.command == 'depgraph': | 694 elif args.command == 'depgraph': |
736 return depgraph(config_file, package_deps, args) | 695 return depgraph(config_file, package_deps, args) |
737 elif args.command == 'refs': | 696 elif args.command == 'refs': |
738 return refs(config_file, package_deps, args) | 697 return refs(config_file, package_deps, args) |
739 elif args.command == 'doc': | 698 elif args.command == 'doc': |
740 return doc(config_file, package_deps, args) | 699 return doc(config_file, package_deps, args) |
(...skipping 29 matching lines...) Expand all Loading... | |
770 | 729 |
771 if not isinstance(ret, int): | 730 if not isinstance(ret, int): |
772 if ret is None: | 731 if ret is None: |
773 ret = 0 | 732 ret = 0 |
774 else: | 733 else: |
775 print >> sys.stderr, ret | 734 print >> sys.stderr, ret |
776 ret = 1 | 735 ret = 1 |
777 sys.stdout.flush() | 736 sys.stdout.flush() |
778 sys.stderr.flush() | 737 sys.stderr.flush() |
779 os._exit(ret) | 738 os._exit(ret) |
OLD | NEW |