Mercurial > public > mercurial-scm > hg
comparison mercurial/wireprotov2server.py @ 39810:0b61d21f05cc
wireprotov2: declare command arguments richly
Previously, we declared command arguments with an example of
their value. After this commit, we declare command arguments
as a dict of metadata. This allows us to define the value
type, whether the argument is required, and provide default
values. This in turn allows us to have nice things, such as
less boilerplate code in individual commands for validating
input and assigning default values. It should also make command
behavior more consistent as a result.
Test output changed slightly because I realized that the "fields"
argument wasn't being consistently defined as a set. Oops!
Other test output changed because of slight differences in code
performing type validation.
Differential Revision: https://phab.mercurial-scm.org/D4615
author | Gregory Szorc <gregory.szorc@gmail.com> |
---|---|
date | Thu, 30 Aug 2018 17:43:47 -0700 |
parents | aa7e312375cf |
children | ae20f52437e9 |
comparison
equal
deleted
inserted
replaced
39798:ddca38941b2b | 39810:0b61d21f05cc |
---|---|
309 objs = dispatch(repo, proto, command['command']) | 309 objs = dispatch(repo, proto, command['command']) |
310 | 310 |
311 action, meta = reactor.oncommandresponsereadyobjects( | 311 action, meta = reactor.oncommandresponsereadyobjects( |
312 outstream, command['requestid'], objs) | 312 outstream, command['requestid'], objs) |
313 | 313 |
314 except error.WireprotoCommandError as e: | |
315 action, meta = reactor.oncommanderror( | |
316 outstream, command['requestid'], e.message, e.messageargs) | |
317 | |
314 except Exception as e: | 318 except Exception as e: |
315 action, meta = reactor.onservererror( | 319 action, meta = reactor.onservererror( |
316 outstream, command['requestid'], | 320 outstream, command['requestid'], |
317 _('exception when invoking command: %s') % e) | 321 _('exception when invoking command: %s') % e) |
318 | 322 |
346 @property | 350 @property |
347 def name(self): | 351 def name(self): |
348 return HTTP_WIREPROTO_V2 | 352 return HTTP_WIREPROTO_V2 |
349 | 353 |
350 def getargs(self, args): | 354 def getargs(self, args): |
355 # First look for args that were passed but aren't registered on this | |
356 # command. | |
357 extra = set(self._args) - set(args) | |
358 if extra: | |
359 raise error.WireprotoCommandError( | |
360 'unsupported argument to command: %s' % | |
361 ', '.join(sorted(extra))) | |
362 | |
363 # And look for required arguments that are missing. | |
364 missing = {a for a in args if args[a]['required']} - set(self._args) | |
365 | |
366 if missing: | |
367 raise error.WireprotoCommandError( | |
368 'missing required arguments: %s' % ', '.join(sorted(missing))) | |
369 | |
370 # Now derive the arguments to pass to the command, taking into | |
371 # account the arguments specified by the client. | |
351 data = {} | 372 data = {} |
352 for k, typ in args.items(): | 373 for k, meta in sorted(args.items()): |
353 if k == '*': | 374 # This argument wasn't passed by the client. |
354 raise NotImplementedError('do not support * args') | 375 if k not in self._args: |
355 elif k in self._args: | 376 data[k] = meta['default']() |
356 # TODO consider validating value types. | 377 continue |
357 data[k] = self._args[k] | 378 |
379 v = self._args[k] | |
380 | |
381 # Sets may be expressed as lists. Silently normalize. | |
382 if meta['type'] == 'set' and isinstance(v, list): | |
383 v = set(v) | |
384 | |
385 # TODO consider more/stronger type validation. | |
386 | |
387 data[k] = v | |
358 | 388 |
359 return data | 389 return data |
360 | 390 |
361 def getprotocaps(self): | 391 def getprotocaps(self): |
362 # Protocol capabilities are currently not implemented for HTTP V2. | 392 # Protocol capabilities are currently not implemented for HTTP V2. |
402 } | 432 } |
403 | 433 |
404 # TODO expose available changesetdata fields. | 434 # TODO expose available changesetdata fields. |
405 | 435 |
406 for command, entry in COMMANDS.items(): | 436 for command, entry in COMMANDS.items(): |
437 args = {arg: meta['example'] for arg, meta in entry.args.items()} | |
438 | |
407 caps['commands'][command] = { | 439 caps['commands'][command] = { |
408 'args': entry.args, | 440 'args': args, |
409 'permissions': [entry.permission], | 441 'permissions': [entry.permission], |
410 } | 442 } |
411 | 443 |
412 if streamclone.allowservergeneration(repo): | 444 if streamclone.allowservergeneration(repo): |
413 caps['rawrepoformats'] = sorted(repo.requirements & | 445 caps['rawrepoformats'] = sorted(repo.requirements & |
498 def wireprotocommand(name, args=None, permission='push'): | 530 def wireprotocommand(name, args=None, permission='push'): |
499 """Decorator to declare a wire protocol command. | 531 """Decorator to declare a wire protocol command. |
500 | 532 |
501 ``name`` is the name of the wire protocol command being provided. | 533 ``name`` is the name of the wire protocol command being provided. |
502 | 534 |
503 ``args`` is a dict of argument names to example values. | 535 ``args`` is a dict defining arguments accepted by the command. Keys are |
536 the argument name. Values are dicts with the following keys: | |
537 | |
538 ``type`` | |
539 The argument data type. Must be one of the following string | |
540 literals: ``bytes``, ``int``, ``list``, ``dict``, ``set``, | |
541 or ``bool``. | |
542 | |
543 ``default`` | |
544 A callable returning the default value for this argument. If not | |
545 specified, ``None`` will be the default value. | |
546 | |
547 ``required`` | |
548 Bool indicating whether the argument is required. | |
549 | |
550 ``example`` | |
551 An example value for this argument. | |
504 | 552 |
505 ``permission`` defines the permission type needed to run this command. | 553 ``permission`` defines the permission type needed to run this command. |
506 Can be ``push`` or ``pull``. These roughly map to read-write and read-only, | 554 Can be ``push`` or ``pull``. These roughly map to read-write and read-only, |
507 respectively. Default is to assume command requires ``push`` permissions | 555 respectively. Default is to assume command requires ``push`` permissions |
508 because otherwise commands not declaring their permissions could modify | 556 because otherwise commands not declaring their permissions could modify |
527 | 575 |
528 if not isinstance(args, dict): | 576 if not isinstance(args, dict): |
529 raise error.ProgrammingError('arguments for version 2 commands ' | 577 raise error.ProgrammingError('arguments for version 2 commands ' |
530 'must be declared as dicts') | 578 'must be declared as dicts') |
531 | 579 |
580 for arg, meta in args.items(): | |
581 if arg == '*': | |
582 raise error.ProgrammingError('* argument name not allowed on ' | |
583 'version 2 commands') | |
584 | |
585 if not isinstance(meta, dict): | |
586 raise error.ProgrammingError('arguments for version 2 commands ' | |
587 'must declare metadata as a dict') | |
588 | |
589 if 'type' not in meta: | |
590 raise error.ProgrammingError('%s argument for command %s does not ' | |
591 'declare type field' % (arg, name)) | |
592 | |
593 if meta['type'] not in ('bytes', 'int', 'list', 'dict', 'set', 'bool'): | |
594 raise error.ProgrammingError('%s argument for command %s has ' | |
595 'illegal type: %s' % (arg, name, | |
596 meta['type'])) | |
597 | |
598 if 'example' not in meta: | |
599 raise error.ProgrammingError('%s argument for command %s does not ' | |
600 'declare example field' % (arg, name)) | |
601 | |
602 if 'default' in meta and meta.get('required'): | |
603 raise error.ProgrammingError('%s argument for command %s is marked ' | |
604 'as required but has a default value' % | |
605 (arg, name)) | |
606 | |
607 meta.setdefault('default', lambda: None) | |
608 meta.setdefault('required', False) | |
609 | |
532 def register(func): | 610 def register(func): |
533 if name in COMMANDS: | 611 if name in COMMANDS: |
534 raise error.ProgrammingError('%s command already registered ' | 612 raise error.ProgrammingError('%s command already registered ' |
535 'for version 2' % name) | 613 'for version 2' % name) |
536 | 614 |
548 | 626 |
549 @wireprotocommand('capabilities', permission='pull') | 627 @wireprotocommand('capabilities', permission='pull') |
550 def capabilitiesv2(repo, proto): | 628 def capabilitiesv2(repo, proto): |
551 yield _capabilitiesv2(repo, proto) | 629 yield _capabilitiesv2(repo, proto) |
552 | 630 |
553 @wireprotocommand('changesetdata', | 631 @wireprotocommand( |
554 args={ | 632 'changesetdata', |
555 'noderange': [[b'0123456...'], [b'abcdef...']], | 633 args={ |
556 'nodes': [b'0123456...'], | 634 'noderange': { |
557 'fields': {b'parents', b'revision'}, | 635 'type': 'list', |
558 }, | 636 'example': [[b'0123456...'], [b'abcdef...']], |
559 permission='pull') | 637 }, |
560 def changesetdata(repo, proto, noderange=None, nodes=None, fields=None): | 638 'nodes': { |
561 fields = fields or set() | 639 'type': 'list', |
562 | 640 'example': [b'0123456...'], |
641 }, | |
642 'fields': { | |
643 'type': 'set', | |
644 'default': set, | |
645 'example': {b'parents', b'revision'}, | |
646 }, | |
647 }, | |
648 permission='pull') | |
649 def changesetdata(repo, proto, noderange, nodes, fields): | |
563 # TODO look for unknown fields and abort when they can't be serviced. | 650 # TODO look for unknown fields and abort when they can't be serviced. |
564 | 651 |
565 if noderange is None and nodes is None: | 652 if noderange is None and nodes is None: |
566 raise error.WireprotoCommandError( | 653 raise error.WireprotoCommandError( |
567 'noderange or nodes must be defined') | 654 'noderange or nodes must be defined') |
689 if not len(fl): | 776 if not len(fl): |
690 raise FileAccessError(path, 'unknown file: %s', (path,)) | 777 raise FileAccessError(path, 'unknown file: %s', (path,)) |
691 | 778 |
692 return fl | 779 return fl |
693 | 780 |
694 @wireprotocommand('filedata', | 781 @wireprotocommand( |
695 args={ | 782 'filedata', |
696 'haveparents': True, | 783 args={ |
697 'nodes': [b'0123456...'], | 784 'haveparents': { |
698 'fields': [b'parents', b'revision'], | 785 'type': 'bool', |
699 'path': b'foo.txt', | 786 'default': lambda: False, |
700 }, | 787 'example': True, |
701 permission='pull') | 788 }, |
702 def filedata(repo, proto, haveparents=False, nodes=None, fields=None, | 789 'nodes': { |
703 path=None): | 790 'type': 'list', |
704 fields = fields or set() | 791 'required': True, |
705 | 792 'example': [b'0123456...'], |
706 if nodes is None: | 793 }, |
707 raise error.WireprotoCommandError('nodes argument must be defined') | 794 'fields': { |
708 | 795 'type': 'set', |
709 if path is None: | 796 'default': set, |
710 raise error.WireprotoCommandError('path argument must be defined') | 797 'example': {b'parents', b'revision'}, |
711 | 798 }, |
799 'path': { | |
800 'type': 'bytes', | |
801 'required': True, | |
802 'example': b'foo.txt', | |
803 } | |
804 }, | |
805 permission='pull') | |
806 def filedata(repo, proto, haveparents, nodes, fields, path): | |
712 try: | 807 try: |
713 # Extensions may wish to access the protocol handler. | 808 # Extensions may wish to access the protocol handler. |
714 store = getfilestore(repo, proto, path) | 809 store = getfilestore(repo, proto, path) |
715 except FileAccessError as e: | 810 except FileAccessError as e: |
716 raise error.WireprotoCommandError(e.msg, e.args) | 811 raise error.WireprotoCommandError(e.msg, e.args) |
774 next(deltas) | 869 next(deltas) |
775 raise error.ProgrammingError('should not have more deltas') | 870 raise error.ProgrammingError('should not have more deltas') |
776 except GeneratorExit: | 871 except GeneratorExit: |
777 pass | 872 pass |
778 | 873 |
779 @wireprotocommand('heads', | 874 @wireprotocommand( |
780 args={ | 875 'heads', |
781 'publiconly': False, | 876 args={ |
782 }, | 877 'publiconly': { |
783 permission='pull') | 878 'type': 'bool', |
784 def headsv2(repo, proto, publiconly=False): | 879 'default': lambda: False, |
880 'example': False, | |
881 }, | |
882 }, | |
883 permission='pull') | |
884 def headsv2(repo, proto, publiconly): | |
785 if publiconly: | 885 if publiconly: |
786 repo = repo.filtered('immutable') | 886 repo = repo.filtered('immutable') |
787 | 887 |
788 yield repo.heads() | 888 yield repo.heads() |
789 | 889 |
790 @wireprotocommand('known', | 890 @wireprotocommand( |
791 args={ | 891 'known', |
792 'nodes': [b'deadbeef'], | 892 args={ |
793 }, | 893 'nodes': { |
794 permission='pull') | 894 'type': 'list', |
795 def knownv2(repo, proto, nodes=None): | 895 'default': list, |
796 nodes = nodes or [] | 896 'example': [b'deadbeef'], |
897 }, | |
898 }, | |
899 permission='pull') | |
900 def knownv2(repo, proto, nodes): | |
797 result = b''.join(b'1' if n else b'0' for n in repo.known(nodes)) | 901 result = b''.join(b'1' if n else b'0' for n in repo.known(nodes)) |
798 yield result | 902 yield result |
799 | 903 |
800 @wireprotocommand('listkeys', | 904 @wireprotocommand( |
801 args={ | 905 'listkeys', |
802 'namespace': b'ns', | 906 args={ |
803 }, | 907 'namespace': { |
804 permission='pull') | 908 'type': 'bytes', |
805 def listkeysv2(repo, proto, namespace=None): | 909 'required': True, |
910 'example': b'ns', | |
911 }, | |
912 }, | |
913 permission='pull') | |
914 def listkeysv2(repo, proto, namespace): | |
806 keys = repo.listkeys(encoding.tolocal(namespace)) | 915 keys = repo.listkeys(encoding.tolocal(namespace)) |
807 keys = {encoding.fromlocal(k): encoding.fromlocal(v) | 916 keys = {encoding.fromlocal(k): encoding.fromlocal(v) |
808 for k, v in keys.iteritems()} | 917 for k, v in keys.iteritems()} |
809 | 918 |
810 yield keys | 919 yield keys |
811 | 920 |
812 @wireprotocommand('lookup', | 921 @wireprotocommand( |
813 args={ | 922 'lookup', |
814 'key': b'foo', | 923 args={ |
815 }, | 924 'key': { |
816 permission='pull') | 925 'type': 'bytes', |
926 'required': True, | |
927 'example': b'foo', | |
928 }, | |
929 }, | |
930 permission='pull') | |
817 def lookupv2(repo, proto, key): | 931 def lookupv2(repo, proto, key): |
818 key = encoding.tolocal(key) | 932 key = encoding.tolocal(key) |
819 | 933 |
820 # TODO handle exception. | 934 # TODO handle exception. |
821 node = repo.lookup(key) | 935 node = repo.lookup(key) |
822 | 936 |
823 yield node | 937 yield node |
824 | 938 |
825 @wireprotocommand('manifestdata', | 939 @wireprotocommand( |
826 args={ | 940 'manifestdata', |
827 'nodes': [b'0123456...'], | 941 args={ |
828 'haveparents': True, | 942 'nodes': { |
829 'fields': [b'parents', b'revision'], | 943 'type': 'list', |
830 'tree': b'', | 944 'required': True, |
831 }, | 945 'example': [b'0123456...'], |
832 permission='pull') | 946 }, |
833 def manifestdata(repo, proto, haveparents=False, nodes=None, fields=None, | 947 'haveparents': { |
834 tree=None): | 948 'type': 'bool', |
835 fields = fields or set() | 949 'default': lambda: False, |
836 | 950 'example': True, |
837 if nodes is None: | 951 }, |
838 raise error.WireprotoCommandError( | 952 'fields': { |
839 'nodes argument must be defined') | 953 'type': 'set', |
840 | 954 'default': set, |
841 if tree is None: | 955 'example': {b'parents', b'revision'}, |
842 raise error.WireprotoCommandError( | 956 }, |
843 'tree argument must be defined') | 957 'tree': { |
844 | 958 'type': 'bytes', |
959 'required': True, | |
960 'example': b'', | |
961 }, | |
962 }, | |
963 permission='pull') | |
964 def manifestdata(repo, proto, haveparents, nodes, fields, tree): | |
845 store = repo.manifestlog.getstorage(tree) | 965 store = repo.manifestlog.getstorage(tree) |
846 | 966 |
847 # Validate the node is known and abort on unknown revisions. | 967 # Validate the node is known and abort on unknown revisions. |
848 for node in nodes: | 968 for node in nodes: |
849 try: | 969 try: |
903 next(deltas) | 1023 next(deltas) |
904 raise error.ProgrammingError('should not have more deltas') | 1024 raise error.ProgrammingError('should not have more deltas') |
905 except GeneratorExit: | 1025 except GeneratorExit: |
906 pass | 1026 pass |
907 | 1027 |
908 @wireprotocommand('pushkey', | 1028 @wireprotocommand( |
909 args={ | 1029 'pushkey', |
910 'namespace': b'ns', | 1030 args={ |
911 'key': b'key', | 1031 'namespace': { |
912 'old': b'old', | 1032 'type': 'bytes', |
913 'new': b'new', | 1033 'required': True, |
914 }, | 1034 'example': b'ns', |
915 permission='push') | 1035 }, |
1036 'key': { | |
1037 'type': 'bytes', | |
1038 'required': True, | |
1039 'example': b'key', | |
1040 }, | |
1041 'old': { | |
1042 'type': 'bytes', | |
1043 'required': True, | |
1044 'example': b'old', | |
1045 }, | |
1046 'new': { | |
1047 'type': 'bytes', | |
1048 'required': True, | |
1049 'example': 'new', | |
1050 }, | |
1051 }, | |
1052 permission='push') | |
916 def pushkeyv2(repo, proto, namespace, key, old, new): | 1053 def pushkeyv2(repo, proto, namespace, key, old, new): |
917 # TODO handle ui output redirection | 1054 # TODO handle ui output redirection |
918 yield repo.pushkey(encoding.tolocal(namespace), | 1055 yield repo.pushkey(encoding.tolocal(namespace), |
919 encoding.tolocal(key), | 1056 encoding.tolocal(key), |
920 encoding.tolocal(old), | 1057 encoding.tolocal(old), |