robotcode-core 2.6.2__tar.gz → 2.8.0.dev548__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.2 → robotcode_core-2.8.0.dev548}/.gitignore +4 -1
  2. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/PKG-INFO +3 -2
  3. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/pyproject.toml +1 -0
  4. robotcode_core-2.8.0.dev548/src/robotcode/core/__version__.py +1 -0
  5. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/concurrent.py +0 -1
  6. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/documents_manager.py +45 -2
  7. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/event.py +16 -3
  8. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/ignore_spec.py +35 -0
  9. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/text_document.py +44 -3
  10. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/path.py +57 -1
  11. robotcode_core-2.6.2/src/robotcode/core/__version__.py +0 -1
  12. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/README.md +0 -0
  13. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/__init__.py +0 -0
  14. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/async_tools.py +0 -0
  15. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/filewatcher.py +0 -0
  16. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/language.py +0 -0
  17. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/lsp/__init__.py +0 -0
  18. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/lsp/types.py +0 -0
  19. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/py.typed +0 -0
  20. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/types.py +0 -0
  21. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/uri.py +0 -0
  22. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/__init__.py +0 -0
  23. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/caching.py +0 -0
  24. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/cli.py +0 -0
  25. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/dataclasses.py +0 -0
  26. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/debugpy.py +0 -0
  27. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/glob_path.py +0 -0
  28. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/inspect.py +0 -0
  29. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/logging.py +0 -0
  30. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/net.py +0 -0
  31. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/process.py +0 -0
  32. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/safe_eval.py +0 -0
  33. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/utils/version.py +0 -0
  34. {robotcode_core-2.6.2 → robotcode_core-2.8.0.dev548}/src/robotcode/core/workspace.py +0 -0
@@ -279,6 +279,9 @@ dist
279
279
  # vuepress build output
280
280
  .vuepress/dist
281
281
 
282
+ # Astro (documentation site)
283
+ docs/.astro/
284
+
282
285
  # Serverless directories
283
286
  .serverless/
284
287
 
@@ -339,7 +342,7 @@ bundled/libs
339
342
  # .agents
340
343
  .agents/
341
344
  skills-lock.json
342
- .claude
345
+ #.claude
343
346
 
344
347
  # sarif files
345
348
  /**/*.sarif.json
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: robotcode-core
3
- Version: 2.6.2
3
+ Version: 2.8.0-dev.548
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
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
21
  Classifier: Programming Language :: Python :: 3.13
22
22
  Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Programming Language :: Python :: 3.15
23
24
  Classifier: Programming Language :: Python :: Implementation :: CPython
24
25
  Classifier: Programming Language :: Python :: Implementation :: PyPy
25
26
  Classifier: Topic :: Utilities
@@ -18,6 +18,7 @@ classifiers = [
18
18
  "Programming Language :: Python :: 3.12",
19
19
  "Programming Language :: Python :: 3.13",
20
20
  "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: Python :: 3.15",
21
22
  "Programming Language :: Python :: Implementation :: CPython",
22
23
  "Programming Language :: Python :: Implementation :: PyPy",
23
24
  "Operating System :: OS Independent",
@@ -0,0 +1 @@
1
+ __version__ = "2.8.0-dev.548"
@@ -228,7 +228,6 @@ def _create_task_in_thread(
228
228
  _running_tasks[future] = thread
229
229
  future.add_done_callback(_remove_future_from_running_tasks)
230
230
 
231
- # TODO: don't set daemon=True because it can be deprecated in future pyhton versions
232
231
  thread.daemon = True
233
232
  return future, thread
234
233
 
@@ -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
@@ -31,16 +31,29 @@ class EventResultIteratorBase(Generic[_TParams, _TResult]):
31
31
 
32
32
  self._listeners: MutableSet[weakref.ref[Any]] = set()
33
33
 
34
+ # The weak reference of a listener calls this when the listener dies. It refers to the event only weakly: a
35
+ # bound method of the event would make every event with a listener a reference cycle that only the garbage
36
+ # collector frees.
37
+ self_ref = weakref.ref(self)
38
+
39
+ def remove_dead_listener(ref: Any) -> None:
40
+ event = self_ref()
41
+ if event is not None:
42
+ event.__remove_listener(ref)
43
+
44
+ self.__remove_dead_listener = remove_dead_listener
45
+
34
46
  def __remove_listener(self, ref: Any) -> None:
35
47
  with self._lock:
36
- self._listeners.remove(ref)
48
+ # a listener that `remove` took out may die only afterwards, while another thread still iterates over it
49
+ self._listeners.discard(ref)
37
50
 
38
51
  def add(self, callback: Callable[_TParams, _TResult]) -> None:
39
52
  with self._lock:
40
53
  if inspect.ismethod(callback):
41
- self._listeners.add(weakref.WeakMethod(callback, self.__remove_listener))
54
+ self._listeners.add(weakref.WeakMethod(callback, self.__remove_dead_listener))
42
55
  else:
43
- self._listeners.add(weakref.ref(callback, self.__remove_listener))
56
+ self._listeners.add(weakref.ref(callback, self.__remove_dead_listener))
44
57
 
45
58
  def remove(self, callback: Callable[_TParams, _TResult]) -> None:
46
59
  with self._lock:
@@ -376,3 +376,38 @@ def _iter_files(
376
376
  )
377
377
  elif p.is_file():
378
378
  yield p
379
+
380
+
381
+ def iter_files_includes(
382
+ path: Path,
383
+ root: Path,
384
+ ignore_files: Iterable[str] = [GIT_IGNORE_FILE],
385
+ include_hidden: bool = True,
386
+ parent_spec: Optional[IgnoreSpec] = None,
387
+ ) -> bool:
388
+ """Whether `iter_files(root, ...)` with these arguments yields the file `path`, without walking other files.
389
+
390
+ It applies the ignore files of the folders on the way from `root` to `path` as `iter_files` does.
391
+ """
392
+ path = Path(os.path.abspath(path))
393
+ root = Path(os.path.abspath(root))
394
+ if not path_is_relative_to(path, root) or not path.is_file():
395
+ return False
396
+
397
+ spec = parent_spec if parent_spec is not None else IgnoreSpec.from_list(DEFAULT_SPEC_RULES, root)
398
+ folder = root
399
+ for name in path.relative_to(root).parts:
400
+ ignore_file = next((folder / f for f in ignore_files if (folder / f).is_file()), None)
401
+ if ignore_file is not None:
402
+ spec = spec + IgnoreSpec.from_gitignore(ignore_file)
403
+ ignore_files = [ignore_file.name]
404
+
405
+ entry = folder / name
406
+ if not include_hidden and _is_hidden(entry):
407
+ return False
408
+ if spec.matches(entry):
409
+ return False
410
+
411
+ folder = entry
412
+
413
+ return True
@@ -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,12 +121,25 @@ 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)
125
128
  self._data: weakref.WeakKeyDictionary[Any, Any] = weakref.WeakKeyDictionary()
126
129
  self.opened_in_editor = False
127
130
 
131
+ # The weak reference of a cache entry calls this when the entry's function dies. It refers to the document
132
+ # only weakly: a bound method of the document would make every document with a cache entry a reference cycle
133
+ # that only the garbage collector frees.
134
+ self_ref = weakref.ref(self)
135
+
136
+ def remove_dead_cache_entry(ref: Any) -> None:
137
+ document = self_ref()
138
+ if document is not None:
139
+ document.__remove_cache_entry(ref)
140
+
141
+ self.__remove_dead_cache_entry = remove_dead_cache_entry
142
+
128
143
  @property
129
144
  def version(self) -> Optional[int]:
130
145
  return self._version
@@ -143,9 +158,24 @@ class TextDocument:
143
158
  with self._lock:
144
159
  return self._text
145
160
 
161
+ @property
162
+ def disk_info(self) -> Optional[DiskInfo]:
163
+ """Stat snapshot of the disk content this document's text was read from.
164
+
165
+ ``None`` means the text is not known to match any disk state (e.g. it
166
+ came from the editor or the file changed while it was being read).
167
+ """
168
+ with self._lock:
169
+ return self._disk_info
170
+
146
171
  def save(self, version: Optional[int], text: Optional[str]) -> None:
147
172
  self.apply_full_change(version, text, save=True)
148
173
 
174
+ def mark_saved(self) -> None:
175
+ """Remember the current text as the saved one, which `revert` returns to, without invalidating the caches."""
176
+ with self._lock:
177
+ self._orig_text = self._text
178
+
149
179
  def revert(self, version: Optional[int]) -> bool:
150
180
  if self._orig_text != self._text or self._orig_version != self._version:
151
181
  self.apply_full_change(version or self._orig_version, self._orig_text)
@@ -158,7 +188,14 @@ class TextDocument:
158
188
  self._lines = None
159
189
 
160
190
  @_logger.call
161
- def apply_full_change(self, version: Optional[int], text: Optional[str], *, save: bool = False) -> None:
191
+ def apply_full_change(
192
+ self,
193
+ version: Optional[int],
194
+ text: Optional[str],
195
+ *,
196
+ save: bool = False,
197
+ disk_info: Optional[DiskInfo] = None,
198
+ ) -> None:
162
199
  with self._cache_invalidating():
163
200
  if version is not None:
164
201
  self._version = version
@@ -167,11 +204,14 @@ class TextDocument:
167
204
  self._lines = None
168
205
  if save:
169
206
  self._orig_text = self._text
207
+ if text is not None or save:
208
+ self._disk_info = disk_info
170
209
 
171
210
  @_logger.call
172
211
  def apply_incremental_change(self, version: Optional[int], range: Range, text: str) -> None:
173
212
  with self._cache_invalidating():
174
213
  try:
214
+ self._disk_info = None
175
215
  if version is not None:
176
216
  self._version = version
177
217
 
@@ -321,9 +361,9 @@ class TextDocument:
321
361
 
322
362
  def __get_cache_reference(self, entry: Callable[..., Any], /, *, add_remove: bool = True) -> "weakref.ref[Any]":
323
363
  if inspect.ismethod(entry):
324
- return weakref.WeakMethod(entry, self.__remove_cache_entry if add_remove else None)
364
+ return weakref.WeakMethod(entry, self.__remove_dead_cache_entry if add_remove else None)
325
365
 
326
- return weakref.ref(entry, self.__remove_cache_entry if add_remove else None)
366
+ return weakref.ref(entry, self.__remove_dead_cache_entry if add_remove else None)
327
367
 
328
368
  def get_cache_value(self, entry: Callable[[Self], _T]) -> Optional[_T]:
329
369
  reference = self.__get_cache_reference(entry)
@@ -371,6 +411,7 @@ class TextDocument:
371
411
 
372
412
  def _clear(self) -> None:
373
413
  self._lines = None
414
+ self._disk_info = None
374
415
  self._invalidate_data()
375
416
 
376
417
  @_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.2"