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),