diff --git a/ipalib/frontend.py b/ipalib/frontend.py index ec14ad102..bca586d37 100644 --- a/ipalib/frontend.py +++ b/ipalib/frontend.py @@ -29,6 +29,7 @@ from ipapython.ipautil import APIVersion from ipalib.base import NameSpace from ipalib.plugable import Plugin, APINameSpace from ipalib.parameters import create_param, Param, Str, Flag +from ipalib.parameters import create_signature from ipalib.parameters import Password # pylint: disable=unused-import from ipalib.output import Output, Entry, ListOfEntries from ipalib.text import _ @@ -37,7 +38,7 @@ from ipalib.errors import (ZeroArgumentError, MaxArgumentError, OverlapError, ValidationError, ConversionError) from ipalib import errors, messages from ipalib.request import context, context_frame -from ipalib.util import classproperty, json_serialize +from ipalib.util import classproperty, classobjectproperty, json_serialize if six.PY3: unicode = str @@ -433,6 +434,26 @@ class Command(HasParam): topic = classproperty(__topic_getter) + @classobjectproperty + @classmethod + def __signature__(cls, obj): + # signature is cached on the class object + if hasattr(cls, "_signature"): + return cls._signature + # can only create signature for 'final' classes + # help(api.Command.user_show) breaks because pydoc inspects parent + # classes and baseuser plugin is not a registered object. + if cls.__subclasses__(): + cls._signature = None + return None + # special, rare case: user calls help() on a plugin class instead of + # an instance + if obj is None: + from ipalib import api + obj = cls(api=api) + cls._signature = signature = create_signature(obj) + return signature + @property def forwarded_name(self): return self.full_name diff --git a/ipalib/parameters.py b/ipalib/parameters.py index 472e7bccc..b49ea8119 100644 --- a/ipalib/parameters.py +++ b/ipalib/parameters.py @@ -103,10 +103,13 @@ import re import decimal import base64 import datetime +import inspect +import typing from xmlrpc.client import MAXINT, MININT import six from cryptography import x509 as crypto_x509 +import dns.name from ipalib.text import _ as ugettext from ipalib.base import check_name @@ -2155,3 +2158,67 @@ class Principal(Param): name=self.get_param_name(), error=_("Service principal is required") ) + + +_map_types = { + # map internal certificate subclass to generic cryptography class + IPACertificate: crypto_x509.Certificate, + # map internal DNS name class to generic dnspython class + DNSName: dns.name.Name, + # DN, Principal have their names mangled in ipaapi.__init__ +} + + +def create_signature(command): + """Create an inspect.Signature for a command + + :param command: ipa plugin instance (server or client) + :return: inspect.Signature instance + """ + + signature_params = [] + seen = set() + args_options = [ + (command.get_args(), inspect.Parameter.POSITIONAL_OR_KEYWORD), + (command.get_options(), inspect.Parameter.KEYWORD_ONLY) + ] + for ipaparams, kind in args_options: + for ipaparam in ipaparams: + # filter out duplicates, for example user_del has a preserve flag + # and preserve bool. + if ipaparam.name in seen: + continue + seen.add(ipaparam.name) + # ipalib.plugins.misc.env has wrong type + if not isinstance(ipaparam, Param): + continue + + if ipaparam.required: + default = inspect.Parameter.empty + else: + default = ipaparam.default + + allowed_types = tuple( + _map_types.get(t, t) for t in ipaparam.allowed_types + ) + # ipalib.parameters.DNSNameParam also handles text + if isinstance(ipaparam, DNSNameParam): + allowed_types += (six.text_type,) + ann = typing.Union[allowed_types] + if ipaparam.multivalue: + ann = typing.List[ann] + + signature_params.append( + inspect.Parameter( + ipaparam.name, kind, default=default, annotation=ann + ) + ) + + # cannot describe return parameter with typing yet. TypedDict + # is only available with mypy_extension. + signature = inspect.Signature( + signature_params, + return_annotation=typing.Dict[typing.Text, typing.Any] + ) + + return signature diff --git a/ipalib/util.py b/ipalib/util.py index e0c658c51..d146581d4 100644 --- a/ipalib/util.py +++ b/ipalib/util.py @@ -1027,6 +1027,7 @@ class classproperty: __slots__ = ('__doc__', 'fget') def __init__(self, fget=None, doc=None): + assert isinstance(fget, classmethod) if doc is None and fget is not None: doc = fget.__doc__ @@ -1049,6 +1050,17 @@ class classproperty: return self +class classobjectproperty(classproperty): + # A class property that also passes the object to the getter + # obj is None for class objects and 'self' for instance objects. + __slots__ = ('__doc__',) + + def __get__(self, obj, obj_type): + if self.fget is not None: + return self.fget.__get__(obj, obj_type)(obj) + raise AttributeError("unreadable attribute") + + def normalize_hostname(hostname): """Use common fqdn form without the trailing dot""" if hostname.endswith(u'.'):