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.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.3
2
+ Name: cosma-client
3
+ Version: 0.0.1.dev1
4
+ Summary: Cosma API client library
5
+ Requires-Dist: niquests>=3.15.2
6
+ Requires-Python: >=3.12
@@ -0,0 +1,10 @@
1
+ [project]
2
+ name = "cosma-client"
3
+ version = "0.0.1.dev1"
4
+ description = "Cosma API client library"
5
+ requires-python = ">=3.12"
6
+ dependencies = ["niquests>=3.15.2"]
7
+
8
+ [build-system]
9
+ requires = ["uv_build>=0.9.8,<0.10.0"]
10
+ build-backend = "uv_build"
@@ -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