agmem 0.1.1__py3-none-any.whl → 0.1.2__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.
- {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/METADATA +20 -3
- agmem-0.1.2.dist-info/RECORD +86 -0
- memvcs/__init__.py +1 -1
- memvcs/cli.py +35 -31
- memvcs/commands/__init__.py +9 -9
- memvcs/commands/add.py +77 -76
- memvcs/commands/blame.py +46 -53
- memvcs/commands/branch.py +13 -33
- memvcs/commands/checkout.py +27 -32
- memvcs/commands/clean.py +18 -23
- memvcs/commands/clone.py +4 -1
- memvcs/commands/commit.py +40 -39
- memvcs/commands/daemon.py +81 -76
- memvcs/commands/decay.py +77 -0
- memvcs/commands/diff.py +56 -57
- memvcs/commands/distill.py +74 -0
- memvcs/commands/fsck.py +55 -61
- memvcs/commands/garden.py +28 -37
- memvcs/commands/graph.py +41 -48
- memvcs/commands/init.py +16 -24
- memvcs/commands/log.py +25 -40
- memvcs/commands/merge.py +16 -28
- memvcs/commands/pack.py +129 -0
- memvcs/commands/pull.py +4 -1
- memvcs/commands/push.py +4 -2
- memvcs/commands/recall.py +145 -0
- memvcs/commands/reflog.py +13 -22
- memvcs/commands/remote.py +1 -0
- memvcs/commands/repair.py +66 -0
- memvcs/commands/reset.py +23 -33
- memvcs/commands/resurrect.py +82 -0
- memvcs/commands/search.py +3 -4
- memvcs/commands/serve.py +2 -1
- memvcs/commands/show.py +66 -36
- memvcs/commands/stash.py +34 -34
- memvcs/commands/status.py +27 -35
- memvcs/commands/tag.py +23 -47
- memvcs/commands/test.py +30 -44
- memvcs/commands/timeline.py +111 -0
- memvcs/commands/tree.py +26 -27
- memvcs/commands/verify.py +59 -0
- memvcs/commands/when.py +115 -0
- memvcs/core/access_index.py +167 -0
- memvcs/core/config_loader.py +3 -1
- memvcs/core/consistency.py +214 -0
- memvcs/core/decay.py +185 -0
- memvcs/core/diff.py +158 -143
- memvcs/core/distiller.py +277 -0
- memvcs/core/gardener.py +164 -132
- memvcs/core/hooks.py +48 -14
- memvcs/core/knowledge_graph.py +134 -138
- memvcs/core/merge.py +248 -171
- memvcs/core/objects.py +95 -96
- memvcs/core/pii_scanner.py +147 -146
- memvcs/core/refs.py +132 -115
- memvcs/core/repository.py +174 -164
- memvcs/core/schema.py +155 -113
- memvcs/core/staging.py +60 -65
- memvcs/core/storage/__init__.py +20 -18
- memvcs/core/storage/base.py +74 -70
- memvcs/core/storage/gcs.py +70 -68
- memvcs/core/storage/local.py +42 -40
- memvcs/core/storage/s3.py +105 -110
- memvcs/core/temporal_index.py +112 -0
- memvcs/core/test_runner.py +101 -93
- memvcs/core/vector_store.py +41 -35
- memvcs/integrations/mcp_server.py +1 -3
- memvcs/integrations/web_ui/server.py +25 -26
- memvcs/retrieval/__init__.py +22 -0
- memvcs/retrieval/base.py +54 -0
- memvcs/retrieval/pack.py +128 -0
- memvcs/retrieval/recaller.py +105 -0
- memvcs/retrieval/strategies.py +314 -0
- memvcs/utils/__init__.py +3 -3
- memvcs/utils/helpers.py +52 -52
- agmem-0.1.1.dist-info/RECORD +0 -67
- {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/WHEEL +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/entry_points.txt +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {agmem-0.1.1.dist-info → agmem-0.1.2.dist-info}/top_level.txt +0 -0
memvcs/core/storage/gcs.py
CHANGED
|
@@ -13,6 +13,7 @@ from datetime import datetime
|
|
|
13
13
|
try:
|
|
14
14
|
from google.cloud import storage
|
|
15
15
|
from google.cloud.exceptions import NotFound
|
|
16
|
+
|
|
16
17
|
GCS_AVAILABLE = True
|
|
17
18
|
except ImportError:
|
|
18
19
|
GCS_AVAILABLE = False
|
|
@@ -26,6 +27,7 @@ def _apply_gcs_config(kwargs: Dict[str, Any], config: Optional[Dict[str, Any]])
|
|
|
26
27
|
return
|
|
27
28
|
try:
|
|
28
29
|
from memvcs.core.config_loader import get_gcs_options_from_config
|
|
30
|
+
|
|
29
31
|
opts = get_gcs_options_from_config(config)
|
|
30
32
|
for key in ("project", "credentials_path", "credentials_info"):
|
|
31
33
|
if opts.get(key) is not None:
|
|
@@ -36,18 +38,18 @@ def _apply_gcs_config(kwargs: Dict[str, Any], config: Optional[Dict[str, Any]])
|
|
|
36
38
|
|
|
37
39
|
class GCSStorageAdapter(StorageAdapter):
|
|
38
40
|
"""Storage adapter for Google Cloud Storage."""
|
|
39
|
-
|
|
41
|
+
|
|
40
42
|
def __init__(
|
|
41
43
|
self,
|
|
42
44
|
bucket: str,
|
|
43
45
|
prefix: str = "",
|
|
44
46
|
project: Optional[str] = None,
|
|
45
47
|
credentials_path: Optional[str] = None,
|
|
46
|
-
credentials_info: Optional[Dict[str, Any]] = None
|
|
48
|
+
credentials_info: Optional[Dict[str, Any]] = None,
|
|
47
49
|
):
|
|
48
50
|
"""
|
|
49
51
|
Initialize GCS storage adapter.
|
|
50
|
-
|
|
52
|
+
|
|
51
53
|
Args:
|
|
52
54
|
bucket: GCS bucket name
|
|
53
55
|
prefix: Key prefix for all operations
|
|
@@ -60,11 +62,11 @@ class GCSStorageAdapter(StorageAdapter):
|
|
|
60
62
|
"google-cloud-storage is required for GCS. "
|
|
61
63
|
"Install with: pip install agmem[cloud]"
|
|
62
64
|
)
|
|
63
|
-
|
|
65
|
+
|
|
64
66
|
self.bucket_name = bucket
|
|
65
|
-
self.prefix = prefix.strip(
|
|
67
|
+
self.prefix = prefix.strip("/")
|
|
66
68
|
self._lock_id = str(uuid.uuid4())
|
|
67
|
-
|
|
69
|
+
|
|
68
70
|
# Build client: info dict > path > project > default
|
|
69
71
|
if credentials_info:
|
|
70
72
|
self.client = storage.Client.from_service_account_info(credentials_info)
|
|
@@ -74,32 +76,32 @@ class GCSStorageAdapter(StorageAdapter):
|
|
|
74
76
|
self.client = storage.Client(project=project)
|
|
75
77
|
else:
|
|
76
78
|
self.client = storage.Client()
|
|
77
|
-
|
|
79
|
+
|
|
78
80
|
self.bucket = self.client.bucket(bucket)
|
|
79
|
-
|
|
81
|
+
|
|
80
82
|
@classmethod
|
|
81
|
-
def from_url(cls, url: str, config: Optional[Dict[str, Any]] = None) ->
|
|
83
|
+
def from_url(cls, url: str, config: Optional[Dict[str, Any]] = None) -> "GCSStorageAdapter":
|
|
82
84
|
"""
|
|
83
85
|
Create adapter from GCS URL. Optional config supplies project,
|
|
84
86
|
credentials_path (validated), or credentials_info from env JSON.
|
|
85
|
-
|
|
87
|
+
|
|
86
88
|
Args:
|
|
87
89
|
url: GCS URL (gs://bucket/prefix)
|
|
88
90
|
config: Optional agmem config dict (cloud.gcs)
|
|
89
|
-
|
|
91
|
+
|
|
90
92
|
Returns:
|
|
91
93
|
GCSStorageAdapter instance
|
|
92
94
|
"""
|
|
93
|
-
if not url.startswith(
|
|
95
|
+
if not url.startswith("gs://"):
|
|
94
96
|
raise ValueError(f"Invalid GCS URL: {url}")
|
|
95
97
|
path = url[5:] # Remove 'gs://'
|
|
96
|
-
parts = path.split(
|
|
98
|
+
parts = path.split("/", 1)
|
|
97
99
|
bucket = parts[0]
|
|
98
100
|
prefix = parts[1] if len(parts) > 1 else ""
|
|
99
101
|
kwargs: Dict[str, Any] = {"bucket": bucket, "prefix": prefix}
|
|
100
102
|
_apply_gcs_config(kwargs, config)
|
|
101
103
|
return cls(**kwargs)
|
|
102
|
-
|
|
104
|
+
|
|
103
105
|
def _key(self, path: str) -> str:
|
|
104
106
|
"""Convert relative path to GCS key."""
|
|
105
107
|
if not path:
|
|
@@ -107,53 +109,53 @@ class GCSStorageAdapter(StorageAdapter):
|
|
|
107
109
|
if self.prefix:
|
|
108
110
|
return f"{self.prefix}/{path}"
|
|
109
111
|
return path
|
|
110
|
-
|
|
112
|
+
|
|
111
113
|
def _path(self, key: str) -> str:
|
|
112
114
|
"""Convert GCS key to relative path."""
|
|
113
|
-
if self.prefix and key.startswith(self.prefix +
|
|
114
|
-
return key[len(self.prefix) + 1:]
|
|
115
|
+
if self.prefix and key.startswith(self.prefix + "/"):
|
|
116
|
+
return key[len(self.prefix) + 1 :]
|
|
115
117
|
return key
|
|
116
|
-
|
|
118
|
+
|
|
117
119
|
def read_file(self, path: str) -> bytes:
|
|
118
120
|
"""Read a file's contents from GCS."""
|
|
119
121
|
key = self._key(path)
|
|
120
122
|
blob = self.bucket.blob(key)
|
|
121
|
-
|
|
123
|
+
|
|
122
124
|
try:
|
|
123
125
|
return blob.download_as_bytes()
|
|
124
126
|
except NotFound:
|
|
125
127
|
raise StorageError(f"File not found: {path}")
|
|
126
128
|
except Exception as e:
|
|
127
129
|
raise StorageError(f"Error reading {path}: {e}")
|
|
128
|
-
|
|
130
|
+
|
|
129
131
|
def write_file(self, path: str, data: bytes) -> None:
|
|
130
132
|
"""Write data to GCS."""
|
|
131
133
|
key = self._key(path)
|
|
132
134
|
blob = self.bucket.blob(key)
|
|
133
|
-
|
|
135
|
+
|
|
134
136
|
try:
|
|
135
137
|
blob.upload_from_string(data)
|
|
136
138
|
except Exception as e:
|
|
137
139
|
raise StorageError(f"Error writing {path}: {e}")
|
|
138
|
-
|
|
140
|
+
|
|
139
141
|
def exists(self, path: str) -> bool:
|
|
140
142
|
"""Check if a key exists in GCS."""
|
|
141
143
|
key = self._key(path)
|
|
142
144
|
blob = self.bucket.blob(key)
|
|
143
|
-
|
|
145
|
+
|
|
144
146
|
if blob.exists():
|
|
145
147
|
return True
|
|
146
|
-
|
|
148
|
+
|
|
147
149
|
# Check if it's a "directory"
|
|
148
|
-
prefix = key +
|
|
150
|
+
prefix = key + "/" if key else ""
|
|
149
151
|
blobs = list(self.bucket.list_blobs(prefix=prefix, max_results=1))
|
|
150
152
|
return len(blobs) > 0
|
|
151
|
-
|
|
153
|
+
|
|
152
154
|
def delete(self, path: str) -> bool:
|
|
153
155
|
"""Delete an object from GCS."""
|
|
154
156
|
key = self._key(path)
|
|
155
157
|
blob = self.bucket.blob(key)
|
|
156
|
-
|
|
158
|
+
|
|
157
159
|
try:
|
|
158
160
|
blob.delete()
|
|
159
161
|
return True
|
|
@@ -161,80 +163,80 @@ class GCSStorageAdapter(StorageAdapter):
|
|
|
161
163
|
return False
|
|
162
164
|
except Exception as e:
|
|
163
165
|
raise StorageError(f"Error deleting {path}: {e}")
|
|
164
|
-
|
|
166
|
+
|
|
165
167
|
def list_dir(self, path: str = "") -> List[FileInfo]:
|
|
166
168
|
"""List contents of a "directory" in GCS."""
|
|
167
169
|
prefix = self._key(path)
|
|
168
|
-
if prefix and not prefix.endswith(
|
|
169
|
-
prefix +=
|
|
170
|
-
|
|
170
|
+
if prefix and not prefix.endswith("/"):
|
|
171
|
+
prefix += "/"
|
|
172
|
+
|
|
171
173
|
result = []
|
|
172
174
|
seen_dirs = set()
|
|
173
|
-
|
|
175
|
+
|
|
174
176
|
try:
|
|
175
177
|
# List with delimiter to get "directories"
|
|
176
|
-
blobs = self.bucket.list_blobs(prefix=prefix, delimiter=
|
|
177
|
-
|
|
178
|
+
blobs = self.bucket.list_blobs(prefix=prefix, delimiter="/")
|
|
179
|
+
|
|
178
180
|
# Process blobs (files)
|
|
179
181
|
for blob in blobs:
|
|
180
182
|
if blob.name == prefix:
|
|
181
183
|
continue
|
|
182
|
-
|
|
183
|
-
result.append(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
|
|
185
|
+
result.append(
|
|
186
|
+
FileInfo(
|
|
187
|
+
path=self._path(blob.name),
|
|
188
|
+
size=blob.size or 0,
|
|
189
|
+
modified=blob.updated.isoformat() if blob.updated else None,
|
|
190
|
+
is_dir=False,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
190
194
|
# Process prefixes (directories)
|
|
191
195
|
for dir_prefix in blobs.prefixes:
|
|
192
|
-
dir_name = dir_prefix.rstrip(
|
|
196
|
+
dir_name = dir_prefix.rstrip("/").split("/")[-1]
|
|
193
197
|
if dir_name not in seen_dirs:
|
|
194
198
|
seen_dirs.add(dir_name)
|
|
195
|
-
result.append(
|
|
196
|
-
path=self._path(dir_prefix.rstrip(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
))
|
|
200
|
-
|
|
199
|
+
result.append(
|
|
200
|
+
FileInfo(path=self._path(dir_prefix.rstrip("/")), size=0, is_dir=True)
|
|
201
|
+
)
|
|
202
|
+
|
|
201
203
|
except Exception as e:
|
|
202
204
|
raise StorageError(f"Error listing {path}: {e}")
|
|
203
|
-
|
|
205
|
+
|
|
204
206
|
return result
|
|
205
|
-
|
|
207
|
+
|
|
206
208
|
def makedirs(self, path: str) -> None:
|
|
207
209
|
"""Create a "directory" in GCS (no-op, directories are implicit)."""
|
|
208
210
|
pass
|
|
209
|
-
|
|
211
|
+
|
|
210
212
|
def is_dir(self, path: str) -> bool:
|
|
211
213
|
"""Check if path is a "directory" in GCS."""
|
|
212
214
|
key = self._key(path)
|
|
213
215
|
if not key:
|
|
214
216
|
return True # Root is always a directory
|
|
215
|
-
|
|
217
|
+
|
|
216
218
|
# Check if there are any keys with this prefix
|
|
217
|
-
prefix = key +
|
|
219
|
+
prefix = key + "/"
|
|
218
220
|
blobs = list(self.bucket.list_blobs(prefix=prefix, max_results=1))
|
|
219
221
|
return len(blobs) > 0
|
|
220
|
-
|
|
222
|
+
|
|
221
223
|
def acquire_lock(self, lock_name: str, timeout: int = 30) -> bool:
|
|
222
224
|
"""
|
|
223
225
|
Acquire a distributed lock using GCS.
|
|
224
|
-
|
|
226
|
+
|
|
225
227
|
Uses generation-based conditional updates for lock safety.
|
|
226
228
|
"""
|
|
227
229
|
start_time = time.time()
|
|
228
230
|
lock_key = self._key(f".locks/{lock_name}.lock")
|
|
229
231
|
blob = self.bucket.blob(lock_key)
|
|
230
|
-
|
|
232
|
+
|
|
231
233
|
while True:
|
|
232
234
|
try:
|
|
233
235
|
# Check if lock exists and is not stale
|
|
234
236
|
if blob.exists():
|
|
235
237
|
blob.reload()
|
|
236
238
|
existing = blob.download_as_string().decode()
|
|
237
|
-
parts = existing.split(
|
|
239
|
+
parts = existing.split(":")
|
|
238
240
|
if len(parts) == 2:
|
|
239
241
|
_, ts = parts
|
|
240
242
|
if int(time.time()) - int(ts) < 300: # Lock is fresh
|
|
@@ -244,23 +246,23 @@ class GCSStorageAdapter(StorageAdapter):
|
|
|
244
246
|
)
|
|
245
247
|
time.sleep(0.5)
|
|
246
248
|
continue
|
|
247
|
-
|
|
249
|
+
|
|
248
250
|
# Create/overwrite lock
|
|
249
251
|
lock_data = f"{self._lock_id}:{int(time.time())}"
|
|
250
252
|
blob.upload_from_string(lock_data)
|
|
251
|
-
|
|
253
|
+
|
|
252
254
|
# Verify we own the lock
|
|
253
255
|
time.sleep(0.1)
|
|
254
256
|
blob.reload()
|
|
255
257
|
content = blob.download_as_string().decode()
|
|
256
258
|
if content.startswith(self._lock_id):
|
|
257
259
|
return True
|
|
258
|
-
|
|
260
|
+
|
|
259
261
|
# Someone else got it
|
|
260
262
|
if time.time() - start_time >= timeout:
|
|
261
263
|
raise LockError(f"Could not acquire lock '{lock_name}' within {timeout}s")
|
|
262
264
|
time.sleep(0.5)
|
|
263
|
-
|
|
265
|
+
|
|
264
266
|
except NotFound:
|
|
265
267
|
# Lock doesn't exist, try to create it
|
|
266
268
|
try:
|
|
@@ -273,12 +275,12 @@ class GCSStorageAdapter(StorageAdapter):
|
|
|
273
275
|
time.sleep(0.5)
|
|
274
276
|
except Exception as e:
|
|
275
277
|
raise StorageError(f"Error acquiring lock: {e}")
|
|
276
|
-
|
|
278
|
+
|
|
277
279
|
def release_lock(self, lock_name: str) -> None:
|
|
278
280
|
"""Release a distributed lock."""
|
|
279
281
|
lock_key = self._key(f".locks/{lock_name}.lock")
|
|
280
282
|
blob = self.bucket.blob(lock_key)
|
|
281
|
-
|
|
283
|
+
|
|
282
284
|
try:
|
|
283
285
|
# Only delete if we own the lock
|
|
284
286
|
if blob.exists():
|
|
@@ -287,18 +289,18 @@ class GCSStorageAdapter(StorageAdapter):
|
|
|
287
289
|
blob.delete()
|
|
288
290
|
except Exception:
|
|
289
291
|
pass # Ignore errors on release
|
|
290
|
-
|
|
292
|
+
|
|
291
293
|
def is_locked(self, lock_name: str) -> bool:
|
|
292
294
|
"""Check if a lock is currently held."""
|
|
293
295
|
lock_key = self._key(f".locks/{lock_name}.lock")
|
|
294
296
|
blob = self.bucket.blob(lock_key)
|
|
295
|
-
|
|
297
|
+
|
|
296
298
|
try:
|
|
297
299
|
if not blob.exists():
|
|
298
300
|
return False
|
|
299
|
-
|
|
301
|
+
|
|
300
302
|
content = blob.download_as_string().decode()
|
|
301
|
-
parts = content.split(
|
|
303
|
+
parts = content.split(":")
|
|
302
304
|
if len(parts) == 2:
|
|
303
305
|
_, ts = parts
|
|
304
306
|
# Lock is valid if less than 5 minutes old
|
memvcs/core/storage/local.py
CHANGED
|
@@ -14,17 +14,17 @@ from .base import StorageAdapter, StorageError, LockError, FileInfo
|
|
|
14
14
|
|
|
15
15
|
class LocalStorageAdapter(StorageAdapter):
|
|
16
16
|
"""Storage adapter for local filesystem."""
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
def __init__(self, root_path: str):
|
|
19
19
|
"""
|
|
20
20
|
Initialize local storage adapter.
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
Args:
|
|
23
23
|
root_path: Root directory for storage
|
|
24
24
|
"""
|
|
25
25
|
self.root = Path(root_path).resolve()
|
|
26
26
|
self._locks: dict = {} # Active lock file handles
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
def _resolve_path(self, path: str) -> Path:
|
|
29
29
|
"""Resolve a relative path to absolute path within root."""
|
|
30
30
|
if not path:
|
|
@@ -34,7 +34,7 @@ class LocalStorageAdapter(StorageAdapter):
|
|
|
34
34
|
if not str(resolved).startswith(str(self.root)):
|
|
35
35
|
raise StorageError(f"Path '{path}' is outside storage root")
|
|
36
36
|
return resolved
|
|
37
|
-
|
|
37
|
+
|
|
38
38
|
def read_file(self, path: str) -> bytes:
|
|
39
39
|
"""Read a file's contents."""
|
|
40
40
|
resolved = self._resolve_path(path)
|
|
@@ -44,7 +44,7 @@ class LocalStorageAdapter(StorageAdapter):
|
|
|
44
44
|
raise StorageError(f"File not found: {path}")
|
|
45
45
|
except IOError as e:
|
|
46
46
|
raise StorageError(f"Error reading file {path}: {e}")
|
|
47
|
-
|
|
47
|
+
|
|
48
48
|
def write_file(self, path: str, data: bytes) -> None:
|
|
49
49
|
"""Write data to a file."""
|
|
50
50
|
resolved = self._resolve_path(path)
|
|
@@ -53,12 +53,12 @@ class LocalStorageAdapter(StorageAdapter):
|
|
|
53
53
|
resolved.write_bytes(data)
|
|
54
54
|
except IOError as e:
|
|
55
55
|
raise StorageError(f"Error writing file {path}: {e}")
|
|
56
|
-
|
|
56
|
+
|
|
57
57
|
def exists(self, path: str) -> bool:
|
|
58
58
|
"""Check if a path exists."""
|
|
59
59
|
resolved = self._resolve_path(path)
|
|
60
60
|
return resolved.exists()
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
def delete(self, path: str) -> bool:
|
|
63
63
|
"""Delete a file."""
|
|
64
64
|
resolved = self._resolve_path(path)
|
|
@@ -72,78 +72,80 @@ class LocalStorageAdapter(StorageAdapter):
|
|
|
72
72
|
return False
|
|
73
73
|
except IOError as e:
|
|
74
74
|
raise StorageError(f"Error deleting {path}: {e}")
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
def list_dir(self, path: str = "") -> List[FileInfo]:
|
|
77
77
|
"""List contents of a directory."""
|
|
78
78
|
resolved = self._resolve_path(path)
|
|
79
|
-
|
|
79
|
+
|
|
80
80
|
if not resolved.exists():
|
|
81
81
|
return []
|
|
82
|
-
|
|
82
|
+
|
|
83
83
|
if not resolved.is_dir():
|
|
84
84
|
raise StorageError(f"Not a directory: {path}")
|
|
85
|
-
|
|
85
|
+
|
|
86
86
|
result = []
|
|
87
87
|
for item in resolved.iterdir():
|
|
88
88
|
try:
|
|
89
89
|
stat = item.stat()
|
|
90
90
|
rel_path = str(item.relative_to(self.root))
|
|
91
|
-
|
|
92
|
-
result.append(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
|
|
92
|
+
result.append(
|
|
93
|
+
FileInfo(
|
|
94
|
+
path=rel_path,
|
|
95
|
+
size=stat.st_size if not item.is_dir() else 0,
|
|
96
|
+
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
97
|
+
is_dir=item.is_dir(),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
98
100
|
except IOError:
|
|
99
101
|
# Skip files we can't stat
|
|
100
102
|
continue
|
|
101
|
-
|
|
103
|
+
|
|
102
104
|
return result
|
|
103
|
-
|
|
105
|
+
|
|
104
106
|
def makedirs(self, path: str) -> None:
|
|
105
107
|
"""Create directory and any necessary parent directories."""
|
|
106
108
|
resolved = self._resolve_path(path)
|
|
107
109
|
resolved.mkdir(parents=True, exist_ok=True)
|
|
108
|
-
|
|
110
|
+
|
|
109
111
|
def is_dir(self, path: str) -> bool:
|
|
110
112
|
"""Check if path is a directory."""
|
|
111
113
|
resolved = self._resolve_path(path)
|
|
112
114
|
return resolved.is_dir()
|
|
113
|
-
|
|
115
|
+
|
|
114
116
|
def acquire_lock(self, lock_name: str, timeout: int = 30) -> bool:
|
|
115
117
|
"""
|
|
116
118
|
Acquire a file-based lock.
|
|
117
|
-
|
|
119
|
+
|
|
118
120
|
Uses fcntl for POSIX systems.
|
|
119
121
|
"""
|
|
120
|
-
lock_path = self.root /
|
|
122
|
+
lock_path = self.root / ".locks" / f"{lock_name}.lock"
|
|
121
123
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
-
|
|
124
|
+
|
|
123
125
|
start_time = time.time()
|
|
124
|
-
|
|
126
|
+
|
|
125
127
|
while True:
|
|
126
128
|
try:
|
|
127
129
|
# Open or create lock file
|
|
128
|
-
lock_file = open(lock_path,
|
|
129
|
-
|
|
130
|
+
lock_file = open(lock_path, "w")
|
|
131
|
+
|
|
130
132
|
# Try to acquire exclusive lock
|
|
131
133
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
132
|
-
|
|
134
|
+
|
|
133
135
|
# Write our PID to the lock file
|
|
134
136
|
lock_file.write(str(os.getpid()))
|
|
135
137
|
lock_file.flush()
|
|
136
|
-
|
|
138
|
+
|
|
137
139
|
# Keep handle open to maintain lock
|
|
138
140
|
self._locks[lock_name] = lock_file
|
|
139
141
|
return True
|
|
140
|
-
|
|
142
|
+
|
|
141
143
|
except (IOError, OSError):
|
|
142
144
|
# Lock is held by another process
|
|
143
145
|
if time.time() - start_time >= timeout:
|
|
144
146
|
raise LockError(f"Could not acquire lock '{lock_name}' within {timeout}s")
|
|
145
147
|
time.sleep(0.1)
|
|
146
|
-
|
|
148
|
+
|
|
147
149
|
def release_lock(self, lock_name: str) -> None:
|
|
148
150
|
"""Release a file-based lock."""
|
|
149
151
|
if lock_name in self._locks:
|
|
@@ -153,30 +155,30 @@ class LocalStorageAdapter(StorageAdapter):
|
|
|
153
155
|
lock_file.close()
|
|
154
156
|
except (IOError, OSError):
|
|
155
157
|
pass
|
|
156
|
-
|
|
158
|
+
|
|
157
159
|
# Try to remove lock file
|
|
158
|
-
lock_path = self.root /
|
|
160
|
+
lock_path = self.root / ".locks" / f"{lock_name}.lock"
|
|
159
161
|
try:
|
|
160
162
|
lock_path.unlink()
|
|
161
163
|
except (IOError, OSError):
|
|
162
164
|
pass
|
|
163
|
-
|
|
165
|
+
|
|
164
166
|
def is_locked(self, lock_name: str) -> bool:
|
|
165
167
|
"""Check if a lock is currently held."""
|
|
166
|
-
lock_path = self.root /
|
|
167
|
-
|
|
168
|
+
lock_path = self.root / ".locks" / f"{lock_name}.lock"
|
|
169
|
+
|
|
168
170
|
if not lock_path.exists():
|
|
169
171
|
return False
|
|
170
|
-
|
|
172
|
+
|
|
171
173
|
try:
|
|
172
174
|
# Try to acquire lock briefly
|
|
173
|
-
with open(lock_path,
|
|
175
|
+
with open(lock_path, "w") as f:
|
|
174
176
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
175
177
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
176
178
|
return False # Lock is free
|
|
177
179
|
except (IOError, OSError):
|
|
178
180
|
return True # Lock is held
|
|
179
|
-
|
|
181
|
+
|
|
180
182
|
def get_root(self) -> Path:
|
|
181
183
|
"""Get the root path of this storage."""
|
|
182
184
|
return self.root
|