robotcode-core 2.6.2__tar.gz → 2.8.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.
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/.gitignore +4 -1
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/PKG-INFO +3 -2
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/pyproject.toml +1 -0
- robotcode_core-2.8.0/src/robotcode/core/__version__.py +1 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/concurrent.py +0 -1
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/documents_manager.py +45 -2
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/event.py +16 -3
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/ignore_spec.py +35 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/text_document.py +44 -3
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/path.py +57 -1
- robotcode_core-2.6.2/src/robotcode/core/__version__.py +0 -1
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/README.md +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/__init__.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/async_tools.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/filewatcher.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/language.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/lsp/__init__.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/lsp/types.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/py.typed +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/types.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/uri.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/__init__.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/caching.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/cli.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/dataclasses.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/debugpy.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/glob_path.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/inspect.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/logging.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/net.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/process.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/safe_eval.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/src/robotcode/core/utils/version.py +0 -0
- {robotcode_core-2.6.2 → robotcode_core-2.8.0}/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
|
-
|
|
345
|
+
#.claude
|
|
343
346
|
|
|
344
347
|
# sarif files
|
|
345
348
|
/**/*.sarif.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: robotcode-core
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.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
|
|
@@ -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"
|
|
@@ -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=
|
|
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
|
-
|
|
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.
|
|
54
|
+
self._listeners.add(weakref.WeakMethod(callback, self.__remove_dead_listener))
|
|
42
55
|
else:
|
|
43
|
-
self._listeners.add(weakref.ref(callback, self.
|
|
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(
|
|
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.
|
|
364
|
+
return weakref.WeakMethod(entry, self.__remove_dead_cache_entry if add_remove else None)
|
|
325
365
|
|
|
326
|
-
return weakref.ref(entry, self.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|