robotcode-core 2.6.1__tar.gz → 2.7.0__tar.gz

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 (34) hide show
  1. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/PKG-INFO +1 -1
  2. robotcode_core-2.7.0/src/robotcode/core/__version__.py +1 -0
  3. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/documents_manager.py +45 -2
  4. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/text_document.py +25 -1
  5. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/path.py +57 -1
  6. robotcode_core-2.6.1/src/robotcode/core/__version__.py +0 -1
  7. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/.gitignore +0 -0
  8. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/README.md +0 -0
  9. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/pyproject.toml +0 -0
  10. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/__init__.py +0 -0
  11. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/async_tools.py +0 -0
  12. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/concurrent.py +0 -0
  13. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/event.py +0 -0
  14. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/filewatcher.py +0 -0
  15. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/ignore_spec.py +0 -0
  16. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/language.py +0 -0
  17. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/lsp/__init__.py +0 -0
  18. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/lsp/types.py +0 -0
  19. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/py.typed +0 -0
  20. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/types.py +0 -0
  21. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/uri.py +0 -0
  22. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/__init__.py +0 -0
  23. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/caching.py +0 -0
  24. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/cli.py +0 -0
  25. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/dataclasses.py +0 -0
  26. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/debugpy.py +0 -0
  27. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/glob_path.py +0 -0
  28. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/inspect.py +0 -0
  29. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/logging.py +0 -0
  30. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/net.py +0 -0
  31. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/process.py +0 -0
  32. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/safe_eval.py +0 -0
  33. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/utils/version.py +0 -0
  34. {robotcode_core-2.6.1 → robotcode_core-2.7.0}/src/robotcode/core/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotcode-core
3
- Version: 2.6.1
3
+ Version: 2.7.0
4
4
  Summary: Some core classes for RobotCode
5
5
  Project-URL: Homepage, https://robotcode.io
6
6
  Project-URL: Donate, https://opencollective.com/robotcode
@@ -0,0 +1 @@
1
+ __version__ = "2.7.0"
@@ -9,6 +9,7 @@ from typing import (
9
9
  Iterator,
10
10
  List,
11
11
  Optional,
12
+ Tuple,
12
13
  Union,
13
14
  )
14
15
 
@@ -17,8 +18,11 @@ from .event import event
17
18
  from .language import LanguageDefinition, language_id_filter
18
19
  from .lsp.types import DocumentUri
19
20
  from .text_document import TextDocument
20
- from .uri import Uri
21
+ from .uri import InvalidUriError, Uri
21
22
  from .utils.logging import LoggingDescriptor
23
+ from .utils.path import DiskInfo, disk_info_from_stat
24
+
25
+ _STAT_VERIFY_MAX_RETRIES: Final = 3
22
26
 
23
27
 
24
28
  class CantReadDocumentError(Exception):
@@ -58,6 +62,39 @@ class DocumentsManager:
58
62
 
59
63
  raise FileNotFoundError(str(uri))
60
64
 
65
+ def read_document_text_with_disk_info(
66
+ self, uri: Uri, language_id: Union[str, Callable[[Any], bool], None]
67
+ ) -> Tuple[str, Optional[DiskInfo]]:
68
+ """Read document text and capture a stat snapshot bound to that exact content.
69
+
70
+ Uses a stat → read → stat-verify protocol: when the file changes while it
71
+ is being read, the read is retried. Returns ``None`` as info when no
72
+ consistent snapshot could be captured (non-file URI, stat error, or the
73
+ file never settled) — such text must not be persisted to or validated
74
+ against a disk cache.
75
+ """
76
+ try:
77
+ path = uri.to_path()
78
+ st_before = os.stat(path)
79
+ except (InvalidUriError, OSError):
80
+ return self.read_document_text(uri, language_id), None
81
+
82
+ text = self.read_document_text(uri, language_id)
83
+
84
+ for _ in range(_STAT_VERIFY_MAX_RETRIES):
85
+ try:
86
+ st_after = os.stat(path)
87
+ except OSError:
88
+ return text, None
89
+
90
+ if (st_before.st_mtime_ns, st_before.st_size) == (st_after.st_mtime_ns, st_after.st_size):
91
+ return text, disk_info_from_stat(st_after)
92
+
93
+ st_before = st_after
94
+ text = self.read_document_text(uri, language_id)
95
+
96
+ return text, None
97
+
61
98
  def detect_language_id(self, path_or_uri: Union[str, "os.PathLike[Any]", Uri]) -> str:
62
99
  path = path_or_uri.to_path() if isinstance(path_or_uri, Uri) else Path(path_or_uri)
63
100
 
@@ -84,11 +121,13 @@ class DocumentsManager:
84
121
  return result
85
122
 
86
123
  try:
124
+ text, disk_info = self.read_document_text_with_disk_info(uri, language_id)
87
125
  return self._append_document(
88
126
  document_uri=DocumentUri(uri),
89
127
  language_id=language_id or self.detect_language_id(path),
90
- text=self.read_document_text(uri, language_id),
128
+ text=text,
91
129
  version=version,
130
+ disk_info=disk_info,
92
131
  )
93
132
  except (SystemExit, KeyboardInterrupt):
94
133
  raise
@@ -147,12 +186,14 @@ class DocumentsManager:
147
186
  text: str,
148
187
  language_id: Optional[str] = None,
149
188
  version: Optional[int] = None,
189
+ disk_info: Optional[DiskInfo] = None,
150
190
  ) -> TextDocument:
151
191
  result = TextDocument(
152
192
  document_uri=document_uri,
153
193
  language_id=language_id,
154
194
  text=text,
155
195
  version=version,
196
+ disk_info=disk_info,
156
197
  )
157
198
 
158
199
  result.cache_invalidate.add(self._on_document_cache_invalidate)
@@ -166,6 +207,7 @@ class DocumentsManager:
166
207
  language_id: str,
167
208
  text: str,
168
209
  version: Optional[int] = None,
210
+ disk_info: Optional[DiskInfo] = None,
169
211
  ) -> TextDocument:
170
212
  with self._lock:
171
213
  document = self._create_document(
@@ -173,6 +215,7 @@ class DocumentsManager:
173
215
  language_id=language_id,
174
216
  text=text,
175
217
  version=version,
218
+ disk_info=disk_info,
176
219
  )
177
220
 
178
221
  self._documents[document_uri] = document
@@ -24,6 +24,7 @@ from robotcode.core.event import event
24
24
  from robotcode.core.lsp.types import DocumentUri, Position, Range
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
 
28
29
 
29
30
  def is_multibyte_char(char: str) -> bool:
@@ -108,6 +109,7 @@ class TextDocument:
108
109
  text: str,
109
110
  language_id: Optional[str] = None,
110
111
  version: Optional[int] = None,
112
+ disk_info: Optional[DiskInfo] = None,
111
113
  ) -> None:
112
114
  super().__init__()
113
115
 
@@ -119,6 +121,7 @@ class TextDocument:
119
121
  self._text = text
120
122
  self._orig_text = text
121
123
  self._orig_version = version
124
+ self._disk_info = disk_info
122
125
  self._lines: Optional[List[str]] = None
123
126
  self._cache: Dict[weakref.ref[Any], CacheEntry] = collections.defaultdict(CacheEntry)
124
127
  self._data_lock = RLock(name=f"Document.data_lock '{document_uri}'", default_timeout=120)
@@ -143,6 +146,16 @@ class TextDocument:
143
146
  with self._lock:
144
147
  return self._text
145
148
 
149
+ @property
150
+ def disk_info(self) -> Optional[DiskInfo]:
151
+ """Stat snapshot of the disk content this document's text was read from.
152
+
153
+ ``None`` means the text is not known to match any disk state (e.g. it
154
+ came from the editor or the file changed while it was being read).
155
+ """
156
+ with self._lock:
157
+ return self._disk_info
158
+
146
159
  def save(self, version: Optional[int], text: Optional[str]) -> None:
147
160
  self.apply_full_change(version, text, save=True)
148
161
 
@@ -158,7 +171,14 @@ class TextDocument:
158
171
  self._lines = None
159
172
 
160
173
  @_logger.call
161
- def apply_full_change(self, version: Optional[int], text: Optional[str], *, save: bool = False) -> None:
174
+ def apply_full_change(
175
+ self,
176
+ version: Optional[int],
177
+ text: Optional[str],
178
+ *,
179
+ save: bool = False,
180
+ disk_info: Optional[DiskInfo] = None,
181
+ ) -> None:
162
182
  with self._cache_invalidating():
163
183
  if version is not None:
164
184
  self._version = version
@@ -167,11 +187,14 @@ class TextDocument:
167
187
  self._lines = None
168
188
  if save:
169
189
  self._orig_text = self._text
190
+ if text is not None or save:
191
+ self._disk_info = disk_info
170
192
 
171
193
  @_logger.call
172
194
  def apply_incremental_change(self, version: Optional[int], range: Range, text: str) -> None:
173
195
  with self._cache_invalidating():
174
196
  try:
197
+ self._disk_info = None
175
198
  if version is not None:
176
199
  self._version = version
177
200
 
@@ -371,6 +394,7 @@ class TextDocument:
371
394
 
372
395
  def _clear(self) -> None:
373
396
  self._lines = None
397
+ self._disk_info = None
374
398
  self._invalidate_data()
375
399
 
376
400
  @_logger.call
@@ -1,8 +1,10 @@
1
1
  import os
2
2
  import re
3
3
  import sys
4
+ import time
5
+ from dataclasses import dataclass, field
4
6
  from pathlib import Path
5
- from typing import Any, Optional, Tuple, Union
7
+ from typing import Any, Final, Optional, Tuple, Union
6
8
 
7
9
 
8
10
  def path_is_relative_to(
@@ -88,3 +90,57 @@ def same_file_id(
88
90
  if id1 is None or id2 is None:
89
91
  return False
90
92
  return id1 == id2
93
+
94
+
95
+ RACY_MTIME_EPSILON_NS: Final = 2_000_000_000
96
+ """Snapshot age below which a file state is not trusted for persistent caching.
97
+
98
+ Analogous to git's "racily clean" handling: if a file's mtime is within this
99
+ epsilon of the current time when its content is read, a later write could land
100
+ in the same filesystem timestamp tick and leave mtime and size unchanged, so
101
+ the snapshot must not be persisted. 2 s covers the coarsest common filesystem
102
+ timestamp granularity (FAT).
103
+
104
+ Known limitation: the age is computed against the local clock, so on a network
105
+ mount whose server clock trails the client by more than the epsilon a
106
+ just-written file already counts as trusted — the stat-only design cannot
107
+ detect that; a content hash would be required to close it.
108
+ """
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class DiskInfo:
113
+ """Stat snapshot taken at the moment file content was read from disk.
114
+
115
+ Equality compares only ``mtime_ns`` and ``size`` — ``trusted`` describes the
116
+ capture moment (see ``RACY_MTIME_EPSILON_NS``), not the file state, and must
117
+ never influence comparisons of stored metadata against fresh probes.
118
+
119
+ Deliberately carries no ``st_ino``: file IDs are 0 or unstable on
120
+ FAT/exFAT/ReFS and change across SMB/FUSE remounts, which would invalidate
121
+ caches spuriously; the racy-mtime guard already covers the same-tick
122
+ rewrite case an inode check would catch.
123
+ """
124
+
125
+ mtime_ns: int
126
+ size: int
127
+ trusted: bool = field(default=True, compare=False)
128
+
129
+
130
+ def disk_info_from_stat(st: os.stat_result, now_ns: Optional[int] = None) -> DiskInfo:
131
+ if now_ns is None:
132
+ now_ns = time.time_ns()
133
+ return DiskInfo(
134
+ mtime_ns=st.st_mtime_ns,
135
+ size=st.st_size,
136
+ # a negative age (mtime in the future, e.g. clock skew) is also untrusted
137
+ trusted=now_ns - st.st_mtime_ns >= RACY_MTIME_EPSILON_NS,
138
+ )
139
+
140
+
141
+ def probe_disk_info(path: Union[str, "os.PathLike[str]"]) -> Optional[DiskInfo]:
142
+ """Return the current :class:`DiskInfo` for *path*, or ``None`` on error."""
143
+ try:
144
+ return disk_info_from_stat(os.stat(path))
145
+ except OSError:
146
+ return None
@@ -1 +0,0 @@
1
- __version__ = "2.6.1"
File without changes