robotcode-robot 2.6.2__py3-none-any.whl → 2.8.0.dev548__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. robotcode/robot/__version__.py +1 -1
  2. robotcode/robot/config/loader.py +18 -5
  3. robotcode/robot/config/model.py +258 -61
  4. robotcode/robot/diagnostics/analyzer_result.py +1 -0
  5. robotcode/robot/diagnostics/data_cache.py +12 -1
  6. robotcode/robot/diagnostics/diagnostic_rules.py +75 -0
  7. robotcode/robot/diagnostics/diagnostics_modifier.py +83 -43
  8. robotcode/robot/diagnostics/document_cache_helper.py +158 -17
  9. robotcode/robot/diagnostics/errors.py +7 -0
  10. robotcode/robot/diagnostics/import_resolver.py +178 -12
  11. robotcode/robot/diagnostics/imports_manager.py +556 -220
  12. robotcode/robot/diagnostics/library_doc.py +874 -162
  13. robotcode/robot/diagnostics/namespace.py +84 -18
  14. robotcode/robot/diagnostics/namespace_analyzer.py +59 -16
  15. robotcode/robot/diagnostics/project_index.py +88 -13
  16. robotcode/robot/diagnostics/scope_tree.py +2 -2
  17. robotcode/robot/diagnostics/semantic_analyzer/analyzer.py +790 -201
  18. robotcode/robot/diagnostics/semantic_analyzer/enums.py +39 -2
  19. robotcode/robot/diagnostics/semantic_analyzer/json_dump.py +364 -0
  20. robotcode/robot/diagnostics/semantic_analyzer/model.py +72 -11
  21. robotcode/robot/diagnostics/semantic_analyzer/nodes.py +7 -1
  22. robotcode/robot/diagnostics/semantic_analyzer/variable_tokenizer.py +218 -112
  23. robotcode/robot/diagnostics/workspace_config.py +3 -0
  24. robotcode/robot/utils/markdown_docs.py +733 -0
  25. robotcode/robot/utils/markdownformatter.py +57 -12
  26. robotcode/robot/utils/match.py +13 -0
  27. robotcode/robot/utils/robot_patching.py +4 -0
  28. robotcode/robot/utils/variables.py +3 -0
  29. {robotcode_robot-2.6.2.dist-info → robotcode_robot-2.8.0.dev548.dist-info}/METADATA +4 -3
  30. robotcode_robot-2.8.0.dev548.dist-info/RECORD +48 -0
  31. {robotcode_robot-2.6.2.dist-info → robotcode_robot-2.8.0.dev548.dist-info}/WHEEL +1 -1
  32. robotcode_robot-2.6.2.dist-info/RECORD +0 -46
@@ -1,21 +1,26 @@
1
1
  import ast
2
2
  import multiprocessing as mp
3
3
  import os
4
+ import sys
4
5
  import threading
6
+ import time
5
7
  import weakref
6
8
  from abc import ABC, abstractmethod
7
- from concurrent.futures import ProcessPoolExecutor, TimeoutError
8
- from dataclasses import dataclass, field
9
+ from concurrent.futures import ProcessPoolExecutor
10
+ from dataclasses import dataclass, field, replace
11
+ from multiprocessing.connection import Connection, wait
9
12
  from pathlib import Path
10
13
  from typing import (
11
14
  TYPE_CHECKING,
12
15
  Any,
16
+ Callable,
13
17
  Dict,
14
18
  List,
15
19
  Mapping,
16
20
  Optional,
17
21
  Set,
18
22
  Tuple,
23
+ Union,
19
24
  final,
20
25
  )
21
26
 
@@ -42,21 +47,34 @@ from robotcode.core.uri import Uri
42
47
  from robotcode.core.utils.caching import SimpleLRUCache
43
48
  from robotcode.core.utils.glob_path import Pattern
44
49
  from robotcode.core.utils.logging import LoggingDescriptor
45
- from robotcode.core.utils.path import normalized_path, path_is_relative_to
50
+ from robotcode.core.utils.path import (
51
+ DiskInfo,
52
+ disk_info_from_stat,
53
+ normalized_path,
54
+ path_is_relative_to,
55
+ probe_disk_info,
56
+ )
46
57
 
47
58
  from ..__version__ import __version__
48
59
  from ..utils import RF_VERSION
49
60
  from ..utils.robot_path import find_file_ex
61
+ from ..utils.stubs import Languages
50
62
  from ..utils.variables import contains_variable
51
63
  from .data_cache import CACHE_DIR_NAME, CacheSection, build_cache_dir
52
64
  from .data_cache import SqliteDataCache as DefaultDataCache
53
65
  from .entities import (
54
66
  CommandLineVariableDefinition,
67
+ Import,
68
+ LibraryImport,
69
+ ResourceImport,
55
70
  VariableDefinition,
71
+ VariablesImport,
56
72
  )
73
+ from .errors import Error as DiagnosticError
57
74
  from .library_doc import (
58
75
  ROBOT_LIBRARY_PACKAGE,
59
76
  CompleteResult,
77
+ Error,
60
78
  LibraryDoc,
61
79
  ModuleSpec,
62
80
  ResourceDoc,
@@ -88,12 +106,49 @@ RESOURCE_EXTENSIONS = (
88
106
  if RF_VERSION >= (6, 1)
89
107
  else {".resource", ".robot", ".txt", ".tsv", ".rst", ".rest"}
90
108
  )
109
+ if RF_VERSION >= (7, 5):
110
+ RESOURCE_EXTENSIONS |= {".md", ".markdown"}
91
111
  REST_EXTENSIONS = (".rst", ".rest")
92
112
 
93
113
 
94
114
  DEFAULT_LOAD_LIBRARY_TIMEOUT: int = 10
95
115
  ENV_LOAD_LIBRARY_TIMEOUT_VAR = "ROBOTCODE_LOAD_LIBRARY_TIMEOUT"
96
116
  COMPLETE_LIBRARY_IMPORT_TIMEOUT = COMPLETE_RESOURCE_IMPORT_TIMEOUT = COMPLETE_VARIABLES_IMPORT_TIMEOUT = 5
117
+ # waiting on a pipe takes the timeout in milliseconds as a C int on POSIX
118
+ MAX_LOAD_WAIT_SECONDS = 2_000_000
119
+
120
+
121
+ class LoadTimeoutError(RuntimeError):
122
+ """Loading a library or variable file took longer than the load library timeout."""
123
+
124
+
125
+ class LoadExitError(RuntimeError):
126
+ """The import of a library or variable file ended before it finished, e.g. through sys.exit()."""
127
+
128
+
129
+ def _run_import_worker(connection: Connection, func: Callable[..., Any], func_args: Tuple[Any, ...]) -> None:
130
+ """Process target of `ImportsManager._run_in_subprocess`: sends `(ok, value)` back to the parent."""
131
+ try:
132
+ result: Tuple[bool, Any] = (True, func(*func_args))
133
+ except Exception as e:
134
+ # a SystemExit ends only the worker, the parent reports its exit code
135
+ result = (False, e)
136
+
137
+ connection.send(result)
138
+ connection.close()
139
+
140
+
141
+ def _end_process(process: mp.process.BaseProcess) -> None:
142
+ if process.pid is None:
143
+ return
144
+
145
+ if process.is_alive():
146
+ process.terminate()
147
+ process.join(1)
148
+ if process.is_alive():
149
+ process.kill()
150
+
151
+ process.join()
97
152
 
98
153
 
99
154
  @dataclass(frozen=True, slots=True)
@@ -272,14 +327,23 @@ class _LibrariesEntry(_ImportEntry):
272
327
  with self._lock:
273
328
  return self._lib_doc is not None
274
329
 
275
- def get_libdoc(self) -> LibraryDoc:
330
+ def get_libdoc_with_meta(self) -> Tuple[LibraryDoc, Optional["LibraryMetaData"]]:
331
+ """Return the LibraryDoc together with the meta of the state it was built from.
332
+
333
+ Doc and meta are taken under the entry lock so they always belong to
334
+ the same state — reading ``get_libdoc()`` and ``meta`` separately can
335
+ interleave with an invalidation and pair a doc with a newer meta.
336
+ """
276
337
  with self._lock:
277
338
  if self._lib_doc is None:
278
339
  self._update()
279
340
 
280
341
  assert self._lib_doc is not None
281
342
 
282
- return self._lib_doc
343
+ return self._lib_doc, self._meta
344
+
345
+ def get_libdoc(self) -> LibraryDoc:
346
+ return self.get_libdoc_with_meta()[0]
283
347
 
284
348
 
285
349
  @dataclass(frozen=True, slots=True)
@@ -343,7 +407,11 @@ class _ResourcesEntry(_ImportEntry):
343
407
  )
344
408
 
345
409
  self._document = self.parent.documents_manager.get_or_open_document(self.source_path)
346
- self._meta = ImportsManager.get_resource_meta(str(self.source_path))
410
+ # Bind the meta to the document's disk state instead of a fresh stat, so
411
+ # it describes exactly the content this entry's ResourceDoc is built
412
+ # from. A dirty/editor-open document yields no meta — its state must
413
+ # never be persisted.
414
+ self._meta = RobotFileMeta.from_document(self._document)
347
415
 
348
416
  if self._document._version is None:
349
417
  self.file_watchers.append(
@@ -390,15 +458,21 @@ class _ResourcesEntry(_ImportEntry):
390
458
  def _get_namespace(self) -> "Namespace":
391
459
  return self.parent.get_namespace_for_resource(self._get_document())
392
460
 
393
- def get_resource_doc(self) -> ResourceDoc:
461
+ def get_resource_doc_with_meta(self) -> Tuple[ResourceDoc, Optional["RobotFileMeta"]]:
462
+ """Return the ResourceDoc together with the meta of the content it was built from.
463
+
464
+ Doc and meta are taken under the entry lock so they always belong to
465
+ the same state — reading ``get_libdoc()`` and ``meta`` separately can
466
+ interleave with an invalidation and pair a doc with a newer meta.
467
+ """
394
468
  with self._lock:
395
469
  if self._lib_doc is None:
396
470
  self._lib_doc = self.parent.get_resource_doc_from_document(self._get_document())
397
471
 
398
- return self._lib_doc
472
+ return self._lib_doc, self._meta
399
473
 
400
474
  def get_libdoc(self) -> ResourceDoc:
401
- return self.get_resource_doc()
475
+ return self.get_resource_doc_with_meta()[0]
402
476
 
403
477
 
404
478
  @dataclass(frozen=True, slots=True)
@@ -474,7 +548,7 @@ class _VariablesEntry(_ImportEntry):
474
548
  self.resolve_command_line_vars,
475
549
  )
476
550
 
477
- if self._lib_doc is not None:
551
+ if self._lib_doc is not None and self._lib_doc.source:
478
552
  self.file_watchers.append(
479
553
  self.parent.file_watcher_manager.add_file_watchers(
480
554
  self.parent.did_change_watched_files,
@@ -495,14 +569,22 @@ class _VariablesEntry(_ImportEntry):
495
569
  with self._lock:
496
570
  return self._lib_doc is not None
497
571
 
498
- def get_libdoc(self) -> VariablesDoc:
572
+ def get_libdoc_with_meta(self) -> Tuple[VariablesDoc, Optional["LibraryMetaData"]]:
573
+ """Return the VariablesDoc together with the meta of the state it was built from.
574
+
575
+ Doc and meta are taken under the entry lock so they always belong to
576
+ the same state (see ``_LibrariesEntry.get_libdoc_with_meta``).
577
+ """
499
578
  with self._lock:
500
579
  if self._lib_doc is None:
501
580
  self._update()
502
581
 
503
582
  assert self._lib_doc is not None
504
583
 
505
- return self._lib_doc
584
+ return self._lib_doc, self._meta
585
+
586
+ def get_libdoc(self) -> VariablesDoc:
587
+ return self.get_libdoc_with_meta()[0]
506
588
 
507
589
 
508
590
  @dataclass(slots=True)
@@ -513,7 +595,7 @@ class LibraryMetaData:
513
595
  submodule_search_locations: Optional[List[str]]
514
596
  by_path: bool
515
597
 
516
- mtimes: Optional[Dict[str, int]] = None
598
+ file_infos: Optional[Dict[str, DiskInfo]] = None
517
599
 
518
600
  has_errors: bool = False
519
601
 
@@ -528,16 +610,38 @@ class LibraryMetaData:
528
610
 
529
611
  raise ValueError("Cannot determine cache key.")
530
612
 
613
+ def cache_key_for_args(self, args: Tuple[Any, ...]) -> str:
614
+ # the documentation can depend on the import arguments, so each argument set gets its own entry
615
+ return f"{self.cache_key}\n{args!r}" if args else self.cache_key
616
+
617
+ @property
618
+ def trusted(self) -> bool:
619
+ return self.file_infos is None or all(info.trusted for info in self.file_infos.values())
620
+
621
+
622
+ def _stat_library_file(path: str) -> os.stat_result:
623
+ # Follow symlinks so target changes behind an unchanged link are detected
624
+ # (editable installs); for a broken link fall back to the link's own state
625
+ # so a single dangling .py file doesn't make the whole library uncacheable.
626
+ try:
627
+ return os.stat(path)
628
+ except OSError:
629
+ return os.stat(path, follow_symlinks=False)
630
+
531
631
 
532
- def _collect_library_mtimes(
632
+ def _collect_library_file_infos(
533
633
  origin: Optional[str],
534
634
  submodule_search_locations: Optional[List[str]],
535
- ) -> Optional[Dict[str, int]]:
536
- """Collect mtimes from origin and submodule_search_locations."""
537
- mtimes: Dict[str, int] = {}
635
+ ) -> Optional[Dict[str, DiskInfo]]:
636
+ """Collect DiskInfo snapshots from origin and submodule_search_locations."""
637
+ file_infos: Dict[str, DiskInfo] = {}
638
+
639
+ # one capture moment for the whole collection, so all snapshots share the
640
+ # same racy-mtime reference point
641
+ now_ns = time.time_ns()
538
642
 
539
643
  if origin is not None:
540
- mtimes[origin] = os.stat(origin, follow_symlinks=False).st_mtime_ns
644
+ file_infos[origin] = disk_info_from_stat(_stat_library_file(origin), now_ns)
541
645
 
542
646
  if submodule_search_locations:
543
647
  for loc in submodule_search_locations:
@@ -545,9 +649,9 @@ def _collect_library_mtimes(
545
649
  for filename in filenames:
546
650
  if filename.endswith(".py"):
547
651
  filepath = os.path.join(dirpath, filename)
548
- mtimes[filepath] = os.stat(filepath, follow_symlinks=False).st_mtime_ns
652
+ file_infos[filepath] = disk_info_from_stat(_stat_library_file(filepath), now_ns)
549
653
 
550
- return mtimes or None
654
+ return file_infos or None
551
655
 
552
656
 
553
657
  def _matches_any_pattern(
@@ -564,7 +668,45 @@ def _matches_any_pattern(
564
668
  @dataclass(slots=True)
565
669
  class RobotFileMeta:
566
670
  source: str
567
- mtime_ns: int
671
+ info: DiskInfo
672
+ # the language settings the file was parsed with; only the cached docs of files carry them
673
+ languages: Any = None
674
+
675
+ @property
676
+ def trusted(self) -> bool:
677
+ return self.info.trusted
678
+
679
+ @classmethod
680
+ def from_document(cls, document: TextDocument) -> Optional["RobotFileMeta"]:
681
+ """Meta describing exactly the disk state the document's text was read from.
682
+
683
+ ``None`` when the document has no disk info, i.e. its text is not known
684
+ to match any on-disk state (editor buffer, read race).
685
+ """
686
+ info = document.disk_info
687
+ if info is None:
688
+ return None
689
+ return cls(str(normalized_path(document.uri.to_path())), info)
690
+
691
+
692
+ def _languages_fingerprint(languages: Optional[Languages]) -> Any:
693
+ """What the parsing of a Robot Framework file takes from the language settings, comparable and picklable."""
694
+ if languages is None:
695
+ return None
696
+ return (
697
+ tuple(sorted(languages.bdd_prefixes)),
698
+ tuple(sorted(languages.headers.items())),
699
+ tuple(sorted(languages.settings.items())),
700
+ tuple(sorted(languages.true_strings)),
701
+ tuple(sorted(languages.false_strings)),
702
+ )
703
+
704
+
705
+ def _has_cache_key(meta: LibraryMetaData, cache_key: str) -> bool:
706
+ try:
707
+ return meta.cache_key == cache_key
708
+ except ValueError:
709
+ return False
568
710
 
569
711
 
570
712
  @dataclass(slots=True)
@@ -576,7 +718,7 @@ class NamespaceMetaData:
576
718
  """
577
719
 
578
720
  source: str
579
- source_mtime_ns: int
721
+ source_info: DiskInfo
580
722
  config_fingerprint: Any
581
723
  dependency_fingerprints: Dict[str, Any] = field(default_factory=dict)
582
724
  semantic_model_enabled: bool = False
@@ -585,6 +727,9 @@ class NamespaceMetaData:
585
727
  class ImportsManager:
586
728
  _logger = LoggingDescriptor()
587
729
 
730
+ # Seconds that changes of resource documents are gathered before their importers are analyzed again.
731
+ _resource_document_changed_timer_interval: float = 1
732
+
588
733
  def __init__(
589
734
  self,
590
735
  documents_manager: DocumentsManager,
@@ -656,24 +801,16 @@ class ImportsManager:
656
801
  if environment:
657
802
  self._environment.update(environment)
658
803
 
659
- workspace_langs = self.document_cache_helper.get_languages_for_document(Uri.from_path(self.root_folder))
660
- if workspace_langs is not None:
661
- languages_fingerprint: Any = (
662
- tuple(sorted(workspace_langs.bdd_prefixes)),
663
- tuple(sorted(workspace_langs.headers.items())),
664
- tuple(sorted(workspace_langs.settings.items())),
665
- tuple(sorted(workspace_langs.true_strings)),
666
- tuple(sorted(workspace_langs.false_strings)),
667
- )
668
- else:
669
- languages_fingerprint = None
804
+ self._languages_fingerprint = _languages_fingerprint(
805
+ self.document_cache_helper.get_languages_for_document(Uri.from_path(self.root_folder))
806
+ )
670
807
 
671
808
  self._config_fingerprint: Any = (
672
809
  tuple(sorted(self.cmd_variables.items())),
673
810
  tuple(self.cmd_variable_files),
674
811
  tuple(sorted((k, v) for k, v in self._environment.items() if k not in os.environ)),
675
812
  tuple(self.global_library_search_order),
676
- languages_fingerprint,
813
+ self._languages_fingerprint,
677
814
  )
678
815
 
679
816
  self._library_files_cache = SimpleLRUCache(2048)
@@ -688,12 +825,14 @@ class ImportsManager:
688
825
  default_timeout=120, name="ImportsManager._resource_document_changed_timer_lock"
689
826
  )
690
827
  self._resource_document_changed_timer: Optional[threading.Timer] = None
691
- self._resource_document_changed_timer_interval = 1
692
828
  self._resource_document_changed_documents: Set[TextDocument] = set()
693
829
 
694
830
  self._resource_libdoc_cache: "weakref.WeakKeyDictionary[ast.AST, Dict[str, ResourceDoc]]" = (
695
831
  weakref.WeakKeyDictionary()
696
832
  )
833
+ # the doc of each file with the disk state it was read at: while the file keeps that state, analyzing it
834
+ # again gets the same doc instead of a new copy from the disk cache
835
+ self._model_docs_by_source: Dict[str, Tuple[RobotFileMeta, ResourceDoc]] = {}
697
836
 
698
837
  self._diagnostics: List[Diagnostic] = []
699
838
 
@@ -761,101 +900,84 @@ class ImportsManager:
761
900
  """
762
901
  return self._config_fingerprint
763
902
 
764
- def compute_dependency_fingerprints(self, namespace: "Namespace") -> Dict[str, Any]:
765
- """Collect current metadata for all dependencies of a namespace.
766
-
767
- Each value is the metadata object itself (LibraryMetaData, RobotFileMeta,
768
- or an mtime int). Changes are detected via direct ``==`` comparison
769
- against the saved metadata — no hashing needed.
903
+ @staticmethod
904
+ def _is_dependency_meta_trusted(value: Any) -> bool:
905
+ # fail closed: a meta type this dispatch does not know must never be
906
+ # persisted — silently treating it as trusted is the exact failure
907
+ # class the freshness system exists to prevent
908
+ return isinstance(value, (RobotFileMeta, LibraryMetaData)) and value.trusted
770
909
 
771
- Uses already-cached metadata from the internal import cache to avoid
772
- expensive module resolution (find_library / get_module_spec).
773
- Falls back to the full get_*_meta methods when no cached entry exists.
910
+ def build_namespace_meta(
911
+ self,
912
+ source: str,
913
+ namespace: "Namespace",
914
+ disk_info: DiskInfo,
915
+ *,
916
+ semantic_model_enabled: bool = False,
917
+ ) -> Optional[NamespaceMetaData]:
918
+ """Build a NamespaceMetaData for the given namespace and its dependencies.
919
+
920
+ ``disk_info`` must be the snapshot of the content the namespace was
921
+ built from; the dependency fingerprints are the metas recorded during
922
+ import resolution. Returns ``None`` when any of that state is missing
923
+ or untrusted — such a namespace must not be persisted, because its
924
+ fingerprints could describe newer file states than the analyzed
925
+ content and would validate as fresh forever.
926
+
927
+ A namespace with failed imports is not persisted either: the validation
928
+ cannot tell that a missing file appeared since, or that a library that
929
+ failed to load would load now, and the cached diagnostics would keep
930
+ reporting the old error.
774
931
  """
775
- fingerprints: Dict[str, Any] = {}
776
-
777
- for entry in namespace.libraries.values():
778
- try:
779
- lib_meta = self.get_cached_library_meta(entry.import_name, entry.args)
780
- if lib_meta is None:
781
- lib_meta, _, _ = self.get_library_meta(entry.import_name)
782
- if lib_meta is not None:
783
- fingerprints[f"lib:{entry.import_name}"] = lib_meta
784
- except (SystemExit, KeyboardInterrupt):
785
- raise
786
- except BaseException:
787
- pass
788
-
789
- for entry in namespace.resources.values():
790
- source = entry.library_doc.source
791
- if source:
792
- try:
793
- res_meta = self.get_cached_resource_meta(source)
794
- if res_meta is None:
795
- res_meta = self.get_resource_meta(source)
796
- if res_meta is not None:
797
- fingerprints[f"res:{source}"] = res_meta
798
- else:
799
- mtime = os.stat(source, follow_symlinks=False).st_mtime_ns
800
- fingerprints[f"res:{source}"] = mtime
801
- except OSError:
802
- pass
932
+ if not disk_info.trusted:
933
+ return None
803
934
 
804
- for entry in namespace.variables_imports.values():
805
- try:
806
- var_meta = self.get_cached_variables_meta(entry.import_name, entry.args)
807
- if var_meta is None:
808
- var_meta, _ = self.get_variables_meta(entry.import_name)
809
- if var_meta is not None:
810
- fingerprints[f"var:{entry.import_name}"] = var_meta
811
- except (SystemExit, KeyboardInterrupt):
812
- raise
813
- except BaseException:
814
- pass
935
+ if namespace.failed_imports:
936
+ return None
815
937
 
816
- return fingerprints
938
+ dependency_metas = namespace.dependency_metas
939
+ if dependency_metas is None:
940
+ return None
817
941
 
818
- def build_namespace_meta(
819
- self, source: str, namespace: "Namespace", *, semantic_model_enabled: bool = False
820
- ) -> NamespaceMetaData:
821
- """Build a NamespaceMetaData for the given namespace and its dependencies."""
822
- try:
823
- source_mtime_ns = os.stat(source, follow_symlinks=False).st_mtime_ns
824
- except OSError:
825
- source_mtime_ns = 0
942
+ for value in dependency_metas.values():
943
+ if value is None or not self._is_dependency_meta_trusted(value):
944
+ return None
826
945
 
827
946
  return NamespaceMetaData(
828
947
  source=source,
829
- source_mtime_ns=source_mtime_ns,
948
+ source_info=disk_info,
830
949
  config_fingerprint=self.config_fingerprint,
831
- dependency_fingerprints=self.compute_dependency_fingerprints(namespace),
950
+ dependency_fingerprints=dict(dependency_metas),
832
951
  semantic_model_enabled=semantic_model_enabled,
833
952
  )
834
953
 
835
- def validate_namespace_meta(self, meta: NamespaceMetaData) -> bool:
836
- """Check whether a cached NamespaceMetaData is still fresh.
954
+ def validate_namespace_meta(self, meta: NamespaceMetaData, source_disk_info: Optional[DiskInfo]) -> bool:
955
+ """Check whether a cached NamespaceMetaData is fresh for the given content.
837
956
 
838
957
  Performs a 2-level validation:
839
- Level 1 (fast): source_mtime_ns, config_fingerprint
958
+ Level 1 (fast): source_disk_info, config_fingerprint
840
959
  Level 2 (dependency check): each dependency fingerprint
960
+
961
+ Validation is against the *effective* content: the source is compared
962
+ to the snapshot of the document the namespace would represent, and
963
+ resource dependencies are checked document-first — a dirty editor
964
+ buffer fails validation so the namespace is rebuilt against the buffer
965
+ instead of being served from disk state.
841
966
  """
842
967
  source = meta.source
843
968
 
844
969
  # Level 1: fast checks
845
- try:
846
- current_mtime = os.stat(source, follow_symlinks=False).st_mtime_ns
847
- except OSError:
970
+ if source_disk_info is None:
848
971
  self._logger.debug(
849
- lambda: f"Cache miss for {source}: source file no longer exists",
972
+ lambda: f"Cache miss for {source}: source has no trustworthy disk state",
850
973
  context_name="cache",
851
974
  )
852
975
  return False
853
976
 
854
- if meta.source_mtime_ns != current_mtime:
977
+ if meta.source_info != source_disk_info:
855
978
  self._logger.debug(
856
979
  lambda: (
857
- f"Cache miss for {source}: source mtime changed"
858
- f" (cached={meta.source_mtime_ns}, current={current_mtime})"
980
+ f"Cache miss for {source}: source changed (cached={meta.source_info}, current={source_disk_info})"
859
981
  ),
860
982
  context_name="cache",
861
983
  )
@@ -869,14 +991,14 @@ class ImportsManager:
869
991
  return False
870
992
 
871
993
  # Level 2: dependency checks — direct comparison, no hashing
872
- base_dir = os.path.dirname(source)
873
994
  for key, saved_value in meta.dependency_fingerprints.items():
874
995
  if key.startswith("lib:"):
996
+ # the cache key of the library's meta: its file, or its module name
875
997
  lib_name = key[4:]
876
998
  try:
877
999
  lib_meta = self.get_cached_library_meta(lib_name)
878
1000
  if lib_meta is None:
879
- lib_meta, _, _ = self.get_library_meta(lib_name, base_dir=base_dir)
1001
+ lib_meta, _, _ = self.get_library_meta(lib_name)
880
1002
  if lib_meta is None or lib_meta != saved_value:
881
1003
  self._logger.debug(
882
1004
  lambda: (
@@ -898,8 +1020,15 @@ class ImportsManager:
898
1020
  elif key.startswith("res:"):
899
1021
  res_source = key[4:]
900
1022
  try:
901
- res_meta = self.get_cached_resource_meta(res_source)
902
- if res_meta is None:
1023
+ # Document-first: an open document is the effective content
1024
+ # of this dependency. A dirty buffer (no disk snapshot)
1025
+ # must fail validation so dependents are rebuilt against
1026
+ # the buffer instead of being served from cached disk
1027
+ # state. Only unopened resources are probed on disk.
1028
+ res_document = self.documents_manager.get(Uri.from_path(res_source).normalized())
1029
+ if res_document is not None:
1030
+ res_meta = RobotFileMeta.from_document(res_document)
1031
+ else:
903
1032
  res_meta = self.get_resource_meta(res_source)
904
1033
  if res_meta is None or res_meta != saved_value:
905
1034
  self._logger.debug(
@@ -920,11 +1049,12 @@ class ImportsManager:
920
1049
  )
921
1050
  return False
922
1051
  elif key.startswith("var:"):
1052
+ # the cache key of the variable file's meta: its file, or its module name
923
1053
  var_name = key[4:]
924
1054
  try:
925
1055
  var_meta = self.get_cached_variables_meta(var_name)
926
1056
  if var_meta is None:
927
- var_meta, _ = self.get_variables_meta(var_name, base_dir=base_dir)
1057
+ var_meta, _ = self.get_variables_meta(var_name)
928
1058
  if var_meta is None or var_meta != saved_value:
929
1059
  self._logger.debug(
930
1060
  lambda: (
@@ -952,18 +1082,39 @@ class ImportsManager:
952
1082
  def get_resource_doc_from_document(self, document: TextDocument) -> ResourceDoc:
953
1083
  source = str(document.uri.to_path())
954
1084
 
955
- if not self._is_document_loaded(source):
956
- cached, _meta = self._get_model_doc_cached(source)
1085
+ # Capture the meta before parsing, so it can only be older than the
1086
+ # parsed content, never newer (stale metas cause a harmless cache
1087
+ # miss, newer ones would poison the cache).
1088
+ meta = RobotFileMeta.from_document(document)
1089
+
1090
+ if meta is not None and meta.trusted:
1091
+ cached = self._get_model_doc_cached(source, meta)
957
1092
  if cached is not None:
958
1093
  return cached
959
1094
 
960
1095
  model = self.document_cache_helper.get_resource_model(document)
961
- return self.get_libdoc_from_model(model, source)
1096
+
1097
+ # The document may have been re-read from disk while parsing; in that
1098
+ # case the model does not necessarily belong to the captured meta.
1099
+ if meta is not None and document.disk_info != meta.info:
1100
+ meta = None
1101
+
1102
+ return self.get_libdoc_from_model(model, source, meta)
962
1103
 
963
1104
  def clear_cache(self) -> None:
1105
+ self._model_docs_by_source.clear()
964
1106
  count = self.data_cache.clear_all()
965
1107
  self._logger.debug(lambda: f"Cleared {count} cache entries from {self.cache_path}")
966
1108
 
1109
+ def remove_cached_resource_docs(self, sources: List[str]) -> None:
1110
+ """Remove the docs of deleted files from the analysis cache and from memory.
1111
+
1112
+ ``sources`` are normalized paths, as the entries of the cache name them.
1113
+ """
1114
+ for source in sources:
1115
+ self._model_docs_by_source.pop(source, None)
1116
+ self.data_cache.remove_entries(CacheSection.RESOURCE, sources)
1117
+
967
1118
  @_logger.call
968
1119
  def get_command_line_variables(self) -> List[VariableDefinition]:
969
1120
  with self._command_line_variables_lock:
@@ -1153,6 +1304,23 @@ class ImportsManager:
1153
1304
  if resource_changed:
1154
1305
  self.resources_changed(self, resource_changed)
1155
1306
 
1307
+ def changed_import_files(self) -> List[str]:
1308
+ """The files of the loaded libraries and variable files that changed since they were loaded."""
1309
+ with self._libaries_lock:
1310
+ entries: List[Union[_LibrariesEntry, _VariablesEntry]] = list(self._libaries.values())
1311
+ with self._variables_lock:
1312
+ entries.extend(self._variables.values())
1313
+
1314
+ result: List[str] = []
1315
+ for entry in entries:
1316
+ meta = entry.meta
1317
+ if meta is None or not meta.file_infos:
1318
+ continue
1319
+ for path, info in meta.file_infos.items():
1320
+ if path not in result and probe_disk_info(path) != info:
1321
+ result.append(path)
1322
+ return result
1323
+
1156
1324
  @_logger.call
1157
1325
  def did_change_watched_files(self, sender: Any, changes: List[FileEvent]) -> None:
1158
1326
  libraries_changed: List[Tuple[_LibrariesEntryKey, FileChangeType, Optional[LibraryDoc]]] = []
@@ -1302,7 +1470,7 @@ class ImportsManager:
1302
1470
  import_name,
1303
1471
  None,
1304
1472
  True,
1305
- mtimes=_collect_library_mtimes(import_name, None),
1473
+ file_infos=_collect_library_file_infos(import_name, None),
1306
1474
  )
1307
1475
  else:
1308
1476
  module_spec = self._get_module_spec_cached(import_name)
@@ -1313,7 +1481,9 @@ class ImportsManager:
1313
1481
  module_spec.origin,
1314
1482
  module_spec.submodule_search_locations,
1315
1483
  False,
1316
- mtimes=_collect_library_mtimes(module_spec.origin, module_spec.submodule_search_locations),
1484
+ file_infos=_collect_library_file_infos(
1485
+ module_spec.origin, module_spec.submodule_search_locations
1486
+ ),
1317
1487
  )
1318
1488
 
1319
1489
  if result is not None:
@@ -1367,7 +1537,7 @@ class ImportsManager:
1367
1537
  import_name,
1368
1538
  None,
1369
1539
  True,
1370
- mtimes=_collect_library_mtimes(import_name, None),
1540
+ file_infos=_collect_library_file_infos(import_name, None),
1371
1541
  )
1372
1542
  else:
1373
1543
  module_spec = self._get_module_spec_cached(import_name)
@@ -1378,7 +1548,9 @@ class ImportsManager:
1378
1548
  module_spec.origin,
1379
1549
  module_spec.submodule_search_locations,
1380
1550
  False,
1381
- mtimes=_collect_library_mtimes(module_spec.origin, module_spec.submodule_search_locations),
1551
+ file_infos=_collect_library_file_infos(
1552
+ module_spec.origin, module_spec.submodule_search_locations
1553
+ ),
1382
1554
  )
1383
1555
 
1384
1556
  if result is not None:
@@ -1399,38 +1571,28 @@ class ImportsManager:
1399
1571
 
1400
1572
  return None, name
1401
1573
 
1402
- def get_cached_library_meta(self, import_name: str, args: Tuple[Any, ...] = ()) -> Optional[LibraryMetaData]:
1403
- """Return already-computed LibraryMetaData from the internal cache, or None.
1574
+ def get_cached_library_meta(self, cache_key: str) -> Optional[LibraryMetaData]:
1575
+ """Return the meta of a loaded library whose meta has the cache key `cache_key`, or None.
1404
1576
 
1405
- Matches by import name and args to find the correct entry, since
1406
- the same library with different args may produce different keywords.
1577
+ The cache key names the library's file (or module), so a library with
1578
+ the same import name from another folder is not found.
1407
1579
  """
1408
1580
  with self._libaries_lock:
1409
- for entry in self._libaries.values():
1410
- if entry.name == import_name and entry.args == args:
1411
- return entry.meta
1412
- return None
1413
-
1414
- def get_cached_resource_meta(self, source: str) -> Optional[RobotFileMeta]:
1415
- """Return already-computed RobotFileMeta from the internal cache, or None."""
1416
- entry_key = _ResourcesEntryKey(str(normalized_path(Path(source))))
1417
- with self._resources_lock:
1418
- entry = self._resources.get(entry_key)
1419
- if entry is not None:
1420
- return entry.meta
1421
- return None
1581
+ return next(
1582
+ (e.meta for e in self._libaries.values() if e.meta is not None and _has_cache_key(e.meta, cache_key)),
1583
+ None,
1584
+ )
1422
1585
 
1423
- def get_cached_variables_meta(self, import_name: str, args: Tuple[Any, ...] = ()) -> Optional[LibraryMetaData]:
1424
- """Return already-computed LibraryMetaData for a variables import, or None.
1586
+ def get_cached_variables_meta(self, cache_key: str) -> Optional[LibraryMetaData]:
1587
+ """Return the meta of a loaded variable file whose meta has the cache key `cache_key`, or None.
1425
1588
 
1426
- Matches by import name and args, since variable files can return
1427
- different content depending on the arguments.
1589
+ See ``get_cached_library_meta``.
1428
1590
  """
1429
1591
  with self._variables_lock:
1430
- for entry in self._variables.values():
1431
- if entry.name == import_name and entry.args == args:
1432
- return entry.meta
1433
- return None
1592
+ return next(
1593
+ (e.meta for e in self._variables.values() if e.meta is not None and _has_cache_key(e.meta, cache_key)),
1594
+ None,
1595
+ )
1434
1596
 
1435
1597
  def find_library(
1436
1598
  self,
@@ -1554,6 +1716,41 @@ class ImportsManager:
1554
1716
 
1555
1717
  return name
1556
1718
 
1719
+ def find_import_source(
1720
+ self, imp: Import, base_dir: str, variables: Optional[Dict[str, Any]] = None
1721
+ ) -> Optional[str]:
1722
+ """The file an import refers to, or `None` when its file or module is not found. Nothing is loaded."""
1723
+ if imp.name is None:
1724
+ return None
1725
+
1726
+ try:
1727
+ if isinstance(imp, ResourceImport):
1728
+ return self.find_resource(imp.name, base_dir, variables=variables)
1729
+
1730
+ if isinstance(imp, LibraryImport):
1731
+ name = self.find_library(imp.name, base_dir, variables)
1732
+ by_path = is_library_by_path(name)
1733
+ elif isinstance(imp, VariablesImport):
1734
+ name = self.find_variables(imp.name, base_dir, variables)
1735
+ by_path = is_variables_by_path(name)
1736
+ else:
1737
+ return None
1738
+
1739
+ # a file imported by path is looked up with its name, and the lookup fails when it is missing
1740
+ if by_path:
1741
+ return name
1742
+
1743
+ module_spec = self._get_module_spec_cached(name)
1744
+ if module_spec is None:
1745
+ return None
1746
+ if module_spec.origin is not None:
1747
+ return module_spec.origin
1748
+ return module_spec.submodule_search_locations[0] if module_spec.submodule_search_locations else None
1749
+ except (SystemExit, KeyboardInterrupt):
1750
+ raise
1751
+ except BaseException:
1752
+ return None
1753
+
1557
1754
  @property
1558
1755
  def executor(self) -> ProcessPoolExecutor:
1559
1756
  with self._executor_lock:
@@ -1569,26 +1766,61 @@ class ImportsManager:
1569
1766
  A fresh process per import is intentional: libraries and variable files
1570
1767
  can pollute the interpreter (e.g. via sys.modules, global state, native
1571
1768
  extensions) and cannot be safely re-imported after on-disk changes.
1769
+
1770
+ The process is ended when the load library timeout expires, so a library
1771
+ that blocks cannot hold up the caller beyond the timeout.
1572
1772
  """
1573
- executor = ProcessPoolExecutor(max_workers=1, mp_context=mp.get_context("spawn"))
1773
+ context = mp.get_context("spawn")
1774
+ receiver, sender = context.Pipe(duplex=False)
1775
+ process = context.Process(target=_run_import_worker, args=(sender, func, func_args))
1574
1776
  try:
1575
1777
  try:
1576
- return executor.submit(func, *func_args).result(self.load_library_timeout)
1577
- except TimeoutError as e:
1578
- raise RuntimeError(
1778
+ process.start()
1779
+ finally:
1780
+ # the worker keeps the sending end (on Windows once it has unpickled its arguments)
1781
+ sender.close()
1782
+
1783
+ # the sentinel also reports a worker that ends before it has taken over the sending end
1784
+ if not wait([receiver, process.sentinel], min(self.load_library_timeout, MAX_LOAD_WAIT_SECONDS)):
1785
+ raise LoadTimeoutError(
1579
1786
  f"{timeout_msg} "
1580
- f"timed out after {self.load_library_timeout} seconds. "
1787
+ f"timed out after {self.load_library_timeout} "
1788
+ f"{'second' if self.load_library_timeout == 1 else 'seconds'}. "
1581
1789
  "The import may be slow or blocked. "
1582
1790
  "If required, increase the timeout by setting the ROBOTCODE_LOAD_LIBRARY_TIMEOUT "
1583
1791
  "environment variable."
1792
+ )
1793
+
1794
+ try:
1795
+ if not receiver.poll(0):
1796
+ # only the sentinel is ready: the worker ended before it took over the sending end
1797
+ raise EOFError
1798
+ ok, value = receiver.recv()
1799
+ except (EOFError, BrokenPipeError) as e:
1800
+ process.join(1)
1801
+ raise LoadExitError(
1802
+ f"{timeout_msg} failed: the import ended with exit code {process.exitcode} before it finished, "
1803
+ "for example through sys.exit() or a crash in the imported code."
1584
1804
  ) from e
1805
+
1806
+ # give the worker the chance to end on its own, the finally block ends it otherwise
1807
+ process.join(1)
1808
+
1809
+ if not ok:
1810
+ raise value
1811
+
1812
+ return value
1585
1813
  except (SystemExit, KeyboardInterrupt):
1586
1814
  raise
1815
+ except (LoadTimeoutError, LoadExitError):
1816
+ # the caller reports a timeout or an import that ended early as a diagnostic, so no traceback
1817
+ raise
1587
1818
  except BaseException as e:
1588
1819
  self._logger.exception(e)
1589
1820
  raise
1590
1821
  finally:
1591
- executor.shutdown(wait=True)
1822
+ receiver.close()
1823
+ _end_process(process)
1592
1824
 
1593
1825
  def _save_import_cache(
1594
1826
  self,
@@ -1599,11 +1831,16 @@ class ImportsManager:
1599
1831
  name: str,
1600
1832
  args: Tuple[Any, ...],
1601
1833
  ) -> None:
1602
- """Save an import result to the disk cache, or log skip if no meta."""
1834
+ """Save an import result to the disk cache under its argument set, or log skip if no meta."""
1603
1835
  try:
1604
- if meta is not None:
1836
+ if meta is not None and not meta.trusted:
1837
+ self._logger.debug(
1838
+ lambda: f"Skip caching {kind} {name}{args!r}: file state too fresh to trust",
1839
+ context_name="import",
1840
+ )
1841
+ elif meta is not None:
1605
1842
  try:
1606
- self.data_cache.save_entry(section, meta.cache_key, meta, result)
1843
+ self.data_cache.save_entry(section, meta.cache_key_for_args(args), meta, result)
1607
1844
  except (SystemExit, KeyboardInterrupt):
1608
1845
  raise
1609
1846
  except BaseException as e:
@@ -1623,11 +1860,18 @@ class ImportsManager:
1623
1860
  base_dir: str,
1624
1861
  variables: Optional[Dict[str, Any]] = None,
1625
1862
  ) -> Tuple[LibraryDoc, Optional[LibraryMetaData]]:
1626
- meta, _source, ignore_arguments = self.get_library_meta(name, base_dir, variables)
1863
+ meta, import_name, ignore_arguments = self.get_library_meta(name, base_dir, variables)
1864
+ cache_args = (
1865
+ ()
1866
+ if ignore_arguments
1867
+ else resolve_args(args, working_dir, base_dir, self.get_resolvable_command_line_variables(), variables)
1868
+ )
1627
1869
 
1628
1870
  if meta is not None and not meta.has_errors:
1629
1871
  try:
1630
- entry = self.data_cache.read_entry(CacheSection.LIBRARY, meta.cache_key, LibraryMetaData, LibraryDoc)
1872
+ entry = self.data_cache.read_entry(
1873
+ CacheSection.LIBRARY, meta.cache_key_for_args(cache_args), LibraryMetaData, LibraryDoc
1874
+ )
1631
1875
  if entry is not None and entry.meta is not None:
1632
1876
  if entry.meta.has_errors:
1633
1877
  self._logger.debug(
@@ -1644,35 +1888,59 @@ class ImportsManager:
1644
1888
 
1645
1889
  self._logger.debug(lambda: f"Load library in process {name}{args!r}", context_name="import")
1646
1890
 
1647
- result = self._run_in_subprocess(
1648
- get_library_doc,
1649
- (
1650
- name,
1651
- args if not ignore_arguments else (),
1652
- working_dir,
1653
- base_dir,
1654
- self.get_resolvable_command_line_variables(),
1655
- variables,
1656
- ),
1657
- f"Loading library {name!r} with args {args!r} (working_dir={working_dir!r}, base_dir={base_dir!r})",
1658
- )
1891
+ try:
1892
+ result = self._run_in_subprocess(
1893
+ get_library_doc,
1894
+ (
1895
+ name,
1896
+ args if not ignore_arguments else (),
1897
+ working_dir,
1898
+ base_dir,
1899
+ self.get_resolvable_command_line_variables(),
1900
+ variables,
1901
+ ),
1902
+ f"Loading library {name!r} with args {args!r}",
1903
+ )
1904
+ except (LoadTimeoutError, LoadExitError) as e:
1905
+ # kept by the import entry until a file changes, but neither written to the disk cache
1906
+ # nor usable as a namespace dependency
1907
+ return (
1908
+ LibraryDoc(
1909
+ name=name,
1910
+ source=(
1911
+ meta.origin if meta is not None else import_name if is_library_by_path(import_name) else None
1912
+ ),
1913
+ errors=[
1914
+ Error(
1915
+ message=str(e),
1916
+ type_name=(
1917
+ DiagnosticError.LIBRARY_TIMEOUT_ERROR
1918
+ if isinstance(e, LoadTimeoutError)
1919
+ else DiagnosticError.LIBRARY_EXIT_ERROR
1920
+ ),
1921
+ )
1922
+ ],
1923
+ python_path=list(sys.path),
1924
+ ),
1925
+ None,
1926
+ )
1659
1927
 
1660
1928
  if meta is not None:
1661
1929
  meta.has_errors = bool(result.errors)
1662
1930
 
1663
- self._save_import_cache(CacheSection.LIBRARY, meta, result, "library", name, args)
1931
+ self._save_import_cache(CacheSection.LIBRARY, meta, result, "library", name, cache_args)
1664
1932
 
1665
1933
  return result, meta
1666
1934
 
1667
1935
  @_logger.call
1668
- def get_libdoc_for_library_import(
1936
+ def get_libdoc_for_library_import_with_meta(
1669
1937
  self,
1670
1938
  name: str,
1671
1939
  args: Tuple[Any, ...],
1672
1940
  base_dir: str,
1673
1941
  sentinel: Any = None,
1674
1942
  variables: Optional[Dict[str, Any]] = None,
1675
- ) -> LibraryDoc:
1943
+ ) -> Tuple[LibraryDoc, Optional[LibraryMetaData]]:
1676
1944
  with self._logger.measure_time(lambda: f"loading library {name}{args!r}", context_name="import"):
1677
1945
  source = self.find_library(name, base_dir, variables)
1678
1946
 
@@ -1701,39 +1969,48 @@ class ImportsManager:
1701
1969
 
1702
1970
  if not entry.ignore_reference and sentinel is not None and sentinel not in entry.references:
1703
1971
  fin = weakref.finalize(sentinel, self.__remove_library_entry, entry_key, entry)
1704
- fin.atexit = False # type: ignore[misc]
1972
+ fin.atexit = False
1705
1973
  entry.references.add(sentinel)
1706
1974
 
1707
- return entry.get_libdoc()
1975
+ return entry.get_libdoc_with_meta()
1708
1976
 
1709
- def _is_document_loaded(self, source: str) -> bool:
1710
- doc = self.documents_manager.get(Uri.from_path(source))
1711
- return doc is not None and doc.version is not None
1977
+ def get_libdoc_for_library_import(
1978
+ self,
1979
+ name: str,
1980
+ args: Tuple[Any, ...],
1981
+ base_dir: str,
1982
+ sentinel: Any = None,
1983
+ variables: Optional[Dict[str, Any]] = None,
1984
+ ) -> LibraryDoc:
1985
+ return self.get_libdoc_for_library_import_with_meta(name, args, base_dir, sentinel, variables)[0]
1712
1986
 
1713
1987
  @_logger.call
1714
1988
  def get_libdoc_from_model(
1715
1989
  self,
1716
1990
  model: ast.AST,
1717
1991
  source: str,
1992
+ meta: Optional[RobotFileMeta] = None,
1718
1993
  ) -> ResourceDoc:
1719
-
1994
+ """Build (or load from cache) the ResourceDoc for a parsed model.
1995
+
1996
+ ``meta`` must describe the disk state of exactly the content the model
1997
+ was parsed from; the disk cache is only consulted and written when such
1998
+ a trusted meta is available. There is deliberately no fallback to a
1999
+ fresh stat here — the file may have changed since the model's text was
2000
+ read, and labeling old content with a newer state would make the cache
2001
+ entry validate as fresh forever.
2002
+ """
1720
2003
  entry = self._resource_libdoc_cache.get(model)
1721
2004
  if entry is not None and source in entry:
1722
2005
  return entry[source]
1723
2006
 
1724
- use_disk_cache = not self._is_document_loaded(source)
1725
-
1726
- result: Optional[ResourceDoc] = None
1727
- meta: Optional[RobotFileMeta] = None
1728
- if use_disk_cache:
1729
- result, meta = self._get_model_doc_cached(source)
1730
- if result is None:
2007
+ if meta is not None and meta.trusted:
2008
+ result = self._get_model_doc_cached(source, meta)
2009
+ if result is None:
2010
+ result = get_model_doc(model=model, source=source)
2011
+ self._save_model_doc_cache(source, result, meta)
2012
+ else:
1731
2013
  result = get_model_doc(model=model, source=source)
1732
- if use_disk_cache:
1733
- if meta is None:
1734
- meta = self.get_resource_meta(source)
1735
- if meta is not None:
1736
- self._save_model_doc_cache(source, result, meta)
1737
2014
 
1738
2015
  if entry is None:
1739
2016
  entry = {}
@@ -1745,22 +2022,23 @@ class ImportsManager:
1745
2022
 
1746
2023
  @staticmethod
1747
2024
  def get_resource_meta(source: str) -> Optional[RobotFileMeta]:
1748
- try:
1749
- normalized = str(normalized_path(source))
1750
- mtime_ns = os.stat(normalized, follow_symlinks=False).st_mtime_ns
1751
- return RobotFileMeta(normalized, mtime_ns)
1752
- except OSError:
2025
+ normalized = str(normalized_path(source))
2026
+ info = probe_disk_info(normalized)
2027
+ if info is None:
1753
2028
  return None
2029
+ return RobotFileMeta(normalized, info)
1754
2030
 
1755
- def _get_model_doc_cached(self, source: str) -> Tuple[Optional[ResourceDoc], Optional["RobotFileMeta"]]:
1756
- meta = self.get_resource_meta(source)
1757
- if meta is None:
1758
- return None, None
2031
+ def _get_model_doc_cached(self, source: str, meta: RobotFileMeta) -> Optional[ResourceDoc]:
2032
+ known = self._model_docs_by_source.get(meta.source)
2033
+ if known is not None and known[0] == meta:
2034
+ return known[1]
1759
2035
 
1760
2036
  try:
1761
2037
  entry = self.data_cache.read_entry(CacheSection.RESOURCE, meta.source, RobotFileMeta, ResourceDoc)
1762
- if entry is not None and entry.meta == meta:
1763
- return entry.data, meta
2038
+ # a doc read with other language settings would hold what the file defines under those settings
2039
+ if entry is not None and entry.meta == replace(meta, languages=self._languages_fingerprint):
2040
+ self._model_docs_by_source[meta.source] = (meta, entry.data)
2041
+ return entry.data
1764
2042
  except (SystemExit, KeyboardInterrupt):
1765
2043
  raise
1766
2044
  except BaseException as e:
@@ -1770,12 +2048,15 @@ class ImportsManager:
1770
2048
  context_name="import",
1771
2049
  )
1772
2050
 
1773
- return None, meta
2051
+ return None
1774
2052
 
1775
2053
  def _save_model_doc_cache(self, source: str, result: ResourceDoc, meta: "RobotFileMeta") -> None:
2054
+ self._model_docs_by_source[meta.source] = (meta, result)
1776
2055
 
1777
2056
  try:
1778
- self.data_cache.save_entry(CacheSection.RESOURCE, meta.source, meta, result)
2057
+ self.data_cache.save_entry(
2058
+ CacheSection.RESOURCE, meta.source, replace(meta, languages=self._languages_fingerprint), result
2059
+ )
1779
2060
  except (SystemExit, KeyboardInterrupt):
1780
2061
  raise
1781
2062
  except BaseException as e:
@@ -1795,7 +2076,7 @@ class ImportsManager:
1795
2076
  resolve_variables: bool = True,
1796
2077
  resolve_command_line_vars: bool = True,
1797
2078
  ) -> Tuple[VariablesDoc, Optional[LibraryMetaData]]:
1798
- meta, _source = self.get_variables_meta(
2079
+ meta, import_name = self.get_variables_meta(
1799
2080
  name,
1800
2081
  base_dir,
1801
2082
  variables,
@@ -1806,7 +2087,7 @@ class ImportsManager:
1806
2087
  if meta is not None:
1807
2088
  try:
1808
2089
  entry = self.data_cache.read_entry(
1809
- CacheSection.VARIABLES, meta.cache_key, LibraryMetaData, VariablesDoc
2090
+ CacheSection.VARIABLES, meta.cache_key_for_args(args), LibraryMetaData, VariablesDoc
1810
2091
  )
1811
2092
  if entry is not None and entry.meta == meta:
1812
2093
  return entry.data, meta
@@ -1815,25 +2096,47 @@ class ImportsManager:
1815
2096
  except BaseException as e:
1816
2097
  self._logger.exception(e)
1817
2098
 
1818
- result = self._run_in_subprocess(
1819
- get_variables_doc,
1820
- (
1821
- name,
1822
- args,
1823
- working_dir,
1824
- base_dir,
1825
- self.get_resolvable_command_line_variables() if resolve_command_line_vars else None,
1826
- variables,
1827
- ),
1828
- f"Loading variables {name!r} with args {args!r} (working_dir={working_dir!r}, base_dir={base_dir!r})",
1829
- )
2099
+ try:
2100
+ result = self._run_in_subprocess(
2101
+ get_variables_doc,
2102
+ (
2103
+ name,
2104
+ args,
2105
+ working_dir,
2106
+ base_dir,
2107
+ self.get_resolvable_command_line_variables() if resolve_command_line_vars else None,
2108
+ variables,
2109
+ ),
2110
+ f"Loading variables {name!r} with args {args!r}",
2111
+ )
2112
+ except (LoadTimeoutError, LoadExitError) as e:
2113
+ # see _get_library_libdoc
2114
+ return (
2115
+ VariablesDoc(
2116
+ name=name,
2117
+ source=(
2118
+ meta.origin if meta is not None else import_name if is_variables_by_path(import_name) else None
2119
+ ),
2120
+ errors=[
2121
+ Error(
2122
+ message=str(e),
2123
+ type_name=(
2124
+ DiagnosticError.VARIABLES_TIMEOUT_ERROR
2125
+ if isinstance(e, LoadTimeoutError)
2126
+ else DiagnosticError.VARIABLES_EXIT_ERROR
2127
+ ),
2128
+ )
2129
+ ],
2130
+ ),
2131
+ None,
2132
+ )
1830
2133
 
1831
2134
  self._save_import_cache(CacheSection.VARIABLES, meta, result, "variables", name, args)
1832
2135
 
1833
2136
  return result, meta
1834
2137
 
1835
2138
  @_logger.call
1836
- def get_libdoc_for_variables_import(
2139
+ def get_libdoc_for_variables_import_with_meta(
1837
2140
  self,
1838
2141
  name: str,
1839
2142
  args: Tuple[Any, ...],
@@ -1842,7 +2145,7 @@ class ImportsManager:
1842
2145
  variables: Optional[Dict[str, Any]] = None,
1843
2146
  resolve_variables: bool = True,
1844
2147
  resolve_command_line_vars: bool = True,
1845
- ) -> VariablesDoc:
2148
+ ) -> Tuple[VariablesDoc, Optional[LibraryMetaData]]:
1846
2149
  with self._logger.measure_time(lambda: f"getting libdoc for variables import {name}", context_name="import"):
1847
2150
  source = self.find_variables(name, base_dir, variables, resolve_variables, resolve_command_line_vars)
1848
2151
 
@@ -1877,9 +2180,29 @@ class ImportsManager:
1877
2180
  if sentinel is not None and sentinel not in entry.references:
1878
2181
  entry.references.add(sentinel)
1879
2182
  fin = weakref.finalize(sentinel, self.__remove_variables_entry, entry_key, entry)
1880
- fin.atexit = False # type: ignore[misc]
2183
+ fin.atexit = False
1881
2184
 
1882
- return entry.get_libdoc()
2185
+ return entry.get_libdoc_with_meta()
2186
+
2187
+ def get_libdoc_for_variables_import(
2188
+ self,
2189
+ name: str,
2190
+ args: Tuple[Any, ...],
2191
+ base_dir: str,
2192
+ sentinel: Any = None,
2193
+ variables: Optional[Dict[str, Any]] = None,
2194
+ resolve_variables: bool = True,
2195
+ resolve_command_line_vars: bool = True,
2196
+ ) -> VariablesDoc:
2197
+ return self.get_libdoc_for_variables_import_with_meta(
2198
+ name,
2199
+ args,
2200
+ base_dir,
2201
+ sentinel,
2202
+ variables,
2203
+ resolve_variables,
2204
+ resolve_command_line_vars,
2205
+ )[0]
1883
2206
 
1884
2207
  @_logger.call
1885
2208
  def _get_entry_for_resource_import(
@@ -1905,7 +2228,7 @@ class ImportsManager:
1905
2228
  if sentinel is not None and sentinel not in entry.references:
1906
2229
  entry.references.add(sentinel)
1907
2230
  fin = weakref.finalize(sentinel, self.__remove_resource_entry, entry_key, entry)
1908
- fin.atexit = False # type: ignore[misc]
2231
+ fin.atexit = False
1909
2232
 
1910
2233
  return entry
1911
2234
 
@@ -1929,7 +2252,7 @@ class ImportsManager:
1929
2252
 
1930
2253
  return namespace, libdoc
1931
2254
 
1932
- def get_resource_doc_for_resource_import(
2255
+ def get_resource_doc_for_resource_import_with_meta(
1933
2256
  self,
1934
2257
  name: str,
1935
2258
  base_dir: str,
@@ -1937,11 +2260,24 @@ class ImportsManager:
1937
2260
  variables: Optional[Dict[str, Any]] = None,
1938
2261
  *,
1939
2262
  source: Optional[str] = None,
1940
- ) -> ResourceDoc:
2263
+ ) -> Tuple[ResourceDoc, Optional[RobotFileMeta]]:
1941
2264
  with self._logger.measure_time(lambda: f"getting resource doc for {name}", context_name="import"):
1942
2265
  entry = self._get_entry_for_resource_import(name, base_dir, sentinel, variables, source=source)
1943
2266
 
1944
- return entry.get_resource_doc()
2267
+ return entry.get_resource_doc_with_meta()
2268
+
2269
+ def get_resource_doc_for_resource_import(
2270
+ self,
2271
+ name: str,
2272
+ base_dir: str,
2273
+ sentinel: Any = None,
2274
+ variables: Optional[Dict[str, Any]] = None,
2275
+ *,
2276
+ source: Optional[str] = None,
2277
+ ) -> ResourceDoc:
2278
+ return self.get_resource_doc_for_resource_import_with_meta(name, base_dir, sentinel, variables, source=source)[
2279
+ 0
2280
+ ]
1945
2281
 
1946
2282
  def complete_library_import(
1947
2283
  self,