cosma-client 0.0.1.dev1__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.
- cosma_client-0.0.1.dev1/PKG-INFO +6 -0
- cosma_client-0.0.1.dev1/pyproject.toml +10 -0
- cosma_client-0.0.1.dev1/src/cosma_client/__init__.py +24 -0
- cosma_client-0.0.1.dev1/src/cosma_client/client.py +349 -0
- cosma_client-0.0.1.dev1/src/cosma_client/exceptions.py +33 -0
- cosma_client-0.0.1.dev1/src/cosma_client/models.py +225 -0
- cosma_client-0.0.1.dev1/src/cosma_client/sync.py +235 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cosma Client - API client library for Cosma.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .client import Client
|
|
6
|
+
from .sync import SyncClient
|
|
7
|
+
from .models import Update, UpdateOpcode
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
CosmaClientError,
|
|
10
|
+
ConnectionError,
|
|
11
|
+
ServerNotRunningError,
|
|
12
|
+
APIError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Client",
|
|
17
|
+
"SyncClient",
|
|
18
|
+
"Update",
|
|
19
|
+
"UpdateOpcode",
|
|
20
|
+
"CosmaClientError",
|
|
21
|
+
"ConnectionError",
|
|
22
|
+
"ServerNotRunningError",
|
|
23
|
+
"APIError",
|
|
24
|
+
]
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import niquests
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional, Dict, Any, List, AsyncIterator
|
|
4
|
+
from urllib.parse import urljoin
|
|
5
|
+
|
|
6
|
+
from .models import Update, UpdateOpcode
|
|
7
|
+
from .exceptions import ServerNotRunningError, APIError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Client:
|
|
11
|
+
"""Async client for the Cosma API."""
|
|
12
|
+
|
|
13
|
+
session: niquests.AsyncSession
|
|
14
|
+
base_url: str
|
|
15
|
+
|
|
16
|
+
def __init__(self, base_url: str = "http://127.0.0.1:60534"):
|
|
17
|
+
self.session = niquests.AsyncSession()
|
|
18
|
+
self.base_url = base_url
|
|
19
|
+
self.session.headers.update({"Content-Type": "application/json"})
|
|
20
|
+
|
|
21
|
+
def _url(self, endpoint: str) -> str:
|
|
22
|
+
"""Helper to build full URL"""
|
|
23
|
+
return urljoin(self.base_url, endpoint)
|
|
24
|
+
|
|
25
|
+
def _handle_response(self, response: niquests.Response) -> Dict[str, Any]:
|
|
26
|
+
"""Handle response and raise on errors"""
|
|
27
|
+
response.raise_for_status()
|
|
28
|
+
return response.json()
|
|
29
|
+
|
|
30
|
+
# =========================================================================
|
|
31
|
+
# Index API
|
|
32
|
+
# =========================================================================
|
|
33
|
+
|
|
34
|
+
async def index_directory(self, directory_path: str) -> Dict[str, Any]:
|
|
35
|
+
"""Index all files in a directory for searching"""
|
|
36
|
+
data = {"directory_path": directory_path}
|
|
37
|
+
try:
|
|
38
|
+
response = await self.session.post(
|
|
39
|
+
self._url("/api/index/directory"),
|
|
40
|
+
data=json.dumps(data)
|
|
41
|
+
)
|
|
42
|
+
return self._handle_response(response)
|
|
43
|
+
except niquests.exceptions.ConnectionError:
|
|
44
|
+
raise ServerNotRunningError(self.base_url)
|
|
45
|
+
|
|
46
|
+
async def index_file(self, file_path: str) -> Dict[str, Any]:
|
|
47
|
+
"""Index a single file"""
|
|
48
|
+
data = {"file_path": file_path}
|
|
49
|
+
try:
|
|
50
|
+
response = await self.session.post(
|
|
51
|
+
self._url("/api/index/file"),
|
|
52
|
+
data=json.dumps(data)
|
|
53
|
+
)
|
|
54
|
+
return self._handle_response(response)
|
|
55
|
+
except niquests.exceptions.ConnectionError:
|
|
56
|
+
raise ServerNotRunningError(self.base_url)
|
|
57
|
+
|
|
58
|
+
async def index_status(self) -> Dict[str, Any]:
|
|
59
|
+
"""Get the current status of indexing operations"""
|
|
60
|
+
try:
|
|
61
|
+
response = await self.session.get(self._url("/api/index/status"))
|
|
62
|
+
return self._handle_response(response)
|
|
63
|
+
except niquests.exceptions.ConnectionError:
|
|
64
|
+
raise ServerNotRunningError(self.base_url)
|
|
65
|
+
|
|
66
|
+
# =========================================================================
|
|
67
|
+
# Status API
|
|
68
|
+
# =========================================================================
|
|
69
|
+
|
|
70
|
+
async def status(self) -> Dict[str, Any]:
|
|
71
|
+
"""Get the current status of the backend"""
|
|
72
|
+
try:
|
|
73
|
+
response = await self.session.get(self._url("/api/status"))
|
|
74
|
+
return self._handle_response(response)
|
|
75
|
+
except niquests.exceptions.ConnectionError:
|
|
76
|
+
raise ServerNotRunningError(self.base_url)
|
|
77
|
+
|
|
78
|
+
# =========================================================================
|
|
79
|
+
# Search API
|
|
80
|
+
# =========================================================================
|
|
81
|
+
|
|
82
|
+
async def search(
|
|
83
|
+
self,
|
|
84
|
+
query: str,
|
|
85
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
86
|
+
limit: int = 50
|
|
87
|
+
) -> Dict[str, Any]:
|
|
88
|
+
"""Search for files based on a query string"""
|
|
89
|
+
data: Dict[str, Any] = {"query": query, "limit": limit}
|
|
90
|
+
if filters:
|
|
91
|
+
data["filters"] = filters
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
response = await self.session.post(
|
|
95
|
+
self._url("/api/search/"),
|
|
96
|
+
data=json.dumps(data)
|
|
97
|
+
)
|
|
98
|
+
return self._handle_response(response)
|
|
99
|
+
except niquests.exceptions.ConnectionError:
|
|
100
|
+
raise ServerNotRunningError(self.base_url)
|
|
101
|
+
|
|
102
|
+
async def search_by_keywords(
|
|
103
|
+
self,
|
|
104
|
+
keywords: List[str],
|
|
105
|
+
match_all: bool = False
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""Search for files by specific keywords"""
|
|
108
|
+
data = {"keywords": keywords, "match_all": match_all}
|
|
109
|
+
try:
|
|
110
|
+
response = await self.session.post(
|
|
111
|
+
self._url("/api/search/keywords"),
|
|
112
|
+
data=json.dumps(data)
|
|
113
|
+
)
|
|
114
|
+
return self._handle_response(response)
|
|
115
|
+
except niquests.exceptions.ConnectionError:
|
|
116
|
+
raise ServerNotRunningError(self.base_url)
|
|
117
|
+
|
|
118
|
+
async def find_similar_files(
|
|
119
|
+
self,
|
|
120
|
+
file_id: int,
|
|
121
|
+
limit: int = 10
|
|
122
|
+
) -> Dict[str, Any]:
|
|
123
|
+
"""Find files similar to a given file"""
|
|
124
|
+
try:
|
|
125
|
+
response = await self.session.get(
|
|
126
|
+
self._url(f"/api/search/{file_id}/similar"),
|
|
127
|
+
params={"limit": limit}
|
|
128
|
+
)
|
|
129
|
+
return self._handle_response(response)
|
|
130
|
+
except niquests.exceptions.ConnectionError:
|
|
131
|
+
raise ServerNotRunningError(self.base_url)
|
|
132
|
+
|
|
133
|
+
async def autocomplete(self, q: str, limit: int = 10) -> Dict[str, Any]:
|
|
134
|
+
"""Get autocomplete suggestions for search queries"""
|
|
135
|
+
try:
|
|
136
|
+
response = await self.session.get(
|
|
137
|
+
self._url("/api/search/autocomplete"),
|
|
138
|
+
params={"q": q, "limit": limit}
|
|
139
|
+
)
|
|
140
|
+
return self._handle_response(response)
|
|
141
|
+
except niquests.exceptions.ConnectionError:
|
|
142
|
+
raise ServerNotRunningError(self.base_url)
|
|
143
|
+
|
|
144
|
+
# =========================================================================
|
|
145
|
+
# Watch API
|
|
146
|
+
# =========================================================================
|
|
147
|
+
|
|
148
|
+
async def watch_directory(self, directory_path: str) -> Dict[str, Any]:
|
|
149
|
+
"""Start watching a directory for changes"""
|
|
150
|
+
data = {"directory_path": directory_path}
|
|
151
|
+
try:
|
|
152
|
+
response = await self.session.post(
|
|
153
|
+
self._url("/api/watch/"),
|
|
154
|
+
data=json.dumps(data)
|
|
155
|
+
)
|
|
156
|
+
return self._handle_response(response)
|
|
157
|
+
except niquests.exceptions.ConnectionError:
|
|
158
|
+
raise ServerNotRunningError(self.base_url)
|
|
159
|
+
|
|
160
|
+
async def get_watch_jobs(self) -> Dict[str, Any]:
|
|
161
|
+
"""Get all watched directory jobs"""
|
|
162
|
+
try:
|
|
163
|
+
response = await self.session.get(self._url("/api/watch/jobs"))
|
|
164
|
+
return self._handle_response(response)
|
|
165
|
+
except niquests.exceptions.ConnectionError:
|
|
166
|
+
raise ServerNotRunningError(self.base_url)
|
|
167
|
+
|
|
168
|
+
async def delete_watch_job(self, job_id: int) -> Dict[str, Any]:
|
|
169
|
+
"""Delete a watched directory job by ID"""
|
|
170
|
+
try:
|
|
171
|
+
response = await self.session.delete(
|
|
172
|
+
self._url(f"/api/watch/jobs/{job_id}")
|
|
173
|
+
)
|
|
174
|
+
return self._handle_response(response)
|
|
175
|
+
except niquests.exceptions.ConnectionError:
|
|
176
|
+
raise ServerNotRunningError(self.base_url)
|
|
177
|
+
|
|
178
|
+
# =========================================================================
|
|
179
|
+
# Files API
|
|
180
|
+
# =========================================================================
|
|
181
|
+
|
|
182
|
+
async def get_file(self, file_id: int) -> Dict[str, Any]:
|
|
183
|
+
"""Get details of a specific file by ID"""
|
|
184
|
+
try:
|
|
185
|
+
response = await self.session.get(
|
|
186
|
+
self._url(f"/api/files/{file_id}")
|
|
187
|
+
)
|
|
188
|
+
return self._handle_response(response)
|
|
189
|
+
except niquests.exceptions.ConnectionError:
|
|
190
|
+
raise ServerNotRunningError(self.base_url)
|
|
191
|
+
|
|
192
|
+
async def get_file_stats(self) -> Dict[str, Any]:
|
|
193
|
+
"""Get statistics about indexed files"""
|
|
194
|
+
try:
|
|
195
|
+
response = await self.session.get(self._url("/api/files/stats"))
|
|
196
|
+
return self._handle_response(response)
|
|
197
|
+
except niquests.exceptions.ConnectionError:
|
|
198
|
+
raise ServerNotRunningError(self.base_url)
|
|
199
|
+
|
|
200
|
+
# =========================================================================
|
|
201
|
+
# Filters API
|
|
202
|
+
# =========================================================================
|
|
203
|
+
|
|
204
|
+
async def get_filter_config(self) -> Dict[str, Any]:
|
|
205
|
+
"""Get the current filter configuration"""
|
|
206
|
+
try:
|
|
207
|
+
response = await self.session.get(self._url("/api/filters/config"))
|
|
208
|
+
return self._handle_response(response)
|
|
209
|
+
except niquests.exceptions.ConnectionError:
|
|
210
|
+
raise ServerNotRunningError(self.base_url)
|
|
211
|
+
|
|
212
|
+
async def update_filter_config(
|
|
213
|
+
self,
|
|
214
|
+
mode: Optional[str] = None,
|
|
215
|
+
exclude: Optional[List[str]] = None,
|
|
216
|
+
include: Optional[List[str]] = None,
|
|
217
|
+
apply_immediately: bool = True,
|
|
218
|
+
) -> Dict[str, Any]:
|
|
219
|
+
"""Update the filter configuration"""
|
|
220
|
+
data: Dict[str, Any] = {"apply_immediately": apply_immediately}
|
|
221
|
+
if mode is not None:
|
|
222
|
+
data["mode"] = mode
|
|
223
|
+
if exclude is not None:
|
|
224
|
+
data["exclude"] = exclude
|
|
225
|
+
if include is not None:
|
|
226
|
+
data["include"] = include
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
response = await self.session.put(
|
|
230
|
+
self._url("/api/filters/config"),
|
|
231
|
+
data=json.dumps(data)
|
|
232
|
+
)
|
|
233
|
+
return self._handle_response(response)
|
|
234
|
+
except niquests.exceptions.ConnectionError:
|
|
235
|
+
raise ServerNotRunningError(self.base_url)
|
|
236
|
+
|
|
237
|
+
async def add_filter_pattern(
|
|
238
|
+
self,
|
|
239
|
+
pattern: str,
|
|
240
|
+
pattern_type: str = "exclude"
|
|
241
|
+
) -> Dict[str, Any]:
|
|
242
|
+
"""Add a pattern to the filter configuration"""
|
|
243
|
+
data = {"pattern": pattern, "pattern_type": pattern_type}
|
|
244
|
+
try:
|
|
245
|
+
response = await self.session.post(
|
|
246
|
+
self._url("/api/filters/pattern"),
|
|
247
|
+
data=json.dumps(data)
|
|
248
|
+
)
|
|
249
|
+
return self._handle_response(response)
|
|
250
|
+
except niquests.exceptions.ConnectionError:
|
|
251
|
+
raise ServerNotRunningError(self.base_url)
|
|
252
|
+
|
|
253
|
+
async def remove_filter_pattern(
|
|
254
|
+
self,
|
|
255
|
+
pattern: str,
|
|
256
|
+
pattern_type: str = "exclude"
|
|
257
|
+
) -> Dict[str, Any]:
|
|
258
|
+
"""Remove a pattern from the filter configuration"""
|
|
259
|
+
data = {"pattern": pattern, "pattern_type": pattern_type}
|
|
260
|
+
try:
|
|
261
|
+
response = await self.session.delete(
|
|
262
|
+
self._url("/api/filters/pattern"),
|
|
263
|
+
data=json.dumps(data)
|
|
264
|
+
)
|
|
265
|
+
return self._handle_response(response)
|
|
266
|
+
except niquests.exceptions.ConnectionError:
|
|
267
|
+
raise ServerNotRunningError(self.base_url)
|
|
268
|
+
|
|
269
|
+
async def test_filter_patterns(
|
|
270
|
+
self,
|
|
271
|
+
patterns: List[str],
|
|
272
|
+
file_paths: List[str],
|
|
273
|
+
mode: str = "blacklist",
|
|
274
|
+
) -> Dict[str, Any]:
|
|
275
|
+
"""Test pattern matching against file paths"""
|
|
276
|
+
data = {"patterns": patterns, "file_paths": file_paths, "mode": mode}
|
|
277
|
+
try:
|
|
278
|
+
response = await self.session.post(
|
|
279
|
+
self._url("/api/filters/test"),
|
|
280
|
+
data=json.dumps(data)
|
|
281
|
+
)
|
|
282
|
+
return self._handle_response(response)
|
|
283
|
+
except niquests.exceptions.ConnectionError:
|
|
284
|
+
raise ServerNotRunningError(self.base_url)
|
|
285
|
+
|
|
286
|
+
async def get_filter_defaults(self) -> Dict[str, Any]:
|
|
287
|
+
"""Get the default filter configuration"""
|
|
288
|
+
try:
|
|
289
|
+
response = await self.session.get(self._url("/api/filters/defaults"))
|
|
290
|
+
return self._handle_response(response)
|
|
291
|
+
except niquests.exceptions.ConnectionError:
|
|
292
|
+
raise ServerNotRunningError(self.base_url)
|
|
293
|
+
|
|
294
|
+
async def apply_filter_changes(self) -> Dict[str, Any]:
|
|
295
|
+
"""Apply current filter configuration to database"""
|
|
296
|
+
try:
|
|
297
|
+
response = await self.session.post(self._url("/api/filters/apply"))
|
|
298
|
+
return self._handle_response(response)
|
|
299
|
+
except niquests.exceptions.ConnectionError:
|
|
300
|
+
raise ServerNotRunningError(self.base_url)
|
|
301
|
+
|
|
302
|
+
async def reset_filters(self) -> Dict[str, Any]:
|
|
303
|
+
"""Reset filter configuration to defaults"""
|
|
304
|
+
try:
|
|
305
|
+
response = await self.session.post(self._url("/api/filters/reset"))
|
|
306
|
+
return self._handle_response(response)
|
|
307
|
+
except niquests.exceptions.ConnectionError:
|
|
308
|
+
raise ServerNotRunningError(self.base_url)
|
|
309
|
+
|
|
310
|
+
# =========================================================================
|
|
311
|
+
# Updates API (SSE)
|
|
312
|
+
# =========================================================================
|
|
313
|
+
|
|
314
|
+
async def stream_updates(self) -> AsyncIterator[Update]:
|
|
315
|
+
"""Stream server-sent events from /api/updates"""
|
|
316
|
+
url = self._url("/api/updates")
|
|
317
|
+
try:
|
|
318
|
+
response = await self.session.get(url, stream=True, timeout=30)
|
|
319
|
+
response.raise_for_status()
|
|
320
|
+
|
|
321
|
+
async for line in response.iter_lines():
|
|
322
|
+
if line:
|
|
323
|
+
line_str = line.decode('utf-8') if isinstance(line, bytes) else line
|
|
324
|
+
# SSE format: "data: {...}"
|
|
325
|
+
if line_str.startswith('data: '):
|
|
326
|
+
data_str = line_str[6:] # Remove "data: " prefix
|
|
327
|
+
try:
|
|
328
|
+
# Parse and create Update instance
|
|
329
|
+
update = Update.from_sse_data(data_str)
|
|
330
|
+
yield update
|
|
331
|
+
except Exception:
|
|
332
|
+
# Fallback: create a generic INFO update
|
|
333
|
+
yield Update.create(UpdateOpcode.INFO, message=data_str)
|
|
334
|
+
except niquests.exceptions.ConnectionError:
|
|
335
|
+
raise ServerNotRunningError(self.base_url)
|
|
336
|
+
|
|
337
|
+
# =========================================================================
|
|
338
|
+
# Context Manager
|
|
339
|
+
# =========================================================================
|
|
340
|
+
|
|
341
|
+
async def close(self):
|
|
342
|
+
"""Close the session"""
|
|
343
|
+
await self.session.close()
|
|
344
|
+
|
|
345
|
+
async def __aenter__(self):
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
349
|
+
await self.close()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exceptions for the Cosma client library.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CosmaClientError(Exception):
|
|
7
|
+
"""Base exception for all Cosma client errors."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConnectionError(CosmaClientError):
|
|
12
|
+
"""Raised when unable to connect to the Cosma server."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ServerNotRunningError(ConnectionError):
|
|
17
|
+
"""Raised when the Cosma server is not running."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, base_url: str = "http://127.0.0.1:60534"):
|
|
20
|
+
self.base_url = base_url
|
|
21
|
+
super().__init__(
|
|
22
|
+
f"Cannot connect to Cosma server at {base_url}. "
|
|
23
|
+
"Is the server running? Start it with: cosma serve"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class APIError(CosmaClientError):
|
|
28
|
+
"""Raised when the API returns an error response."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, status_code: int, message: str):
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.message = message
|
|
33
|
+
super().__init__(f"API error ({status_code}): {message}")
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Dict, Self
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UpdateOpcode(enum.Enum):
|
|
8
|
+
"""
|
|
9
|
+
Opcodes for different types of backend updates sent to the frontend via SSE.
|
|
10
|
+
"""
|
|
11
|
+
# File processing updates
|
|
12
|
+
FILE_PARSING = "file_parsing"
|
|
13
|
+
FILE_PARSED = "file_parsed"
|
|
14
|
+
FILE_SUMMARIZING = "file_summarizing"
|
|
15
|
+
FILE_SUMMARIZED = "file_summarized"
|
|
16
|
+
FILE_EMBEDDING = "file_embedding"
|
|
17
|
+
FILE_EMBEDDED = "file_embedded"
|
|
18
|
+
FILE_COMPLETE = "file_complete"
|
|
19
|
+
FILE_FAILED = "file_failed"
|
|
20
|
+
FILE_SKIPPED = "file_skipped"
|
|
21
|
+
|
|
22
|
+
# File system events (from watcher)
|
|
23
|
+
FILE_CREATED = "file_created"
|
|
24
|
+
FILE_MODIFIED = "file_modified"
|
|
25
|
+
FILE_DELETED = "file_deleted"
|
|
26
|
+
FILE_MOVED = "file_moved"
|
|
27
|
+
|
|
28
|
+
# Watch directory updates
|
|
29
|
+
WATCH_ADDED = "watch_added"
|
|
30
|
+
WATCH_REMOVED = "watch_removed"
|
|
31
|
+
WATCH_STARTED = "watch_started"
|
|
32
|
+
|
|
33
|
+
# Directory processing updates
|
|
34
|
+
DIRECTORY_PROCESSING_STARTED = "directory_processing_started"
|
|
35
|
+
DIRECTORY_PROCESSING_COMPLETED = "directory_processing_completed"
|
|
36
|
+
|
|
37
|
+
# General updates
|
|
38
|
+
STATUS_UPDATE = "status_update"
|
|
39
|
+
ERROR = "error"
|
|
40
|
+
INFO = "info"
|
|
41
|
+
|
|
42
|
+
SHUTTING_DOWN = "shutting_down"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Update:
|
|
47
|
+
"""
|
|
48
|
+
A model representing a backend update received from the server via SSE.
|
|
49
|
+
|
|
50
|
+
Each update has an opcode (message type) and optional data payload.
|
|
51
|
+
"""
|
|
52
|
+
opcode: UpdateOpcode
|
|
53
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def create(cls, opcode: UpdateOpcode, **kwargs) -> Self:
|
|
57
|
+
"""
|
|
58
|
+
Create an Update instance with the given opcode and data.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
opcode: The type of update (UpdateOpcode enum value)
|
|
62
|
+
**kwargs: Arbitrary keyword arguments that will be stored in the data dict
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
An Update instance
|
|
66
|
+
"""
|
|
67
|
+
return cls(opcode=opcode, data=kwargs)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_dict(cls, data: Dict[str, Any]) -> Self:
|
|
71
|
+
"""
|
|
72
|
+
Create an Update instance from a dictionary (parsed from SSE).
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
data: Dictionary with 'opcode' and 'data' keys
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
An Update instance
|
|
79
|
+
"""
|
|
80
|
+
opcode_str = data.get('opcode', '')
|
|
81
|
+
try:
|
|
82
|
+
opcode = UpdateOpcode(opcode_str)
|
|
83
|
+
except ValueError:
|
|
84
|
+
# Fallback to INFO if unknown opcode
|
|
85
|
+
opcode = UpdateOpcode.INFO
|
|
86
|
+
|
|
87
|
+
update_data = data.get('data', {})
|
|
88
|
+
return cls(opcode=opcode, data=update_data)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_sse_data(cls, sse_data: str) -> Self:
|
|
92
|
+
"""
|
|
93
|
+
Create an Update instance from SSE data string.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
sse_data: Raw data string from SSE (JSON)
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
An Update instance
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(sse_data)
|
|
103
|
+
return cls.from_dict(data)
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
# Fallback: treat as INFO message with raw data
|
|
106
|
+
return cls.create(UpdateOpcode.INFO, message=sse_data)
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
109
|
+
"""
|
|
110
|
+
Convert the Update to a dictionary for serialization.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
A dictionary with 'opcode' and 'data' keys
|
|
114
|
+
"""
|
|
115
|
+
return {
|
|
116
|
+
"opcode": self.opcode.value,
|
|
117
|
+
"data": self.data
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def get_display_message(self) -> str:
|
|
121
|
+
"""
|
|
122
|
+
Get a human-readable display message for this update.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A formatted string suitable for display
|
|
126
|
+
"""
|
|
127
|
+
# File processing messages
|
|
128
|
+
if self.opcode == UpdateOpcode.FILE_PARSING:
|
|
129
|
+
filename = self.data.get('filename', 'Unknown file')
|
|
130
|
+
return f"Parsing {filename}..."
|
|
131
|
+
|
|
132
|
+
elif self.opcode == UpdateOpcode.FILE_PARSED:
|
|
133
|
+
filename = self.data.get('filename', 'Unknown file')
|
|
134
|
+
return f"Parsed {filename}"
|
|
135
|
+
|
|
136
|
+
elif self.opcode == UpdateOpcode.FILE_SUMMARIZING:
|
|
137
|
+
filename = self.data.get('filename', 'Unknown file')
|
|
138
|
+
return f"Summarizing {filename}..."
|
|
139
|
+
|
|
140
|
+
elif self.opcode == UpdateOpcode.FILE_SUMMARIZED:
|
|
141
|
+
filename = self.data.get('filename', 'Unknown file')
|
|
142
|
+
return f"Summarized {filename}"
|
|
143
|
+
|
|
144
|
+
elif self.opcode == UpdateOpcode.FILE_EMBEDDING:
|
|
145
|
+
filename = self.data.get('filename', 'Unknown file')
|
|
146
|
+
return f"Embedding {filename}..."
|
|
147
|
+
|
|
148
|
+
elif self.opcode == UpdateOpcode.FILE_EMBEDDED:
|
|
149
|
+
filename = self.data.get('filename', 'Unknown file')
|
|
150
|
+
return f"Embedded {filename}"
|
|
151
|
+
|
|
152
|
+
elif self.opcode == UpdateOpcode.FILE_COMPLETE:
|
|
153
|
+
filename = self.data.get('filename', 'Unknown file')
|
|
154
|
+
return f"Completed {filename}"
|
|
155
|
+
|
|
156
|
+
elif self.opcode == UpdateOpcode.FILE_FAILED:
|
|
157
|
+
filename = self.data.get('filename', 'Unknown file')
|
|
158
|
+
error = self.data.get('error', 'Unknown error')
|
|
159
|
+
return f"Failed {filename}: {error}"
|
|
160
|
+
|
|
161
|
+
elif self.opcode == UpdateOpcode.FILE_SKIPPED:
|
|
162
|
+
filename = self.data.get('filename', 'Unknown file')
|
|
163
|
+
reason = self.data.get('reason', 'Unknown reason')
|
|
164
|
+
return f"Skipped {filename}: {reason}"
|
|
165
|
+
|
|
166
|
+
# File system events
|
|
167
|
+
elif self.opcode == UpdateOpcode.FILE_CREATED:
|
|
168
|
+
path = self.data.get('path', 'Unknown path')
|
|
169
|
+
return f"Created {path}"
|
|
170
|
+
|
|
171
|
+
elif self.opcode == UpdateOpcode.FILE_MODIFIED:
|
|
172
|
+
path = self.data.get('path', 'Unknown path')
|
|
173
|
+
return f"Modified {path}"
|
|
174
|
+
|
|
175
|
+
elif self.opcode == UpdateOpcode.FILE_DELETED:
|
|
176
|
+
path = self.data.get('path', 'Unknown path')
|
|
177
|
+
return f"Deleted {path}"
|
|
178
|
+
|
|
179
|
+
elif self.opcode == UpdateOpcode.FILE_MOVED:
|
|
180
|
+
src = self.data.get('src_path', 'Unknown source')
|
|
181
|
+
dst = self.data.get('dest_path', 'Unknown destination')
|
|
182
|
+
return f"Moved {src} -> {dst}"
|
|
183
|
+
|
|
184
|
+
# Directory processing
|
|
185
|
+
elif self.opcode == UpdateOpcode.DIRECTORY_PROCESSING_STARTED:
|
|
186
|
+
path = self.data.get('path', 'Unknown path')
|
|
187
|
+
return f"Processing directory: {path}"
|
|
188
|
+
|
|
189
|
+
elif self.opcode == UpdateOpcode.DIRECTORY_PROCESSING_COMPLETED:
|
|
190
|
+
path = self.data.get('path', 'Unknown path')
|
|
191
|
+
return f"Completed directory: {path}"
|
|
192
|
+
|
|
193
|
+
# Watch directory updates
|
|
194
|
+
elif self.opcode == UpdateOpcode.WATCH_ADDED:
|
|
195
|
+
return f"Added watch directory"
|
|
196
|
+
|
|
197
|
+
elif self.opcode == UpdateOpcode.WATCH_REMOVED:
|
|
198
|
+
return f"Removed watch directory"
|
|
199
|
+
|
|
200
|
+
elif self.opcode == UpdateOpcode.WATCH_STARTED:
|
|
201
|
+
return "Started watching for changes"
|
|
202
|
+
|
|
203
|
+
# General updates
|
|
204
|
+
elif self.opcode == UpdateOpcode.STATUS_UPDATE:
|
|
205
|
+
message = self.data.get('message', 'Status update')
|
|
206
|
+
return f"Status: {message}"
|
|
207
|
+
|
|
208
|
+
elif self.opcode == UpdateOpcode.ERROR:
|
|
209
|
+
message = self.data.get('message', 'Error occurred')
|
|
210
|
+
return f"Error: {message}"
|
|
211
|
+
|
|
212
|
+
elif self.opcode == UpdateOpcode.INFO:
|
|
213
|
+
message = self.data.get('message', 'Info')
|
|
214
|
+
return f"Info: {message}"
|
|
215
|
+
|
|
216
|
+
elif self.opcode == UpdateOpcode.SHUTTING_DOWN:
|
|
217
|
+
return "Server shutting down"
|
|
218
|
+
|
|
219
|
+
# Fallback for unknown opcodes
|
|
220
|
+
else:
|
|
221
|
+
return f"Unknown update: {self.opcode.value}"
|
|
222
|
+
|
|
223
|
+
def __str__(self) -> str:
|
|
224
|
+
"""Return a string representation of the update."""
|
|
225
|
+
return self.get_display_message()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Synchronous wrapper for the async Cosma client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import warnings
|
|
7
|
+
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Optional
|
|
8
|
+
|
|
9
|
+
from .client import Client
|
|
10
|
+
from .models import Update
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SyncClient:
|
|
14
|
+
"""
|
|
15
|
+
Synchronous wrapper around the async Client.
|
|
16
|
+
|
|
17
|
+
This class provides a synchronous API for the Cosma client,
|
|
18
|
+
making it easier to use in non-async contexts like CLI commands.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, base_url: str = "http://127.0.0.1:60534"):
|
|
22
|
+
self._base_url = base_url
|
|
23
|
+
|
|
24
|
+
def _run_with_client(
|
|
25
|
+
self,
|
|
26
|
+
async_method: Callable[[Client], Coroutine[Any, Any, Any]],
|
|
27
|
+
) -> Any:
|
|
28
|
+
"""Run an async method with a fresh client and proper cleanup."""
|
|
29
|
+
async def run_with_cleanup():
|
|
30
|
+
client = Client(self._base_url)
|
|
31
|
+
try:
|
|
32
|
+
return await async_method(client)
|
|
33
|
+
finally:
|
|
34
|
+
await client.close()
|
|
35
|
+
|
|
36
|
+
# Suppress the "Task was destroyed" warning for urllib3's idle connection watcher
|
|
37
|
+
with warnings.catch_warnings():
|
|
38
|
+
warnings.filterwarnings("ignore", message=".*Task was destroyed.*")
|
|
39
|
+
return asyncio.run(run_with_cleanup())
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def base_url(self) -> str:
|
|
43
|
+
return self._base_url
|
|
44
|
+
|
|
45
|
+
# =========================================================================
|
|
46
|
+
# Index API
|
|
47
|
+
# =========================================================================
|
|
48
|
+
|
|
49
|
+
def index_directory(self, directory_path: str) -> Dict[str, Any]:
|
|
50
|
+
"""Index all files in a directory for searching"""
|
|
51
|
+
return self._run_with_client(
|
|
52
|
+
lambda c: c.index_directory(directory_path)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def index_file(self, file_path: str) -> Dict[str, Any]:
|
|
56
|
+
"""Index a single file"""
|
|
57
|
+
return self._run_with_client(lambda c: c.index_file(file_path))
|
|
58
|
+
|
|
59
|
+
def index_status(self) -> Dict[str, Any]:
|
|
60
|
+
"""Get the current status of indexing operations"""
|
|
61
|
+
return self._run_with_client(lambda c: c.index_status())
|
|
62
|
+
|
|
63
|
+
# =========================================================================
|
|
64
|
+
# Status API
|
|
65
|
+
# =========================================================================
|
|
66
|
+
|
|
67
|
+
def status(self) -> Dict[str, Any]:
|
|
68
|
+
"""Get the current status of the backend"""
|
|
69
|
+
return self._run_with_client(lambda c: c.status())
|
|
70
|
+
|
|
71
|
+
# =========================================================================
|
|
72
|
+
# Search API
|
|
73
|
+
# =========================================================================
|
|
74
|
+
|
|
75
|
+
def search(
|
|
76
|
+
self,
|
|
77
|
+
query: str,
|
|
78
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
79
|
+
limit: int = 50
|
|
80
|
+
) -> Dict[str, Any]:
|
|
81
|
+
"""Search for files based on a query string"""
|
|
82
|
+
return self._run_with_client(lambda c: c.search(query, filters, limit))
|
|
83
|
+
|
|
84
|
+
def search_by_keywords(
|
|
85
|
+
self,
|
|
86
|
+
keywords: List[str],
|
|
87
|
+
match_all: bool = False
|
|
88
|
+
) -> Dict[str, Any]:
|
|
89
|
+
"""Search for files by specific keywords"""
|
|
90
|
+
return self._run_with_client(
|
|
91
|
+
lambda c: c.search_by_keywords(keywords, match_all)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def find_similar_files(self, file_id: int, limit: int = 10) -> Dict[str, Any]:
|
|
95
|
+
"""Find files similar to a given file"""
|
|
96
|
+
return self._run_with_client(
|
|
97
|
+
lambda c: c.find_similar_files(file_id, limit)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def autocomplete(self, q: str, limit: int = 10) -> Dict[str, Any]:
|
|
101
|
+
"""Get autocomplete suggestions for search queries"""
|
|
102
|
+
return self._run_with_client(lambda c: c.autocomplete(q, limit))
|
|
103
|
+
|
|
104
|
+
# =========================================================================
|
|
105
|
+
# Watch API
|
|
106
|
+
# =========================================================================
|
|
107
|
+
|
|
108
|
+
def watch_directory(self, directory_path: str) -> Dict[str, Any]:
|
|
109
|
+
"""Start watching a directory for changes"""
|
|
110
|
+
return self._run_with_client(
|
|
111
|
+
lambda c: c.watch_directory(directory_path)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def get_watch_jobs(self) -> Dict[str, Any]:
|
|
115
|
+
"""Get all watched directory jobs"""
|
|
116
|
+
return self._run_with_client(lambda c: c.get_watch_jobs())
|
|
117
|
+
|
|
118
|
+
def delete_watch_job(self, job_id: int) -> Dict[str, Any]:
|
|
119
|
+
"""Delete a watched directory job by ID"""
|
|
120
|
+
return self._run_with_client(lambda c: c.delete_watch_job(job_id))
|
|
121
|
+
|
|
122
|
+
# =========================================================================
|
|
123
|
+
# Files API
|
|
124
|
+
# =========================================================================
|
|
125
|
+
|
|
126
|
+
def get_file(self, file_id: int) -> Dict[str, Any]:
|
|
127
|
+
"""Get details of a specific file by ID"""
|
|
128
|
+
return self._run_with_client(lambda c: c.get_file(file_id))
|
|
129
|
+
|
|
130
|
+
def get_file_stats(self) -> Dict[str, Any]:
|
|
131
|
+
"""Get statistics about indexed files"""
|
|
132
|
+
return self._run_with_client(lambda c: c.get_file_stats())
|
|
133
|
+
|
|
134
|
+
# =========================================================================
|
|
135
|
+
# Filters API
|
|
136
|
+
# =========================================================================
|
|
137
|
+
|
|
138
|
+
def get_filter_config(self) -> Dict[str, Any]:
|
|
139
|
+
"""Get the current filter configuration"""
|
|
140
|
+
return self._run_with_client(lambda c: c.get_filter_config())
|
|
141
|
+
|
|
142
|
+
def update_filter_config(
|
|
143
|
+
self,
|
|
144
|
+
mode: Optional[str] = None,
|
|
145
|
+
exclude: Optional[List[str]] = None,
|
|
146
|
+
include: Optional[List[str]] = None,
|
|
147
|
+
apply_immediately: bool = True,
|
|
148
|
+
) -> Dict[str, Any]:
|
|
149
|
+
"""Update the filter configuration"""
|
|
150
|
+
return self._run_with_client(
|
|
151
|
+
lambda c: c.update_filter_config(
|
|
152
|
+
mode, exclude, include, apply_immediately
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def add_filter_pattern(
|
|
157
|
+
self,
|
|
158
|
+
pattern: str,
|
|
159
|
+
pattern_type: str = "exclude"
|
|
160
|
+
) -> Dict[str, Any]:
|
|
161
|
+
"""Add a pattern to the filter configuration"""
|
|
162
|
+
return self._run_with_client(
|
|
163
|
+
lambda c: c.add_filter_pattern(pattern, pattern_type)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def remove_filter_pattern(
|
|
167
|
+
self,
|
|
168
|
+
pattern: str,
|
|
169
|
+
pattern_type: str = "exclude"
|
|
170
|
+
) -> Dict[str, Any]:
|
|
171
|
+
"""Remove a pattern from the filter configuration"""
|
|
172
|
+
return self._run_with_client(
|
|
173
|
+
lambda c: c.remove_filter_pattern(pattern, pattern_type)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def test_filter_patterns(
|
|
177
|
+
self,
|
|
178
|
+
patterns: List[str],
|
|
179
|
+
file_paths: List[str],
|
|
180
|
+
mode: str = "blacklist",
|
|
181
|
+
) -> Dict[str, Any]:
|
|
182
|
+
"""Test pattern matching against file paths"""
|
|
183
|
+
return self._run_with_client(
|
|
184
|
+
lambda c: c.test_filter_patterns(patterns, file_paths, mode)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def get_filter_defaults(self) -> Dict[str, Any]:
|
|
188
|
+
"""Get the default filter configuration"""
|
|
189
|
+
return self._run_with_client(lambda c: c.get_filter_defaults())
|
|
190
|
+
|
|
191
|
+
def apply_filter_changes(self) -> Dict[str, Any]:
|
|
192
|
+
"""Apply current filter configuration to database"""
|
|
193
|
+
return self._run_with_client(lambda c: c.apply_filter_changes())
|
|
194
|
+
|
|
195
|
+
def reset_filters(self) -> Dict[str, Any]:
|
|
196
|
+
"""Reset filter configuration to defaults"""
|
|
197
|
+
return self._run_with_client(lambda c: c.reset_filters())
|
|
198
|
+
|
|
199
|
+
# =========================================================================
|
|
200
|
+
# Updates API
|
|
201
|
+
# =========================================================================
|
|
202
|
+
|
|
203
|
+
def stream_updates(self) -> Iterator[Update]:
|
|
204
|
+
"""
|
|
205
|
+
Stream updates from the server.
|
|
206
|
+
|
|
207
|
+
Note: This is a blocking iterator. For async usage, use the
|
|
208
|
+
async Client directly.
|
|
209
|
+
"""
|
|
210
|
+
async def stream_all():
|
|
211
|
+
client = Client(self._base_url)
|
|
212
|
+
try:
|
|
213
|
+
async for update in client.stream_updates():
|
|
214
|
+
yield update
|
|
215
|
+
finally:
|
|
216
|
+
await client.close()
|
|
217
|
+
|
|
218
|
+
# For streaming, we need a different approach - collect all at once
|
|
219
|
+
# or use a queue-based approach
|
|
220
|
+
async def get_updates():
|
|
221
|
+
updates = []
|
|
222
|
+
client = Client(self._base_url)
|
|
223
|
+
try:
|
|
224
|
+
async for update in client.stream_updates():
|
|
225
|
+
updates.append(update)
|
|
226
|
+
finally:
|
|
227
|
+
await client.close()
|
|
228
|
+
return updates
|
|
229
|
+
|
|
230
|
+
with warnings.catch_warnings():
|
|
231
|
+
warnings.filterwarnings("ignore", message=".*Task was destroyed.*")
|
|
232
|
+
updates = asyncio.run(get_updates())
|
|
233
|
+
|
|
234
|
+
for update in updates:
|
|
235
|
+
yield update
|