Repository: ipython/traitlets
Pull request #538 (2019-11-04 08:04:54)

Add trait-names autocompletion support
This PR takes over #515


Patch:
diff --git a/docs/source/utils.rst b/docs/source/utils.rst
index 7867b94..df5bd1c 100644
--- a/docs/source/utils.rst
+++ b/docs/source/utils.rst
@@ -7,6 +7,30 @@ A simple utility to import something by its string name.
 
 .. autofunction:: import_item
 
+A way to expand the signature of the ``HasTraits`` class constructor. This enables auto-completion of trait-names in IPython and xeus-python when having Jedi>=0.15 by adding trait names with their default values in the constructor signature.
+Example:
+
+.. code:: Python
+
+    from inspect import signature
+
+    from traitlets import HasTraits, Int, Unicode, signature_has_traits
+
+    @signature_has_traits
+    class Foo(HasTraits):
+        number1 = Int()
+        number2 = Int()
+        value = Unicode('Hello')
+
+        def __init__(self, arg1, **kwargs):
+            self.arg1 = arg1
+
+            super(Foo, self).__init__(**kwargs)
+
+    print(signature(Foo))  # <Signature (arg1, *, number1=0, number2=0, value='Hello', **kwargs)>
+
+.. autofunction:: signature_has_traits
+
 Links
 -----
 
diff --git a/setup.py b/setup.py
index b7245db..b5a539f 100644
--- a/setup.py
+++ b/setup.py
@@ -82,6 +82,7 @@ install_requires = setuptools_args['install_requires'] = [
     'ipython_genutils',
     'six',
     'decorator',
+    'funcsigs',
 ]
 
 extras_require = setuptools_args['extras_require'] = {
diff --git a/traitlets/__init__.py b/traitlets/__init__.py
index b609adb..41f9a56 100644
--- a/traitlets/__init__.py
+++ b/traitlets/__init__.py
@@ -1,3 +1,4 @@
 from .traitlets import *
 from .utils.importstring import import_item
+from .utils.decorators import signature_has_traits
 from ._version import version_info, __version__
diff --git a/traitlets/utils/decorators.py b/traitlets/utils/decorators.py
new file mode 100644
index 0000000..807c2ac
--- /dev/null
+++ b/traitlets/utils/decorators.py
@@ -0,0 +1,83 @@
+"""Useful decorators for Traitlets users."""
+
+import copy
+
+try:
+    from inspect import Signature, Parameter, signature
+except ImportError:
+    from funcsigs import Signature, Parameter, signature
+
+from ..traitlets import Undefined
+
+
+def _get_default(value):
+    """Get default argument value, given the trait default value."""
+    return Parameter.empty if value == Undefined else value
+
+
+def signature_has_traits(cls):
+    """Return a decorated class with a constructor signature that contain Trait names as kwargs."""
+    traits = [
+        (name, _get_default(value.default_value))
+        for name, value in cls.class_traits().items()
+        if not name.startswith('_')
+    ]
+
+    # Taking the __init__ signature, as the cls signature is not initialized yet
+    old_signature = signature(cls.__init__)
+    old_parameter_names = list(old_signature.parameters)
+
+    old_positional_parameters = []
+    old_var_positional_parameter = None  # This won't be None if the old signature contains *args
+    old_keyword_only_parameters = []
+    old_var_keyword_parameter = None  # This won't be None if the old signature contains **kwargs
+
+    for parameter_name in old_signature.parameters:
+        # Copy the parameter
+        parameter = copy.copy(old_signature.parameters[parameter_name])
+
+        if parameter.kind is Parameter.POSITIONAL_ONLY or parameter.kind is Parameter.POSITIONAL_OR_KEYWORD:
+            old_positional_parameters.append(parameter)
+
+        elif parameter.kind is Parameter.VAR_POSITIONAL:
+            old_var_positional_parameter = parameter
+
+        elif parameter.kind is Parameter.KEYWORD_ONLY:
+            old_keyword_only_parameters.append(parameter)
+
+        elif parameter.kind is Parameter.VAR_KEYWORD:
+            old_var_keyword_parameter = parameter
+
+    # Unfortunately, if the old signature does not contain **kwargs, we can't do anything,
+    # because it can't accept traits as keyword arguments
+    if old_var_keyword_parameter is None:
+        raise RuntimeError(
+            'The {} constructor does not take **kwargs, which means that the signature can not be expanded with trait names'
+            .format(cls)
+        )
+
+    new_parameters = []
+
+    # Append the old positional parameters (except `self` which is the first parameter)
+    new_parameters += old_positional_parameters[1:]
+
+    # Append *args if the old signature had it
+    if old_var_positional_parameter is not None:
+        new_parameters.append(old_var_positional_parameter)
+
+    # Append the old keyword only parameters
+    new_parameters += old_keyword_only_parameters
+
+    # Append trait names as keyword only parameters in the signature
+    new_parameters += [
+        Parameter(name, kind=Parameter.KEYWORD_ONLY, default=default)
+        for name, default in traits
+        if name not in old_parameter_names
+    ]
+
+    # Append **kwargs
+    new_parameters.append(old_var_keyword_parameter)
+
+    cls.__signature__ = Signature(new_parameters)
+
+    return cls


Test patch:
diff --git a/traitlets/utils/tests/test_decorators.py b/traitlets/utils/tests/test_decorators.py
new file mode 100644
index 0000000..fc38731
--- /dev/null
+++ b/traitlets/utils/tests/test_decorators.py
@@ -0,0 +1,141 @@
+from unittest import TestCase
+try:
+    from inspect import Signature, Parameter, signature
+except ImportError:
+    from funcsigs import Signature, Parameter, signature
+
+from ...traitlets import HasTraits, Int, Unicode
+
+from ..decorators import signature_has_traits
+
+
+class TestExpandSignature(TestCase):
+
+    def test_no_init(self):
+        @signature_has_traits
+        class Foo(HasTraits):
+            number1 = Int()
+            number2 = Int()
+            value = Unicode('Hello')
+
+        parameters = signature(Foo).parameters
+        parameter_names = list(parameters)
+
+        self.assertIs(parameters['args'].kind, Parameter.VAR_POSITIONAL)
+        self.assertEqual('args', parameter_names[0])
+
+        self.assertIs(parameters['number1'].kind, Parameter.KEYWORD_ONLY)
+        self.assertIs(parameters['number2'].kind, Parameter.KEYWORD_ONLY)
+        self.assertIs(parameters['value'].kind, Parameter.KEYWORD_ONLY)
+
+        self.assertIs(parameters['kwargs'].kind, Parameter.VAR_KEYWORD)
+        self.assertEqual('kwargs', parameter_names[-1])
+
+        f = Foo(number1=32, value='World')
+        self.assertEqual(f.number1, 32)
+        self.assertEqual(f.number2, 0)
+        self.assertEqual(f.value, 'World')
+
+    def test_partial_init(self):
+        @signature_has_traits
+        class Foo(HasTraits):
+            number1 = Int()
+            number2 = Int()
+            value = Unicode('Hello')
+
+            def __init__(self, arg1, **kwargs):
+                self.arg1 = arg1
+
+                super(Foo, self).__init__(**kwargs)
+
+        parameters = signature(Foo).parameters
+        parameter_names = list(parameters)
+
+        self.assertIs(parameters['arg1'].kind, Parameter.POSITIONAL_OR_KEYWORD)
+        self.assertEqual('arg1', parameter_names[0])
+
+        self.assertIs(parameters['number1'].kind, Parameter.KEYWORD_ONLY)
+        self.assertIs(parameters['number2'].kind, Parameter.KEYWORD_ONLY)
+        self.assertIs(parameters['value'].kind, Parameter.KEYWORD_ONLY)
+
+        self.assertIs(parameters['kwargs'].kind, Parameter.VAR_KEYWORD)
+        self.assertEqual('kwargs', parameter_names[-1])
+
+        f = Foo(1, number1=32, value='World')
+        self.assertEqual(f.arg1, 1)
+        self.assertEqual(f.number1, 32)
+        self.assertEqual(f.number2, 0)
+        self.assertEqual(f.value, 'World')
+
+    def test_duplicate_init(self):
+        @signature_has_traits
+        class Foo(HasTraits):
+            number1 = Int()
+            number2 = Int()
+
+            def __init__(self, number1, **kwargs):
+                self.test = number1
+
+                super(Foo, self).__init__(number1=number1, **kwargs)
+
+        parameters = signature(Foo).parameters
+        parameter_names = list(parameters)
+
+        self.assertListEqual(parameter_names, ['number1', 'number2', 'kwargs'])
+
+        f = Foo(number1=32, number2=36)
+        self.assertEqual(f.test, 32)
+        self.assertEqual(f.number1, 32)
+        self.assertEqual(f.number2, 36)
+
+    def test_full_init(self):
+        @signature_has_traits
+        class Foo(HasTraits):
+            number1 = Int()
+            number2 = Int()
+            value = Unicode('Hello')
+
+            def __init__(self, arg1, arg2=None, *pos_args, **kw_args):
+                self.arg1 = arg1
+                self.arg2 = arg2
+                self.pos_args = pos_args
+                self.kw_args = kw_args
+
+                super(Foo, self).__init__(*pos_args, **kw_args)
+
+        parameters = signature(Foo).parameters
+        parameter_names = list(parameters)
+
+        self.assertIs(parameters['arg1'].kind, Parameter.POSITIONAL_OR_KEYWORD)
+        self.assertEqual('arg1', parameter_names[0])
+
+        self.assertIs(parameters['arg2'].kind, Parameter.POSITIONAL_OR_KEYWORD)
+        self.assertEqual('arg2', parameter_names[1])
+
+        self.assertIs(parameters['pos_args'].kind, Parameter.VAR_POSITIONAL)
+        self.assertEqual('pos_args', parameter_names[2])
+
+        self.assertIs(parameters['number1'].kind, Parameter.KEYWORD_ONLY)
+        self.assertIs(parameters['number2'].kind, Parameter.KEYWORD_ONLY)
+        self.assertIs(parameters['value'].kind, Parameter.KEYWORD_ONLY)
+
+        self.assertIs(parameters['kw_args'].kind, Parameter.VAR_KEYWORD)
+        self.assertEqual('kw_args', parameter_names[-1])
+
+        f = Foo(1, 3, 45, 'hey', number1=32, value='World')
+        self.assertEqual(f.arg1, 1)
+        self.assertEqual(f.arg2, 3)
+        self.assertTupleEqual(f.pos_args, (45, 'hey'))
+        self.assertEqual(f.number1, 32)
+        self.assertEqual(f.number2, 0)
+        self.assertEqual(f.value, 'World')
+
+    def test_no_kwargs(self):
+        with self.assertRaises(RuntimeError):
+            @signature_has_traits
+            class Foo(HasTraits):
+                number1 = Int()
+                number2 = Int()
+
+                def __init__(self, arg1, arg2=None):
+                    pass