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.
@@ -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