code-analysis-client 1.0.4__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.4 → code_analysis_client-1.0.6}/PKG-INFO +25 -11
  2. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/README.md +24 -10
  3. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/__init__.py +4 -0
  4. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/client.py +1 -1
  5. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/file_session.py +282 -56
  6. {code_analysis_client-1.0.4 → 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.4 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/PKG-INFO +25 -11
  9. code_analysis_client-1.0.4/code_analysis_client/version.txt +0 -1
  10. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/commands_proxy.py +0 -0
  11. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/config.py +0 -0
  12. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/exceptions.py +0 -0
  13. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/py.typed +0 -0
  14. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/responses.py +0 -0
  15. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/server_schema.py +0 -0
  16. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/universal_file.py +0 -0
  17. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client/validation.py +0 -0
  18. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/SOURCES.txt +0 -0
  19. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/dependency_links.txt +0 -0
  20. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/requires.txt +0 -0
  21. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/code_analysis_client.egg-info/top_level.txt +0 -0
  22. {code_analysis_client-1.0.4 → code_analysis_client-1.0.6}/pyproject.toml +0 -0
  23. {code_analysis_client-1.0.4 → 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.4
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,8 +31,38 @@ 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
+
52
+ def _extract_file_id(payload: Dict[str, Any], *, field: str = "file_id") -> str:
53
+ """Return non-empty ``file_id`` from a command payload or raise."""
54
+ fid = str(payload.get(field) or "").strip()
55
+ if not fid:
56
+ raise ClientValidationError(
57
+ f"{field} missing from server response",
58
+ field=field,
59
+ details=payload,
60
+ )
61
+ return fid
62
+
63
+
35
64
  class FileSessionClient:
36
- """Session-scoped file download/upload and lock helpers."""
65
+ """Session-scoped DB sessions, subordinate links, file locks, and transfer helpers."""
37
66
 
38
67
  __slots__ = ("_client",)
39
68
 
@@ -75,14 +104,163 @@ class FileSessionClient:
75
104
  async def delete_session(
76
105
  self, session_id: str, *, force: bool = False
77
106
  ) -> Dict[str, Any]:
78
- """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
79
120
  return _unwrap(
80
121
  await self._client.call_validated(
81
122
  "session_delete",
82
- {"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
+ },
190
+ )
191
+ )
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
+ },
83
214
  )
84
215
  )
85
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
+
86
264
  async def list_sessions(
87
265
  self,
88
266
  *,
@@ -205,71 +383,56 @@ class FileSessionClient:
205
383
  )
206
384
  )
207
385
 
208
- async def begin_download_locked(
386
+ async def _begin_download(
209
387
  self,
210
388
  session_id: str,
211
389
  *,
212
- project_id: Optional[str] = None,
213
- file_id: Optional[str] = None,
214
- file_path: Optional[str] = None,
390
+ file_id: str,
215
391
  compression: str = "identity",
216
- lock_mode: str = "full",
392
+ lock: bool = True,
217
393
  chunk_size: Optional[int] = None,
218
394
  include_backup_history: bool = False,
219
395
  ) -> Dict[str, Any]:
220
- """Start a chunked download and acquire an advisory lock (``project_file_transfer_download_begin``)."""
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)
221
399
  await self.assert_session_exists(session_id)
222
400
  params: Dict[str, Any] = {
223
401
  "session_id": session_id,
224
402
  "compression": compression,
225
- "lock_mode": lock_mode,
403
+ "lock_mode": _lock_mode_from_flag(lock),
226
404
  "include_backup_history": include_backup_history,
405
+ "file_id": fid,
227
406
  }
228
- if project_id is not None:
229
- params["project_id"] = project_id
230
- if file_id is not None:
231
- params["file_id"] = file_id
232
- if file_path is not None:
233
- params["file_path"] = file_path
234
407
  if chunk_size is not None:
235
408
  params["chunk_size"] = chunk_size
236
- return _unwrap(
409
+ payload = _unwrap(
237
410
  await self._client.call_validated(
238
411
  "project_file_transfer_download_begin",
239
412
  params,
240
413
  )
241
414
  )
415
+ _extract_file_id(payload)
416
+ return payload
242
417
 
243
- async def download_to_path(
244
- self,
245
- transfer_id: str,
246
- destination: Union[str, Path],
247
- ) -> Any:
248
- """Stream download chunks to ``destination`` via the adapter client."""
249
- return await self._client.rpc.download_file(
250
- str(transfer_id).strip(),
251
- str(destination),
252
- )
253
-
254
- async def download_file_locked(
418
+ async def download(
255
419
  self,
256
420
  session_id: str,
257
421
  destination: Union[str, Path],
422
+ file_id: str,
258
423
  *,
259
- project_id: Optional[str] = None,
260
- file_id: Optional[str] = None,
261
- file_path: Optional[str] = None,
262
424
  compression: str = "identity",
263
- lock_mode: str = "full",
425
+ lock: bool = True,
426
+ include_backup_history: bool = False,
264
427
  ) -> Tuple[Dict[str, Any], Any]:
265
- """Download + lock in one workflow; returns (begin_payload, download_receipt)."""
266
- 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(
267
431
  session_id,
268
- project_id=project_id,
269
- file_id=file_id,
270
- file_path=file_path,
432
+ file_id=fid,
271
433
  compression=compression,
272
- lock_mode=lock_mode,
434
+ lock=lock,
435
+ include_backup_history=include_backup_history,
273
436
  )
274
437
  transfer_id = str(begin.get("transfer_id") or "").strip()
275
438
  if not transfer_id:
@@ -281,6 +444,17 @@ class FileSessionClient:
281
444
  receipt = await self.download_to_path(transfer_id, destination)
282
445
  return begin, receipt
283
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
+
284
458
  async def upload_bytes(
285
459
  self,
286
460
  payload: bytes,
@@ -301,7 +475,7 @@ class FileSessionClient:
301
475
  if dest.is_file():
302
476
  dest.unlink(missing_ok=True)
303
477
 
304
- async def save_upload_and_unlock(
478
+ async def _commit_upload(
305
479
  self,
306
480
  session_id: str,
307
481
  transfer_id: str,
@@ -313,7 +487,7 @@ class FileSessionClient:
313
487
  dry_run: bool = False,
314
488
  backup: bool = True,
315
489
  ) -> Dict[str, Any]:
316
- """Persist uploaded buffer and release locks (``project_file_transfer_upload_save``)."""
490
+ """Persist a completed adapter upload (``project_file_transfer_upload_save``)."""
317
491
  await self.assert_session_exists(session_id)
318
492
  params: Dict[str, Any] = {
319
493
  "session_id": session_id,
@@ -335,21 +509,71 @@ class FileSessionClient:
335
509
  )
336
510
  )
337
511
 
338
- async def upload_file_and_unlock(
512
+ async def upload_new(
339
513
  self,
340
514
  session_id: str,
341
515
  payload: bytes,
516
+ project_id: str,
517
+ file_path: str,
342
518
  *,
343
- project_id: Optional[str] = None,
344
- file_id: Optional[str] = None,
345
- file_path: Optional[str] = None,
346
519
  filename: Optional[str] = None,
347
520
  compression: str = "identity",
348
- unlock_after_write: bool = True,
521
+ unlock: bool = True,
522
+ dry_run: bool = False,
349
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,
350
569
  ) -> Dict[str, Any]:
351
- """Upload bytes then save to the project file and unlock."""
352
- 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"
353
577
  receipt = await self.upload_bytes(
354
578
  payload,
355
579
  filename=name,
@@ -361,12 +585,14 @@ class FileSessionClient:
361
585
  field="transfer_id",
362
586
  details={"receipt": repr(receipt)},
363
587
  )
364
- return await self.save_upload_and_unlock(
588
+ saved = await self._commit_upload(
365
589
  session_id,
366
590
  str(receipt.transfer_id),
367
- project_id=project_id,
368
- file_id=file_id,
369
- file_path=file_path,
370
- unlock_after_write=unlock_after_write,
591
+ file_id=fid,
592
+ unlock_after_write=unlock,
371
593
  backup=backup,
594
+ dry_run=dry_run,
372
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.4
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