Skip to content

Commit 975661c

Browse files
committed
feat(robotlangserver): Speedup loading of class and module libraries
implement a caching mechanism to load and analyse libraries only once or update the cache if the library is changed
1 parent 748ea68 commit 975661c

File tree

13 files changed

+277
-56
lines changed

13 files changed

+277
-56
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,6 @@ report.html
284284

285285
# pyenv
286286
.python-version
287+
288+
# robotcode
289+
.robotcode_cache/

.vscodeignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,5 @@ coverage.xml
4949
# others
5050
scripts
5151
doc
52+
53+
**/.robotcode_cache

package.json

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,7 @@
103103
},
104104
{
105105
"scope": "entity.name.function.keyword-call.keyword-call.robotframework",
106-
"settings": {
107-
}
106+
"settings": {}
108107
},
109108
{
110109
"scope": "entity.name.function.testcase.name.robotframework",
@@ -120,8 +119,7 @@
120119
},
121120
{
122121
"scope": "variable.other.readwrite.robotframework",
123-
"settings": {
124-
}
122+
"settings": {}
125123
},
126124
{
127125
"scope": "keyword.control.import.robotframework",
@@ -167,8 +165,7 @@
167165
},
168166
{
169167
"scope": "constant.character.escape.robotframework",
170-
"settings": {
171-
}
168+
"settings": {}
172169
}
173170
]
174171
},
@@ -237,7 +234,6 @@
237234
],
238235
"keywordCall": [
239236
"entity.name.function.keyword-call.robotframework"
240-
241237
],
242238
"keywordCallInner": [
243239
"entity.name.function.keyword-call.inner.robotframework"
@@ -625,7 +621,8 @@
625621
"**/node_modules/**",
626622
"**/.pytest_cache/**",
627623
"**/__pycache__/**",
628-
"**/.mypy_cache/**"
624+
"**/.mypy_cache/**",
625+
"**/.robotcode_cache/**"
629626
],
630627
"items": {
631628
"type": "string"
@@ -1185,4 +1182,4 @@
11851182
"webpack": "^5.75.0",
11861183
"webpack-cli": "^5.0.1"
11871184
}
1188-
}
1185+
}

robotcode/debugger/dap_types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ class Source(Model):
250250

251251
class OutputCategory(Enum):
252252
CONSOLE = "console"
253+
IMPORTANT = "important"
253254
STDOUT = "stdout"
254255
STDERR = "stderr"
255256
TELEMETRY = "telemetry"
@@ -452,7 +453,7 @@ class _RunInTerminalRequest(Model):
452453
@dataclass
453454
class RunInTerminalRequest(Request, _RunInTerminalRequest):
454455
arguments: RunInTerminalRequestArguments = field()
455-
command: str = field(default="runInTerminal", init=False)
456+
command: str = field(default="runInTerminal", init=False, metadata={"force_json": True})
456457

457458

458459
@dataclass

robotcode/jsonrpc2/protocol.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ class JsonRPCErrors:
8484

8585
@dataclass
8686
class JsonRPCMessage:
87-
jsonrpc: str = field(default=PROTOCOL_VERSION, init=False)
87+
jsonrpc: str = field(default=PROTOCOL_VERSION, init=False, metadata={"force_json": True})
8888

8989

9090
@dataclass

robotcode/language_server/robotframework/diagnostics/imports_manager.py

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
import ast
44
import asyncio
5+
import itertools
56
import os
7+
import sys
68
import weakref
9+
import zlib
710
from abc import ABC, abstractmethod
811
from collections import OrderedDict
912
from concurrent.futures import ProcessPoolExecutor
@@ -23,8 +26,11 @@
2326
final,
2427
)
2528

29+
from ....__version__ import __version__
2630
from ....utils.async_cache import AsyncSimpleLRUCache
2731
from ....utils.async_tools import Lock, async_tasking_event, create_sub_task, threaded
32+
from ....utils.dataclasses import as_json, from_json
33+
from ....utils.glob_path import iter_files
2834
from ....utils.logging import LoggingDescriptor
2935
from ....utils.path import path_is_relative_to
3036
from ....utils.uri import Uri
@@ -36,14 +42,13 @@
3642
from ..utils.ast_utils import HasError, HasErrors, Token
3743
from ..utils.async_ast import walk
3844
from ..utils.robot_path import find_file_ex
39-
from ..utils.version import get_robot_version
45+
from ..utils.version import get_robot_version, get_robot_version_str
4046
from .entities import CommandLineVariableDefinition, VariableDefinition
4147

4248
if TYPE_CHECKING:
4349
from ..protocol import RobotLanguageServerProtocol
4450
from .namespace import Namespace
4551

46-
4752
from .library_doc import (
4853
ROBOT_LIBRARY_PACKAGE,
4954
ArgumentSpec,
@@ -53,6 +58,8 @@
5358
KeywordDoc,
5459
KeywordStore,
5560
LibraryDoc,
61+
LibraryType,
62+
ModuleSpec,
5663
VariablesDoc,
5764
complete_library_import,
5865
complete_resource_import,
@@ -61,6 +68,7 @@
6168
find_library,
6269
find_variables,
6370
get_library_doc,
71+
get_module_spec,
6472
get_variables_doc,
6573
is_embedded_keyword,
6674
is_library_by_path,
@@ -451,13 +459,54 @@ async def get_libdoc(self) -> VariablesDoc:
451459
return self._lib_doc
452460

453461

462+
@dataclass
463+
class LibraryMetaData:
464+
meta_version: str
465+
name: Optional[str]
466+
origin: Optional[str]
467+
submodule_search_locations: Optional[List[str]]
468+
by_path: bool
469+
470+
mtimes: Optional[Dict[str, int]] = None
471+
472+
@property
473+
def filepath_base(self) -> Path:
474+
if self.by_path:
475+
if self.origin is not None:
476+
p = Path(self.origin)
477+
478+
return Path(f"{zlib.adler32(str(p.parent).encode('utf-8')):08x}_{p.stem}")
479+
else:
480+
if self.name is not None:
481+
return Path(self.name.replace(".", "/"))
482+
483+
raise ValueError("Cannot determine filepath base.")
484+
485+
454486
class ImportsManager:
455487
_logger = LoggingDescriptor()
456488

457489
def __init__(self, parent_protocol: RobotLanguageServerProtocol, folder: Uri, config: RobotConfig) -> None:
458490
super().__init__()
459491
self.parent_protocol = parent_protocol
492+
460493
self.folder = folder
494+
get_robot_version()
495+
496+
cache_base_path = self.folder.to_path()
497+
if isinstance(self.parent_protocol.initialization_options, dict):
498+
if "storageUri" in self.parent_protocol.initialization_options:
499+
cache_base_path = Uri(self.parent_protocol.initialization_options["storageUri"]).to_path()
500+
self._logger.trace(lambda: f"use {cache_base_path} as base for caching")
501+
502+
self.lib_doc_cache_path = (
503+
cache_base_path
504+
/ ".robotcode_cache"
505+
/ f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
506+
/ get_robot_version_str()
507+
/ "libdoc"
508+
)
509+
461510
self.config: RobotConfig = config
462511
self._libaries_lock = Lock()
463512
self._libaries: OrderedDict[_LibrariesEntryKey, _LibrariesEntry] = OrderedDict()
@@ -693,6 +742,58 @@ async def remove(k: _VariablesEntryKey, e: _VariablesEntry) -> None:
693742
except RuntimeError:
694743
pass
695744

745+
async def get_library_meta(
746+
self,
747+
name: str,
748+
base_dir: str = ".",
749+
variables: Optional[Dict[str, Optional[Any]]] = None,
750+
) -> Tuple[Optional[LibraryMetaData], str]:
751+
try:
752+
import_name = await self.find_library(
753+
name,
754+
base_dir=base_dir,
755+
variables=variables,
756+
)
757+
758+
result: Optional[LibraryMetaData] = None
759+
module_spec: Optional[ModuleSpec] = None
760+
if is_library_by_path(import_name):
761+
if (p := Path(import_name)).exists():
762+
result = LibraryMetaData(__version__, p.stem, import_name, None, True)
763+
else:
764+
module_spec = get_module_spec(import_name)
765+
if module_spec is not None and module_spec.origin is not None:
766+
result = LibraryMetaData(
767+
__version__,
768+
module_spec.name,
769+
module_spec.origin,
770+
module_spec.submodule_search_locations,
771+
False,
772+
)
773+
if result is not None:
774+
if result.origin is not None:
775+
result.mtimes = {result.origin: Path(result.origin).resolve().stat().st_mtime_ns}
776+
777+
if result.submodule_search_locations:
778+
if result.mtimes is None:
779+
result.mtimes = {}
780+
result.mtimes.update(
781+
{
782+
str(f): f.resolve().stat().st_mtime_ns
783+
for f in itertools.chain(
784+
*(iter_files(loc, "**/*.py") for loc in result.submodule_search_locations)
785+
)
786+
}
787+
)
788+
789+
return result, import_name
790+
except (SystemExit, KeyboardInterrupt):
791+
raise
792+
except BaseException:
793+
pass
794+
795+
return None, import_name
796+
696797
async def find_library(self, name: str, base_dir: str, variables: Optional[Dict[str, Any]] = None) -> str:
697798
return await self._library_files_cache.get(self._find_library, name, base_dir, variables)
698799

@@ -776,14 +877,30 @@ async def get_libdoc_for_library_import(
776877
sentinel: Any = None,
777878
variables: Optional[Dict[str, Any]] = None,
778879
) -> LibraryDoc:
779-
source = await self.find_library(
880+
meta, source = await self.get_library_meta(
780881
name,
781882
base_dir,
782883
variables,
783884
)
784885

785886
async def _get_libdoc() -> LibraryDoc:
786887
self._logger.debug(lambda: f"Load Library {source}{repr(args)}")
888+
if meta is not None:
889+
meta_file = Path(self.lib_doc_cache_path, meta.filepath_base.with_suffix(".meta.json"))
890+
if meta_file.exists():
891+
try:
892+
saved_meta = from_json(meta_file.read_text("utf-8"), LibraryMetaData)
893+
if saved_meta == meta:
894+
return from_json(
895+
Path(self.lib_doc_cache_path, meta.filepath_base.with_suffix(".spec.json")).read_text(
896+
"utf-8"
897+
),
898+
LibraryDoc,
899+
)
900+
except (SystemExit, KeyboardInterrupt):
901+
raise
902+
except BaseException as e:
903+
self._logger.exception(e)
787904

788905
with ProcessPoolExecutor(max_workers=1) as executor:
789906
result = await asyncio.wait_for(
@@ -804,6 +921,18 @@ async def _get_libdoc() -> LibraryDoc:
804921
self._logger.warning(
805922
lambda: f"stdout captured at loading library {name}{repr(args)}:\n{result.stdout}"
806923
)
924+
try:
925+
if meta is not None and result.library_type in [LibraryType.CLASS, LibraryType.MODULE]:
926+
meta_file = Path(self.lib_doc_cache_path, meta.filepath_base.with_suffix(".meta.json"))
927+
meta_file.parent.mkdir(parents=True, exist_ok=True)
928+
meta_file.write_text(as_json(meta), "utf-8")
929+
930+
spec_file = Path(self.lib_doc_cache_path, meta.filepath_base.with_suffix(".spec.json"))
931+
spec_file.write_text(as_json(result), "utf-8")
932+
except (SystemExit, KeyboardInterrupt):
933+
raise
934+
except BaseException as e:
935+
self._logger.exception(e)
807936

808937
return result
809938

@@ -921,9 +1050,9 @@ def _create_handler(self, kw: Any) -> Any:
9211050
keywords=[
9221051
KeywordDoc(
9231052
name=kw[0].name,
924-
args=tuple(KeywordArgumentDoc.from_robot(a) for a in kw[0].args),
1053+
args=list(KeywordArgumentDoc.from_robot(a) for a in kw[0].args),
9251054
doc=kw[0].doc,
926-
tags=tuple(kw[0].tags),
1055+
tags=list(kw[0].tags),
9271056
source=kw[0].source,
9281057
name_token=get_keyword_name_token_from_line(kw[0].lineno),
9291058
line_no=kw[0].lineno,
@@ -940,7 +1069,7 @@ def _create_handler(self, kw: Any) -> Any:
9401069
if isinstance(kw[1], UserErrorHandler)
9411070
else None,
9421071
arguments=ArgumentSpec.from_robot_argument_spec(kw[1].arguments),
943-
parent=libdoc,
1072+
parent=libdoc.digest,
9441073
)
9451074
for kw in [(KeywordDocBuilder(resource=True).build_keyword(lw), lw) for lw in lib.handlers]
9461075
],

0 commit comments

Comments
 (0)