Repository: python-lsp/python-lsp-server
Pull request #83 (2021-09-07 02:40:33)

Create a cache for snippets
- This builds on top of the excellent work done by @krassowski on PR #26 to build a cache for snippets.
- As with labels, this increases completions speed dramatically.

krassowski: Looks good to me. I won't have time to properly test it until next week though.
ccordoba12: No prob. I can wait for your review. 
ccordoba12: Hey @krassowski, any news on this?
krassowski: Sorry, not yet! Feel free to merge - I will take time to look at it ultimately and create a follow up PR if find anything in need of improvement.
stonebig: any hope to have this soon ?
ccordoba12: It will come along with Spyder 5.2.0, to be released in November.

Patch:
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index 46ab4ad..5f32f78 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -22,8 +22,8 @@ This server can be configured using `workspace/didChangeConfiguration` method. E
 | `pylsp.plugins.jedi_completion.include_class_objects` | `boolean` | Adds class objects as a separate completion item. | `true` |
 | `pylsp.plugins.jedi_completion.fuzzy` | `boolean` | Enable fuzzy when requesting autocomplete. | `false` |
 | `pylsp.plugins.jedi_completion.eager` | `boolean` | Resolve documentation and detail eagerly. | `false` |
-| `pylsp.plugins.jedi_completion.resolve_at_most_labels` | `number`  | How many labels (at most) should be resolved? | `25` |
-| `pylsp.plugins.jedi_completion.cache_labels_for` | `array`  of  `string` items | Modules for which the labels should be cached. | `["pandas", "numpy", "tensorflow", "matplotlib"]` |
+| `pylsp.plugins.jedi_completion.resolve_at_most` | `number`  | How many labels and snippets (at most) should be resolved? | `25` |
+| `pylsp.plugins.jedi_completion.cache_for` | `array`  of  `string` items | Modules for which labels and snippets should be cached. | `["pandas", "numpy", "tensorflow", "matplotlib"]` |
 | `pylsp.plugins.jedi_definition.enabled` | `boolean` | Enable or disable the plugin. | `true` |
 | `pylsp.plugins.jedi_definition.follow_imports` | `boolean` | The goto call will follow imports. | `true` |
 | `pylsp.plugins.jedi_definition.follow_builtin_imports` | `boolean` | If follow_imports is True will decide if it follow builtin imports. | `true` |
diff --git a/pylsp/config/schema.json b/pylsp/config/schema.json
index d95db74..b2d369a 100644
--- a/pylsp/config/schema.json
+++ b/pylsp/config/schema.json
@@ -104,18 +104,18 @@
       "default": false,
       "description": "Resolve documentation and detail eagerly."
     },
-    "pylsp.plugins.jedi_completion.resolve_at_most_labels": {
+    "pylsp.plugins.jedi_completion.resolve_at_most": {
       "type": "number",
       "default": 25,
-      "description": "How many labels (at most) should be resolved?"
+      "description": "How many labels and snippets (at most) should be resolved?"
     },
-    "pylsp.plugins.jedi_completion.cache_labels_for": {
+    "pylsp.plugins.jedi_completion.cache_for": {
       "type": "array",
       "items": {
         "type": "string"
       },
       "default": ["pandas", "numpy", "tensorflow", "matplotlib"],
-      "description": "Modules for which the labels should be cached."
+      "description": "Modules for which labels and snippets should be cached."
     },
     "pylsp.plugins.jedi_definition.enabled": {
       "type": "boolean",
diff --git a/pylsp/plugins/_resolvers.py b/pylsp/plugins/_resolvers.py
new file mode 100644
index 0000000..4fae86e
--- /dev/null
+++ b/pylsp/plugins/_resolvers.py
@@ -0,0 +1,135 @@
+# Copyright 2017-2020 Palantir Technologies, Inc.
+# Copyright 2021- Python Language Server Contributors.
+
+from collections import defaultdict
+import logging
+from time import time
+
+from jedi.api.classes import Completion
+
+from pylsp import lsp
+
+
+log = logging.getLogger(__name__)
+
+
+# ---- Base class
+# -----------------------------------------------------------------------------
+class Resolver:
+
+    def __init__(self, callback, resolve_on_error, time_to_live=60 * 30):
+        self.callback = callback
+        self.resolve_on_error = resolve_on_error
+        self._cache = {}
+        self._time_to_live = time_to_live
+        self._cache_ttl = defaultdict(set)
+        self._clear_every = 2
+        # see https://github.com/davidhalter/jedi/blob/master/jedi/inference/helpers.py#L194-L202
+        self._cached_modules = {'pandas', 'numpy', 'tensorflow', 'matplotlib'}
+
+    @property
+    def cached_modules(self):
+        return self._cached_modules
+
+    @cached_modules.setter
+    def cached_modules(self, new_value):
+        self._cached_modules = set(new_value)
+
+    def clear_outdated(self):
+        now = self.time_key()
+        to_clear = [
+            timestamp
+            for timestamp in self._cache_ttl
+            if timestamp < now
+        ]
+        for time_key in to_clear:
+            for key in self._cache_ttl[time_key]:
+                del self._cache[key]
+            del self._cache_ttl[time_key]
+
+    def time_key(self):
+        return int(time() / self._time_to_live)
+
+    def get_or_create(self, completion: Completion):
+        if not completion.full_name:
+            use_cache = False
+        else:
+            module_parts = completion.full_name.split('.')
+            use_cache = module_parts and module_parts[0] in self._cached_modules
+
+        if use_cache:
+            key = self._create_completion_id(completion)
+            if key not in self._cache:
+                if self.time_key() % self._clear_every == 0:
+                    self.clear_outdated()
+
+                self._cache[key] = self.resolve(completion)
+                self._cache_ttl[self.time_key()].add(key)
+            return self._cache[key]
+
+        return self.resolve(completion)
+
+    def _create_completion_id(self, completion: Completion):
+        return (
+            completion.full_name, completion.module_path,
+            completion.line, completion.column,
+            self.time_key()
+        )
+
+    def resolve(self, completion):
+        try:
+            sig = completion.get_signatures()
+            return self.callback(completion, sig)
+        except Exception as e:  # pylint: disable=broad-except
+            log.warning(
+                'Something went wrong when resolving label for {completion}: {e}',
+                completion=completion, e=e
+            )
+            return self.resolve_on_error
+
+
+# ---- Label resolver
+# -----------------------------------------------------------------------------
+def format_label(completion, sig):
+    if sig and completion.type in ('function', 'method'):
+        params = ', '.join(param.name for param in sig[0].params)
+        label = '{}({})'.format(completion.name, params)
+        return label
+    return completion.name
+
+
+LABEL_RESOLVER = Resolver(callback=format_label, resolve_on_error='')
+
+
+# ---- Snippets resolver
+# -----------------------------------------------------------------------------
+def format_snippet(completion, sig):
+    if not sig:
+        return {}
+
+    snippet_completion = {}
+
+    positional_args = [param for param in sig[0].params
+                       if '=' not in param.description and
+                       param.name not in {'/', '*'}]
+
+    if len(positional_args) > 1:
+        # For completions with params, we can generate a snippet instead
+        snippet_completion['insertTextFormat'] = lsp.InsertTextFormat.Snippet
+        snippet = completion.name + '('
+        for i, param in enumerate(positional_args):
+            snippet += '${%s:%s}' % (i + 1, param.name)
+            if i < len(positional_args) - 1:
+                snippet += ', '
+        snippet += ')$0'
+        snippet_completion['insertText'] = snippet
+    elif len(positional_args) == 1:
+        snippet_completion['insertTextFormat'] = lsp.InsertTextFormat.Snippet
+        snippet_completion['insertText'] = completion.name + '($0)'
+    else:
+        snippet_completion['insertText'] = completion.name + '()'
+
+    return snippet_completion
+
+
+SNIPPET_RESOLVER = Resolver(callback=format_snippet, resolve_on_error={})
diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py
index 41e2e57..a98f3d2 100644
--- a/pylsp/plugins/jedi_completion.py
+++ b/pylsp/plugins/jedi_completion.py
@@ -3,13 +3,11 @@
 
 import logging
 import os.path as osp
-from collections import defaultdict
-from time import time
 
-from jedi.api.classes import Completion
 import parso
 
 from pylsp import _utils, hookimpl, lsp
+from pylsp.plugins._resolvers import LABEL_RESOLVER, SNIPPET_RESOLVER
 
 log = logging.getLogger(__name__)
 
@@ -57,10 +55,11 @@ def pylsp_completions(config, document, position):
     should_include_params = settings.get('include_params')
     should_include_class_objects = settings.get('include_class_objects', True)
 
-    max_labels_resolve = settings.get('resolve_at_most_labels', 25)
-    modules_to_cache_labels_for = settings.get('cache_labels_for', None)
-    if modules_to_cache_labels_for is not None:
-        LABEL_RESOLVER.cached_modules = modules_to_cache_labels_for
+    max_to_resolve = settings.get('resolve_at_most', 25)
+    modules_to_cache_for = settings.get('cache_for', None)
+    if modules_to_cache_for is not None:
+        LABEL_RESOLVER.cached_modules = modules_to_cache_for
+        SNIPPET_RESOLVER.cached_modules = modules_to_cache_for
 
     include_params = snippet_support and should_include_params and use_snippets(document, position)
     include_class_objects = snippet_support and should_include_class_objects and use_snippets(document, position)
@@ -70,7 +69,7 @@ def pylsp_completions(config, document, position):
             c,
             include_params,
             resolve=resolve_eagerly,
-            resolve_label=(i < max_labels_resolve)
+            resolve_label_or_snippet=(i < max_to_resolve)
         )
         for i, c in enumerate(completions)
     ]
@@ -83,7 +82,7 @@ def pylsp_completions(config, document, position):
                     c,
                     False,
                     resolve=resolve_eagerly,
-                    resolve_label=(i < max_labels_resolve)
+                    resolve_label_or_snippet=(i < max_to_resolve)
                 )
                 completion_dict['kind'] = lsp.CompletionItemKind.TypeParameter
                 completion_dict['label'] += ' object'
@@ -175,9 +174,9 @@ def _resolve_completion(completion, d):
     return completion
 
 
-def _format_completion(d, include_params=True, resolve=False, resolve_label=False):
+def _format_completion(d, include_params=True, resolve=False, resolve_label_or_snippet=False):
     completion = {
-        'label': _label(d, resolve_label),
+        'label': _label(d, resolve_label_or_snippet),
         'kind': _TYPE_MAP.get(d.type),
         'sortText': _sort_text(d),
         'insertText': d.name
@@ -193,29 +192,8 @@ def _format_completion(d, include_params=True, resolve=False, resolve_label=Fals
         completion['insertText'] = path
 
     if include_params and not is_exception_class(d.name):
-        sig = d.get_signatures()
-        if not sig:
-            return completion
-
-        positional_args = [param for param in sig[0].params
-                           if '=' not in param.description and
-                           param.name not in {'/', '*'}]
-
-        if len(positional_args) > 1:
-            # For completions with params, we can generate a snippet instead
-            completion['insertTextFormat'] = lsp.InsertTextFormat.Snippet
-            snippet = d.name + '('
-            for i, param in enumerate(positional_args):
-                snippet += '${%s:%s}' % (i + 1, param.name)
-                if i < len(positional_args) - 1:
-                    snippet += ', '
-            snippet += ')$0'
-            completion['insertText'] = snippet
-        elif len(positional_args) == 1:
-            completion['insertTextFormat'] = lsp.InsertTextFormat.Snippet
-            completion['insertText'] = d.name + '($0)'
-        else:
-            completion['insertText'] = d.name + '()'
+        snippet = _snippet(d, resolve_label_or_snippet)
+        completion.update(snippet)
 
     return completion
 
@@ -229,6 +207,13 @@ def _label(definition, resolve=False):
     return definition.name
 
 
+def _snippet(definition, resolve=False):
+    if not resolve:
+        return {}
+    snippet = SNIPPET_RESOLVER.get_or_create(definition)
+    return snippet
+
+
 def _detail(definition):
     try:
         return definition.parent().full_name or ''
@@ -244,86 +229,3 @@ def _sort_text(definition):
     # If its 'hidden', put it next last
     prefix = 'z{}' if definition.name.startswith('_') else 'a{}'
     return prefix.format(definition.name)
-
-
-class LabelResolver:
-
-    def __init__(self, format_label_callback, time_to_live=60 * 30):
-        self.format_label = format_label_callback
-        self._cache = {}
-        self._time_to_live = time_to_live
-        self._cache_ttl = defaultdict(set)
-        self._clear_every = 2
-        # see https://github.com/davidhalter/jedi/blob/master/jedi/inference/helpers.py#L194-L202
-        self._cached_modules = {'pandas', 'numpy', 'tensorflow', 'matplotlib'}
-
-    @property
-    def cached_modules(self):
-        return self._cached_modules
-
-    @cached_modules.setter
-    def cached_modules(self, new_value):
-        self._cached_modules = set(new_value)
-
-    def clear_outdated(self):
-        now = self.time_key()
-        to_clear = [
-            timestamp
-            for timestamp in self._cache_ttl
-            if timestamp < now
-        ]
-        for time_key in to_clear:
-            for key in self._cache_ttl[time_key]:
-                del self._cache[key]
-            del self._cache_ttl[time_key]
-
-    def time_key(self):
-        return int(time() / self._time_to_live)
-
-    def get_or_create(self, completion: Completion):
-        if not completion.full_name:
-            use_cache = False
-        else:
-            module_parts = completion.full_name.split('.')
-            use_cache = module_parts and module_parts[0] in self._cached_modules
-
-        if use_cache:
-            key = self._create_completion_id(completion)
-            if key not in self._cache:
-                if self.time_key() % self._clear_every == 0:
-                    self.clear_outdated()
-
-                self._cache[key] = self.resolve_label(completion)
-                self._cache_ttl[self.time_key()].add(key)
-            return self._cache[key]
-
-        return self.resolve_label(completion)
-
-    def _create_completion_id(self, completion: Completion):
-        return (
-            completion.full_name, completion.module_path,
-            completion.line, completion.column,
-            self.time_key()
-        )
-
-    def resolve_label(self, completion):
-        try:
-            sig = completion.get_signatures()
-            return self.format_label(completion, sig)
-        except Exception as e:  # pylint: disable=broad-except
-            log.warning(
-                'Something went wrong when resolving label for {completion}: {e}',
-                completion=completion, e=e
-            )
-            return ''
-
-
-def format_label(completion, sig):
-    if sig and completion.type in ('function', 'method'):
-        params = ', '.join(param.name for param in sig[0].params)
-        label = '{}({})'.format(completion.name, params)
-        return label
-    return completion.name
-
-
-LABEL_RESOLVER = LabelResolver(format_label)


Test patch:
diff --git a/test/plugins/test_completion.py b/test/plugins/test_completion.py
index 1afee4a..7eef065 100644
--- a/test/plugins/test_completion.py
+++ b/test/plugins/test_completion.py
@@ -149,7 +149,7 @@ def test_jedi_completion_item_resolve(config, workspace):
     # Over the blank line
     com_position = {'line': 8, 'character': 0}
     doc = Document(DOC_URI, workspace, DOC)
-    config.update({'plugins': {'jedi_completion': {'resolve_at_most_labels': math.inf}}})
+    config.update({'plugins': {'jedi_completion': {'resolve_at_most': math.inf}}})
     completions = pylsp_jedi_completions(config, doc, com_position)
 
     items = {c['label']: c for c in completions}
@@ -187,13 +187,13 @@ def test_jedi_completion_resolve_at_most(config, workspace):
     doc = Document(DOC_URI, workspace, DOC)
 
     # Do not resolve any labels
-    config.update({'plugins': {'jedi_completion': {'resolve_at_most_labels': 0}}})
+    config.update({'plugins': {'jedi_completion': {'resolve_at_most': 0}}})
     items = pylsp_jedi_completions(config, doc, com_position)
     labels = {i['label'] for i in items}
     assert 'isabs' in labels
 
     # Resolve all items
-    config.update({'plugins': {'jedi_completion': {'resolve_at_most_labels': math.inf}}})
+    config.update({'plugins': {'jedi_completion': {'resolve_at_most': math.inf}}})
     items = pylsp_jedi_completions(config, doc, com_position)
     labels = {i['label'] for i in items}
     assert 'isabs(path)' in labels
@@ -214,7 +214,7 @@ def test_jedi_completion_ordering(config, workspace):
     # Over the blank line
     com_position = {'line': 8, 'character': 0}
     doc = Document(DOC_URI, workspace, DOC)
-    config.update({'plugins': {'jedi_completion': {'resolve_at_most_labels': math.inf}}})
+    config.update({'plugins': {'jedi_completion': {'resolve_at_most': math.inf}}})
     completions = pylsp_jedi_completions(config, doc, com_position)
 
     items = {c['label']: c['sortText'] for c in completions}
@@ -318,6 +318,20 @@ def test_snippets_completion(config, workspace):
     assert completions[0]['insertTextFormat'] == lsp.InsertTextFormat.Snippet
 
 
+def test_snippets_completion_at_most(config, workspace):
+    doc_snippets = 'from collections import defaultdict \na=defaultdict'
+    doc = Document(DOC_URI, workspace, doc_snippets)
+    config.capabilities['textDocument'] = {
+        'completion': {'completionItem': {'snippetSupport': True}}}
+    config.update({'plugins': {'jedi_completion': {'include_params': True}}})
+    config.update({'plugins': {'jedi_completion': {'resolve_at_most': 0}}})
+
+    com_position = {'line': 1, 'character': len(doc_snippets)}
+    completions = pylsp_jedi_completions(config, doc, com_position)
+    assert completions[0]['insertText'] == 'defaultdict'
+    assert not completions[0].get('insertTextFormat', None)
+
+
 def test_completion_with_class_objects(config, workspace):
     doc_text = 'class FOOBAR(Object): pass\nFOOB'
     com_position = {'line': 1, 'character': 4}