code-analysis-client 1.0.0__tar.gz → 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/PKG-INFO +13 -2
  2. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/README.md +11 -0
  3. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client/__init__.py +4 -0
  4. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client/client.py +6 -0
  5. code_analysis_client-1.0.2/code_analysis_client/file_session.py +385 -0
  6. code_analysis_client-1.0.2/code_analysis_client/version.txt +1 -0
  7. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client.egg-info/PKG-INFO +13 -2
  8. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client.egg-info/SOURCES.txt +1 -0
  9. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client.egg-info/requires.txt +1 -1
  10. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/pyproject.toml +1 -1
  11. code_analysis_client-1.0.0/code_analysis_client/version.txt +0 -1
  12. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client/commands_proxy.py +0 -0
  13. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client/config.py +0 -0
  14. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client/exceptions.py +0 -0
  15. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client/py.typed +0 -0
  16. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client/server_schema.py +0 -0
  17. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client/validation.py +0 -0
  18. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client.egg-info/dependency_links.txt +0 -0
  19. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/code_analysis_client.egg-info/top_level.txt +0 -0
  20. {code_analysis_client-1.0.0 → code_analysis_client-1.0.2}/setup.cfg +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-analysis-client
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Async JSON-RPC client for the code-analysis MCP server (mcp-proxy-adapter JsonRpcClient)
5
5
  Author-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
- Requires-Dist: mcp-proxy-adapter>=8.10.12
8
+ Requires-Dist: mcp-proxy-adapter>=8.10.13
9
9
  Provides-Extra: dev
10
10
  Requires-Dist: pytest>=8.0; extra == "dev"
11
11
  Requires-Dist: pytest-asyncio>=0.25.0; extra == "dev"
@@ -91,3 +91,14 @@ From the repository root:
91
91
  pip install -e ./client
92
92
  pytest tests/test_code_analysis_client.py
93
93
  ```
94
+
95
+ ### Releasing to PyPI (version = root ``code-analysis`` project)
96
+
97
+ The client wheel version is read from ``client/code_analysis_client/version.txt``.
98
+ That file must match ``[project].version`` in the **repository root**
99
+ ``pyproject.toml``. Sync before build:
100
+
101
+ ```bash
102
+ python scripts/sync_code_analysis_client_version.py
103
+ cd client && python -m build && twine check dist/* && twine upload dist/*
104
+ ```
@@ -79,3 +79,14 @@ From the repository root:
79
79
  pip install -e ./client
80
80
  pytest tests/test_code_analysis_client.py
81
81
  ```
82
+
83
+ ### Releasing to PyPI (version = root ``code-analysis`` project)
84
+
85
+ The client wheel version is read from ``client/code_analysis_client/version.txt``.
86
+ That file must match ``[project].version`` in the **repository root**
87
+ ``pyproject.toml``. Sync before build:
88
+
89
+ ```bash
90
+ python scripts/sync_code_analysis_client_version.py
91
+ cd client && python -m build && twine check dist/* && twine upload dist/*
92
+ ```
@@ -17,6 +17,7 @@ from code_analysis_client.config import (
17
17
  load_server_config,
18
18
  )
19
19
  from code_analysis_client.exceptions import ClientValidationError
20
+ from code_analysis_client.file_session import FileSessionClient, SessionNotFoundError
20
21
  from code_analysis_client.server_schema import (
21
22
  fetch_command_schema_from_server,
22
23
  parse_schema_from_help_payload,
@@ -29,6 +30,8 @@ from code_analysis_client.validation import (
29
30
  __all__ = [
30
31
  "ClientValidationError",
31
32
  "CodeAnalysisAsyncClient",
33
+ "FileSessionClient",
34
+ "SessionNotFoundError",
32
35
  "ValidatedCommandsProxy",
33
36
  "adapter_settings_from_server_config",
34
37
  "adapter_settings_to_jsonrpc_kwargs",
@@ -39,6 +42,7 @@ __all__ = [
39
42
  "validate_params_against_schema",
40
43
  ]
41
44
 
45
+
42
46
  def _read_package_version() -> str:
43
47
  vf = Path(__file__).resolve().parent / "version.txt"
44
48
  if vf.is_file():
@@ -21,6 +21,7 @@ from code_analysis_client.config import (
21
21
  adapter_settings_to_jsonrpc_kwargs,
22
22
  load_server_config,
23
23
  )
24
+ from code_analysis_client.file_session import FileSessionClient
24
25
  from code_analysis_client.server_schema import fetch_command_schema_from_server
25
26
  from code_analysis_client.validation import (
26
27
  prepare_params_for_schema,
@@ -119,6 +120,11 @@ class CodeAnalysisAsyncClient:
119
120
  """Drop cached command schemas (after ``reload`` or when server definitions change)."""
120
121
  self._command_schema_cache.clear()
121
122
 
123
+ @property
124
+ def file_sessions(self) -> FileSessionClient:
125
+ """Session-scoped download/upload and lock workflow (no CST/universal edit details)."""
126
+ return FileSessionClient(self)
127
+
122
128
  async def get_command_schema(
123
129
  self, command: str, *, refresh: bool = False
124
130
  ) -> Dict[str, Any]:
@@ -0,0 +1,385 @@
1
+ """
2
+ High-level file transfer and session workflow on top of CodeAnalysisAsyncClient.
3
+
4
+ Hides transfer/lock command names behind a small API: create a client session,
5
+ download a project file with an advisory lock, upload changes with unlock, and
6
+ lock/unlock indexed files without moving bytes.
7
+
8
+ Author: Vasiliy Zdanovskiy
9
+ email: vasilyvz@gmail.com
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
16
+
17
+ from code_analysis_client.exceptions import ClientValidationError
18
+
19
+ if TYPE_CHECKING:
20
+ from code_analysis_client.client import CodeAnalysisAsyncClient
21
+
22
+
23
+ class SessionNotFoundError(ClientValidationError):
24
+ """Raised when session_id is absent from client_sessions."""
25
+
26
+
27
+ def _unwrap(data: Dict[str, Any]) -> Dict[str, Any]:
28
+ if data.get("success"):
29
+ inner = data.get("data")
30
+ return inner if isinstance(inner, dict) else data
31
+ code_raw = data.get("code")
32
+ message = data.get("message")
33
+ err = data.get("error")
34
+ if code_raw is None and isinstance(err, dict):
35
+ code_raw = err.get("code")
36
+ if message is None:
37
+ message = err.get("message")
38
+ elif code_raw is None and isinstance(err, str):
39
+ code_raw = err
40
+ code = str(code_raw or "COMMAND_FAILED")
41
+ if message is None:
42
+ message = str(data)
43
+ if code == "SESSION_NOT_FOUND":
44
+ raise SessionNotFoundError(message, field="session_id", details=data)
45
+ raise ClientValidationError(message, field="command", details=data)
46
+
47
+
48
+ class FileSessionClient:
49
+ """Session-scoped file download/upload and lock helpers."""
50
+
51
+ __slots__ = ("_client",)
52
+
53
+ def __init__(self, client: CodeAnalysisAsyncClient) -> None:
54
+ self._client = client
55
+
56
+ async def create_session(
57
+ self,
58
+ comment: str,
59
+ *,
60
+ role_ids: Optional[List[str]] = None,
61
+ ) -> str:
62
+ """Create a client session (``session_create``) and return ``session_id``."""
63
+ params: Dict[str, Any] = {"comment": comment}
64
+ if role_ids is not None:
65
+ params["role_ids"] = role_ids
66
+ payload = _unwrap(await self._client.call_validated("session_create", params))
67
+ sid = str(payload.get("session_id") or "").strip()
68
+ if not sid:
69
+ raise ClientValidationError(
70
+ "session_create returned no session_id",
71
+ field="session_id",
72
+ details=payload,
73
+ )
74
+ return sid
75
+
76
+ async def assert_session_exists(self, session_id: str) -> None:
77
+ """Verify ``session_id`` is registered (touch via ``session_list_file_locks``)."""
78
+ sid = str(session_id).strip()
79
+ if not sid:
80
+ raise ClientValidationError("session_id is required", field="session_id")
81
+ _unwrap(
82
+ await self._client.call_validated(
83
+ "session_list_file_locks",
84
+ {"session_id": sid},
85
+ )
86
+ )
87
+
88
+ async def delete_session(
89
+ self, session_id: str, *, force: bool = False
90
+ ) -> Dict[str, Any]:
91
+ """Delete a client session (``session_delete``)."""
92
+ return _unwrap(
93
+ await self._client.call_validated(
94
+ "session_delete",
95
+ {"session_id": session_id, "force": force},
96
+ )
97
+ )
98
+
99
+ async def list_sessions(
100
+ self,
101
+ *,
102
+ session_id: Optional[str] = None,
103
+ stale_threshold_seconds: Optional[int] = None,
104
+ ) -> Dict[str, Any]:
105
+ """List client sessions (``session_list``)."""
106
+ params: Dict[str, Any] = {}
107
+ if session_id is not None:
108
+ params["session_id"] = session_id
109
+ if stale_threshold_seconds is not None:
110
+ params["stale_threshold_seconds"] = stale_threshold_seconds
111
+ return _unwrap(await self._client.call_validated("session_list", params))
112
+
113
+ async def lock_file(
114
+ self,
115
+ session_id: str,
116
+ project_id: str,
117
+ file_id: str,
118
+ ) -> Dict[str, Any]:
119
+ """Acquire a DB file lock without transfer (``session_open_file``)."""
120
+ await self.assert_session_exists(session_id)
121
+ return _unwrap(
122
+ await self._client.call_validated(
123
+ "session_open_file",
124
+ {
125
+ "session_id": session_id,
126
+ "project_id": project_id,
127
+ "file_id": file_id,
128
+ },
129
+ )
130
+ )
131
+
132
+ async def unlock_file(
133
+ self,
134
+ session_id: str,
135
+ project_id: str,
136
+ file_id: str,
137
+ ) -> Dict[str, Any]:
138
+ """Release a DB file lock without transfer (``session_close_file``)."""
139
+ await self.assert_session_exists(session_id)
140
+ return _unwrap(
141
+ await self._client.call_validated(
142
+ "session_close_file",
143
+ {
144
+ "session_id": session_id,
145
+ "project_id": project_id,
146
+ "file_id": file_id,
147
+ },
148
+ )
149
+ )
150
+
151
+ async def list_file_locks(self, session_id: str) -> Dict[str, Any]:
152
+ """Return locks held by the session (``session_list_file_locks``)."""
153
+ return _unwrap(
154
+ await self._client.call_validated(
155
+ "session_list_file_locks",
156
+ {"session_id": session_id},
157
+ )
158
+ )
159
+
160
+ async def lock_files_advisory(
161
+ self,
162
+ session_id: str,
163
+ project_id: str,
164
+ file_paths: List[str],
165
+ *,
166
+ lock_mode: str = "full",
167
+ timeout_seconds: Optional[float] = None,
168
+ ) -> Dict[str, Any]:
169
+ """Cooperative flock + DB lease for paths (``project_file_advisory_lock_batch``)."""
170
+ await self.assert_session_exists(session_id)
171
+ items = [
172
+ {
173
+ "session_id": session_id,
174
+ "project_id": project_id,
175
+ "file_path": path,
176
+ "action": "lock",
177
+ "lock_mode": lock_mode,
178
+ }
179
+ for path in file_paths
180
+ ]
181
+ params: Dict[str, Any] = {
182
+ "items": items,
183
+ "allow_foreign_session": True,
184
+ }
185
+ if timeout_seconds is not None:
186
+ params["timeout_seconds"] = timeout_seconds
187
+ return _unwrap(
188
+ await self._client.call_validated(
189
+ "project_file_advisory_lock_batch",
190
+ params,
191
+ )
192
+ )
193
+
194
+ async def unlock_files_advisory(
195
+ self,
196
+ session_id: str,
197
+ project_id: str,
198
+ file_paths: List[str],
199
+ ) -> Dict[str, Any]:
200
+ """Release cooperative locks without transfer."""
201
+ await self.assert_session_exists(session_id)
202
+ items = [
203
+ {
204
+ "session_id": session_id,
205
+ "project_id": project_id,
206
+ "file_path": path,
207
+ "action": "unlock",
208
+ }
209
+ for path in file_paths
210
+ ]
211
+ return _unwrap(
212
+ await self._client.call_validated(
213
+ "project_file_advisory_lock_batch",
214
+ {
215
+ "items": items,
216
+ "allow_foreign_session": True,
217
+ },
218
+ )
219
+ )
220
+
221
+ async def begin_download_locked(
222
+ self,
223
+ session_id: str,
224
+ *,
225
+ project_id: Optional[str] = None,
226
+ file_id: Optional[str] = None,
227
+ file_path: Optional[str] = None,
228
+ compression: str = "identity",
229
+ lock_mode: str = "full",
230
+ chunk_size: Optional[int] = None,
231
+ include_backup_history: bool = False,
232
+ ) -> Dict[str, Any]:
233
+ """Start a chunked download and acquire an advisory lock (``project_file_transfer_download_begin``)."""
234
+ await self.assert_session_exists(session_id)
235
+ params: Dict[str, Any] = {
236
+ "session_id": session_id,
237
+ "compression": compression,
238
+ "lock_mode": lock_mode,
239
+ "include_backup_history": include_backup_history,
240
+ }
241
+ if project_id is not None:
242
+ params["project_id"] = project_id
243
+ if file_id is not None:
244
+ params["file_id"] = file_id
245
+ if file_path is not None:
246
+ params["file_path"] = file_path
247
+ if chunk_size is not None:
248
+ params["chunk_size"] = chunk_size
249
+ return _unwrap(
250
+ await self._client.call_validated(
251
+ "project_file_transfer_download_begin",
252
+ params,
253
+ )
254
+ )
255
+
256
+ async def download_to_path(
257
+ self,
258
+ transfer_id: str,
259
+ destination: Union[str, Path],
260
+ ) -> Any:
261
+ """Stream download chunks to ``destination`` via the adapter client."""
262
+ return await self._client.rpc.download_file(
263
+ str(transfer_id).strip(),
264
+ str(destination),
265
+ )
266
+
267
+ async def download_file_locked(
268
+ self,
269
+ session_id: str,
270
+ destination: Union[str, Path],
271
+ *,
272
+ project_id: Optional[str] = None,
273
+ file_id: Optional[str] = None,
274
+ file_path: Optional[str] = None,
275
+ compression: str = "identity",
276
+ lock_mode: str = "full",
277
+ ) -> Tuple[Dict[str, Any], Any]:
278
+ """Download + lock in one workflow; returns (begin_payload, download_receipt)."""
279
+ begin = await self.begin_download_locked(
280
+ session_id,
281
+ project_id=project_id,
282
+ file_id=file_id,
283
+ file_path=file_path,
284
+ compression=compression,
285
+ lock_mode=lock_mode,
286
+ )
287
+ transfer_id = str(begin.get("transfer_id") or "").strip()
288
+ if not transfer_id:
289
+ raise ClientValidationError(
290
+ "download begin returned no transfer_id",
291
+ field="transfer_id",
292
+ details=begin,
293
+ )
294
+ receipt = await self.download_to_path(transfer_id, destination)
295
+ return begin, receipt
296
+
297
+ async def upload_bytes(
298
+ self,
299
+ payload: bytes,
300
+ *,
301
+ filename: str,
302
+ compression: str = "identity",
303
+ ) -> Any:
304
+ """Upload raw bytes through the adapter buffer; returns upload receipt."""
305
+ dest = Path(filename)
306
+ dest.write_bytes(payload)
307
+ try:
308
+ return await self._client.rpc.upload_file(
309
+ str(dest),
310
+ filename=filename,
311
+ compression=compression,
312
+ )
313
+ finally:
314
+ if dest.is_file():
315
+ dest.unlink(missing_ok=True)
316
+
317
+ async def save_upload_and_unlock(
318
+ self,
319
+ session_id: str,
320
+ transfer_id: str,
321
+ *,
322
+ project_id: Optional[str] = None,
323
+ file_id: Optional[str] = None,
324
+ file_path: Optional[str] = None,
325
+ unlock_after_write: bool = True,
326
+ dry_run: bool = False,
327
+ backup: bool = True,
328
+ ) -> Dict[str, Any]:
329
+ """Persist uploaded buffer and release locks (``project_file_transfer_upload_save``)."""
330
+ await self.assert_session_exists(session_id)
331
+ params: Dict[str, Any] = {
332
+ "session_id": session_id,
333
+ "transfer_id": transfer_id,
334
+ "unlock_after_write": unlock_after_write,
335
+ "dry_run": dry_run,
336
+ "backup": backup,
337
+ }
338
+ if project_id is not None:
339
+ params["project_id"] = project_id
340
+ if file_id is not None:
341
+ params["file_id"] = file_id
342
+ if file_path is not None:
343
+ params["file_path"] = file_path
344
+ return _unwrap(
345
+ await self._client.call_validated(
346
+ "project_file_transfer_upload_save",
347
+ params,
348
+ )
349
+ )
350
+
351
+ async def upload_file_and_unlock(
352
+ self,
353
+ session_id: str,
354
+ payload: bytes,
355
+ *,
356
+ project_id: Optional[str] = None,
357
+ file_id: Optional[str] = None,
358
+ file_path: Optional[str] = None,
359
+ filename: Optional[str] = None,
360
+ compression: str = "identity",
361
+ unlock_after_write: bool = True,
362
+ backup: bool = True,
363
+ ) -> Dict[str, Any]:
364
+ """Upload bytes then save to the project file and unlock."""
365
+ name = filename or (Path(file_path).name if file_path else "payload.bin")
366
+ receipt = await self.upload_bytes(
367
+ payload,
368
+ filename=name,
369
+ compression=compression,
370
+ )
371
+ if not getattr(receipt, "completed", False):
372
+ raise ClientValidationError(
373
+ "upload did not complete",
374
+ field="transfer_id",
375
+ details={"receipt": repr(receipt)},
376
+ )
377
+ return await self.save_upload_and_unlock(
378
+ session_id,
379
+ str(receipt.transfer_id),
380
+ project_id=project_id,
381
+ file_id=file_id,
382
+ file_path=file_path,
383
+ unlock_after_write=unlock_after_write,
384
+ backup=backup,
385
+ )
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-analysis-client
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Async JSON-RPC client for the code-analysis MCP server (mcp-proxy-adapter JsonRpcClient)
5
5
  Author-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
- Requires-Dist: mcp-proxy-adapter>=8.10.12
8
+ Requires-Dist: mcp-proxy-adapter>=8.10.13
9
9
  Provides-Extra: dev
10
10
  Requires-Dist: pytest>=8.0; extra == "dev"
11
11
  Requires-Dist: pytest-asyncio>=0.25.0; extra == "dev"
@@ -91,3 +91,14 @@ From the repository root:
91
91
  pip install -e ./client
92
92
  pytest tests/test_code_analysis_client.py
93
93
  ```
94
+
95
+ ### Releasing to PyPI (version = root ``code-analysis`` project)
96
+
97
+ The client wheel version is read from ``client/code_analysis_client/version.txt``.
98
+ That file must match ``[project].version`` in the **repository root**
99
+ ``pyproject.toml``. Sync before build:
100
+
101
+ ```bash
102
+ python scripts/sync_code_analysis_client_version.py
103
+ cd client && python -m build && twine check dist/* && twine upload dist/*
104
+ ```
@@ -5,6 +5,7 @@ code_analysis_client/client.py
5
5
  code_analysis_client/commands_proxy.py
6
6
  code_analysis_client/config.py
7
7
  code_analysis_client/exceptions.py
8
+ code_analysis_client/file_session.py
8
9
  code_analysis_client/py.typed
9
10
  code_analysis_client/server_schema.py
10
11
  code_analysis_client/validation.py
@@ -1,4 +1,4 @@
1
- mcp-proxy-adapter>=8.10.12
1
+ mcp-proxy-adapter>=8.10.13
2
2
 
3
3
  [dev]
4
4
  pytest>=8.0
@@ -12,7 +12,7 @@ authors = [
12
12
  { name = "Vasiliy Zdanovskiy", email = "vasilyvz@gmail.com" },
13
13
  ]
14
14
  dependencies = [
15
- "mcp-proxy-adapter>=8.10.12",
15
+ "mcp-proxy-adapter>=8.10.13",
16
16
  ]
17
17
 
18
18
  [project.optional-dependencies]