agentfense 0.2.1__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.
agentfense/client.py ADDED
@@ -0,0 +1,751 @@
1
+ """Sandbox SDK Client for interacting with the Sandbox service."""
2
+
3
+ from datetime import timedelta
4
+ from functools import wraps
5
+ from typing import Callable, Dict, Iterator, List, Optional, TypeVar, Union
6
+
7
+ import grpc
8
+ from google.protobuf.duration_pb2 import Duration
9
+
10
+ from ._gen import sandbox_pb2, sandbox_pb2_grpc
11
+ from ._gen import codebase_pb2, codebase_pb2_grpc
12
+ from ._gen import common_pb2
13
+ from .types import (
14
+ Codebase,
15
+ ExecResult,
16
+ FileInfo,
17
+ Permission,
18
+ PatternType,
19
+ PermissionRule,
20
+ ResourceLimits,
21
+ RuntimeType,
22
+ Sandbox,
23
+ SandboxStatus,
24
+ Session,
25
+ SessionStatus,
26
+ UploadResult,
27
+ )
28
+ from .exceptions import (
29
+ SandboxError,
30
+ SandboxNotFoundError,
31
+ CodebaseNotFoundError,
32
+ CommandTimeoutError,
33
+ ConnectionError,
34
+ PermissionDeniedError,
35
+ SessionNotFoundError,
36
+ from_grpc_error,
37
+ )
38
+ from ._shared import (
39
+ permission_to_proto,
40
+ pattern_type_to_proto,
41
+ runtime_type_to_proto,
42
+ resource_limits_to_proto,
43
+ duration_to_timedelta,
44
+ proto_to_sandbox,
45
+ proto_to_codebase,
46
+ proto_to_file_info,
47
+ proto_to_session,
48
+ )
49
+
50
+ # Type variable for decorator
51
+ T = TypeVar("T")
52
+
53
+
54
+ def _handle_grpc_errors(context: str = "") -> Callable:
55
+ """Decorator to convert gRPC errors to SDK exceptions.
56
+
57
+ Args:
58
+ context: Description of the operation for error messages.
59
+ """
60
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
61
+ @wraps(func)
62
+ def wrapper(*args, **kwargs) -> T:
63
+ try:
64
+ return func(*args, **kwargs)
65
+ except grpc.RpcError as e:
66
+ raise from_grpc_error(e, context)
67
+ return wrapper
68
+ return decorator
69
+
70
+
71
+ class SessionWrapper:
72
+ """Wrapper for a shell session with context manager support.
73
+
74
+ A session maintains a persistent shell process that preserves
75
+ working directory, environment variables, and background processes.
76
+
77
+ Example:
78
+ >>> with sandbox.session() as session:
79
+ ... session.exec("cd /workspace")
80
+ ... session.exec("npm install")
81
+ ... result = session.exec("npm test")
82
+ """
83
+
84
+ def __init__(self, client: "SandboxClient", session: Session):
85
+ """Initialize the SessionWrapper.
86
+
87
+ Args:
88
+ client: The SandboxClient instance.
89
+ session: The Session object.
90
+ """
91
+ self._client = client
92
+ self._session = session
93
+
94
+ @property
95
+ def id(self) -> str:
96
+ """Get the session ID."""
97
+ return self._session.id
98
+
99
+ @property
100
+ def sandbox_id(self) -> str:
101
+ """Get the sandbox ID."""
102
+ return self._session.sandbox_id
103
+
104
+ @property
105
+ def status(self) -> SessionStatus:
106
+ """Get the session status."""
107
+ return self._session.status
108
+
109
+ @property
110
+ def shell(self) -> str:
111
+ """Get the shell binary path."""
112
+ return self._session.shell
113
+
114
+ def exec(
115
+ self,
116
+ command: str,
117
+ timeout: Optional[timedelta] = None,
118
+ ) -> ExecResult:
119
+ """Execute a command in the session.
120
+
121
+ The command runs in the context of the persistent shell,
122
+ so working directory and environment changes persist.
123
+
124
+ Args:
125
+ command: The command to execute.
126
+ timeout: Optional timeout duration.
127
+
128
+ Returns:
129
+ The ExecResult with stdout, stderr, and exit code.
130
+ """
131
+ return self._client.session_exec(self.id, command, timeout)
132
+
133
+ def close(self):
134
+ """Close the session and clean up all child processes."""
135
+ self._client.destroy_session(self.id)
136
+
137
+ def __enter__(self) -> "SessionWrapper":
138
+ """Enter context manager."""
139
+ return self
140
+
141
+ def __exit__(self, exc_type, exc_val, exc_tb):
142
+ """Exit context manager, closing the session."""
143
+ self.close()
144
+
145
+
146
+ class SandboxClient:
147
+ """Client for interacting with the Sandbox service.
148
+
149
+ Example:
150
+ >>> client = SandboxClient(endpoint="localhost:9000")
151
+ >>> sandbox = client.create_sandbox(
152
+ ... codebase_id="cb_123",
153
+ ... permissions=[
154
+ ... {"pattern": "/docs/**", "permission": "write"},
155
+ ... {"pattern": "**/*.py", "permission": "read"},
156
+ ... ]
157
+ ... )
158
+ >>> client.start_sandbox(sandbox.id)
159
+ >>> result = client.exec(sandbox.id, command="ls -la /workspace")
160
+ >>> print(result.stdout)
161
+ """
162
+
163
+ def __init__(self, endpoint: str = "localhost:9000", secure: bool = False):
164
+ """Initialize the SandboxClient.
165
+
166
+ Args:
167
+ endpoint: The gRPC server endpoint (host:port).
168
+ secure: Whether to use TLS for the connection.
169
+ """
170
+ if secure:
171
+ self._channel = grpc.secure_channel(endpoint, grpc.ssl_channel_credentials())
172
+ else:
173
+ self._channel = grpc.insecure_channel(endpoint)
174
+
175
+ self._sandbox_stub = sandbox_pb2_grpc.SandboxServiceStub(self._channel)
176
+ self._codebase_stub = codebase_pb2_grpc.CodebaseServiceStub(self._channel)
177
+
178
+ def close(self):
179
+ """Close the gRPC channel."""
180
+ self._channel.close()
181
+
182
+ def __enter__(self):
183
+ return self
184
+
185
+ def __exit__(self, exc_type, exc_val, exc_tb):
186
+ self.close()
187
+
188
+ # ============================================
189
+ # Sandbox Operations
190
+ # ============================================
191
+
192
+ @_handle_grpc_errors("create sandbox")
193
+ def create_sandbox(
194
+ self,
195
+ codebase_id: str,
196
+ permissions: Optional[List[Union[PermissionRule, Dict]]] = None,
197
+ labels: Optional[Dict[str, str]] = None,
198
+ expires_in: Optional[timedelta] = None,
199
+ runtime: RuntimeType = RuntimeType.BWRAP,
200
+ image: Optional[str] = None,
201
+ resources: Optional[ResourceLimits] = None,
202
+ ) -> Sandbox:
203
+ """Create a new sandbox.
204
+
205
+ Args:
206
+ codebase_id: The ID of the codebase to use.
207
+ permissions: List of permission rules (PermissionRule or dict).
208
+ labels: Optional labels for the sandbox.
209
+ expires_in: Optional expiration duration.
210
+ runtime: Runtime type (bwrap or docker). Default is bwrap.
211
+ image: Docker image to use (required for docker runtime).
212
+ resources: Resource limits (memory, CPU, processes).
213
+
214
+ Returns:
215
+ The created Sandbox object.
216
+
217
+ Raises:
218
+ SandboxError: If sandbox creation fails.
219
+ InvalidConfigurationError: If configuration is invalid.
220
+
221
+ Example:
222
+ >>> sandbox = client.create_sandbox(
223
+ ... codebase_id="cb_123",
224
+ ... permissions=[{"pattern": "**/*", "permission": "read"}],
225
+ ... runtime=RuntimeType.DOCKER,
226
+ ... image="python:3.11-slim",
227
+ ... resources=ResourceLimits(memory_bytes=512*1024*1024),
228
+ ... )
229
+ """
230
+ # Convert permissions to protobuf
231
+ pb_permissions = []
232
+ if permissions:
233
+ for p in permissions:
234
+ if isinstance(p, PermissionRule):
235
+ pb_permissions.append(common_pb2.PermissionRule(
236
+ pattern=p.pattern,
237
+ permission=permission_to_proto(p.permission),
238
+ type=pattern_type_to_proto(p.type),
239
+ priority=p.priority,
240
+ ))
241
+ elif isinstance(p, dict):
242
+ perm = Permission(p.get("permission", "read"))
243
+ ptype = PatternType(p.get("type", "glob"))
244
+ pb_permissions.append(common_pb2.PermissionRule(
245
+ pattern=p["pattern"],
246
+ permission=permission_to_proto(perm),
247
+ type=pattern_type_to_proto(ptype),
248
+ priority=int(p.get("priority", 0)),
249
+ ))
250
+
251
+ # Build request
252
+ request = sandbox_pb2.CreateSandboxRequest(
253
+ codebase_id=codebase_id,
254
+ permissions=pb_permissions,
255
+ labels=labels or {},
256
+ runtime=runtime_type_to_proto(runtime),
257
+ image=image or "",
258
+ )
259
+
260
+ if expires_in:
261
+ request.expires_in.CopyFrom(Duration(
262
+ seconds=int(expires_in.total_seconds()),
263
+ nanos=int((expires_in.total_seconds() % 1) * 1e9),
264
+ ))
265
+
266
+ if resources:
267
+ pb_resources = resource_limits_to_proto(resources)
268
+ if pb_resources:
269
+ request.resources.CopyFrom(pb_resources)
270
+
271
+ response = self._sandbox_stub.CreateSandbox(request)
272
+ return proto_to_sandbox(response)
273
+
274
+ @_handle_grpc_errors("get sandbox")
275
+ def get_sandbox(self, sandbox_id: str) -> Sandbox:
276
+ """Get information about a sandbox.
277
+
278
+ Args:
279
+ sandbox_id: The ID of the sandbox.
280
+
281
+ Returns:
282
+ The Sandbox object.
283
+
284
+ Raises:
285
+ SandboxNotFoundError: If the sandbox doesn't exist.
286
+ """
287
+ request = sandbox_pb2.GetSandboxRequest(sandbox_id=sandbox_id)
288
+ response = self._sandbox_stub.GetSandbox(request)
289
+ return proto_to_sandbox(response)
290
+
291
+ @_handle_grpc_errors("list sandboxes")
292
+ def list_sandboxes(self, codebase_id: Optional[str] = None) -> List[Sandbox]:
293
+ """List all sandboxes.
294
+
295
+ Args:
296
+ codebase_id: Optional filter by codebase ID.
297
+
298
+ Returns:
299
+ List of Sandbox objects.
300
+ """
301
+ request = sandbox_pb2.ListSandboxesRequest(codebase_id=codebase_id or "")
302
+ response = self._sandbox_stub.ListSandboxes(request)
303
+ return [proto_to_sandbox(sb) for sb in response.sandboxes]
304
+
305
+ @_handle_grpc_errors("start sandbox")
306
+ def start_sandbox(self, sandbox_id: str) -> Sandbox:
307
+ """Start a pending sandbox.
308
+
309
+ Args:
310
+ sandbox_id: The ID of the sandbox to start.
311
+
312
+ Returns:
313
+ The updated Sandbox object.
314
+
315
+ Raises:
316
+ SandboxNotFoundError: If the sandbox doesn't exist.
317
+ """
318
+ request = sandbox_pb2.StartSandboxRequest(sandbox_id=sandbox_id)
319
+ response = self._sandbox_stub.StartSandbox(request)
320
+ return proto_to_sandbox(response)
321
+
322
+ @_handle_grpc_errors("stop sandbox")
323
+ def stop_sandbox(self, sandbox_id: str) -> Sandbox:
324
+ """Stop a running sandbox.
325
+
326
+ Args:
327
+ sandbox_id: The ID of the sandbox to stop.
328
+
329
+ Returns:
330
+ The updated Sandbox object.
331
+
332
+ Raises:
333
+ SandboxNotFoundError: If the sandbox doesn't exist.
334
+ """
335
+ request = sandbox_pb2.StopSandboxRequest(sandbox_id=sandbox_id)
336
+ response = self._sandbox_stub.StopSandbox(request)
337
+ return proto_to_sandbox(response)
338
+
339
+ @_handle_grpc_errors("destroy sandbox")
340
+ def destroy_sandbox(self, sandbox_id: str) -> None:
341
+ """Destroy a sandbox and release resources.
342
+
343
+ Args:
344
+ sandbox_id: The ID of the sandbox to destroy.
345
+
346
+ Raises:
347
+ SandboxNotFoundError: If the sandbox doesn't exist.
348
+ """
349
+ request = sandbox_pb2.DestroySandboxRequest(sandbox_id=sandbox_id)
350
+ self._sandbox_stub.DestroySandbox(request)
351
+
352
+ @_handle_grpc_errors("execute command")
353
+ def exec(
354
+ self,
355
+ sandbox_id: str,
356
+ command: str,
357
+ stdin: Optional[str] = None,
358
+ env: Optional[Dict[str, str]] = None,
359
+ workdir: Optional[str] = None,
360
+ timeout: Optional[timedelta] = None,
361
+ ) -> ExecResult:
362
+ """Execute a command in a sandbox.
363
+
364
+ Args:
365
+ sandbox_id: The ID of the sandbox.
366
+ command: The command to execute.
367
+ stdin: Optional stdin input.
368
+ env: Optional environment variables.
369
+ workdir: Optional working directory.
370
+ timeout: Optional timeout duration.
371
+
372
+ Returns:
373
+ The ExecResult with stdout, stderr, and exit code.
374
+
375
+ Raises:
376
+ SandboxNotFoundError: If the sandbox doesn't exist.
377
+ SandboxNotRunningError: If the sandbox isn't running.
378
+ CommandTimeoutError: If the command times out.
379
+ """
380
+ request = sandbox_pb2.ExecRequest(
381
+ sandbox_id=sandbox_id,
382
+ command=command,
383
+ stdin=stdin or "",
384
+ env=env or {},
385
+ workdir=workdir or "",
386
+ )
387
+
388
+ if timeout:
389
+ request.timeout.CopyFrom(Duration(
390
+ seconds=int(timeout.total_seconds()),
391
+ nanos=int((timeout.total_seconds() % 1) * 1e9),
392
+ ))
393
+
394
+ response = self._sandbox_stub.Exec(request)
395
+ return ExecResult(
396
+ stdout=response.stdout,
397
+ stderr=response.stderr,
398
+ exit_code=response.exit_code,
399
+ duration=duration_to_timedelta(response.duration),
400
+ command=command,
401
+ )
402
+
403
+ @_handle_grpc_errors("execute command (stream)")
404
+ def exec_stream(
405
+ self,
406
+ sandbox_id: str,
407
+ command: str,
408
+ stdin: Optional[str] = None,
409
+ env: Optional[Dict[str, str]] = None,
410
+ workdir: Optional[str] = None,
411
+ timeout: Optional[timedelta] = None,
412
+ ) -> Iterator[bytes]:
413
+ """Execute a command and stream the output.
414
+
415
+ Args:
416
+ sandbox_id: The ID of the sandbox.
417
+ command: The command to execute.
418
+ stdin: Optional stdin input.
419
+ env: Optional environment variables.
420
+ workdir: Optional working directory.
421
+ timeout: Optional timeout duration.
422
+
423
+ Yields:
424
+ Chunks of output data.
425
+
426
+ Raises:
427
+ SandboxNotFoundError: If the sandbox doesn't exist.
428
+ SandboxNotRunningError: If the sandbox isn't running.
429
+ """
430
+ request = sandbox_pb2.ExecRequest(
431
+ sandbox_id=sandbox_id,
432
+ command=command,
433
+ stdin=stdin or "",
434
+ env=env or {},
435
+ workdir=workdir or "",
436
+ )
437
+
438
+ if timeout:
439
+ request.timeout.CopyFrom(Duration(
440
+ seconds=int(timeout.total_seconds()),
441
+ nanos=int((timeout.total_seconds() % 1) * 1e9),
442
+ ))
443
+
444
+ for response in self._sandbox_stub.ExecStream(request):
445
+ yield response.data
446
+
447
+ # ============================================
448
+ # Session Operations
449
+ # ============================================
450
+
451
+ @_handle_grpc_errors("create session")
452
+ def create_session(
453
+ self,
454
+ sandbox_id: str,
455
+ shell: str = "/bin/sh",
456
+ env: Optional[Dict[str, str]] = None,
457
+ ) -> SessionWrapper:
458
+ """Create a new shell session within a sandbox.
459
+
460
+ A session maintains a persistent shell process that preserves
461
+ working directory, environment variables, and background processes.
462
+
463
+ Args:
464
+ sandbox_id: The ID of the sandbox.
465
+ shell: The shell binary to use (default: /bin/bash).
466
+ env: Optional initial environment variables.
467
+
468
+ Returns:
469
+ A SessionWrapper object for the new session.
470
+
471
+ Raises:
472
+ SandboxNotFoundError: If the sandbox doesn't exist.
473
+ SandboxNotRunningError: If the sandbox isn't running.
474
+
475
+ Example:
476
+ >>> session = client.create_session(sandbox_id)
477
+ >>> session.exec("cd /workspace")
478
+ >>> session.exec("npm install")
479
+ >>> session.close()
480
+ """
481
+ request = sandbox_pb2.CreateSessionRequest(
482
+ sandbox_id=sandbox_id,
483
+ shell=shell,
484
+ env=env or {},
485
+ )
486
+ response = self._sandbox_stub.CreateSession(request)
487
+ session = proto_to_session(response)
488
+ return SessionWrapper(self, session)
489
+
490
+ def session(
491
+ self,
492
+ sandbox_id: str,
493
+ shell: str = "/bin/sh",
494
+ env: Optional[Dict[str, str]] = None,
495
+ ) -> SessionWrapper:
496
+ """Create a session with context manager support (alias for create_session).
497
+
498
+ Example:
499
+ >>> with client.session(sandbox_id) as session:
500
+ ... session.exec("cd /workspace")
501
+ ... session.exec("npm install")
502
+ """
503
+ return self.create_session(sandbox_id, shell, env)
504
+
505
+ @_handle_grpc_errors("get session")
506
+ def get_session(self, session_id: str) -> Session:
507
+ """Get information about a session.
508
+
509
+ Args:
510
+ session_id: The ID of the session.
511
+
512
+ Returns:
513
+ The Session object.
514
+
515
+ Raises:
516
+ SessionNotFoundError: If the session doesn't exist.
517
+ """
518
+ request = sandbox_pb2.GetSessionRequest(session_id=session_id)
519
+ response = self._sandbox_stub.GetSession(request)
520
+ return proto_to_session(response)
521
+
522
+ @_handle_grpc_errors("list sessions")
523
+ def list_sessions(self, sandbox_id: str) -> List[Session]:
524
+ """List all sessions for a sandbox.
525
+
526
+ Args:
527
+ sandbox_id: The ID of the sandbox.
528
+
529
+ Returns:
530
+ List of Session objects.
531
+ """
532
+ request = sandbox_pb2.ListSessionsRequest(sandbox_id=sandbox_id)
533
+ response = self._sandbox_stub.ListSessions(request)
534
+ return [proto_to_session(s) for s in response.sessions]
535
+
536
+ @_handle_grpc_errors("destroy session")
537
+ def destroy_session(self, session_id: str) -> None:
538
+ """Destroy a session and clean up all child processes.
539
+
540
+ Args:
541
+ session_id: The ID of the session to destroy.
542
+
543
+ Raises:
544
+ SessionNotFoundError: If the session doesn't exist.
545
+ """
546
+ request = sandbox_pb2.DestroySessionRequest(session_id=session_id)
547
+ self._sandbox_stub.DestroySession(request)
548
+
549
+ @_handle_grpc_errors("session exec")
550
+ def session_exec(
551
+ self,
552
+ session_id: str,
553
+ command: str,
554
+ timeout: Optional[timedelta] = None,
555
+ ) -> ExecResult:
556
+ """Execute a command within a session (stateful).
557
+
558
+ The command runs in the context of the persistent shell,
559
+ so working directory and environment changes persist.
560
+
561
+ Args:
562
+ session_id: The ID of the session.
563
+ command: The command to execute.
564
+ timeout: Optional timeout duration.
565
+
566
+ Returns:
567
+ The ExecResult with stdout, stderr, and exit code.
568
+
569
+ Raises:
570
+ SessionNotFoundError: If the session doesn't exist.
571
+ SessionClosedError: If the session is closed.
572
+ CommandTimeoutError: If the command times out.
573
+ """
574
+ request = sandbox_pb2.SessionExecRequest(
575
+ session_id=session_id,
576
+ command=command,
577
+ )
578
+
579
+ if timeout:
580
+ request.timeout.CopyFrom(Duration(
581
+ seconds=int(timeout.total_seconds()),
582
+ nanos=int((timeout.total_seconds() % 1) * 1e9),
583
+ ))
584
+
585
+ response = self._sandbox_stub.SessionExec(request)
586
+ return ExecResult(
587
+ stdout=response.stdout,
588
+ stderr=response.stderr,
589
+ exit_code=response.exit_code,
590
+ duration=duration_to_timedelta(response.duration),
591
+ command=command,
592
+ )
593
+
594
+ # ============================================
595
+ # Codebase Operations
596
+ # ============================================
597
+
598
+ @_handle_grpc_errors("create codebase")
599
+ def create_codebase(self, name: str, owner_id: str) -> Codebase:
600
+ """Create a new codebase.
601
+
602
+ Args:
603
+ name: The name of the codebase.
604
+ owner_id: The ID of the owner.
605
+
606
+ Returns:
607
+ The created Codebase object.
608
+ """
609
+ request = codebase_pb2.CreateCodebaseRequest(name=name, owner_id=owner_id)
610
+ response = self._codebase_stub.CreateCodebase(request)
611
+ return proto_to_codebase(response)
612
+
613
+ @_handle_grpc_errors("get codebase")
614
+ def get_codebase(self, codebase_id: str) -> Codebase:
615
+ """Get information about a codebase.
616
+
617
+ Args:
618
+ codebase_id: The ID of the codebase.
619
+
620
+ Returns:
621
+ The Codebase object.
622
+
623
+ Raises:
624
+ CodebaseNotFoundError: If the codebase doesn't exist.
625
+ """
626
+ request = codebase_pb2.GetCodebaseRequest(codebase_id=codebase_id)
627
+ response = self._codebase_stub.GetCodebase(request)
628
+ return proto_to_codebase(response)
629
+
630
+ @_handle_grpc_errors("list codebases")
631
+ def list_codebases(self, owner_id: Optional[str] = None) -> List[Codebase]:
632
+ """List all codebases.
633
+
634
+ Args:
635
+ owner_id: Optional filter by owner ID.
636
+
637
+ Returns:
638
+ List of Codebase objects.
639
+ """
640
+ request = codebase_pb2.ListCodebasesRequest(owner_id=owner_id or "")
641
+ response = self._codebase_stub.ListCodebases(request)
642
+ return [proto_to_codebase(cb) for cb in response.codebases]
643
+
644
+ @_handle_grpc_errors("delete codebase")
645
+ def delete_codebase(self, codebase_id: str) -> None:
646
+ """Delete a codebase.
647
+
648
+ Args:
649
+ codebase_id: The ID of the codebase to delete.
650
+
651
+ Raises:
652
+ CodebaseNotFoundError: If the codebase doesn't exist.
653
+ """
654
+ request = codebase_pb2.DeleteCodebaseRequest(codebase_id=codebase_id)
655
+ self._codebase_stub.DeleteCodebase(request)
656
+
657
+ @_handle_grpc_errors("upload file")
658
+ def upload_file(
659
+ self,
660
+ codebase_id: str,
661
+ file_path: str,
662
+ content: bytes,
663
+ chunk_size: int = 64 * 1024,
664
+ ) -> UploadResult:
665
+ """Upload a file to a codebase.
666
+
667
+ Args:
668
+ codebase_id: The ID of the codebase.
669
+ file_path: The path where the file should be stored.
670
+ content: The file content as bytes.
671
+ chunk_size: Size of upload chunks (default 64KB).
672
+
673
+ Returns:
674
+ The UploadResult with file info.
675
+
676
+ Raises:
677
+ CodebaseNotFoundError: If the codebase doesn't exist.
678
+ UploadError: If the upload fails.
679
+ """
680
+ def generate_chunks():
681
+ # First send metadata
682
+ yield codebase_pb2.UploadChunk(
683
+ metadata=codebase_pb2.UploadChunk.Metadata(
684
+ codebase_id=codebase_id,
685
+ file_path=file_path,
686
+ total_size=len(content),
687
+ )
688
+ )
689
+ # Then send data chunks
690
+ for i in range(0, len(content), chunk_size):
691
+ yield codebase_pb2.UploadChunk(data=content[i:i + chunk_size])
692
+
693
+ response = self._codebase_stub.UploadFiles(generate_chunks())
694
+ return UploadResult(
695
+ codebase_id=response.codebase_id,
696
+ file_path=response.file_path,
697
+ size=response.size,
698
+ checksum=response.checksum,
699
+ )
700
+
701
+ @_handle_grpc_errors("download file")
702
+ def download_file(self, codebase_id: str, file_path: str) -> bytes:
703
+ """Download a file from a codebase.
704
+
705
+ Args:
706
+ codebase_id: The ID of the codebase.
707
+ file_path: The path of the file to download.
708
+
709
+ Returns:
710
+ The file content as bytes.
711
+
712
+ Raises:
713
+ CodebaseNotFoundError: If the codebase doesn't exist.
714
+ FileNotFoundError: If the file doesn't exist.
715
+ """
716
+ request = codebase_pb2.DownloadFileRequest(
717
+ codebase_id=codebase_id,
718
+ file_path=file_path,
719
+ )
720
+ chunks = []
721
+ for response in self._codebase_stub.DownloadFile(request):
722
+ chunks.append(response.data)
723
+ return b"".join(chunks)
724
+
725
+ @_handle_grpc_errors("list files")
726
+ def list_files(
727
+ self,
728
+ codebase_id: str,
729
+ path: str = "",
730
+ recursive: bool = False,
731
+ ) -> List[FileInfo]:
732
+ """List files in a codebase directory.
733
+
734
+ Args:
735
+ codebase_id: The ID of the codebase.
736
+ path: The directory path (empty for root).
737
+ recursive: Whether to list recursively.
738
+
739
+ Returns:
740
+ List of FileInfo objects.
741
+
742
+ Raises:
743
+ CodebaseNotFoundError: If the codebase doesn't exist.
744
+ """
745
+ request = codebase_pb2.ListFilesRequest(
746
+ codebase_id=codebase_id,
747
+ path=path,
748
+ recursive=recursive,
749
+ )
750
+ response = self._codebase_stub.ListFiles(request)
751
+ return [proto_to_file_info(f) for f in response.files]