code-analysis-client-dev 1.6.341__py3-none-any.whl
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.
- code_analysis_client/__init__.py +142 -0
- code_analysis_client/check_chain.py +374 -0
- code_analysis_client/client.py +728 -0
- code_analysis_client/commands_proxy.py +63 -0
- code_analysis_client/config.py +198 -0
- code_analysis_client/exceptions.py +188 -0
- code_analysis_client/file_session.py +823 -0
- code_analysis_client/project.py +59 -0
- code_analysis_client/py.typed +0 -0
- code_analysis_client/queue_wait.py +341 -0
- code_analysis_client/responses.py +126 -0
- code_analysis_client/schema_disk_cache.py +207 -0
- code_analysis_client/server_api.py +291 -0
- code_analysis_client/server_schema.py +57 -0
- code_analysis_client/symbol_tree.py +13 -0
- code_analysis_client/universal_file.py +50 -0
- code_analysis_client/validation.py +98 -0
- code_analysis_client/vectorizer_state.py +159 -0
- code_analysis_client/version.txt +1 -0
- code_analysis_client/worktree.py +533 -0
- code_analysis_client_dev-1.6.341.dist-info/METADATA +251 -0
- code_analysis_client_dev-1.6.341.dist-info/RECORD +24 -0
- code_analysis_client_dev-1.6.341.dist-info/WHEEL +5 -0
- code_analysis_client_dev-1.6.341.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
"""
|
|
2
|
+
High-level file transfer and session workflow on top of CodeAnalysisAsyncClient.
|
|
3
|
+
|
|
4
|
+
Wraps ``session_*`` and ``subordinate_session_*`` MCP commands plus transfer and
|
|
5
|
+
advisory-lock commands that accept ``session_id``.
|
|
6
|
+
|
|
7
|
+
Transfer mapping (client façade → server command):
|
|
8
|
+
|
|
9
|
+
* **Download** — ``download`` → ``project_file_transfer_download_begin`` (``file_id``
|
|
10
|
+
required; optional ``project_id`` scopes the lookup). Chunk streaming uses the
|
|
11
|
+
adapter ``download_file`` helper via ``download_to_path``.
|
|
12
|
+
* **Upload** — two façade methods share one save command
|
|
13
|
+
``project_file_transfer_upload_save``:
|
|
14
|
+
|
|
15
|
+
- ``upload`` — update mode: ``file_id`` only (overwrite an indexed file).
|
|
16
|
+
- ``upload_new`` — create mode: ``project_id`` + ``file_path`` (path must not
|
|
17
|
+
be indexed yet).
|
|
18
|
+
|
|
19
|
+
Both methods upload bytes through the adapter, then call the same save command
|
|
20
|
+
with the completed ``transfer_id``.
|
|
21
|
+
|
|
22
|
+
Author: Vasiliy Zdanovskiy
|
|
23
|
+
email: vasilyvz@gmail.com
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import tempfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
|
|
31
|
+
|
|
32
|
+
from code_analysis_client.exceptions import ClientValidationError
|
|
33
|
+
from code_analysis_client.responses import unwrap_command_result, FacadeCaller
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from code_analysis_client.client import CodeAnalysisAsyncClient
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionNotFoundError(ClientValidationError):
|
|
40
|
+
"""Raised when session_id is absent from client_sessions."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _unwrap(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
44
|
+
"""Unwrap command result payloads and map missing sessions to SessionNotFoundError."""
|
|
45
|
+
return unwrap_command_result(
|
|
46
|
+
data,
|
|
47
|
+
session_not_found_type=SessionNotFoundError,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _require_non_empty(value: str, *, field: str) -> str:
|
|
52
|
+
"""Return stripped text or raise ClientValidationError for an empty field."""
|
|
53
|
+
text = str(value or "").strip()
|
|
54
|
+
if not text:
|
|
55
|
+
raise ClientValidationError(f"{field} is required", field=field)
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_download_target(*, file_id: Optional[str]) -> None:
|
|
60
|
+
"""Download requires ``file_id`` (server rejects ``file_path``)."""
|
|
61
|
+
fid = str(file_id or "").strip()
|
|
62
|
+
if not fid:
|
|
63
|
+
raise ClientValidationError("file_id is required", field="file_id")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _validate_upload_selector(
|
|
67
|
+
*,
|
|
68
|
+
file_id: Optional[str] = None,
|
|
69
|
+
file_path: Optional[str] = None,
|
|
70
|
+
project_id: Optional[str] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Mirror server ``_validate_upload_selector_params`` for save payloads."""
|
|
73
|
+
fid = str(file_id or "").strip()
|
|
74
|
+
fp = str(file_path or "").strip()
|
|
75
|
+
pid = str(project_id or "").strip()
|
|
76
|
+
if fid and fp:
|
|
77
|
+
raise ClientValidationError(
|
|
78
|
+
"Specify exactly one of file_id or file_path, not both",
|
|
79
|
+
field="file_id",
|
|
80
|
+
)
|
|
81
|
+
if fid:
|
|
82
|
+
return
|
|
83
|
+
if not pid:
|
|
84
|
+
raise ClientValidationError(
|
|
85
|
+
"project_id is required when file_id is omitted",
|
|
86
|
+
field="project_id",
|
|
87
|
+
)
|
|
88
|
+
if not fp:
|
|
89
|
+
raise ClientValidationError(
|
|
90
|
+
"file_path is required when file_id is omitted",
|
|
91
|
+
field="file_path",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _lock_mode_from_flag(lock: bool) -> str:
|
|
96
|
+
"""Convert a boolean lock flag into the server lock_mode value."""
|
|
97
|
+
return "full" if lock else "none"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _extract_file_id(payload: Dict[str, Any], *, field: str = "file_id") -> str:
|
|
101
|
+
"""Return non-empty ``file_id`` from a command payload or raise."""
|
|
102
|
+
fid = str(payload.get(field) or "").strip()
|
|
103
|
+
if not fid:
|
|
104
|
+
raise ClientValidationError(
|
|
105
|
+
f"{field} missing from server response",
|
|
106
|
+
field=field,
|
|
107
|
+
details=payload,
|
|
108
|
+
)
|
|
109
|
+
return fid
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FileSessionClient:
|
|
113
|
+
"""Session-scoped DB sessions, subordinate links, file locks, and transfer helpers."""
|
|
114
|
+
|
|
115
|
+
__slots__ = ("_client",)
|
|
116
|
+
|
|
117
|
+
def __init__(self, client: CodeAnalysisAsyncClient) -> None:
|
|
118
|
+
"""Store the underlying async client used to call session commands."""
|
|
119
|
+
self._client = FacadeCaller(client) # type: ignore[assignment]
|
|
120
|
+
|
|
121
|
+
async def create_session(
|
|
122
|
+
self,
|
|
123
|
+
comment: str,
|
|
124
|
+
*,
|
|
125
|
+
role_ids: Optional[List[str]] = None,
|
|
126
|
+
session_id: Optional[str] = None,
|
|
127
|
+
is_foreign: Optional[bool] = None,
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Create a client session with an optional ID and ownership marker."""
|
|
130
|
+
params: Dict[str, Any] = {"comment": comment}
|
|
131
|
+
if role_ids is not None:
|
|
132
|
+
params["role_ids"] = role_ids
|
|
133
|
+
if session_id is not None:
|
|
134
|
+
params["session_id"] = session_id
|
|
135
|
+
if is_foreign is not None:
|
|
136
|
+
params["is_foreign"] = is_foreign
|
|
137
|
+
payload = _unwrap(await self._client.call_validated("session_create", params))
|
|
138
|
+
sid = str(payload.get("session_id") or "").strip()
|
|
139
|
+
if not sid:
|
|
140
|
+
raise ClientValidationError(
|
|
141
|
+
"session_create returned no session_id",
|
|
142
|
+
field="session_id",
|
|
143
|
+
details=payload,
|
|
144
|
+
)
|
|
145
|
+
return sid
|
|
146
|
+
|
|
147
|
+
async def validate_session(
|
|
148
|
+
self, session_id: str, *, touch: bool = False
|
|
149
|
+
) -> Dict[str, Any]:
|
|
150
|
+
"""Confirm ``session_id`` exists (``session_validate``)."""
|
|
151
|
+
sid = _require_non_empty(session_id, field="session_id")
|
|
152
|
+
params: Dict[str, Any] = {"session_id": sid}
|
|
153
|
+
if touch:
|
|
154
|
+
params["touch"] = True
|
|
155
|
+
return _unwrap(await self._client.call_validated("session_validate", params))
|
|
156
|
+
|
|
157
|
+
async def assert_session_exists(self, session_id: str) -> None:
|
|
158
|
+
"""Verify ``session_id`` is registered (``session_validate``)."""
|
|
159
|
+
await self.validate_session(session_id, touch=False)
|
|
160
|
+
|
|
161
|
+
async def delete_session(
|
|
162
|
+
self, session_id: str, *, force: bool = False
|
|
163
|
+
) -> Dict[str, Any]:
|
|
164
|
+
"""Delete a client session (``session_delete``).
|
|
165
|
+
|
|
166
|
+
``force`` defaults to false (omit on the wire when false). Safe delete
|
|
167
|
+
requires no ``session_file_locks``, no ``subordinate_sessions`` rows
|
|
168
|
+
where this session is ``parent_session_id``, and no advisory file-lock
|
|
169
|
+
leases. When ``force`` is true, subordinate link rows, DB file locks, and
|
|
170
|
+
advisory leases for this session are released before the session row is
|
|
171
|
+
deleted.
|
|
172
|
+
|
|
173
|
+
Returns ``session_id``, ``deleted``, ``released_lock_count``,
|
|
174
|
+
``released_subordinate_count``, ``released_advisory_lease_count``,
|
|
175
|
+
``worktrees_removed``, ``checks_stopped`` and ``branches_deleted``. The session's checks
|
|
176
|
+
are stopped and every owned worktree and ``cas-worktree/*`` branch is removed whatever
|
|
177
|
+
``force`` is. Every server refusal RAISES :class:`ClientValidationError` with ``code``
|
|
178
|
+
equal to the server error code and ``details`` the server details.
|
|
179
|
+
"""
|
|
180
|
+
params: Dict[str, Any] = {"session_id": session_id}
|
|
181
|
+
if force:
|
|
182
|
+
params["force"] = True
|
|
183
|
+
return _unwrap(
|
|
184
|
+
await self._client.call_validated(
|
|
185
|
+
"session_delete",
|
|
186
|
+
params,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def session_worktrees_delete(
|
|
191
|
+
self, project_id: str, session_id: str
|
|
192
|
+
) -> Dict[str, Any]:
|
|
193
|
+
"""Delete every check worktree and branch of a session in one project.
|
|
194
|
+
|
|
195
|
+
Calls ``session_worktrees_delete`` (the editor's cleanup after it closed its last file;
|
|
196
|
+
the session itself stays valid). Returns ``project_id``, ``session_id``,
|
|
197
|
+
``session_valid``, ``checks_stopped``, ``worktrees_removed``, ``directory_removed``,
|
|
198
|
+
``branches_deleted`` and ``already_clean``.
|
|
199
|
+
|
|
200
|
+
Every server refusal RAISES :class:`ClientValidationError` whose ``code`` is the server
|
|
201
|
+
error code (``SESSION_OPEN_IN_EDITOR`` with ``details['files']``,
|
|
202
|
+
``SESSION_EDITOR_UNREACHABLE``, ``SESSION_CHECKS_STILL_RUNNING``,
|
|
203
|
+
``WORKTREE_REMOVE_FAILED``, ``PROJECT_NOT_FOUND``, ``SESSION_NOT_FOUND``, ...) and whose
|
|
204
|
+
``details`` is the server details object.
|
|
205
|
+
"""
|
|
206
|
+
return _unwrap(
|
|
207
|
+
await self._client.call_validated(
|
|
208
|
+
"session_worktrees_delete",
|
|
209
|
+
{
|
|
210
|
+
"project_id": _require_non_empty(project_id, field="project_id"),
|
|
211
|
+
"session_id": _require_non_empty(session_id, field="session_id"),
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def delete_foreign_session(
|
|
217
|
+
self, session_id: str, *, force: bool = False
|
|
218
|
+
) -> Dict[str, Any]:
|
|
219
|
+
"""Explicitly delete a caller-ID session (``session_delete_foreign``)."""
|
|
220
|
+
params: Dict[str, Any] = {"session_id": _require_non_empty(session_id, field="session_id")}
|
|
221
|
+
if force:
|
|
222
|
+
params["force"] = True
|
|
223
|
+
return _unwrap(
|
|
224
|
+
await self._client.call_validated("session_delete_foreign", params)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
async def view_session(self, session_id: str) -> Dict[str, Any]:
|
|
228
|
+
"""Aggregated session view (``session_view``).
|
|
229
|
+
|
|
230
|
+
Returns ``locked_files_by_project`` (file_id, file_path,
|
|
231
|
+
project_presentation per project) and ``subordinate_sessions``
|
|
232
|
+
(session_id, server_uuid, session_presentation, server_presentation,
|
|
233
|
+
link_comment).
|
|
234
|
+
"""
|
|
235
|
+
sid = _require_non_empty(session_id, field="session_id")
|
|
236
|
+
return _unwrap(
|
|
237
|
+
await self._client.call_validated(
|
|
238
|
+
"session_view",
|
|
239
|
+
{"session_id": sid},
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
async def create_subordinate_session(
|
|
244
|
+
self,
|
|
245
|
+
parent_session_id: str,
|
|
246
|
+
comment: str,
|
|
247
|
+
*,
|
|
248
|
+
server_uuid: Optional[str] = None,
|
|
249
|
+
) -> Dict[str, Any]:
|
|
250
|
+
"""Register leading session on a subordinate server (``subordinate_session_create``).
|
|
251
|
+
|
|
252
|
+
Subordinate servers use ``parent_session_id`` as ``session_id``.
|
|
253
|
+
``server_uuid`` defaults to the server's ``registration.instance_uuid``
|
|
254
|
+
when omitted.
|
|
255
|
+
"""
|
|
256
|
+
params: Dict[str, Any] = {
|
|
257
|
+
"parent_session_id": _require_non_empty(
|
|
258
|
+
parent_session_id, field="parent_session_id"
|
|
259
|
+
),
|
|
260
|
+
"comment": comment,
|
|
261
|
+
}
|
|
262
|
+
if server_uuid is not None:
|
|
263
|
+
params["server_uuid"] = _require_non_empty(server_uuid, field="server_uuid")
|
|
264
|
+
return _unwrap(
|
|
265
|
+
await self._client.call_validated("subordinate_session_create", params)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def get_subordinate_session(
|
|
269
|
+
self,
|
|
270
|
+
parent_session_id: str,
|
|
271
|
+
server_uuid: str,
|
|
272
|
+
) -> Dict[str, Any]:
|
|
273
|
+
"""Fetch one subordinate server link (``subordinate_session_get``)."""
|
|
274
|
+
return _unwrap(
|
|
275
|
+
await self._client.call_validated(
|
|
276
|
+
"subordinate_session_get",
|
|
277
|
+
{
|
|
278
|
+
"parent_session_id": _require_non_empty(
|
|
279
|
+
parent_session_id, field="parent_session_id"
|
|
280
|
+
),
|
|
281
|
+
"server_uuid": _require_non_empty(server_uuid, field="server_uuid"),
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def update_subordinate_session(
|
|
287
|
+
self,
|
|
288
|
+
parent_session_id: str,
|
|
289
|
+
server_uuid: str,
|
|
290
|
+
comment: str,
|
|
291
|
+
) -> Dict[str, Any]:
|
|
292
|
+
"""Update link comment (``subordinate_session_update``)."""
|
|
293
|
+
return _unwrap(
|
|
294
|
+
await self._client.call_validated(
|
|
295
|
+
"subordinate_session_update",
|
|
296
|
+
{
|
|
297
|
+
"parent_session_id": _require_non_empty(
|
|
298
|
+
parent_session_id, field="parent_session_id"
|
|
299
|
+
),
|
|
300
|
+
"server_uuid": _require_non_empty(server_uuid, field="server_uuid"),
|
|
301
|
+
"comment": comment,
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
async def delete_subordinate_session(
|
|
307
|
+
self,
|
|
308
|
+
parent_session_id: str,
|
|
309
|
+
server_uuid: str,
|
|
310
|
+
) -> Dict[str, Any]:
|
|
311
|
+
"""Remove subordinate server link (``subordinate_session_delete``).
|
|
312
|
+
|
|
313
|
+
Does not delete the leading ``client_sessions`` row; use :meth:`delete_session`.
|
|
314
|
+
"""
|
|
315
|
+
return _unwrap(
|
|
316
|
+
await self._client.call_validated(
|
|
317
|
+
"subordinate_session_delete",
|
|
318
|
+
{
|
|
319
|
+
"parent_session_id": _require_non_empty(
|
|
320
|
+
parent_session_id, field="parent_session_id"
|
|
321
|
+
),
|
|
322
|
+
"server_uuid": _require_non_empty(server_uuid, field="server_uuid"),
|
|
323
|
+
},
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def list_subordinate_sessions(
|
|
328
|
+
self,
|
|
329
|
+
*,
|
|
330
|
+
parent_session_id: Optional[str] = None,
|
|
331
|
+
server_uuid: Optional[str] = None,
|
|
332
|
+
) -> Dict[str, Any]:
|
|
333
|
+
"""List subordinate server links (``subordinate_session_list``).
|
|
334
|
+
|
|
335
|
+
Returns ``links`` and ``count``. All filter parameters are optional.
|
|
336
|
+
"""
|
|
337
|
+
params: Dict[str, Any] = {}
|
|
338
|
+
if parent_session_id is not None:
|
|
339
|
+
params["parent_session_id"] = parent_session_id
|
|
340
|
+
if server_uuid is not None:
|
|
341
|
+
params["server_uuid"] = server_uuid
|
|
342
|
+
return _unwrap(
|
|
343
|
+
await self._client.call_validated("subordinate_session_list", params)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
async def list_sessions(
|
|
347
|
+
self,
|
|
348
|
+
*,
|
|
349
|
+
session_id: Optional[str] = None,
|
|
350
|
+
stale_threshold_seconds: Optional[int] = None,
|
|
351
|
+
) -> Dict[str, Any]:
|
|
352
|
+
"""List client sessions (``session_list``)."""
|
|
353
|
+
params: Dict[str, Any] = {}
|
|
354
|
+
if session_id is not None:
|
|
355
|
+
params["session_id"] = session_id
|
|
356
|
+
if stale_threshold_seconds is not None:
|
|
357
|
+
params["stale_threshold_seconds"] = stale_threshold_seconds
|
|
358
|
+
return _unwrap(await self._client.call_validated("session_list", params))
|
|
359
|
+
|
|
360
|
+
async def lock_file(
|
|
361
|
+
self,
|
|
362
|
+
session_id: str,
|
|
363
|
+
project_id: str,
|
|
364
|
+
file_id: str,
|
|
365
|
+
) -> Dict[str, Any]:
|
|
366
|
+
"""Acquire a DB file lock without transfer (``session_open_file``)."""
|
|
367
|
+
await self.assert_session_exists(session_id)
|
|
368
|
+
return _unwrap(
|
|
369
|
+
await self._client.call_validated(
|
|
370
|
+
"session_open_file",
|
|
371
|
+
{
|
|
372
|
+
"session_id": session_id,
|
|
373
|
+
"project_id": project_id,
|
|
374
|
+
"file_id": file_id,
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
async def unlock_file(
|
|
380
|
+
self,
|
|
381
|
+
session_id: str,
|
|
382
|
+
project_id: str,
|
|
383
|
+
file_id: str,
|
|
384
|
+
discard_documentation_edit: Optional[bool] = None,
|
|
385
|
+
) -> Dict[str, Any]:
|
|
386
|
+
"""Release a DB file lock without transfer (``session_close_file``).
|
|
387
|
+
|
|
388
|
+
``discard_documentation_edit`` (sent only when not None) matters only for a fetched
|
|
389
|
+
documentation copy with an edit the documentation server did not receive: without it
|
|
390
|
+
(or False) the server refuses the close with DOCUMENTATION_EDIT_UNSENT and keeps the
|
|
391
|
+
lock; True releases the lock and deletes the copy and the edit. The result carries
|
|
392
|
+
``documentation_edit_discarded``.
|
|
393
|
+
"""
|
|
394
|
+
await self.assert_session_exists(session_id)
|
|
395
|
+
params: Dict[str, Any] = {
|
|
396
|
+
"session_id": session_id,
|
|
397
|
+
"project_id": project_id,
|
|
398
|
+
"file_id": file_id,
|
|
399
|
+
}
|
|
400
|
+
if discard_documentation_edit is not None:
|
|
401
|
+
params["discard_documentation_edit"] = bool(discard_documentation_edit)
|
|
402
|
+
return _unwrap(
|
|
403
|
+
await self._client.call_validated("session_close_file", params)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
async def open_documentation_document(
|
|
407
|
+
self,
|
|
408
|
+
session_id: str,
|
|
409
|
+
project_id: str,
|
|
410
|
+
document_id: str,
|
|
411
|
+
) -> Dict[str, Any]:
|
|
412
|
+
"""Fetch a documentation-server document and lock the copy (``session_open_documentation_document``).
|
|
413
|
+
|
|
414
|
+
Returns ``session_id``, ``project_id``, ``document_id``, ``file_id``, ``file_path``
|
|
415
|
+
(``<directory>/<document_id>-<name>``), ``created``, ``size_bytes``, ``sha256``,
|
|
416
|
+
``waited_seconds``, ``lock_file`` and ``documentation_warning`` (``None`` or the
|
|
417
|
+
``UNDELIVERED_EDITS_RECOVERED`` mapping the reader must be shown with the document).
|
|
418
|
+
The open never writes to the documentation server: saved edits are delivered by
|
|
419
|
+
``file_check_and_promote``.
|
|
420
|
+
"""
|
|
421
|
+
return _unwrap(
|
|
422
|
+
await self._client.call_validated(
|
|
423
|
+
"session_open_documentation_document",
|
|
424
|
+
{
|
|
425
|
+
"session_id": session_id,
|
|
426
|
+
"project_id": project_id,
|
|
427
|
+
"document_id": document_id,
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
async def list_documentation_orphans(
|
|
433
|
+
self,
|
|
434
|
+
*,
|
|
435
|
+
project_id: Optional[str] = None,
|
|
436
|
+
document_id: Optional[str] = None,
|
|
437
|
+
limit: Optional[int] = None,
|
|
438
|
+
offset: Optional[int] = None,
|
|
439
|
+
) -> Dict[str, Any]:
|
|
440
|
+
"""List documentation copies with undelivered edits and no holding session (read-only)."""
|
|
441
|
+
params: Dict[str, Any] = {}
|
|
442
|
+
for key, value in (
|
|
443
|
+
("project_id", project_id),
|
|
444
|
+
("document_id", document_id),
|
|
445
|
+
("limit", limit),
|
|
446
|
+
("offset", offset),
|
|
447
|
+
):
|
|
448
|
+
if value is not None:
|
|
449
|
+
params[key] = value
|
|
450
|
+
return _unwrap(await self._client.call_validated("list_documentation_orphans", params))
|
|
451
|
+
|
|
452
|
+
async def list_file_locks(self, session_id: str) -> Dict[str, Any]:
|
|
453
|
+
"""Return locks held by the session (``session_list_file_locks``)."""
|
|
454
|
+
return _unwrap(
|
|
455
|
+
await self._client.call_validated(
|
|
456
|
+
"session_list_file_locks",
|
|
457
|
+
{"session_id": session_id},
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
async def lock_files_advisory(
|
|
462
|
+
self,
|
|
463
|
+
session_id: str,
|
|
464
|
+
project_id: str,
|
|
465
|
+
file_paths: List[str],
|
|
466
|
+
*,
|
|
467
|
+
lock_mode: str = "full",
|
|
468
|
+
timeout_seconds: Optional[float] = None,
|
|
469
|
+
) -> Dict[str, Any]:
|
|
470
|
+
"""Cooperative flock + DB lease for paths (``project_file_advisory_lock_batch``)."""
|
|
471
|
+
await self.assert_session_exists(session_id)
|
|
472
|
+
items = [
|
|
473
|
+
{
|
|
474
|
+
"session_id": session_id,
|
|
475
|
+
"project_id": project_id,
|
|
476
|
+
"file_path": path,
|
|
477
|
+
"action": "lock",
|
|
478
|
+
"lock_mode": lock_mode,
|
|
479
|
+
}
|
|
480
|
+
for path in file_paths
|
|
481
|
+
]
|
|
482
|
+
params: Dict[str, Any] = {
|
|
483
|
+
"items": items,
|
|
484
|
+
"allow_foreign_session": True,
|
|
485
|
+
}
|
|
486
|
+
if timeout_seconds is not None:
|
|
487
|
+
params["timeout_seconds"] = timeout_seconds
|
|
488
|
+
return _unwrap(
|
|
489
|
+
await self._client.call_validated(
|
|
490
|
+
"project_file_advisory_lock_batch",
|
|
491
|
+
params,
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
async def unlock_files_advisory(
|
|
496
|
+
self,
|
|
497
|
+
session_id: str,
|
|
498
|
+
project_id: str,
|
|
499
|
+
file_paths: List[str],
|
|
500
|
+
) -> Dict[str, Any]:
|
|
501
|
+
"""Release cooperative locks without transfer."""
|
|
502
|
+
await self.assert_session_exists(session_id)
|
|
503
|
+
items = [
|
|
504
|
+
{
|
|
505
|
+
"session_id": session_id,
|
|
506
|
+
"project_id": project_id,
|
|
507
|
+
"file_path": path,
|
|
508
|
+
"action": "unlock",
|
|
509
|
+
}
|
|
510
|
+
for path in file_paths
|
|
511
|
+
]
|
|
512
|
+
return _unwrap(
|
|
513
|
+
await self._client.call_validated(
|
|
514
|
+
"project_file_advisory_lock_batch",
|
|
515
|
+
{
|
|
516
|
+
"items": items,
|
|
517
|
+
"allow_foreign_session": True,
|
|
518
|
+
},
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
async def file_lock_write_check(
|
|
523
|
+
self,
|
|
524
|
+
document_id: str,
|
|
525
|
+
session_id: Optional[str] = None,
|
|
526
|
+
) -> Dict[str, Any]:
|
|
527
|
+
"""Strictly read-only: may the documentation server write ``document_id``?
|
|
528
|
+
|
|
529
|
+
Calls ``file_lock_write_check`` directly. Unlike ``lock_file`` / ``unlock_file`` it does
|
|
530
|
+
NOT call :meth:`assert_session_exists` (that would touch the session); an unknown or
|
|
531
|
+
expired ``session_id`` surfaces as :class:`SessionNotFoundError` from the server answer.
|
|
532
|
+
``session_id=None`` is the same as not passing it.
|
|
533
|
+
"""
|
|
534
|
+
params: Dict[str, Any] = {"document_id": document_id}
|
|
535
|
+
if session_id is not None:
|
|
536
|
+
params["session_id"] = session_id
|
|
537
|
+
return _unwrap(
|
|
538
|
+
await self._client.call_validated("file_lock_write_check", params)
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
async def _begin_download(
|
|
542
|
+
self,
|
|
543
|
+
session_id: str,
|
|
544
|
+
*,
|
|
545
|
+
file_id: str,
|
|
546
|
+
compression: str = "identity",
|
|
547
|
+
lock: bool = True,
|
|
548
|
+
chunk_size: Optional[int] = None,
|
|
549
|
+
include_backup_history: bool = True,
|
|
550
|
+
project_id: Optional[str] = None,
|
|
551
|
+
) -> Dict[str, Any]:
|
|
552
|
+
"""Internal: call ``project_file_transfer_download_begin``."""
|
|
553
|
+
fid = _require_non_empty(file_id, field="file_id")
|
|
554
|
+
_validate_download_target(file_id=fid)
|
|
555
|
+
await self.assert_session_exists(session_id)
|
|
556
|
+
params: Dict[str, Any] = {
|
|
557
|
+
"session_id": session_id,
|
|
558
|
+
"compression": compression,
|
|
559
|
+
"lock_mode": _lock_mode_from_flag(lock),
|
|
560
|
+
"include_backup_history": include_backup_history,
|
|
561
|
+
"file_id": fid,
|
|
562
|
+
}
|
|
563
|
+
if chunk_size is not None:
|
|
564
|
+
params["chunk_size"] = chunk_size
|
|
565
|
+
if project_id is not None:
|
|
566
|
+
params["project_id"] = _require_non_empty(project_id, field="project_id")
|
|
567
|
+
payload = _unwrap(
|
|
568
|
+
await self._client.call_validated(
|
|
569
|
+
"project_file_transfer_download_begin",
|
|
570
|
+
params,
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
_extract_file_id(payload)
|
|
574
|
+
return payload
|
|
575
|
+
|
|
576
|
+
async def download(
|
|
577
|
+
self,
|
|
578
|
+
session_id: str,
|
|
579
|
+
destination: Union[str, Path],
|
|
580
|
+
file_id: str,
|
|
581
|
+
*,
|
|
582
|
+
compression: str = "identity",
|
|
583
|
+
lock: bool = True,
|
|
584
|
+
include_backup_history: bool = True,
|
|
585
|
+
project_id: Optional[str] = None,
|
|
586
|
+
) -> Tuple[Dict[str, Any], Any]:
|
|
587
|
+
"""Download an indexed file by ``file_id`` (``project_file_transfer_download_begin``).
|
|
588
|
+
|
|
589
|
+
Returns ``(begin_payload, adapter_receipt)``. ``file_path`` is not accepted —
|
|
590
|
+
resolve ``file_id`` from ``list_project_files`` or ``session_view`` first.
|
|
591
|
+
"""
|
|
592
|
+
fid = _require_non_empty(file_id, field="file_id")
|
|
593
|
+
begin = await self._begin_download(
|
|
594
|
+
session_id,
|
|
595
|
+
file_id=fid,
|
|
596
|
+
compression=compression,
|
|
597
|
+
lock=lock,
|
|
598
|
+
include_backup_history=include_backup_history,
|
|
599
|
+
project_id=project_id,
|
|
600
|
+
)
|
|
601
|
+
transfer_id = str(begin.get("transfer_id") or "").strip()
|
|
602
|
+
if not transfer_id:
|
|
603
|
+
raise ClientValidationError(
|
|
604
|
+
"download begin returned no transfer_id",
|
|
605
|
+
field="transfer_id",
|
|
606
|
+
details=begin,
|
|
607
|
+
)
|
|
608
|
+
receipt = await self.download_to_path(transfer_id, destination)
|
|
609
|
+
return begin, receipt
|
|
610
|
+
|
|
611
|
+
async def download_to_path(
|
|
612
|
+
self,
|
|
613
|
+
transfer_id: str,
|
|
614
|
+
destination: Union[str, Path],
|
|
615
|
+
) -> Any:
|
|
616
|
+
"""Stream download chunks to ``destination`` via the adapter client."""
|
|
617
|
+
return await self._client.rpc.download_file(
|
|
618
|
+
str(transfer_id).strip(),
|
|
619
|
+
str(destination),
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
async def upload_bytes(
|
|
623
|
+
self,
|
|
624
|
+
payload: bytes,
|
|
625
|
+
*,
|
|
626
|
+
filename: str,
|
|
627
|
+
compression: str = "identity",
|
|
628
|
+
) -> Any:
|
|
629
|
+
"""Upload raw bytes through the adapter buffer; returns upload receipt.
|
|
630
|
+
|
|
631
|
+
``filename`` is a NAME reported to the server, never a path this client
|
|
632
|
+
may write to. Bug c3fafbd4: this method used to stage the payload at
|
|
633
|
+
``Path(filename)`` -- relative, therefore inside the CALLER's working
|
|
634
|
+
directory -- and delete it in a ``finally``, so uploading a file named
|
|
635
|
+
``pyproject.toml`` overwrote and then deleted the caller's own
|
|
636
|
+
``pyproject.toml``. The live pipeline, which runs from the repository
|
|
637
|
+
root and seeds fixtures with exactly those names, destroyed developer
|
|
638
|
+
files on every sweep while reporting success. The payload is now staged
|
|
639
|
+
in a private temporary directory that is removed with its contents; the
|
|
640
|
+
caller's cwd is never read from or written to.
|
|
641
|
+
"""
|
|
642
|
+
with tempfile.TemporaryDirectory(prefix="ca_upload_") as staging_dir:
|
|
643
|
+
# Keep the basename so the transport still sees the intended name;
|
|
644
|
+
# any directory component in `filename` is not a path to honour here.
|
|
645
|
+
dest = Path(staging_dir) / (Path(filename).name or "upload.bin")
|
|
646
|
+
dest.write_bytes(payload)
|
|
647
|
+
return await self._client.rpc.upload_file(
|
|
648
|
+
str(dest),
|
|
649
|
+
filename=filename,
|
|
650
|
+
compression=compression,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
async def _commit_upload(
|
|
654
|
+
self,
|
|
655
|
+
session_id: str,
|
|
656
|
+
transfer_id: str,
|
|
657
|
+
*,
|
|
658
|
+
project_id: Optional[str] = None,
|
|
659
|
+
file_id: Optional[str] = None,
|
|
660
|
+
file_path: Optional[str] = None,
|
|
661
|
+
unlock_after_write: bool = True,
|
|
662
|
+
dry_run: bool = False,
|
|
663
|
+
backup: bool = True,
|
|
664
|
+
diff: bool = False,
|
|
665
|
+
diff_context_lines: Optional[int] = None,
|
|
666
|
+
commit_message: Optional[str] = None,
|
|
667
|
+
validate_syntax_only: bool = False,
|
|
668
|
+
tree_id: Optional[str] = None,
|
|
669
|
+
lock_mode: Optional[str] = None,
|
|
670
|
+
) -> Dict[str, Any]:
|
|
671
|
+
"""Persist a completed adapter upload (``project_file_transfer_upload_save``)."""
|
|
672
|
+
_validate_upload_selector(
|
|
673
|
+
file_id=file_id,
|
|
674
|
+
file_path=file_path,
|
|
675
|
+
project_id=project_id,
|
|
676
|
+
)
|
|
677
|
+
await self.assert_session_exists(session_id)
|
|
678
|
+
params: Dict[str, Any] = {
|
|
679
|
+
"session_id": session_id,
|
|
680
|
+
"transfer_id": transfer_id,
|
|
681
|
+
"unlock_after_write": unlock_after_write,
|
|
682
|
+
"dry_run": dry_run,
|
|
683
|
+
"backup": backup,
|
|
684
|
+
}
|
|
685
|
+
if project_id is not None:
|
|
686
|
+
params["project_id"] = project_id
|
|
687
|
+
if file_id is not None:
|
|
688
|
+
params["file_id"] = file_id
|
|
689
|
+
if file_path is not None:
|
|
690
|
+
params["file_path"] = file_path
|
|
691
|
+
if diff:
|
|
692
|
+
params["diff"] = True
|
|
693
|
+
if diff_context_lines is not None:
|
|
694
|
+
params["diff_context_lines"] = diff_context_lines
|
|
695
|
+
if commit_message is not None:
|
|
696
|
+
params["commit_message"] = commit_message
|
|
697
|
+
if validate_syntax_only:
|
|
698
|
+
params["validate_syntax_only"] = True
|
|
699
|
+
if tree_id is not None:
|
|
700
|
+
params["tree_id"] = tree_id
|
|
701
|
+
if lock_mode is not None:
|
|
702
|
+
params["lock_mode"] = lock_mode
|
|
703
|
+
return _unwrap(
|
|
704
|
+
await self._client.call_validated(
|
|
705
|
+
"project_file_transfer_upload_save",
|
|
706
|
+
params,
|
|
707
|
+
)
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
async def upload_new(
|
|
711
|
+
self,
|
|
712
|
+
session_id: str,
|
|
713
|
+
payload: bytes,
|
|
714
|
+
project_id: str,
|
|
715
|
+
file_path: str,
|
|
716
|
+
*,
|
|
717
|
+
filename: Optional[str] = None,
|
|
718
|
+
compression: str = "identity",
|
|
719
|
+
unlock: bool = True,
|
|
720
|
+
dry_run: bool = False,
|
|
721
|
+
backup: bool = True,
|
|
722
|
+
diff: bool = False,
|
|
723
|
+
diff_context_lines: Optional[int] = None,
|
|
724
|
+
commit_message: Optional[str] = None,
|
|
725
|
+
validate_syntax_only: bool = False,
|
|
726
|
+
tree_id: Optional[str] = None,
|
|
727
|
+
lock_mode: Optional[str] = None,
|
|
728
|
+
) -> str:
|
|
729
|
+
"""Create a new project file (``project_file_transfer_upload_save`` create mode).
|
|
730
|
+
|
|
731
|
+
Pass ``project_id`` and project-relative ``file_path`` only — do not pass
|
|
732
|
+
``file_id``. The path must not already be indexed (``FILE_ALREADY_INDEXED``
|
|
733
|
+
otherwise). Returns the new ``file_id`` (may be empty on ``dry_run``).
|
|
734
|
+
"""
|
|
735
|
+
pid = _require_non_empty(project_id, field="project_id")
|
|
736
|
+
fp = _require_non_empty(file_path, field="file_path")
|
|
737
|
+
name = filename or Path(fp).name
|
|
738
|
+
receipt = await self.upload_bytes(
|
|
739
|
+
payload,
|
|
740
|
+
filename=name,
|
|
741
|
+
compression=compression,
|
|
742
|
+
)
|
|
743
|
+
if not getattr(receipt, "completed", False):
|
|
744
|
+
raise ClientValidationError(
|
|
745
|
+
"upload did not complete",
|
|
746
|
+
field="transfer_id",
|
|
747
|
+
details={"receipt": repr(receipt)},
|
|
748
|
+
)
|
|
749
|
+
saved = await self._commit_upload(
|
|
750
|
+
session_id,
|
|
751
|
+
str(receipt.transfer_id),
|
|
752
|
+
project_id=pid,
|
|
753
|
+
file_path=fp,
|
|
754
|
+
unlock_after_write=unlock,
|
|
755
|
+
backup=backup,
|
|
756
|
+
dry_run=dry_run,
|
|
757
|
+
diff=diff,
|
|
758
|
+
diff_context_lines=diff_context_lines,
|
|
759
|
+
commit_message=commit_message,
|
|
760
|
+
validate_syntax_only=validate_syntax_only,
|
|
761
|
+
tree_id=tree_id,
|
|
762
|
+
lock_mode=lock_mode,
|
|
763
|
+
)
|
|
764
|
+
if dry_run:
|
|
765
|
+
fid = str(saved.get("file_id") or "").strip()
|
|
766
|
+
return fid
|
|
767
|
+
return _extract_file_id(saved)
|
|
768
|
+
|
|
769
|
+
async def upload(
|
|
770
|
+
self,
|
|
771
|
+
session_id: str,
|
|
772
|
+
payload: bytes,
|
|
773
|
+
file_id: str,
|
|
774
|
+
*,
|
|
775
|
+
project_id: Optional[str] = None,
|
|
776
|
+
filename: Optional[str] = None,
|
|
777
|
+
compression: str = "identity",
|
|
778
|
+
unlock: bool = True,
|
|
779
|
+
backup: bool = True,
|
|
780
|
+
dry_run: bool = False,
|
|
781
|
+
diff: bool = False,
|
|
782
|
+
diff_context_lines: Optional[int] = None,
|
|
783
|
+
commit_message: Optional[str] = None,
|
|
784
|
+
validate_syntax_only: bool = False,
|
|
785
|
+
tree_id: Optional[str] = None,
|
|
786
|
+
lock_mode: Optional[str] = None,
|
|
787
|
+
) -> Dict[str, Any]:
|
|
788
|
+
"""Overwrite an existing indexed file (``project_file_transfer_upload_save`` update mode).
|
|
789
|
+
|
|
790
|
+
Pass ``file_id`` only. Optional ``project_id`` must match the row if provided.
|
|
791
|
+
Do not pass ``file_path``. Returns the server save payload.
|
|
792
|
+
"""
|
|
793
|
+
fid = _require_non_empty(file_id, field="file_id")
|
|
794
|
+
name = filename or "payload.bin"
|
|
795
|
+
receipt = await self.upload_bytes(
|
|
796
|
+
payload,
|
|
797
|
+
filename=name,
|
|
798
|
+
compression=compression,
|
|
799
|
+
)
|
|
800
|
+
if not getattr(receipt, "completed", False):
|
|
801
|
+
raise ClientValidationError(
|
|
802
|
+
"upload did not complete",
|
|
803
|
+
field="transfer_id",
|
|
804
|
+
details={"receipt": repr(receipt)},
|
|
805
|
+
)
|
|
806
|
+
saved = await self._commit_upload(
|
|
807
|
+
session_id,
|
|
808
|
+
str(receipt.transfer_id),
|
|
809
|
+
file_id=fid,
|
|
810
|
+
project_id=project_id,
|
|
811
|
+
unlock_after_write=unlock,
|
|
812
|
+
backup=backup,
|
|
813
|
+
dry_run=dry_run,
|
|
814
|
+
diff=diff,
|
|
815
|
+
diff_context_lines=diff_context_lines,
|
|
816
|
+
commit_message=commit_message,
|
|
817
|
+
validate_syntax_only=validate_syntax_only,
|
|
818
|
+
tree_id=tree_id,
|
|
819
|
+
lock_mode=lock_mode,
|
|
820
|
+
)
|
|
821
|
+
if not dry_run:
|
|
822
|
+
_extract_file_id(saved)
|
|
823
|
+
return saved
|