code-analysis-client 1.0.5__tar.gz → 1.0.6__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 (23) hide show
  1. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/PKG-INFO +25 -11
  2. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/README.md +24 -10
  3. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/__init__.py +4 -0
  4. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/client.py +1 -1
  5. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/file_session.py +267 -58
  6. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/server_api.py +42 -1
  7. code_analysis_client-1.0.6/code_analysis_client/version.txt +1 -0
  8. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/PKG-INFO +25 -11
  9. code_analysis_client-1.0.5/code_analysis_client/version.txt +0 -1
  10. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/commands_proxy.py +0 -0
  11. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/config.py +0 -0
  12. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/exceptions.py +0 -0
  13. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/py.typed +0 -0
  14. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/responses.py +0 -0
  15. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/server_schema.py +0 -0
  16. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/universal_file.py +0 -0
  17. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client/validation.py +0 -0
  18. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/SOURCES.txt +0 -0
  19. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/dependency_links.txt +0 -0
  20. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/requires.txt +0 -0
  21. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/top_level.txt +0 -0
  22. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/pyproject.toml +0 -0
  23. {code_analysis_client-1.0.5 → code_analysis_client-1.0.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-analysis-client
3
- Version: 1.0.5
3
+ Version: 1.0.6
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
@@ -75,7 +75,7 @@ async with CodeAnalysisAsyncClient(host="127.0.0.1", port=15001) as client:
75
75
 
76
76
  Use `call_unified_validated` when you need queue polling. Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
77
77
 
78
- ## High-level facades (aligned with server 1.0.3)
78
+ ## High-level facades (aligned with live server registry)
79
79
 
80
80
  The client does **not** wrap CST commands (`cst_load_file`, …) or legacy file I/O
81
81
  (`universal_file_read`, `read_project_text_file`, …). Those commands are removed
@@ -83,28 +83,42 @@ from the server registry. Use the facades below or generic `call` / `commands.*`
83
83
 
84
84
  | Facade | Property | Server commands |
85
85
  |--------|----------|-----------------|
86
- | Client DB sessions + transfer | `client.file_sessions` | `session_*`, `project_file_transfer_*`, `project_file_advisory_lock_batch` |
86
+ | Client DB sessions + transfer | `client.file_sessions` | `session_*`, `subordinate_session_*`, `project_file_transfer_*`, `project_file_advisory_lock_batch` |
87
87
  | Universal edit sessions | `client.universal_files` | `universal_file_open`, `edit`, `write`, `close`, `preview` |
88
88
  | Any registered command | `client.call` / `client.commands.<name>` | schema from live `help()` |
89
89
 
90
- Canonical command lists: `code_analysis_client.server_api` (also exported as
91
- `REMOVED_COMMANDS`, `CLIENT_FACADE_COMMANDS`).
90
+ Canonical command lists: `code_analysis_client.server_api` exported as
91
+ `FILE_SESSION_COMMANDS`, `FILE_SESSION_FACADE_METHODS`, `CLIENT_FACADE_COMMANDS`,
92
+ `REMOVED_COMMANDS`.
92
93
 
93
- Sync check (in-process registry):
94
+ Sync checks (in-process registry):
94
95
 
95
96
  ```bash
96
- pytest tests/test_client_server_api_sync.py
97
+ pytest tests/test_client_server_api_sync.py tests/test_code_analysis_client.py -k session
97
98
  ```
98
99
 
99
- Package version matches the **code-analysis** server project (currently **1.0.4**).
100
+ Package version is in ``client/code_analysis_client/version.txt`` (synced with the
101
+ root ``code-analysis`` project via ``scripts/sync_code_analysis_client_version.py``).
100
102
 
101
103
  ## Examples (this repository)
102
104
 
103
105
  Runnable scripts live under `client/examples/`. **Long-form “man page” style
104
106
  documentation** is embedded in the **module docstrings** of those Python files
105
- (see `client/examples/README.md` for how to read them). Full API walkthrough:
106
- `python client/examples/run_all_examples.py` with the daemon up and
107
- `CODE_ANALYSIS_CONFIG` or default `config.json` at the repo root.
107
+ (see `client/examples/README.md` for how to read them).
108
+
109
+ | Script | Purpose |
110
+ |--------|---------|
111
+ | `run_all_examples.py` | Full API tour + runs all live sibling scripts |
112
+ | `ex_minimal_validated.py` | Smallest validated RPC example |
113
+ | `ex_universal_files.py` | All `UniversalFileClient` methods (open/edit/write/close/preview) |
114
+ | `ex_session_view_subordinates.py` | `session_view` and subordinate CRUD |
115
+ | `ex_file_sessions.py` | Sessions, locks, transfer roundtrip |
116
+ | `ex_config_only.py` | Parse `config.json` without TCP |
117
+
118
+ ```bash
119
+ casmgr --config config.json start
120
+ python client/examples/run_all_examples.py
121
+ ```
108
122
 
109
123
  ## Development
110
124
 
@@ -63,7 +63,7 @@ async with CodeAnalysisAsyncClient(host="127.0.0.1", port=15001) as client:
63
63
 
64
64
  Use `call_unified_validated` when you need queue polling. Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
65
65
 
66
- ## High-level facades (aligned with server 1.0.3)
66
+ ## High-level facades (aligned with live server registry)
67
67
 
68
68
  The client does **not** wrap CST commands (`cst_load_file`, …) or legacy file I/O
69
69
  (`universal_file_read`, `read_project_text_file`, …). Those commands are removed
@@ -71,28 +71,42 @@ from the server registry. Use the facades below or generic `call` / `commands.*`
71
71
 
72
72
  | Facade | Property | Server commands |
73
73
  |--------|----------|-----------------|
74
- | Client DB sessions + transfer | `client.file_sessions` | `session_*`, `project_file_transfer_*`, `project_file_advisory_lock_batch` |
74
+ | Client DB sessions + transfer | `client.file_sessions` | `session_*`, `subordinate_session_*`, `project_file_transfer_*`, `project_file_advisory_lock_batch` |
75
75
  | Universal edit sessions | `client.universal_files` | `universal_file_open`, `edit`, `write`, `close`, `preview` |
76
76
  | Any registered command | `client.call` / `client.commands.<name>` | schema from live `help()` |
77
77
 
78
- Canonical command lists: `code_analysis_client.server_api` (also exported as
79
- `REMOVED_COMMANDS`, `CLIENT_FACADE_COMMANDS`).
78
+ Canonical command lists: `code_analysis_client.server_api` exported as
79
+ `FILE_SESSION_COMMANDS`, `FILE_SESSION_FACADE_METHODS`, `CLIENT_FACADE_COMMANDS`,
80
+ `REMOVED_COMMANDS`.
80
81
 
81
- Sync check (in-process registry):
82
+ Sync checks (in-process registry):
82
83
 
83
84
  ```bash
84
- pytest tests/test_client_server_api_sync.py
85
+ pytest tests/test_client_server_api_sync.py tests/test_code_analysis_client.py -k session
85
86
  ```
86
87
 
87
- Package version matches the **code-analysis** server project (currently **1.0.4**).
88
+ Package version is in ``client/code_analysis_client/version.txt`` (synced with the
89
+ root ``code-analysis`` project via ``scripts/sync_code_analysis_client_version.py``).
88
90
 
89
91
  ## Examples (this repository)
90
92
 
91
93
  Runnable scripts live under `client/examples/`. **Long-form “man page” style
92
94
  documentation** is embedded in the **module docstrings** of those Python files
93
- (see `client/examples/README.md` for how to read them). Full API walkthrough:
94
- `python client/examples/run_all_examples.py` with the daemon up and
95
- `CODE_ANALYSIS_CONFIG` or default `config.json` at the repo root.
95
+ (see `client/examples/README.md` for how to read them).
96
+
97
+ | Script | Purpose |
98
+ |--------|---------|
99
+ | `run_all_examples.py` | Full API tour + runs all live sibling scripts |
100
+ | `ex_minimal_validated.py` | Smallest validated RPC example |
101
+ | `ex_universal_files.py` | All `UniversalFileClient` methods (open/edit/write/close/preview) |
102
+ | `ex_session_view_subordinates.py` | `session_view` and subordinate CRUD |
103
+ | `ex_file_sessions.py` | Sessions, locks, transfer roundtrip |
104
+ | `ex_config_only.py` | Parse `config.json` without TCP |
105
+
106
+ ```bash
107
+ casmgr --config config.json start
108
+ python client/examples/run_all_examples.py
109
+ ```
96
110
 
97
111
  ## Development
98
112
 
@@ -21,6 +21,8 @@ from code_analysis_client.file_session import FileSessionClient, SessionNotFound
21
21
  from code_analysis_client.server_api import (
22
22
  CLIENT_FACADE_COMMANDS,
23
23
  CST_REMOVED_COMMANDS,
24
+ FILE_SESSION_COMMANDS,
25
+ FILE_SESSION_FACADE_METHODS,
24
26
  LEGACY_REMOVED_COMMANDS,
25
27
  REMOVED_COMMANDS,
26
28
  UNIVERSAL_FILE_COMMANDS,
@@ -40,6 +42,8 @@ __all__ = [
40
42
  "CST_REMOVED_COMMANDS",
41
43
  "ClientValidationError",
42
44
  "CodeAnalysisAsyncClient",
45
+ "FILE_SESSION_COMMANDS",
46
+ "FILE_SESSION_FACADE_METHODS",
43
47
  "FileSessionClient",
44
48
  "LEGACY_REMOVED_COMMANDS",
45
49
  "REMOVED_COMMANDS",
@@ -123,7 +123,7 @@ class CodeAnalysisAsyncClient:
123
123
 
124
124
  @property
125
125
  def file_sessions(self) -> FileSessionClient:
126
- """Session-scoped download/upload and lock workflow (``session_*``, transfer)."""
126
+ """Session workflow: ``session_*``, ``subordinate_session_*``, transfer, advisory locks."""
127
127
  return FileSessionClient(self)
128
128
 
129
129
  @property
@@ -1,9 +1,8 @@
1
1
  """
2
2
  High-level file transfer and session workflow on top of CodeAnalysisAsyncClient.
3
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.
4
+ Wraps ``session_*`` and ``subordinate_session_*`` MCP commands plus transfer and
5
+ advisory-lock commands that accept ``session_id``.
7
6
 
8
7
  Author: Vasiliy Zdanovskiy
9
8
  email: vasilyvz@gmail.com
@@ -32,6 +31,24 @@ def _unwrap(data: Dict[str, Any]) -> Dict[str, Any]:
32
31
  )
33
32
 
34
33
 
34
+ def _require_non_empty(value: str, *, field: str) -> str:
35
+ text = str(value or "").strip()
36
+ if not text:
37
+ raise ClientValidationError(f"{field} is required", field=field)
38
+ return text
39
+
40
+
41
+ def _validate_download_target(*, file_id: Optional[str]) -> None:
42
+ """Download requires ``file_id``."""
43
+ fid = str(file_id or "").strip()
44
+ if not fid:
45
+ raise ClientValidationError("file_id is required", field="file_id")
46
+
47
+
48
+ def _lock_mode_from_flag(lock: bool) -> str:
49
+ return "full" if lock else "none"
50
+
51
+
35
52
  def _extract_file_id(payload: Dict[str, Any], *, field: str = "file_id") -> str:
36
53
  """Return non-empty ``file_id`` from a command payload or raise."""
37
54
  fid = str(payload.get(field) or "").strip()
@@ -45,7 +62,7 @@ def _extract_file_id(payload: Dict[str, Any], *, field: str = "file_id") -> str:
45
62
 
46
63
 
47
64
  class FileSessionClient:
48
- """Session-scoped file download/upload and lock helpers."""
65
+ """Session-scoped DB sessions, subordinate links, file locks, and transfer helpers."""
49
66
 
50
67
  __slots__ = ("_client",)
51
68
 
@@ -87,14 +104,163 @@ class FileSessionClient:
87
104
  async def delete_session(
88
105
  self, session_id: str, *, force: bool = False
89
106
  ) -> Dict[str, Any]:
90
- """Delete a client session (``session_delete``)."""
107
+ """Delete a client session (``session_delete``).
108
+
109
+ ``force`` defaults to false (omit on the wire when false). Safe delete
110
+ requires no ``session_file_locks`` and no ``subordinate_sessions`` rows
111
+ where this session is ``parent_session_id``. When ``force`` is true,
112
+ linked subordinate client sessions and file locks are removed first.
113
+
114
+ Returns ``session_id``, ``deleted``, ``released_lock_count``, and
115
+ ``released_subordinate_count``.
116
+ """
117
+ params: Dict[str, Any] = {"session_id": session_id}
118
+ if force:
119
+ params["force"] = True
91
120
  return _unwrap(
92
121
  await self._client.call_validated(
93
122
  "session_delete",
94
- {"session_id": session_id, "force": force},
123
+ params,
124
+ )
125
+ )
126
+
127
+ async def view_session(self, session_id: str) -> Dict[str, Any]:
128
+ """Aggregated session view (``session_view``).
129
+
130
+ Returns ``locked_files_by_project`` (file_id, file_path,
131
+ project_presentation per project) and ``subordinate_sessions``
132
+ (subordinate_session_id, server_uuid, session_presentation,
133
+ server_presentation, link_comment).
134
+ """
135
+ sid = _require_non_empty(session_id, field="session_id")
136
+ return _unwrap(
137
+ await self._client.call_validated(
138
+ "session_view",
139
+ {"session_id": sid},
140
+ )
141
+ )
142
+
143
+ async def create_subordinate_session(
144
+ self,
145
+ parent_session_id: str,
146
+ subordinate_session_id: str,
147
+ comment: str,
148
+ *,
149
+ server_uuid: Optional[str] = None,
150
+ ) -> Dict[str, Any]:
151
+ """Link subordinate to parent (``subordinate_session_create``).
152
+
153
+ ``server_uuid`` defaults to the server's ``registration.instance_uuid``
154
+ when omitted.
155
+ """
156
+ params: Dict[str, Any] = {
157
+ "parent_session_id": _require_non_empty(
158
+ parent_session_id, field="parent_session_id"
159
+ ),
160
+ "subordinate_session_id": _require_non_empty(
161
+ subordinate_session_id, field="subordinate_session_id"
162
+ ),
163
+ "comment": comment,
164
+ }
165
+ if server_uuid is not None:
166
+ params["server_uuid"] = _require_non_empty(server_uuid, field="server_uuid")
167
+ return _unwrap(
168
+ await self._client.call_validated("subordinate_session_create", params)
169
+ )
170
+
171
+ async def get_subordinate_session(
172
+ self,
173
+ parent_session_id: str,
174
+ subordinate_session_id: str,
175
+ server_uuid: str,
176
+ ) -> Dict[str, Any]:
177
+ """Fetch one subordinate link (``subordinate_session_get``)."""
178
+ return _unwrap(
179
+ await self._client.call_validated(
180
+ "subordinate_session_get",
181
+ {
182
+ "parent_session_id": _require_non_empty(
183
+ parent_session_id, field="parent_session_id"
184
+ ),
185
+ "subordinate_session_id": _require_non_empty(
186
+ subordinate_session_id, field="subordinate_session_id"
187
+ ),
188
+ "server_uuid": _require_non_empty(server_uuid, field="server_uuid"),
189
+ },
95
190
  )
96
191
  )
97
192
 
193
+ async def update_subordinate_session(
194
+ self,
195
+ parent_session_id: str,
196
+ subordinate_session_id: str,
197
+ server_uuid: str,
198
+ comment: str,
199
+ ) -> Dict[str, Any]:
200
+ """Update link comment (``subordinate_session_update``)."""
201
+ return _unwrap(
202
+ await self._client.call_validated(
203
+ "subordinate_session_update",
204
+ {
205
+ "parent_session_id": _require_non_empty(
206
+ parent_session_id, field="parent_session_id"
207
+ ),
208
+ "subordinate_session_id": _require_non_empty(
209
+ subordinate_session_id, field="subordinate_session_id"
210
+ ),
211
+ "server_uuid": _require_non_empty(server_uuid, field="server_uuid"),
212
+ "comment": comment,
213
+ },
214
+ )
215
+ )
216
+
217
+ async def delete_subordinate_session(
218
+ self,
219
+ parent_session_id: str,
220
+ subordinate_session_id: str,
221
+ server_uuid: str,
222
+ ) -> Dict[str, Any]:
223
+ """Remove subordinate link only (``subordinate_session_delete``).
224
+
225
+ Does not delete ``client_sessions`` rows; use :meth:`delete_session` for that.
226
+ """
227
+ return _unwrap(
228
+ await self._client.call_validated(
229
+ "subordinate_session_delete",
230
+ {
231
+ "parent_session_id": _require_non_empty(
232
+ parent_session_id, field="parent_session_id"
233
+ ),
234
+ "subordinate_session_id": _require_non_empty(
235
+ subordinate_session_id, field="subordinate_session_id"
236
+ ),
237
+ "server_uuid": _require_non_empty(server_uuid, field="server_uuid"),
238
+ },
239
+ )
240
+ )
241
+
242
+ async def list_subordinate_sessions(
243
+ self,
244
+ *,
245
+ parent_session_id: Optional[str] = None,
246
+ subordinate_session_id: Optional[str] = None,
247
+ server_uuid: Optional[str] = None,
248
+ ) -> Dict[str, Any]:
249
+ """List subordinate links (``subordinate_session_list``).
250
+
251
+ Returns ``links`` and ``count``. All filter parameters are optional.
252
+ """
253
+ params: Dict[str, Any] = {}
254
+ if parent_session_id is not None:
255
+ params["parent_session_id"] = parent_session_id
256
+ if subordinate_session_id is not None:
257
+ params["subordinate_session_id"] = subordinate_session_id
258
+ if server_uuid is not None:
259
+ params["server_uuid"] = server_uuid
260
+ return _unwrap(
261
+ await self._client.call_validated("subordinate_session_list", params)
262
+ )
263
+
98
264
  async def list_sessions(
99
265
  self,
100
266
  *,
@@ -217,32 +383,27 @@ class FileSessionClient:
217
383
  )
218
384
  )
219
385
 
220
- async def begin_download_locked(
386
+ async def _begin_download(
221
387
  self,
222
388
  session_id: str,
223
389
  *,
224
- project_id: Optional[str] = None,
225
- file_id: Optional[str] = None,
226
- file_path: Optional[str] = None,
390
+ file_id: str,
227
391
  compression: str = "identity",
228
- lock_mode: str = "full",
392
+ lock: bool = True,
229
393
  chunk_size: Optional[int] = None,
230
394
  include_backup_history: bool = False,
231
395
  ) -> Dict[str, Any]:
232
396
  """Begin chunked download; returns payload including ``file_id`` and ``transfer_id``."""
397
+ fid = _require_non_empty(file_id, field="file_id")
398
+ _validate_download_target(file_id=fid)
233
399
  await self.assert_session_exists(session_id)
234
400
  params: Dict[str, Any] = {
235
401
  "session_id": session_id,
236
402
  "compression": compression,
237
- "lock_mode": lock_mode,
403
+ "lock_mode": _lock_mode_from_flag(lock),
238
404
  "include_backup_history": include_backup_history,
405
+ "file_id": fid,
239
406
  }
240
- if project_id is not None:
241
- params["project_id"] = project_id
242
- if file_id is not None:
243
- params["file_id"] = file_id
244
- if file_path is not None:
245
- params["file_path"] = file_path
246
407
  if chunk_size is not None:
247
408
  params["chunk_size"] = chunk_size
248
409
  payload = _unwrap(
@@ -254,36 +415,24 @@ class FileSessionClient:
254
415
  _extract_file_id(payload)
255
416
  return payload
256
417
 
257
- async def download_to_path(
258
- self,
259
- transfer_id: str,
260
- destination: Union[str, Path],
261
- ) -> Any:
262
- """Stream download chunks to ``destination`` via the adapter client."""
263
- return await self._client.rpc.download_file(
264
- str(transfer_id).strip(),
265
- str(destination),
266
- )
267
-
268
- async def download_file_locked(
418
+ async def download(
269
419
  self,
270
420
  session_id: str,
271
421
  destination: Union[str, Path],
422
+ file_id: str,
272
423
  *,
273
- project_id: Optional[str] = None,
274
- file_id: Optional[str] = None,
275
- file_path: Optional[str] = None,
276
424
  compression: str = "identity",
277
- lock_mode: str = "full",
425
+ lock: bool = True,
426
+ include_backup_history: bool = False,
278
427
  ) -> Tuple[Dict[str, Any], Any]:
279
- """Download + lock; returns (begin_payload with ``file_id``, download_receipt)."""
280
- begin = await self.begin_download_locked(
428
+ """Download an indexed file by ``file_id``; returns (begin_payload, receipt)."""
429
+ fid = _require_non_empty(file_id, field="file_id")
430
+ begin = await self._begin_download(
281
431
  session_id,
282
- project_id=project_id,
283
- file_id=file_id,
284
- file_path=file_path,
432
+ file_id=fid,
285
433
  compression=compression,
286
- lock_mode=lock_mode,
434
+ lock=lock,
435
+ include_backup_history=include_backup_history,
287
436
  )
288
437
  transfer_id = str(begin.get("transfer_id") or "").strip()
289
438
  if not transfer_id:
@@ -295,6 +444,17 @@ class FileSessionClient:
295
444
  receipt = await self.download_to_path(transfer_id, destination)
296
445
  return begin, receipt
297
446
 
447
+ async def download_to_path(
448
+ self,
449
+ transfer_id: str,
450
+ destination: Union[str, Path],
451
+ ) -> Any:
452
+ """Stream download chunks to ``destination`` via the adapter client."""
453
+ return await self._client.rpc.download_file(
454
+ str(transfer_id).strip(),
455
+ str(destination),
456
+ )
457
+
298
458
  async def upload_bytes(
299
459
  self,
300
460
  payload: bytes,
@@ -315,7 +475,7 @@ class FileSessionClient:
315
475
  if dest.is_file():
316
476
  dest.unlink(missing_ok=True)
317
477
 
318
- async def save_upload_and_unlock(
478
+ async def _commit_upload(
319
479
  self,
320
480
  session_id: str,
321
481
  transfer_id: str,
@@ -327,7 +487,7 @@ class FileSessionClient:
327
487
  dry_run: bool = False,
328
488
  backup: bool = True,
329
489
  ) -> Dict[str, Any]:
330
- """Persist uploaded buffer and release locks; response includes ``file_id``."""
490
+ """Persist a completed adapter upload (``project_file_transfer_upload_save``)."""
331
491
  await self.assert_session_exists(session_id)
332
492
  params: Dict[str, Any] = {
333
493
  "session_id": session_id,
@@ -342,31 +502,78 @@ class FileSessionClient:
342
502
  params["file_id"] = file_id
343
503
  if file_path is not None:
344
504
  params["file_path"] = file_path
345
- payload = _unwrap(
505
+ return _unwrap(
346
506
  await self._client.call_validated(
347
507
  "project_file_transfer_upload_save",
348
508
  params,
349
509
  )
350
510
  )
351
- if not dry_run:
352
- _extract_file_id(payload)
353
- return payload
354
511
 
355
- async def upload_file_and_unlock(
512
+ async def upload_new(
356
513
  self,
357
514
  session_id: str,
358
515
  payload: bytes,
516
+ project_id: str,
517
+ file_path: str,
359
518
  *,
360
- project_id: Optional[str] = None,
361
- file_id: Optional[str] = None,
362
- file_path: Optional[str] = None,
363
519
  filename: Optional[str] = None,
364
520
  compression: str = "identity",
365
- unlock_after_write: bool = True,
521
+ unlock: bool = True,
522
+ dry_run: bool = False,
366
523
  backup: bool = True,
524
+ ) -> str:
525
+ """Create a new project file from uploaded bytes; returns the new ``file_id``.
526
+
527
+ ``project_id`` and project-relative ``file_path`` are required. The path must
528
+ not already be indexed in the database (new file). Server errors otherwise.
529
+ """
530
+ pid = _require_non_empty(project_id, field="project_id")
531
+ fp = _require_non_empty(file_path, field="file_path")
532
+ name = filename or Path(fp).name
533
+ receipt = await self.upload_bytes(
534
+ payload,
535
+ filename=name,
536
+ compression=compression,
537
+ )
538
+ if not getattr(receipt, "completed", False):
539
+ raise ClientValidationError(
540
+ "upload did not complete",
541
+ field="transfer_id",
542
+ details={"receipt": repr(receipt)},
543
+ )
544
+ saved = await self._commit_upload(
545
+ session_id,
546
+ str(receipt.transfer_id),
547
+ project_id=pid,
548
+ file_path=fp,
549
+ unlock_after_write=unlock,
550
+ backup=backup,
551
+ dry_run=dry_run,
552
+ )
553
+ if dry_run:
554
+ fid = str(saved.get("file_id") or "").strip()
555
+ return fid
556
+ return _extract_file_id(saved)
557
+
558
+ async def upload(
559
+ self,
560
+ session_id: str,
561
+ payload: bytes,
562
+ file_id: str,
563
+ *,
564
+ filename: Optional[str] = None,
565
+ compression: str = "identity",
566
+ unlock: bool = True,
567
+ backup: bool = True,
568
+ dry_run: bool = False,
367
569
  ) -> Dict[str, Any]:
368
- """Upload bytes, save to project file, unlock; returns save payload with ``file_id``."""
369
- name = filename or (Path(file_path).name if file_path else "payload.bin")
570
+ """Overwrite an existing indexed file from uploaded bytes.
571
+
572
+ Only ``file_id`` is required. Returns the server save payload on success
573
+ or raises :class:`ClientValidationError` on failure.
574
+ """
575
+ fid = _require_non_empty(file_id, field="file_id")
576
+ name = filename or "payload.bin"
370
577
  receipt = await self.upload_bytes(
371
578
  payload,
372
579
  filename=name,
@@ -378,12 +585,14 @@ class FileSessionClient:
378
585
  field="transfer_id",
379
586
  details={"receipt": repr(receipt)},
380
587
  )
381
- return await self.save_upload_and_unlock(
588
+ saved = await self._commit_upload(
382
589
  session_id,
383
590
  str(receipt.transfer_id),
384
- project_id=project_id,
385
- file_id=file_id,
386
- file_path=file_path,
387
- unlock_after_write=unlock_after_write,
591
+ file_id=fid,
592
+ unlock_after_write=unlock,
388
593
  backup=backup,
594
+ dry_run=dry_run,
389
595
  )
596
+ if not dry_run:
597
+ _extract_file_id(saved)
598
+ return saved
@@ -12,7 +12,7 @@ email: vasilyvz@gmail.com
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- from typing import Callable, FrozenSet
15
+ from typing import Callable, Dict, FrozenSet
16
16
 
17
17
  # Removed from the public server surface — do not use in client facades or docs.
18
18
  LEGACY_REMOVED_COMMANDS: FrozenSet[str] = frozenset(
@@ -54,9 +54,15 @@ FILE_SESSION_COMMANDS: FrozenSet[str] = frozenset(
54
54
  "session_create",
55
55
  "session_delete",
56
56
  "session_list",
57
+ "session_view",
57
58
  "session_open_file",
58
59
  "session_close_file",
59
60
  "session_list_file_locks",
61
+ "subordinate_session_create",
62
+ "subordinate_session_get",
63
+ "subordinate_session_update",
64
+ "subordinate_session_delete",
65
+ "subordinate_session_list",
60
66
  }
61
67
  )
62
68
 
@@ -82,6 +88,41 @@ CLIENT_FACADE_COMMANDS: FrozenSet[str] = (
82
88
  FILE_SESSION_COMMANDS | TRANSFER_AND_LOCK_COMMANDS | UNIVERSAL_FILE_COMMANDS
83
89
  )
84
90
 
91
+ # ``FileSessionClient`` method names for each ``FILE_SESSION_COMMANDS`` entry.
92
+ # ``session_list_file_locks`` is also used by ``assert_session_exists``.
93
+ FILE_SESSION_FACADE_METHODS: Dict[str, str] = {
94
+ "session_create": "create_session",
95
+ "session_delete": "delete_session",
96
+ "session_list": "list_sessions",
97
+ "session_view": "view_session",
98
+ "session_open_file": "lock_file",
99
+ "session_close_file": "unlock_file",
100
+ "session_list_file_locks": "list_file_locks",
101
+ "subordinate_session_create": "create_subordinate_session",
102
+ "subordinate_session_get": "get_subordinate_session",
103
+ "subordinate_session_update": "update_subordinate_session",
104
+ "subordinate_session_delete": "delete_subordinate_session",
105
+ "subordinate_session_list": "list_subordinate_sessions",
106
+ }
107
+
108
+
109
+ def assert_file_session_facade_complete() -> None:
110
+ """Raise ``AssertionError`` if ``FILE_SESSION_FACADE_METHODS`` is incomplete."""
111
+ missing_cmds = FILE_SESSION_COMMANDS - set(FILE_SESSION_FACADE_METHODS)
112
+ extra_cmds = set(FILE_SESSION_FACADE_METHODS) - FILE_SESSION_COMMANDS
113
+ if missing_cmds or extra_cmds:
114
+ raise AssertionError(
115
+ "FILE_SESSION_FACADE_METHODS out of sync with FILE_SESSION_COMMANDS: "
116
+ f"missing={sorted(missing_cmds)!r} extra={sorted(extra_cmds)!r}"
117
+ )
118
+ from code_analysis_client.file_session import FileSessionClient
119
+
120
+ for command, method_name in FILE_SESSION_FACADE_METHODS.items():
121
+ if not hasattr(FileSessionClient, method_name):
122
+ raise AssertionError(
123
+ f"FileSessionClient missing method {method_name!r} for command {command!r}"
124
+ )
125
+
85
126
 
86
127
  def _command_registered(get_command: Callable[[str], object], name: str) -> bool:
87
128
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-analysis-client
3
- Version: 1.0.5
3
+ Version: 1.0.6
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
@@ -75,7 +75,7 @@ async with CodeAnalysisAsyncClient(host="127.0.0.1", port=15001) as client:
75
75
 
76
76
  Use `call_unified_validated` when you need queue polling. Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
77
77
 
78
- ## High-level facades (aligned with server 1.0.3)
78
+ ## High-level facades (aligned with live server registry)
79
79
 
80
80
  The client does **not** wrap CST commands (`cst_load_file`, …) or legacy file I/O
81
81
  (`universal_file_read`, `read_project_text_file`, …). Those commands are removed
@@ -83,28 +83,42 @@ from the server registry. Use the facades below or generic `call` / `commands.*`
83
83
 
84
84
  | Facade | Property | Server commands |
85
85
  |--------|----------|-----------------|
86
- | Client DB sessions + transfer | `client.file_sessions` | `session_*`, `project_file_transfer_*`, `project_file_advisory_lock_batch` |
86
+ | Client DB sessions + transfer | `client.file_sessions` | `session_*`, `subordinate_session_*`, `project_file_transfer_*`, `project_file_advisory_lock_batch` |
87
87
  | Universal edit sessions | `client.universal_files` | `universal_file_open`, `edit`, `write`, `close`, `preview` |
88
88
  | Any registered command | `client.call` / `client.commands.<name>` | schema from live `help()` |
89
89
 
90
- Canonical command lists: `code_analysis_client.server_api` (also exported as
91
- `REMOVED_COMMANDS`, `CLIENT_FACADE_COMMANDS`).
90
+ Canonical command lists: `code_analysis_client.server_api` exported as
91
+ `FILE_SESSION_COMMANDS`, `FILE_SESSION_FACADE_METHODS`, `CLIENT_FACADE_COMMANDS`,
92
+ `REMOVED_COMMANDS`.
92
93
 
93
- Sync check (in-process registry):
94
+ Sync checks (in-process registry):
94
95
 
95
96
  ```bash
96
- pytest tests/test_client_server_api_sync.py
97
+ pytest tests/test_client_server_api_sync.py tests/test_code_analysis_client.py -k session
97
98
  ```
98
99
 
99
- Package version matches the **code-analysis** server project (currently **1.0.4**).
100
+ Package version is in ``client/code_analysis_client/version.txt`` (synced with the
101
+ root ``code-analysis`` project via ``scripts/sync_code_analysis_client_version.py``).
100
102
 
101
103
  ## Examples (this repository)
102
104
 
103
105
  Runnable scripts live under `client/examples/`. **Long-form “man page” style
104
106
  documentation** is embedded in the **module docstrings** of those Python files
105
- (see `client/examples/README.md` for how to read them). Full API walkthrough:
106
- `python client/examples/run_all_examples.py` with the daemon up and
107
- `CODE_ANALYSIS_CONFIG` or default `config.json` at the repo root.
107
+ (see `client/examples/README.md` for how to read them).
108
+
109
+ | Script | Purpose |
110
+ |--------|---------|
111
+ | `run_all_examples.py` | Full API tour + runs all live sibling scripts |
112
+ | `ex_minimal_validated.py` | Smallest validated RPC example |
113
+ | `ex_universal_files.py` | All `UniversalFileClient` methods (open/edit/write/close/preview) |
114
+ | `ex_session_view_subordinates.py` | `session_view` and subordinate CRUD |
115
+ | `ex_file_sessions.py` | Sessions, locks, transfer roundtrip |
116
+ | `ex_config_only.py` | Parse `config.json` without TCP |
117
+
118
+ ```bash
119
+ casmgr --config config.json start
120
+ python client/examples/run_all_examples.py
121
+ ```
108
122
 
109
123
  ## Development
110
124