robotcode-robot 2.6.2__py3-none-any.whl → 2.7.0__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.
@@ -1 +1 @@
1
- __version__ = "2.6.2"
1
+ __version__ = "2.7.0"
@@ -2912,6 +2912,23 @@ class RobotBaseProfile(CommonOptions, CommonExtendOptions, RobotOptions, RobotEx
2912
2912
  """
2913
2913
  )
2914
2914
 
2915
+ wrapper: Optional[List[Union[str, StringExpression]]] = field(
2916
+ description="""\
2917
+ Command prefix the test run is executed through (the actual
2918
+ `robotcode` command line is appended to it), e.g. to bring up the
2919
+ services, servers, mocks or session the tests need around them. For
2920
+ plain environment variables use `env` instead. The selected
2921
+ profile's `env` is applied first, so the wrapper can rely on it.
2922
+
2923
+ Examples:
2924
+ ```toml
2925
+ [profiles.integration]
2926
+ wrapper = ["./with-test-services.sh"]
2927
+ # or a ready-made tool, e.g. ["xvfb-run", "-a"] for headless UI tests
2928
+ ```
2929
+ """,
2930
+ )
2931
+
2915
2932
  rebot: Optional[RebotProfile] = field(
2916
2933
  description="""\
2917
2934
  Options to be passed to _rebot_.
@@ -2979,6 +2996,18 @@ class RobotExtendBaseProfile(RobotBaseProfile):
2979
2996
  alias="extend-env",
2980
2997
  )
2981
2998
 
2999
+ extend_wrapper: Optional[List[Union[str, StringExpression]]] = field(
3000
+ description="""\
3001
+ Append extra entries to the `wrapper` command prefix.
3002
+
3003
+ Examples:
3004
+ ```toml
3005
+ extend-wrapper = ["--some-flag"]
3006
+ ```
3007
+ """,
3008
+ alias="extend-wrapper",
3009
+ )
3010
+
2982
3011
  extend_paths: Union[str, List[str], None] = field(
2983
3012
  description="""\
2984
3013
  Append extra entries to the paths argument.
@@ -24,6 +24,7 @@ from robotcode.core.filewatcher import FileWatcherManagerBase
24
24
  from robotcode.core.text_document import TextDocument
25
25
  from robotcode.core.uri import Uri
26
26
  from robotcode.core.utils.logging import LoggingDescriptor
27
+ from robotcode.core.utils.path import DiskInfo
27
28
  from robotcode.core.workspace import Workspace, WorkspaceFolder
28
29
  from robotcode.robot.diagnostics.diagnostics_modifier import (
29
30
  DiagnosticModifiersConfig,
@@ -58,6 +59,18 @@ class UnknownFileTypeError(Exception):
58
59
  pass
59
60
 
60
61
 
62
+ def _namespace_cache_key(source: str, document_type: Optional[DocumentType]) -> str:
63
+ """Disk-cache entry name for a namespace.
64
+
65
+ Includes the document type because the same file can be analyzed both as
66
+ a suite (GENERAL) and as an imported resource (RESOURCE) — the results
67
+ must not overwrite each other. "\\n" as separator is impossible in Windows
68
+ paths and never appears in real-world POSIX paths (where it is technically
69
+ legal); the key is an opaque entry name and is never parsed back.
70
+ """
71
+ return f"{source}\n{document_type.value if document_type is not None else ''}"
72
+
73
+
61
74
  class _CacheEntry:
62
75
  pass
63
76
 
@@ -438,11 +451,16 @@ class DocumentsCacheHelper:
438
451
  imports_manager = self.get_imports_manager(document)
439
452
  semantic_model_enabled = self._is_semantic_model_enabled(document)
440
453
 
454
+ # Capture the disk state before the model is parsed: this snapshot can
455
+ # only be older than the analyzed content, never newer, so anything
456
+ # persisted under it self-heals with a cache miss.
457
+ disk_info = document.disk_info
458
+
441
459
  # --- Try disk cache (cold-start acceleration) ---
442
460
  cache_namespaces = self.analysis_config.cache.cache_namespaces
443
- if cache_namespaces and document.version is None:
461
+ if cache_namespaces and document.version is None and disk_info is not None:
444
462
  result = self._try_load_cached_namespace(
445
- source, document, document_type, imports_manager, semantic_model_enabled
463
+ source, document, document_type, imports_manager, semantic_model_enabled, disk_info
446
464
  )
447
465
  if result is not None:
448
466
  return result
@@ -457,6 +475,13 @@ class DocumentsCacheHelper:
457
475
  else:
458
476
  model = self.get_model(document)
459
477
 
478
+ # The document may have been re-read from disk while the model was
479
+ # parsed; the captured snapshot then no longer describes the parsed
480
+ # content and must not label anything written during the build (the
481
+ # builder writes RESOURCE cache entries under this snapshot).
482
+ if disk_info is not None and document.disk_info != disk_info:
483
+ disk_info = None
484
+
460
485
  languages, workspace_languages = self.build_languages_from_model(document, model)
461
486
  builder = NamespaceBuilder(
462
487
  imports_manager,
@@ -466,14 +491,20 @@ class DocumentsCacheHelper:
466
491
  document_type,
467
492
  languages,
468
493
  workspace_languages,
494
+ disk_info=disk_info,
469
495
  )
470
496
  builder.set_semantic_model_enabled(semantic_model_enabled)
471
497
 
472
498
  result = builder.build()
473
499
 
474
- # Save to disk cache
475
- if cache_namespaces:
476
- self._save_namespace_to_cache(source, result, imports_manager, semantic_model_enabled)
500
+ # Save to disk cache — the re-checks in _namespace_is_cacheable catch
501
+ # a watcher re-read (disk_info) and a didOpen on another thread
502
+ # (version) that happened during the build.
503
+ if cache_namespaces and self._namespace_is_cacheable(document, disk_info):
504
+ assert disk_info is not None
505
+ self._save_namespace_to_cache(
506
+ source, document, document_type, result, imports_manager, disk_info, semantic_model_enabled
507
+ )
477
508
 
478
509
  # Update the folder-scoped reference index
479
510
  self.get_project_index(document).update_file(result.source, result)
@@ -491,6 +522,18 @@ class DocumentsCacheHelper:
491
522
  experimental_config = self.workspace.get_configuration(ExperimentalConfig, document.uri)
492
523
  return self.analysis_config.semantic_model or experimental_config.semantic_model
493
524
 
525
+ @staticmethod
526
+ def _namespace_is_cacheable(document: TextDocument, disk_info: Optional[DiskInfo]) -> bool:
527
+ """Whether a namespace built from this document may be persisted.
528
+
529
+ Only when the analyzed content is the on-disk state: not an editor
530
+ buffer (version), backed by a trusted snapshot, and the document still
531
+ holds exactly that snapshot.
532
+ """
533
+ return (
534
+ document.version is None and disk_info is not None and disk_info.trusted and document.disk_info == disk_info
535
+ )
536
+
494
537
  def _try_load_cached_namespace(
495
538
  self,
496
539
  source: str,
@@ -498,10 +541,14 @@ class DocumentsCacheHelper:
498
541
  document_type: Optional[DocumentType],
499
542
  imports_manager: ImportsManager,
500
543
  semantic_model_enabled: bool,
544
+ disk_info: DiskInfo,
501
545
  ) -> Optional[Namespace]:
502
546
  """Attempt to load a Namespace from the disk cache.
503
547
 
504
- Returns None on cache miss or validation failure.
548
+ ``disk_info`` is the snapshot of the document content the namespace
549
+ will represent; the cached entry is validated against it, not against
550
+ the current disk state. Returns None on cache miss or validation
551
+ failure.
505
552
  """
506
553
  data_cache = imports_manager.data_cache
507
554
 
@@ -510,7 +557,9 @@ class DocumentsCacheHelper:
510
557
  return None
511
558
 
512
559
  try:
513
- entry = data_cache.read_entry(CacheSection.NAMESPACE, source, NamespaceMetaData, NamespaceData)
560
+ entry = data_cache.read_entry(
561
+ CacheSection.NAMESPACE, _namespace_cache_key(source, document_type), NamespaceMetaData, NamespaceData
562
+ )
514
563
  except (SystemExit, KeyboardInterrupt):
515
564
  raise
516
565
  except BaseException as e:
@@ -536,7 +585,7 @@ class DocumentsCacheHelper:
536
585
  )
537
586
  return None
538
587
 
539
- if not imports_manager.validate_namespace_meta(meta):
588
+ if not imports_manager.validate_namespace_meta(meta, disk_info):
540
589
  self._logger.debug(
541
590
  lambda: f"Cache miss for {source}: cached entry is stale, re-analyzing",
542
591
  context_name="cache",
@@ -588,19 +637,43 @@ class DocumentsCacheHelper:
588
637
  def _save_namespace_to_cache(
589
638
  self,
590
639
  source: str,
640
+ document: TextDocument,
641
+ document_type: Optional[DocumentType],
591
642
  namespace: Namespace,
592
643
  imports_manager: ImportsManager,
644
+ disk_info: DiskInfo,
593
645
  semantic_model_enabled: bool,
594
646
  ) -> None:
595
- """Save a Namespace to the disk cache."""
647
+ """Save a Namespace to the disk cache.
648
+
649
+ ``disk_info`` must be the snapshot captured before the namespace was
650
+ built; the entry is skipped when the namespace's state is not fully
651
+ trustworthy or the document changed in the meantime.
652
+ """
596
653
  try:
597
654
  meta = imports_manager.build_namespace_meta(
598
- source, namespace, semantic_model_enabled=semantic_model_enabled
655
+ source, namespace, disk_info, semantic_model_enabled=semantic_model_enabled
599
656
  )
657
+ if meta is None:
658
+ self._logger.debug(
659
+ lambda: f"Skip caching namespace for {source}: state not trustworthy",
660
+ context_name="cache",
661
+ )
662
+ return
663
+
600
664
  data = namespace.to_data()
601
665
 
666
+ # to_data() takes time — re-check that the document still holds
667
+ # exactly the content this namespace was built from.
668
+ if not self._namespace_is_cacheable(document, disk_info):
669
+ self._logger.debug(
670
+ lambda: f"Skip caching namespace for {source}: document changed during save",
671
+ context_name="cache",
672
+ )
673
+ return
674
+
602
675
  data_cache = imports_manager.data_cache
603
- data_cache.save_entry(CacheSection.NAMESPACE, source, meta, data)
676
+ data_cache.save_entry(CacheSection.NAMESPACE, _namespace_cache_key(source, document_type), meta, data)
604
677
  except (SystemExit, KeyboardInterrupt):
605
678
  raise
606
679
  except BaseException as e:
@@ -59,6 +59,12 @@ class ResolvedImports:
59
59
  resources: Dict[str, ResourceEntry] = field(default_factory=dict)
60
60
  variables_imports: Dict[str, VariablesEntry] = field(default_factory=dict)
61
61
 
62
+ # Metadata of each resolved dependency, captured together with its doc at
63
+ # resolve time (keys: "lib:{import_name}", "res:{source}", "var:{import_name}").
64
+ # A None value means the dependency was resolved but no trustworthy meta
65
+ # exists — namespaces built from it must not be persisted.
66
+ dependency_metas: Dict[str, Optional[Any]] = field(default_factory=dict)
67
+
62
68
 
63
69
  class ImportResolver:
64
70
  """Resolves all imports for a Robot Framework file.
@@ -78,6 +84,7 @@ class ImportResolver:
78
84
 
79
85
  __slots__ = (
80
86
  "_base_dir",
87
+ "_dependency_metas",
81
88
  "_diagnostics",
82
89
  "_import_entries",
83
90
  "_imports_manager",
@@ -115,6 +122,7 @@ class ImportResolver:
115
122
  self._variables: Dict[str, Any] = {}
116
123
  self._variables_dirty = True
117
124
  self._source_hints: Dict[str, str] = {}
125
+ self._dependency_metas: Dict[str, Optional[Any]] = {}
118
126
 
119
127
  def resolve(self, imports: Iterable[Import], *, source_hints: Optional[Dict[str, str]] = None) -> ResolvedImports:
120
128
  """Resolve all imports and return the result."""
@@ -129,6 +137,7 @@ class ImportResolver:
129
137
  variables_imports=self._variables_imports,
130
138
  import_entries=self._import_entries,
131
139
  diagnostics=self._diagnostics,
140
+ dependency_metas=self._dependency_metas,
132
141
  )
133
142
 
134
143
  # ------------------------------------------------------------------
@@ -186,13 +195,14 @@ class ImportResolver:
186
195
  ):
187
196
  for library in DEFAULT_LIBRARIES:
188
197
  try:
189
- library_doc = self._imports_manager.get_libdoc_for_library_import(
198
+ library_doc, meta = self._imports_manager.get_libdoc_for_library_import_with_meta(
190
199
  library,
191
200
  (),
192
201
  base_dir=self._base_dir,
193
202
  sentinel=None,
194
203
  variables=self._variables,
195
204
  )
205
+ self._dependency_metas[f"lib:{library}"] = meta
196
206
  entry = LibraryEntry(
197
207
  name=library_doc.name,
198
208
  import_name=library,
@@ -312,13 +322,14 @@ class ImportResolver:
312
322
  def _import_library(self, imp: LibraryImport, base_dir: str, *, top_level: bool) -> None:
313
323
  assert imp.name is not None
314
324
 
315
- library_doc = self._imports_manager.get_libdoc_for_library_import(
325
+ library_doc, meta = self._imports_manager.get_libdoc_for_library_import_with_meta(
316
326
  imp.name,
317
327
  imp.args,
318
328
  base_dir=base_dir,
319
329
  sentinel=self._sentinel,
320
330
  variables=self._variables,
321
331
  )
332
+ self._dependency_metas[f"lib:{imp.name}"] = meta
322
333
  entry = LibraryEntry(
323
334
  name=library_doc.name,
324
335
  import_name=imp.name,
@@ -407,13 +418,17 @@ class ImportResolver:
407
418
  if hint is not None and os.path.isfile(hint):
408
419
  source_hint = hint
409
420
 
410
- resource_doc = self._imports_manager.get_resource_doc_for_resource_import(
421
+ resource_doc, meta = self._imports_manager.get_resource_doc_for_resource_import_with_meta(
411
422
  imp.name,
412
423
  base_dir,
413
424
  sentinel=self._sentinel,
414
425
  variables=self._variables,
415
426
  source=source_hint,
416
427
  )
428
+ if resource_doc.source is not None:
429
+ # setdefault: on re-imports of the same resource the meta captured
430
+ # with the first (analyzed) doc wins.
431
+ self._dependency_metas.setdefault(f"res:{resource_doc.source}", meta)
417
432
  entry = ResourceEntry(
418
433
  name=resource_doc.name,
419
434
  import_name=imp.name,
@@ -535,13 +550,14 @@ class ImportResolver:
535
550
  def _import_variables(self, imp: VariablesImport, base_dir: str, *, top_level: bool) -> None:
536
551
  assert imp.name is not None
537
552
 
538
- library_doc = self._imports_manager.get_libdoc_for_variables_import(
553
+ library_doc, meta = self._imports_manager.get_libdoc_for_variables_import_with_meta(
539
554
  imp.name,
540
555
  imp.args,
541
556
  base_dir=base_dir,
542
557
  sentinel=self._sentinel,
543
558
  variables=self._variables,
544
559
  )
560
+ self._dependency_metas[f"var:{imp.name}"] = meta
545
561
  entry = VariablesEntry(
546
562
  name=library_doc.name,
547
563
  import_name=imp.name,
@@ -2,6 +2,7 @@ import ast
2
2
  import multiprocessing as mp
3
3
  import os
4
4
  import threading
5
+ import time
5
6
  import weakref
6
7
  from abc import ABC, abstractmethod
7
8
  from concurrent.futures import ProcessPoolExecutor, TimeoutError
@@ -42,7 +43,13 @@ from robotcode.core.uri import Uri
42
43
  from robotcode.core.utils.caching import SimpleLRUCache
43
44
  from robotcode.core.utils.glob_path import Pattern
44
45
  from robotcode.core.utils.logging import LoggingDescriptor
45
- from robotcode.core.utils.path import normalized_path, path_is_relative_to
46
+ from robotcode.core.utils.path import (
47
+ DiskInfo,
48
+ disk_info_from_stat,
49
+ normalized_path,
50
+ path_is_relative_to,
51
+ probe_disk_info,
52
+ )
46
53
 
47
54
  from ..__version__ import __version__
48
55
  from ..utils import RF_VERSION
@@ -272,14 +279,23 @@ class _LibrariesEntry(_ImportEntry):
272
279
  with self._lock:
273
280
  return self._lib_doc is not None
274
281
 
275
- def get_libdoc(self) -> LibraryDoc:
282
+ def get_libdoc_with_meta(self) -> Tuple[LibraryDoc, Optional["LibraryMetaData"]]:
283
+ """Return the LibraryDoc together with the meta of the state it was built from.
284
+
285
+ Doc and meta are taken under the entry lock so they always belong to
286
+ the same state — reading ``get_libdoc()`` and ``meta`` separately can
287
+ interleave with an invalidation and pair a doc with a newer meta.
288
+ """
276
289
  with self._lock:
277
290
  if self._lib_doc is None:
278
291
  self._update()
279
292
 
280
293
  assert self._lib_doc is not None
281
294
 
282
- return self._lib_doc
295
+ return self._lib_doc, self._meta
296
+
297
+ def get_libdoc(self) -> LibraryDoc:
298
+ return self.get_libdoc_with_meta()[0]
283
299
 
284
300
 
285
301
  @dataclass(frozen=True, slots=True)
@@ -343,7 +359,11 @@ class _ResourcesEntry(_ImportEntry):
343
359
  )
344
360
 
345
361
  self._document = self.parent.documents_manager.get_or_open_document(self.source_path)
346
- self._meta = ImportsManager.get_resource_meta(str(self.source_path))
362
+ # Bind the meta to the document's disk state instead of a fresh stat, so
363
+ # it describes exactly the content this entry's ResourceDoc is built
364
+ # from. A dirty/editor-open document yields no meta — its state must
365
+ # never be persisted.
366
+ self._meta = RobotFileMeta.from_document(self._document)
347
367
 
348
368
  if self._document._version is None:
349
369
  self.file_watchers.append(
@@ -390,15 +410,21 @@ class _ResourcesEntry(_ImportEntry):
390
410
  def _get_namespace(self) -> "Namespace":
391
411
  return self.parent.get_namespace_for_resource(self._get_document())
392
412
 
393
- def get_resource_doc(self) -> ResourceDoc:
413
+ def get_resource_doc_with_meta(self) -> Tuple[ResourceDoc, Optional["RobotFileMeta"]]:
414
+ """Return the ResourceDoc together with the meta of the content it was built from.
415
+
416
+ Doc and meta are taken under the entry lock so they always belong to
417
+ the same state — reading ``get_libdoc()`` and ``meta`` separately can
418
+ interleave with an invalidation and pair a doc with a newer meta.
419
+ """
394
420
  with self._lock:
395
421
  if self._lib_doc is None:
396
422
  self._lib_doc = self.parent.get_resource_doc_from_document(self._get_document())
397
423
 
398
- return self._lib_doc
424
+ return self._lib_doc, self._meta
399
425
 
400
426
  def get_libdoc(self) -> ResourceDoc:
401
- return self.get_resource_doc()
427
+ return self.get_resource_doc_with_meta()[0]
402
428
 
403
429
 
404
430
  @dataclass(frozen=True, slots=True)
@@ -495,14 +521,22 @@ class _VariablesEntry(_ImportEntry):
495
521
  with self._lock:
496
522
  return self._lib_doc is not None
497
523
 
498
- def get_libdoc(self) -> VariablesDoc:
524
+ def get_libdoc_with_meta(self) -> Tuple[VariablesDoc, Optional["LibraryMetaData"]]:
525
+ """Return the VariablesDoc together with the meta of the state it was built from.
526
+
527
+ Doc and meta are taken under the entry lock so they always belong to
528
+ the same state (see ``_LibrariesEntry.get_libdoc_with_meta``).
529
+ """
499
530
  with self._lock:
500
531
  if self._lib_doc is None:
501
532
  self._update()
502
533
 
503
534
  assert self._lib_doc is not None
504
535
 
505
- return self._lib_doc
536
+ return self._lib_doc, self._meta
537
+
538
+ def get_libdoc(self) -> VariablesDoc:
539
+ return self.get_libdoc_with_meta()[0]
506
540
 
507
541
 
508
542
  @dataclass(slots=True)
@@ -513,7 +547,7 @@ class LibraryMetaData:
513
547
  submodule_search_locations: Optional[List[str]]
514
548
  by_path: bool
515
549
 
516
- mtimes: Optional[Dict[str, int]] = None
550
+ file_infos: Optional[Dict[str, DiskInfo]] = None
517
551
 
518
552
  has_errors: bool = False
519
553
 
@@ -528,16 +562,34 @@ class LibraryMetaData:
528
562
 
529
563
  raise ValueError("Cannot determine cache key.")
530
564
 
565
+ @property
566
+ def trusted(self) -> bool:
567
+ return self.file_infos is None or all(info.trusted for info in self.file_infos.values())
568
+
569
+
570
+ def _stat_library_file(path: str) -> os.stat_result:
571
+ # Follow symlinks so target changes behind an unchanged link are detected
572
+ # (editable installs); for a broken link fall back to the link's own state
573
+ # so a single dangling .py file doesn't make the whole library uncacheable.
574
+ try:
575
+ return os.stat(path)
576
+ except OSError:
577
+ return os.stat(path, follow_symlinks=False)
531
578
 
532
- def _collect_library_mtimes(
579
+
580
+ def _collect_library_file_infos(
533
581
  origin: Optional[str],
534
582
  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] = {}
583
+ ) -> Optional[Dict[str, DiskInfo]]:
584
+ """Collect DiskInfo snapshots from origin and submodule_search_locations."""
585
+ file_infos: Dict[str, DiskInfo] = {}
586
+
587
+ # one capture moment for the whole collection, so all snapshots share the
588
+ # same racy-mtime reference point
589
+ now_ns = time.time_ns()
538
590
 
539
591
  if origin is not None:
540
- mtimes[origin] = os.stat(origin, follow_symlinks=False).st_mtime_ns
592
+ file_infos[origin] = disk_info_from_stat(_stat_library_file(origin), now_ns)
541
593
 
542
594
  if submodule_search_locations:
543
595
  for loc in submodule_search_locations:
@@ -545,9 +597,9 @@ def _collect_library_mtimes(
545
597
  for filename in filenames:
546
598
  if filename.endswith(".py"):
547
599
  filepath = os.path.join(dirpath, filename)
548
- mtimes[filepath] = os.stat(filepath, follow_symlinks=False).st_mtime_ns
600
+ file_infos[filepath] = disk_info_from_stat(_stat_library_file(filepath), now_ns)
549
601
 
550
- return mtimes or None
602
+ return file_infos or None
551
603
 
552
604
 
553
605
  def _matches_any_pattern(
@@ -564,7 +616,23 @@ def _matches_any_pattern(
564
616
  @dataclass(slots=True)
565
617
  class RobotFileMeta:
566
618
  source: str
567
- mtime_ns: int
619
+ info: DiskInfo
620
+
621
+ @property
622
+ def trusted(self) -> bool:
623
+ return self.info.trusted
624
+
625
+ @classmethod
626
+ def from_document(cls, document: TextDocument) -> Optional["RobotFileMeta"]:
627
+ """Meta describing exactly the disk state the document's text was read from.
628
+
629
+ ``None`` when the document has no disk info, i.e. its text is not known
630
+ to match any on-disk state (editor buffer, read race).
631
+ """
632
+ info = document.disk_info
633
+ if info is None:
634
+ return None
635
+ return cls(str(normalized_path(document.uri.to_path())), info)
568
636
 
569
637
 
570
638
  @dataclass(slots=True)
@@ -576,7 +644,7 @@ class NamespaceMetaData:
576
644
  """
577
645
 
578
646
  source: str
579
- source_mtime_ns: int
647
+ source_info: DiskInfo
580
648
  config_fingerprint: Any
581
649
  dependency_fingerprints: Dict[str, Any] = field(default_factory=dict)
582
650
  semantic_model_enabled: bool = False
@@ -761,101 +829,76 @@ class ImportsManager:
761
829
  """
762
830
  return self._config_fingerprint
763
831
 
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.
832
+ @staticmethod
833
+ def _is_dependency_meta_trusted(value: Any) -> bool:
834
+ # fail closed: a meta type this dispatch does not know must never be
835
+ # persisted — silently treating it as trusted is the exact failure
836
+ # class the freshness system exists to prevent
837
+ return isinstance(value, (RobotFileMeta, LibraryMetaData)) and value.trusted
770
838
 
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.
839
+ def build_namespace_meta(
840
+ self,
841
+ source: str,
842
+ namespace: "Namespace",
843
+ disk_info: DiskInfo,
844
+ *,
845
+ semantic_model_enabled: bool = False,
846
+ ) -> Optional[NamespaceMetaData]:
847
+ """Build a NamespaceMetaData for the given namespace and its dependencies.
848
+
849
+ ``disk_info`` must be the snapshot of the content the namespace was
850
+ built from; the dependency fingerprints are the metas recorded during
851
+ import resolution. Returns ``None`` when any of that state is missing
852
+ or untrusted — such a namespace must not be persisted, because its
853
+ fingerprints could describe newer file states than the analyzed
854
+ content and would validate as fresh forever.
774
855
  """
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
803
-
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
856
+ if not disk_info.trusted:
857
+ return None
815
858
 
816
- return fingerprints
859
+ dependency_metas = namespace.dependency_metas
860
+ if dependency_metas is None:
861
+ return None
817
862
 
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
863
+ for value in dependency_metas.values():
864
+ if value is None or not self._is_dependency_meta_trusted(value):
865
+ return None
826
866
 
827
867
  return NamespaceMetaData(
828
868
  source=source,
829
- source_mtime_ns=source_mtime_ns,
869
+ source_info=disk_info,
830
870
  config_fingerprint=self.config_fingerprint,
831
- dependency_fingerprints=self.compute_dependency_fingerprints(namespace),
871
+ dependency_fingerprints=dict(dependency_metas),
832
872
  semantic_model_enabled=semantic_model_enabled,
833
873
  )
834
874
 
835
- def validate_namespace_meta(self, meta: NamespaceMetaData) -> bool:
836
- """Check whether a cached NamespaceMetaData is still fresh.
875
+ def validate_namespace_meta(self, meta: NamespaceMetaData, source_disk_info: Optional[DiskInfo]) -> bool:
876
+ """Check whether a cached NamespaceMetaData is fresh for the given content.
837
877
 
838
878
  Performs a 2-level validation:
839
- Level 1 (fast): source_mtime_ns, config_fingerprint
879
+ Level 1 (fast): source_disk_info, config_fingerprint
840
880
  Level 2 (dependency check): each dependency fingerprint
881
+
882
+ Validation is against the *effective* content: the source is compared
883
+ to the snapshot of the document the namespace would represent, and
884
+ resource dependencies are checked document-first — a dirty editor
885
+ buffer fails validation so the namespace is rebuilt against the buffer
886
+ instead of being served from disk state.
841
887
  """
842
888
  source = meta.source
843
889
 
844
890
  # Level 1: fast checks
845
- try:
846
- current_mtime = os.stat(source, follow_symlinks=False).st_mtime_ns
847
- except OSError:
891
+ if source_disk_info is None:
848
892
  self._logger.debug(
849
- lambda: f"Cache miss for {source}: source file no longer exists",
893
+ lambda: f"Cache miss for {source}: source has no trustworthy disk state",
850
894
  context_name="cache",
851
895
  )
852
896
  return False
853
897
 
854
- if meta.source_mtime_ns != current_mtime:
898
+ if meta.source_info != source_disk_info:
855
899
  self._logger.debug(
856
900
  lambda: (
857
- f"Cache miss for {source}: source mtime changed"
858
- f" (cached={meta.source_mtime_ns}, current={current_mtime})"
901
+ f"Cache miss for {source}: source changed (cached={meta.source_info}, current={source_disk_info})"
859
902
  ),
860
903
  context_name="cache",
861
904
  )
@@ -874,7 +917,7 @@ class ImportsManager:
874
917
  if key.startswith("lib:"):
875
918
  lib_name = key[4:]
876
919
  try:
877
- lib_meta = self.get_cached_library_meta(lib_name)
920
+ lib_meta = self.get_cached_library_meta(lib_name, args=None)
878
921
  if lib_meta is None:
879
922
  lib_meta, _, _ = self.get_library_meta(lib_name, base_dir=base_dir)
880
923
  if lib_meta is None or lib_meta != saved_value:
@@ -898,8 +941,15 @@ class ImportsManager:
898
941
  elif key.startswith("res:"):
899
942
  res_source = key[4:]
900
943
  try:
901
- res_meta = self.get_cached_resource_meta(res_source)
902
- if res_meta is None:
944
+ # Document-first: an open document is the effective content
945
+ # of this dependency. A dirty buffer (no disk snapshot)
946
+ # must fail validation so dependents are rebuilt against
947
+ # the buffer instead of being served from cached disk
948
+ # state. Only unopened resources are probed on disk.
949
+ res_document = self.documents_manager.get(Uri.from_path(res_source))
950
+ if res_document is not None:
951
+ res_meta = RobotFileMeta.from_document(res_document)
952
+ else:
903
953
  res_meta = self.get_resource_meta(res_source)
904
954
  if res_meta is None or res_meta != saved_value:
905
955
  self._logger.debug(
@@ -922,7 +972,7 @@ class ImportsManager:
922
972
  elif key.startswith("var:"):
923
973
  var_name = key[4:]
924
974
  try:
925
- var_meta = self.get_cached_variables_meta(var_name)
975
+ var_meta = self.get_cached_variables_meta(var_name, args=None)
926
976
  if var_meta is None:
927
977
  var_meta, _ = self.get_variables_meta(var_name, base_dir=base_dir)
928
978
  if var_meta is None or var_meta != saved_value:
@@ -952,13 +1002,24 @@ class ImportsManager:
952
1002
  def get_resource_doc_from_document(self, document: TextDocument) -> ResourceDoc:
953
1003
  source = str(document.uri.to_path())
954
1004
 
955
- if not self._is_document_loaded(source):
956
- cached, _meta = self._get_model_doc_cached(source)
1005
+ # Capture the meta before parsing, so it can only be older than the
1006
+ # parsed content, never newer (stale metas cause a harmless cache
1007
+ # miss, newer ones would poison the cache).
1008
+ meta = RobotFileMeta.from_document(document)
1009
+
1010
+ if meta is not None and meta.trusted:
1011
+ cached = self._get_model_doc_cached(source, meta)
957
1012
  if cached is not None:
958
1013
  return cached
959
1014
 
960
1015
  model = self.document_cache_helper.get_resource_model(document)
961
- return self.get_libdoc_from_model(model, source)
1016
+
1017
+ # The document may have been re-read from disk while parsing; in that
1018
+ # case the model does not necessarily belong to the captured meta.
1019
+ if meta is not None and document.disk_info != meta.info:
1020
+ meta = None
1021
+
1022
+ return self.get_libdoc_from_model(model, source, meta)
962
1023
 
963
1024
  def clear_cache(self) -> None:
964
1025
  count = self.data_cache.clear_all()
@@ -1302,7 +1363,7 @@ class ImportsManager:
1302
1363
  import_name,
1303
1364
  None,
1304
1365
  True,
1305
- mtimes=_collect_library_mtimes(import_name, None),
1366
+ file_infos=_collect_library_file_infos(import_name, None),
1306
1367
  )
1307
1368
  else:
1308
1369
  module_spec = self._get_module_spec_cached(import_name)
@@ -1313,7 +1374,9 @@ class ImportsManager:
1313
1374
  module_spec.origin,
1314
1375
  module_spec.submodule_search_locations,
1315
1376
  False,
1316
- mtimes=_collect_library_mtimes(module_spec.origin, module_spec.submodule_search_locations),
1377
+ file_infos=_collect_library_file_infos(
1378
+ module_spec.origin, module_spec.submodule_search_locations
1379
+ ),
1317
1380
  )
1318
1381
 
1319
1382
  if result is not None:
@@ -1367,7 +1430,7 @@ class ImportsManager:
1367
1430
  import_name,
1368
1431
  None,
1369
1432
  True,
1370
- mtimes=_collect_library_mtimes(import_name, None),
1433
+ file_infos=_collect_library_file_infos(import_name, None),
1371
1434
  )
1372
1435
  else:
1373
1436
  module_spec = self._get_module_spec_cached(import_name)
@@ -1378,7 +1441,9 @@ class ImportsManager:
1378
1441
  module_spec.origin,
1379
1442
  module_spec.submodule_search_locations,
1380
1443
  False,
1381
- mtimes=_collect_library_mtimes(module_spec.origin, module_spec.submodule_search_locations),
1444
+ file_infos=_collect_library_file_infos(
1445
+ module_spec.origin, module_spec.submodule_search_locations
1446
+ ),
1382
1447
  )
1383
1448
 
1384
1449
  if result is not None:
@@ -1399,36 +1464,44 @@ class ImportsManager:
1399
1464
 
1400
1465
  return None, name
1401
1466
 
1402
- def get_cached_library_meta(self, import_name: str, args: Tuple[Any, ...] = ()) -> Optional[LibraryMetaData]:
1467
+ def get_cached_library_meta(
1468
+ self, import_name: str, args: Optional[Tuple[Any, ...]] = ()
1469
+ ) -> Optional[LibraryMetaData]:
1403
1470
  """Return already-computed LibraryMetaData from the internal cache, or None.
1404
1471
 
1405
- Matches by import name and args to find the correct entry, since
1406
- the same library with different args may produce different keywords.
1472
+ Matches by import name and args to find the correct entry, since the
1473
+ same library with different args may produce different keywords.
1474
+ ``args=None`` matches by name only — the metadata describes the
1475
+ library's module files and is the same regardless of the arguments.
1407
1476
  """
1408
1477
  with self._libaries_lock:
1409
1478
  for entry in self._libaries.values():
1410
- if entry.name == import_name and entry.args == args:
1479
+ if entry.name != import_name:
1480
+ continue
1481
+ if args is not None:
1482
+ if entry.args == args:
1483
+ return entry.meta
1484
+ elif entry.meta is not None:
1411
1485
  return entry.meta
1412
1486
  return None
1413
1487
 
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
1422
-
1423
- def get_cached_variables_meta(self, import_name: str, args: Tuple[Any, ...] = ()) -> Optional[LibraryMetaData]:
1488
+ def get_cached_variables_meta(
1489
+ self, import_name: str, args: Optional[Tuple[Any, ...]] = ()
1490
+ ) -> Optional[LibraryMetaData]:
1424
1491
  """Return already-computed LibraryMetaData for a variables import, or None.
1425
1492
 
1426
1493
  Matches by import name and args, since variable files can return
1427
- different content depending on the arguments.
1494
+ different content depending on the arguments. ``args=None`` matches by
1495
+ name only (see ``get_cached_library_meta``).
1428
1496
  """
1429
1497
  with self._variables_lock:
1430
1498
  for entry in self._variables.values():
1431
- if entry.name == import_name and entry.args == args:
1499
+ if entry.name != import_name:
1500
+ continue
1501
+ if args is not None:
1502
+ if entry.args == args:
1503
+ return entry.meta
1504
+ elif entry.meta is not None:
1432
1505
  return entry.meta
1433
1506
  return None
1434
1507
 
@@ -1601,7 +1674,12 @@ class ImportsManager:
1601
1674
  ) -> None:
1602
1675
  """Save an import result to the disk cache, or log skip if no meta."""
1603
1676
  try:
1604
- if meta is not None:
1677
+ if meta is not None and not meta.trusted:
1678
+ self._logger.debug(
1679
+ lambda: f"Skip caching {kind} {name}{args!r}: file state too fresh to trust",
1680
+ context_name="import",
1681
+ )
1682
+ elif meta is not None:
1605
1683
  try:
1606
1684
  self.data_cache.save_entry(section, meta.cache_key, meta, result)
1607
1685
  except (SystemExit, KeyboardInterrupt):
@@ -1665,14 +1743,14 @@ class ImportsManager:
1665
1743
  return result, meta
1666
1744
 
1667
1745
  @_logger.call
1668
- def get_libdoc_for_library_import(
1746
+ def get_libdoc_for_library_import_with_meta(
1669
1747
  self,
1670
1748
  name: str,
1671
1749
  args: Tuple[Any, ...],
1672
1750
  base_dir: str,
1673
1751
  sentinel: Any = None,
1674
1752
  variables: Optional[Dict[str, Any]] = None,
1675
- ) -> LibraryDoc:
1753
+ ) -> Tuple[LibraryDoc, Optional[LibraryMetaData]]:
1676
1754
  with self._logger.measure_time(lambda: f"loading library {name}{args!r}", context_name="import"):
1677
1755
  source = self.find_library(name, base_dir, variables)
1678
1756
 
@@ -1704,36 +1782,45 @@ class ImportsManager:
1704
1782
  fin.atexit = False # type: ignore[misc]
1705
1783
  entry.references.add(sentinel)
1706
1784
 
1707
- return entry.get_libdoc()
1785
+ return entry.get_libdoc_with_meta()
1708
1786
 
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
1787
+ def get_libdoc_for_library_import(
1788
+ self,
1789
+ name: str,
1790
+ args: Tuple[Any, ...],
1791
+ base_dir: str,
1792
+ sentinel: Any = None,
1793
+ variables: Optional[Dict[str, Any]] = None,
1794
+ ) -> LibraryDoc:
1795
+ return self.get_libdoc_for_library_import_with_meta(name, args, base_dir, sentinel, variables)[0]
1712
1796
 
1713
1797
  @_logger.call
1714
1798
  def get_libdoc_from_model(
1715
1799
  self,
1716
1800
  model: ast.AST,
1717
1801
  source: str,
1802
+ meta: Optional[RobotFileMeta] = None,
1718
1803
  ) -> ResourceDoc:
1719
-
1804
+ """Build (or load from cache) the ResourceDoc for a parsed model.
1805
+
1806
+ ``meta`` must describe the disk state of exactly the content the model
1807
+ was parsed from; the disk cache is only consulted and written when such
1808
+ a trusted meta is available. There is deliberately no fallback to a
1809
+ fresh stat here — the file may have changed since the model's text was
1810
+ read, and labeling old content with a newer state would make the cache
1811
+ entry validate as fresh forever.
1812
+ """
1720
1813
  entry = self._resource_libdoc_cache.get(model)
1721
1814
  if entry is not None and source in entry:
1722
1815
  return entry[source]
1723
1816
 
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:
1817
+ if meta is not None and meta.trusted:
1818
+ result = self._get_model_doc_cached(source, meta)
1819
+ if result is None:
1820
+ result = get_model_doc(model=model, source=source)
1821
+ self._save_model_doc_cache(source, result, meta)
1822
+ else:
1731
1823
  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
1824
 
1738
1825
  if entry is None:
1739
1826
  entry = {}
@@ -1745,22 +1832,17 @@ class ImportsManager:
1745
1832
 
1746
1833
  @staticmethod
1747
1834
  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:
1835
+ normalized = str(normalized_path(source))
1836
+ info = probe_disk_info(normalized)
1837
+ if info is None:
1753
1838
  return None
1839
+ return RobotFileMeta(normalized, info)
1754
1840
 
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
1759
-
1841
+ def _get_model_doc_cached(self, source: str, meta: RobotFileMeta) -> Optional[ResourceDoc]:
1760
1842
  try:
1761
1843
  entry = self.data_cache.read_entry(CacheSection.RESOURCE, meta.source, RobotFileMeta, ResourceDoc)
1762
1844
  if entry is not None and entry.meta == meta:
1763
- return entry.data, meta
1845
+ return entry.data
1764
1846
  except (SystemExit, KeyboardInterrupt):
1765
1847
  raise
1766
1848
  except BaseException as e:
@@ -1770,7 +1852,7 @@ class ImportsManager:
1770
1852
  context_name="import",
1771
1853
  )
1772
1854
 
1773
- return None, meta
1855
+ return None
1774
1856
 
1775
1857
  def _save_model_doc_cache(self, source: str, result: ResourceDoc, meta: "RobotFileMeta") -> None:
1776
1858
 
@@ -1833,7 +1915,7 @@ class ImportsManager:
1833
1915
  return result, meta
1834
1916
 
1835
1917
  @_logger.call
1836
- def get_libdoc_for_variables_import(
1918
+ def get_libdoc_for_variables_import_with_meta(
1837
1919
  self,
1838
1920
  name: str,
1839
1921
  args: Tuple[Any, ...],
@@ -1842,7 +1924,7 @@ class ImportsManager:
1842
1924
  variables: Optional[Dict[str, Any]] = None,
1843
1925
  resolve_variables: bool = True,
1844
1926
  resolve_command_line_vars: bool = True,
1845
- ) -> VariablesDoc:
1927
+ ) -> Tuple[VariablesDoc, Optional[LibraryMetaData]]:
1846
1928
  with self._logger.measure_time(lambda: f"getting libdoc for variables import {name}", context_name="import"):
1847
1929
  source = self.find_variables(name, base_dir, variables, resolve_variables, resolve_command_line_vars)
1848
1930
 
@@ -1879,7 +1961,27 @@ class ImportsManager:
1879
1961
  fin = weakref.finalize(sentinel, self.__remove_variables_entry, entry_key, entry)
1880
1962
  fin.atexit = False # type: ignore[misc]
1881
1963
 
1882
- return entry.get_libdoc()
1964
+ return entry.get_libdoc_with_meta()
1965
+
1966
+ def get_libdoc_for_variables_import(
1967
+ self,
1968
+ name: str,
1969
+ args: Tuple[Any, ...],
1970
+ base_dir: str,
1971
+ sentinel: Any = None,
1972
+ variables: Optional[Dict[str, Any]] = None,
1973
+ resolve_variables: bool = True,
1974
+ resolve_command_line_vars: bool = True,
1975
+ ) -> VariablesDoc:
1976
+ return self.get_libdoc_for_variables_import_with_meta(
1977
+ name,
1978
+ args,
1979
+ base_dir,
1980
+ sentinel,
1981
+ variables,
1982
+ resolve_variables,
1983
+ resolve_command_line_vars,
1984
+ )[0]
1883
1985
 
1884
1986
  @_logger.call
1885
1987
  def _get_entry_for_resource_import(
@@ -1929,7 +2031,7 @@ class ImportsManager:
1929
2031
 
1930
2032
  return namespace, libdoc
1931
2033
 
1932
- def get_resource_doc_for_resource_import(
2034
+ def get_resource_doc_for_resource_import_with_meta(
1933
2035
  self,
1934
2036
  name: str,
1935
2037
  base_dir: str,
@@ -1937,11 +2039,24 @@ class ImportsManager:
1937
2039
  variables: Optional[Dict[str, Any]] = None,
1938
2040
  *,
1939
2041
  source: Optional[str] = None,
1940
- ) -> ResourceDoc:
2042
+ ) -> Tuple[ResourceDoc, Optional[RobotFileMeta]]:
1941
2043
  with self._logger.measure_time(lambda: f"getting resource doc for {name}", context_name="import"):
1942
2044
  entry = self._get_entry_for_resource_import(name, base_dir, sentinel, variables, source=source)
1943
2045
 
1944
- return entry.get_resource_doc()
2046
+ return entry.get_resource_doc_with_meta()
2047
+
2048
+ def get_resource_doc_for_resource_import(
2049
+ self,
2050
+ name: str,
2051
+ base_dir: str,
2052
+ sentinel: Any = None,
2053
+ variables: Optional[Dict[str, Any]] = None,
2054
+ *,
2055
+ source: Optional[str] = None,
2056
+ ) -> ResourceDoc:
2057
+ return self.get_resource_doc_for_resource_import_with_meta(name, base_dir, sentinel, variables, source=source)[
2058
+ 0
2059
+ ]
1945
2060
 
1946
2061
  def complete_library_import(
1947
2062
  self,
@@ -26,7 +26,7 @@ from robotcode.core.lsp.types import (
26
26
  from robotcode.core.text_document import TextDocument
27
27
  from robotcode.core.uri import Uri
28
28
  from robotcode.core.utils.logging import LoggingDescriptor
29
- from robotcode.core.utils.path import FileId, file_id
29
+ from robotcode.core.utils.path import DiskInfo, FileId, file_id, normalized_path
30
30
 
31
31
  from ..utils.stubs import Languages
32
32
  from ..utils.variables import (
@@ -47,7 +47,7 @@ from .entities import (
47
47
  )
48
48
  from .errors import DIAGNOSTICS_SOURCE_NAME
49
49
  from .import_resolver import ImportResolver
50
- from .imports_manager import ImportsManager
50
+ from .imports_manager import ImportsManager, RobotFileMeta
51
51
  from .keyword_finder import KeywordFinder
52
52
  from .library_doc import (
53
53
  KeywordDoc,
@@ -184,6 +184,7 @@ class Namespace:
184
184
  finder: KeywordFinder,
185
185
  sentinel: object,
186
186
  semantic_model: Optional["SemanticModel"] = None,
187
+ dependency_metas: Optional[Dict[str, Optional[Any]]] = None,
187
188
  ) -> None:
188
189
  self.imports_manager = imports_manager
189
190
  self.source = source
@@ -211,6 +212,7 @@ class Namespace:
211
212
  self._finder: KeywordFinder = finder
212
213
  self._sentinel = sentinel # prevent GC — ref-counted by imports_manager
213
214
  self._semantic_model = semantic_model
215
+ self._dependency_metas = dependency_metas
214
216
 
215
217
  # Lazy-computed caches
216
218
  self._namespaces: Optional[Dict[KeywordMatcher, List[LibraryEntry]]] = None
@@ -222,6 +224,15 @@ class Namespace:
222
224
  imports_manager.resources_changed.add(self._on_resources_changed)
223
225
  imports_manager.variables_changed.add(self._on_variables_changed)
224
226
 
227
+ @property
228
+ def dependency_metas(self) -> Optional[Dict[str, Optional[Any]]]:
229
+ """Metadata of the dependencies captured while this namespace was resolved.
230
+
231
+ ``None`` when the namespace was built by a path that did not record
232
+ them — such a namespace must never be persisted to the disk cache.
233
+ """
234
+ return self._dependency_metas
235
+
225
236
  @property
226
237
  def document(self) -> Optional[TextDocument]:
227
238
  return self._document() if self._document is not None else None
@@ -702,6 +713,7 @@ class Namespace:
702
713
  finder=finder,
703
714
  sentinel=sentinel,
704
715
  semantic_model=semantic_model,
716
+ dependency_metas=resolved.dependency_metas,
705
717
  )
706
718
 
707
719
 
@@ -723,6 +735,8 @@ class NamespaceBuilder:
723
735
  document_type: Optional[DocumentType] = None,
724
736
  languages: Optional[Languages] = None,
725
737
  workspace_languages: Optional[Languages] = None,
738
+ *,
739
+ disk_info: Optional[DiskInfo] = None,
726
740
  ) -> None:
727
741
  self._imports_manager = imports_manager
728
742
  self._model = model
@@ -732,6 +746,7 @@ class NamespaceBuilder:
732
746
  self._document_type = document_type
733
747
  self._languages = languages
734
748
  self._workspace_languages = workspace_languages
749
+ self._disk_info = disk_info
735
750
  self._enable_semantic_model = False
736
751
 
737
752
  def set_semantic_model_enabled(self, enabled: bool) -> None:
@@ -747,7 +762,12 @@ class NamespaceBuilder:
747
762
  from .semantic_analyzer.analyzer import SemanticAnalyzer
748
763
 
749
764
  with self._logger.measure_time(lambda: f"Build Namespace for {self._source}", context_name="import"):
750
- library_doc = self._imports_manager.get_libdoc_from_model(self._model, self._source)
765
+ source_meta = (
766
+ RobotFileMeta(str(normalized_path(self._source)), self._disk_info)
767
+ if self._disk_info is not None
768
+ else None
769
+ )
770
+ library_doc = self._imports_manager.get_libdoc_from_model(self._model, self._source, source_meta)
751
771
  document_uri = (
752
772
  self._document.document_uri if self._document is not None else str(Uri.from_path(self._source))
753
773
  )
@@ -839,4 +859,5 @@ class NamespaceBuilder:
839
859
  finder=finder,
840
860
  sentinel=sentinel,
841
861
  semantic_model=analyzer_result.semantic_model,
862
+ dependency_metas=resolved.dependency_metas,
842
863
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotcode-robot
3
- Version: 2.6.2
3
+ Version: 2.7.0
4
4
  Summary: Support classes for RobotCode for handling Robot Framework projects.
5
5
  Project-URL: Homepage, https://robotcode.io
6
6
  Project-URL: Donate, https://opencollective.com/robotcode
@@ -26,7 +26,7 @@ Classifier: Topic :: Utilities
26
26
  Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.10
28
28
  Requires-Dist: platformdirs>=4.3
29
- Requires-Dist: robotcode-core==2.6.2
29
+ Requires-Dist: robotcode-core==2.7.0
30
30
  Requires-Dist: robotframework>=5.0.0
31
31
  Requires-Dist: tomli>=1.1.0; python_version < '3.11'
32
32
  Description-Content-Type: text/markdown
@@ -1,24 +1,24 @@
1
1
  robotcode/robot/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- robotcode/robot/__version__.py,sha256=53Sii4w6BIWn-1RhaTyqUO46gDe4nDCRQDAcpsWFH24,22
2
+ robotcode/robot/__version__.py,sha256=EtKWW0Hnl5oWglRNH0HZigvcDT2FEs58ek8buJdwW1E,22
3
3
  robotcode/robot/py.typed,sha256=bWew9mHgMy8LqMu7RuqQXFXLBxh2CRx0dUbSx-3wE48,27
4
4
  robotcode/robot/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  robotcode/robot/config/loader.py,sha256=qrP810HNMDpqhXopWa0dOa0Wq_zQfVctsNYKY6sLKGI,8654
6
- robotcode/robot/config/model.py,sha256=nwXBBub17eHvng0UJUCvEgdIeZnk5Ciga-ux7bQNYUQ,103582
6
+ robotcode/robot/config/model.py,sha256=WjaAKyhPzjQvjI2iDW19y0GxRY4JiSJKTtWs4fAgtFQ,104628
7
7
  robotcode/robot/config/utils.py,sha256=A8gD5IItxuC7AY5Olcc8tmsSbqCCi_vZPVoK3BqWt_8,4070
8
8
  robotcode/robot/diagnostics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  robotcode/robot/diagnostics/analyzer_result.py,sha256=hcakb6YE8zVDKPXsUeaxOldW3dAThOk7Z4w-IRejKoY,1012
10
10
  robotcode/robot/diagnostics/data_cache.py,sha256=KmSKbGPPqL-uU1lHwd_tWIyTKnVTPTNJzJZGZB8_Vdw,17720
11
11
  robotcode/robot/diagnostics/diagnostic_rules.py,sha256=nhtkLFZP0hzFRj9-i7Zz7klO1mRz3aNBs1PrvurbSrc,408
12
12
  robotcode/robot/diagnostics/diagnostics_modifier.py,sha256=7SGr9Knh41bacGPTwn2VDg5cgXVILBeRB6FN_jyjvVk,9945
13
- robotcode/robot/diagnostics/document_cache_helper.py,sha256=oOKYQazT-b9VEt7rP3PZ4CNGZAEqvUo0lypCWfBgivU,28008
13
+ robotcode/robot/diagnostics/document_cache_helper.py,sha256=3Hb43euh_QSTlG934DmAnk68c-QkavqKLexj1VjMI9o,31604
14
14
  robotcode/robot/diagnostics/entities.py,sha256=P1R1kJPvXNGjjah7c6TVfltCF_u1NAKGpzFnPthw3F8,11215
15
15
  robotcode/robot/diagnostics/errors.py,sha256=n14np0VmXLMT5Y6R9Dw6tfhreHV_xvPXSPz-a5-OTMM,2363
16
- robotcode/robot/diagnostics/import_resolver.py,sha256=qqF6dvwLBFljmPOVnDR07iew0_WEc7tetg-wBsJ5uQs,25647
17
- robotcode/robot/diagnostics/imports_manager.py,sha256=nZ9Vc8rLtl5GkK0u8-nfBNse_e-6Dz5BPQCGV7-dOc4,75294
16
+ robotcode/robot/diagnostics/import_resolver.py,sha256=hoxtMPSMXD_hX8JxJ97nO7LUnaL8QGwqQ9-aw7i1AF8,26675
17
+ robotcode/robot/diagnostics/imports_manager.py,sha256=y7RJ82ftqX6MAQS7xmSMddMf6QHEcG1AnI6Kf2AzZCU,80282
18
18
  robotcode/robot/diagnostics/keyword_finder.py,sha256=nwTmjvhyu5H7HHzyk8gt-pny2K2xdH2qCNyKXwwXRnk,18998
19
19
  robotcode/robot/diagnostics/library_doc.py,sha256=wGJ1eQtfu3GwC1tvkOFpM60uCF1Z-GwbKbUV5epH-WM,121608
20
20
  robotcode/robot/diagnostics/model_helper.py,sha256=_ySXwerkK5xvjWa82T0DFy8jpxRzcaavTJiAHnk2lXQ,29740
21
- robotcode/robot/diagnostics/namespace.py,sha256=8QuKEVqkIP04fnLxkkgYDlPJBCgPy6eTxZOx7QTvobQ,32770
21
+ robotcode/robot/diagnostics/namespace.py,sha256=OV7aYNTb7pMsJLc8QDl-oI49Zdhd3EXsw8UPi_LdpqU,33724
22
22
  robotcode/robot/diagnostics/namespace_analyzer.py,sha256=QVGm9AjukAGDul7WjNuxcbLDpgdEtfze2sa5_ni1Eac,100143
23
23
  robotcode/robot/diagnostics/project_index.py,sha256=nOBvfAxLS9KoKjye9_4YWmHPuJoZqspCQ1PLHieZKuc,7209
24
24
  robotcode/robot/diagnostics/scope_tree.py,sha256=2Ev4BhzTbVSsXku0p8naftbTWQXbGqCiEMBUrtiVicI,8337
@@ -41,6 +41,6 @@ robotcode/robot/utils/robot_path.py,sha256=ObLP8PqamTZAz5oi0eDXE_dnyZwAPXp4TJYm6
41
41
  robotcode/robot/utils/stubs.py,sha256=umugZYAyneFNgqRJBRMJPzm0u0B_TH8Sx_y-ykXnxpw,351
42
42
  robotcode/robot/utils/variables.py,sha256=q4lcigv9_NxiSgIrxvzmfmjKoKj_o_qMjWwnSKRH71k,9309
43
43
  robotcode/robot/utils/visitor.py,sha256=nP3O0qh3YYuxR6S8wYJRBFfNwIVgsgohURBlrnFkRYQ,2299
44
- robotcode_robot-2.6.2.dist-info/METADATA,sha256=YJYk6RH0dDmtMBNElnSOBh38WqxKZ_VQyjA6HrB8o5Q,2893
45
- robotcode_robot-2.6.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
46
- robotcode_robot-2.6.2.dist-info/RECORD,,
44
+ robotcode_robot-2.7.0.dist-info/METADATA,sha256=UJBll3gAP1xot1Ffu-iiB0fOVGrNB-7hCw4IuZ2x-2c,2893
45
+ robotcode_robot-2.7.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
46
+ robotcode_robot-2.7.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any