chuk-artifacts 0.3__py3-none-any.whl → 0.4__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.
chuk_artifacts/grid.py CHANGED
@@ -1,28 +1,161 @@
1
+ # -*- coding: utf-8 -*-
1
2
  # chuk_artifacts/grid.py
2
3
  """Utility helpers for grid-style paths.
3
4
 
4
- Pattern: grid/{sandbox_id}/{session_id}/{artifact_id}[/{subpath}]"""
5
+ Pattern: grid/{sandbox_id}/{session_id}/{artifact_id}[/{subpath}]
6
+
7
+ All components (sandbox_id, session_id, artifact_id) must be non-empty strings
8
+ to ensure proper grid organization and prevent path collisions.
9
+ """
5
10
 
6
11
  from typing import Optional, Dict
7
12
 
8
13
  _ROOT = "grid"
9
14
 
10
15
 
16
+ class GridError(ValueError):
17
+ """Raised when grid path operations encounter invalid input."""
18
+ pass
19
+
20
+
21
+ def _validate_component(component: str, name: str) -> None:
22
+ """Validate a grid path component."""
23
+ if not isinstance(component, str):
24
+ raise GridError(f"{name} must be a string, got {type(component).__name__}")
25
+ if not component:
26
+ raise GridError(f"{name} cannot be empty")
27
+ if "/" in component:
28
+ raise GridError(f"{name} cannot contain '/' characters: {component!r}")
29
+
30
+
11
31
  def canonical_prefix(sandbox_id: str, session_id: str) -> str:
32
+ """
33
+ Generate canonical prefix for a sandbox/session combination.
34
+
35
+ Args:
36
+ sandbox_id: Non-empty sandbox identifier
37
+ session_id: Non-empty session identifier
38
+
39
+ Returns:
40
+ Canonical prefix ending with '/'
41
+
42
+ Raises:
43
+ GridError: If any component is invalid
44
+ """
45
+ _validate_component(sandbox_id, "sandbox_id")
46
+ _validate_component(session_id, "session_id")
47
+
12
48
  return f"{_ROOT}/{sandbox_id}/{session_id}/"
13
49
 
14
50
 
15
51
  def artifact_key(sandbox_id: str, session_id: str, artifact_id: str) -> str:
52
+ """
53
+ Generate artifact key for grid storage.
54
+
55
+ Args:
56
+ sandbox_id: Non-empty sandbox identifier
57
+ session_id: Non-empty session identifier
58
+ artifact_id: Non-empty artifact identifier
59
+
60
+ Returns:
61
+ Grid artifact key
62
+
63
+ Raises:
64
+ GridError: If any component is invalid
65
+ """
66
+ _validate_component(sandbox_id, "sandbox_id")
67
+ _validate_component(session_id, "session_id")
68
+ _validate_component(artifact_id, "artifact_id")
69
+
16
70
  return f"{_ROOT}/{sandbox_id}/{session_id}/{artifact_id}"
17
71
 
18
72
 
19
- def parse(key: str) -> Optional[Dict[str, str]]:
73
+ def parse(key: str) -> Optional[Dict[str, Optional[str]]]:
74
+ """
75
+ Parse a grid key into components.
76
+
77
+ Args:
78
+ key: Grid key to parse
79
+
80
+ Returns:
81
+ Dictionary with components, or None if invalid
82
+
83
+ Examples:
84
+ >>> parse("grid/sandbox/session/artifact")
85
+ {'sandbox_id': 'sandbox', 'session_id': 'session', 'artifact_id': 'artifact', 'subpath': None}
86
+
87
+ >>> parse("grid/sandbox/session/artifact/sub/path")
88
+ {'sandbox_id': 'sandbox', 'session_id': 'session', 'artifact_id': 'artifact', 'subpath': 'sub/path'}
89
+
90
+ >>> parse("invalid/key")
91
+ None
92
+ """
93
+ if not isinstance(key, str):
94
+ return None
95
+
20
96
  parts = key.split("/")
21
- if len(parts) < 4 or parts[0] != _ROOT:
97
+
98
+ # Must have at least 4 parts: root, sandbox, session, artifact
99
+ if len(parts) < 4:
22
100
  return None
101
+
102
+ # Must start with correct root
103
+ if parts[0] != _ROOT:
104
+ return None
105
+
106
+ # Extract components
107
+ sandbox_id = parts[1]
108
+ session_id = parts[2]
109
+ artifact_id = parts[3]
110
+
111
+ # Validate that core components are non-empty
112
+ if not sandbox_id or not session_id or not artifact_id:
113
+ return None
114
+
115
+ # Handle subpath
116
+ subpath = None
117
+ if len(parts) > 4:
118
+ subpath_parts = parts[4:]
119
+ subpath = "/".join(subpath_parts)
120
+ # Convert empty subpath to None for consistency
121
+ if subpath == "":
122
+ subpath = None
123
+
23
124
  return {
24
- "sandbox_id": parts[1],
25
- "session_id": parts[2],
26
- "artifact_id": parts[3] if len(parts) > 3 else None,
27
- "subpath": "/".join(parts[4:]) if len(parts) > 4 else None,
125
+ "sandbox_id": sandbox_id,
126
+ "session_id": session_id,
127
+ "artifact_id": artifact_id,
128
+ "subpath": subpath,
28
129
  }
130
+
131
+
132
+ def is_valid_grid_key(key: str) -> bool:
133
+ """
134
+ Check if a string is a valid grid key.
135
+
136
+ Args:
137
+ key: String to validate
138
+
139
+ Returns:
140
+ True if valid grid key, False otherwise
141
+ """
142
+ return parse(key) is not None
143
+
144
+
145
+ def validate_grid_key(key: str) -> Dict[str, Optional[str]]:
146
+ """
147
+ Validate and parse a grid key, raising an exception if invalid.
148
+
149
+ Args:
150
+ key: Grid key to validate
151
+
152
+ Returns:
153
+ Parsed grid components
154
+
155
+ Raises:
156
+ GridError: If key is invalid
157
+ """
158
+ result = parse(key)
159
+ if result is None:
160
+ raise GridError(f"Invalid grid key: {key!r}")
161
+ return result
@@ -53,6 +53,7 @@ class MetadataOperations:
53
53
  # Delete metadata from session provider
54
54
  session_ctx_mgr = self.artifact_store._session_factory()
55
55
  async with session_ctx_mgr as session:
56
+ # Fix: hasattr is not async, don't await it
56
57
  if hasattr(session, 'delete'):
57
58
  await session.delete(artifact_id)
58
59
 
chuk_artifacts/models.py CHANGED
@@ -1,7 +1,7 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  # chuk_artifacts/models.py
3
3
  from typing import Any, Dict
4
- from pydantic import BaseModel, Field
4
+ from pydantic import BaseModel, Field, ConfigDict
5
5
 
6
6
 
7
7
  class ArtifactEnvelope(BaseModel):
@@ -19,5 +19,5 @@ class ArtifactEnvelope(BaseModel):
19
19
  summary: str # human-readable description / alt
20
20
  meta: Dict[str, Any] = Field(default_factory=dict)
21
21
 
22
- class Config:
23
- extra = "allow" # future-proof: lets tools add keys
22
+ # Pydantic V2 configuration using ConfigDict
23
+ model_config = ConfigDict(extra="allow") # future-proof: lets tools add keys
@@ -88,7 +88,9 @@ class _FilesystemClient:
88
88
 
89
89
  async with self._lock:
90
90
  await self._ensure_parent_dir(object_path)
91
+ # Write the main object file
91
92
  await asyncio.to_thread(object_path.write_bytes, Body)
93
+ # Write the metadata file
92
94
  await self._write_metadata(meta_path, ContentType, Metadata, len(Body), etag)
93
95
 
94
96
  return {
@@ -110,7 +112,16 @@ class _FilesystemClient:
110
112
  meta_path = self._get_metadata_path(object_path)
111
113
 
112
114
  if not object_path.exists():
113
- raise FileNotFoundError(f"NoSuchKey: {Key}")
115
+ # Mimic S3 NoSuchKey error
116
+ error = {
117
+ "Error": {
118
+ "Code": "NoSuchKey",
119
+ "Message": "The specified key does not exist.",
120
+ "Key": Key,
121
+ "BucketName": Bucket,
122
+ }
123
+ }
124
+ raise Exception(f"NoSuchKey: {error}")
114
125
 
115
126
  async with self._lock:
116
127
  body = await asyncio.to_thread(object_path.read_bytes)
@@ -142,7 +153,16 @@ class _FilesystemClient:
142
153
  meta_path = self._get_metadata_path(object_path)
143
154
 
144
155
  if not object_path.exists():
145
- raise FileNotFoundError(f"NoSuchKey: {Key}")
156
+ # Mimic S3 NoSuchKey error
157
+ error = {
158
+ "Error": {
159
+ "Code": "NoSuchKey",
160
+ "Message": "The specified key does not exist.",
161
+ "Key": Key,
162
+ "BucketName": Bucket,
163
+ }
164
+ }
165
+ raise Exception(f"NoSuchKey: {error}")
146
166
 
147
167
  async with self._lock:
148
168
  metadata = await self._read_metadata(meta_path)
@@ -163,7 +183,7 @@ class _FilesystemClient:
163
183
 
164
184
  bucket_path = self._root / Bucket
165
185
  if not bucket_path.exists():
166
- raise FileNotFoundError(f"NoSuchBucket: {Bucket}")
186
+ bucket_path.mkdir(parents=True, exist_ok=True)
167
187
 
168
188
  return {"ResponseMetadata": {"HTTPStatusCode": 200}}
169
189
 
@@ -2,74 +2,30 @@
2
2
  # chuk_artifacts/providers/ibm_cos.py
3
3
  """
4
4
  Factory for an aioboto3 client wired for IBM Cloud Object Storage (COS).
5
- Supports both IAM and HMAC auth.
6
5
 
7
- aioboto3 12 returns an *async-context* client, so we expose
8
- factory() - preferred, used by provider_factory
9
- client() - retained for backward-compat tests/manual use
6
+ AUTO-GENERATED by IBM COS Signature Tester
7
+ Best configuration: Signature v2 + Virtual (IBM COS Alt)
8
+ Score: 10/10
9
+ Tested on: 2025-06-20 16:03:24
10
10
  """
11
11
 
12
12
  from __future__ import annotations
13
13
  import os, aioboto3
14
- from aioboto3.session import AioConfig # ✅ CRITICAL: Import AioConfig
14
+ from aioboto3.session import AioConfig
15
15
  from typing import Optional, Callable, AsyncContextManager
16
16
 
17
- # ──────────────────────────────────────────────────────────────────
18
- # internal helper that actually builds the client
19
- # ──────────────────────────────────────────────────────────────────
20
- def _build_client(
21
- *,
22
- endpoint_url: str,
23
- region: str,
24
- ibm_api_key: Optional[str],
25
- ibm_instance_crn: Optional[str],
26
- access_key: Optional[str],
27
- secret_key: Optional[str],
28
- ):
29
- session = aioboto3.Session()
30
-
31
- # IAM auth (preferred)
32
- if not access_key and not secret_key:
33
- return session.client(
34
- "s3",
35
- endpoint_url=endpoint_url,
36
- region_name=region,
37
- ibm_api_key_id=ibm_api_key,
38
- ibm_service_instance_id=ibm_instance_crn,
39
- # ✅ Use SigV2 for IBM COS IAM + path style
40
- config=AioConfig(
41
- signature_version='s3',
42
- s3={'addressing_style': 'path'}
43
- )
44
- )
45
-
46
- # HMAC auth
47
- return session.client(
48
- "s3",
49
- endpoint_url=endpoint_url,
50
- region_name=region,
51
- aws_access_key_id=access_key,
52
- aws_secret_access_key=secret_key,
53
- # ✅ Use SigV2 for IBM COS HMAC + path style
54
- config=AioConfig(
55
- signature_version='s3',
56
- s3={'addressing_style': 'path'}
57
- )
58
- )
59
-
60
17
 
61
- # ──────────────────────────────────────────────────────────────────
62
- # public factory (provider_factory expects this)
63
- # ──────────────────────────────────────────────────────────────────
64
18
  def factory(
65
19
  *,
66
20
  endpoint_url: Optional[str] = None,
67
21
  region: str = "us-south",
68
22
  access_key: Optional[str] = None,
69
23
  secret_key: Optional[str] = None,
70
- ):
24
+ ) -> Callable[[], AsyncContextManager]:
71
25
  """
72
26
  Return an async-context S3 client for IBM COS (HMAC only).
27
+
28
+ Tested configuration: Signature v2 + Virtual (IBM COS Alt)
73
29
  """
74
30
  endpoint_url = endpoint_url or os.getenv(
75
31
  "IBM_COS_ENDPOINT",
@@ -78,18 +34,21 @@ def factory(
78
34
  access_key = access_key or os.getenv("AWS_ACCESS_KEY_ID")
79
35
  secret_key = secret_key or os.getenv("AWS_SECRET_ACCESS_KEY")
80
36
 
81
- # Extract region from endpoint to ensure they match
37
+ # Extract region from endpoint
82
38
  if endpoint_url:
83
39
  if "us-south" in endpoint_url:
84
40
  region = "us-south"
85
41
  elif "us-east" in endpoint_url:
86
- region = "us-east-1"
42
+ region = "us-east"
87
43
  elif "eu-gb" in endpoint_url:
88
44
  region = "eu-gb"
89
45
  elif "eu-de" in endpoint_url:
90
46
  region = "eu-de"
47
+ elif "jp-tok" in endpoint_url:
48
+ region = "jp-tok"
49
+ elif "au-syd" in endpoint_url:
50
+ region = "au-syd"
91
51
 
92
- # Check AWS_REGION environment variable as override
93
52
  env_region = os.getenv('AWS_REGION')
94
53
  if env_region:
95
54
  region = env_region
@@ -109,13 +68,56 @@ def factory(
109
68
  region_name=region,
110
69
  aws_access_key_id=access_key,
111
70
  aws_secret_access_key=secret_key,
112
- # ✅ CRITICAL: IBM COS requires Signature Version 2 for writes AND presigned URLs
113
71
  config=AioConfig(
114
72
  signature_version='s3',
115
- s3={
116
- 'addressing_style': 'path' # Also ensure path-style addressing
73
+ s3={'addressing_style': 'virtual'},
74
+ read_timeout=60,
75
+ connect_timeout=30,
76
+ retries={
77
+ 'max_attempts': 3,
78
+ 'mode': 'adaptive'
117
79
  }
118
80
  )
119
81
  )
120
82
 
121
- return _make
83
+ return _make
84
+
85
+
86
+ def client(
87
+ *,
88
+ endpoint_url: Optional[str] = None,
89
+ region: Optional[str] = None,
90
+ access_key: Optional[str] = None,
91
+ secret_key: Optional[str] = None,
92
+ ):
93
+ """Return an aioboto3 S3 client context manager for IBM COS."""
94
+ session = aioboto3.Session()
95
+
96
+ endpoint_url = endpoint_url or os.getenv(
97
+ "IBM_COS_ENDPOINT",
98
+ "https://s3.us-south.cloud-object-storage.appdomain.cloud"
99
+ )
100
+
101
+ if not region:
102
+ if "us-south" in endpoint_url:
103
+ region = "us-south"
104
+ elif "us-east" in endpoint_url:
105
+ region = "us-east"
106
+ elif "eu-gb" in endpoint_url:
107
+ region = "eu-gb"
108
+ else:
109
+ region = "us-south"
110
+
111
+ return session.client(
112
+ "s3",
113
+ endpoint_url=endpoint_url,
114
+ region_name=region,
115
+ aws_access_key_id=access_key or os.getenv("AWS_ACCESS_KEY_ID"),
116
+ aws_secret_access_key=secret_key or os.getenv("AWS_SECRET_ACCESS_KEY"),
117
+ config=AioConfig(
118
+ signature_version='s3',
119
+ s3={'addressing_style': 'virtual'},
120
+ read_timeout=60,
121
+ connect_timeout=30
122
+ )
123
+ )
@@ -5,6 +5,11 @@ Async wrapper for IBM Cloud Object Storage using IAM API-key (oauth).
5
5
 
6
6
  ✓ Fits the aioboto3-style interface that ArtifactStore expects:
7
7
  • async put_object(...)
8
+ • async get_object(...)
9
+ • async head_object(...)
10
+ • async delete_object(...)
11
+ • async list_objects_v2(...)
12
+ • async head_bucket(...)
8
13
  • async generate_presigned_url(...)
9
14
  ✓ No HMAC keys required - just IBM_COS_APIKEY + IBM_COS_INSTANCE_CRN.
10
15
 
@@ -27,6 +32,7 @@ from ibm_botocore.client import Config
27
32
 
28
33
  # ─────────────────────────────────────────────────────────────────────
29
34
  def _sync_client():
35
+ """Create synchronous IBM COS client with IAM authentication."""
30
36
  endpoint = os.getenv(
31
37
  "IBM_COS_ENDPOINT",
32
38
  "https://s3.us-south.cloud-object-storage.appdomain.cloud",
@@ -49,19 +55,52 @@ def _sync_client():
49
55
 
50
56
  # ─────────────────────────────────────────────────────────────────────
51
57
  class _AsyncIBMClient:
52
- """Minimal async façade over synchronous ibm_boto3 S3 client."""
58
+ """Complete async façade over synchronous ibm_boto3 S3 client."""
59
+
53
60
  def __init__(self, sync_client):
54
61
  self._c = sync_client
55
62
 
56
- # ---- methods used by ArtifactStore -------------------------------------
57
- async def put_object(self, **kw) -> Dict[str, Any]:
58
- return await asyncio.to_thread(self._c.put_object, **kw)
63
+ # ---- Core S3 operations used by ArtifactStore -------------------------
64
+ async def put_object(self, **kwargs) -> Dict[str, Any]:
65
+ """Store object in IBM COS."""
66
+ return await asyncio.to_thread(self._c.put_object, **kwargs)
59
67
 
60
- async def generate_presigned_url(self, *a, **kw) -> str:
61
- return await asyncio.to_thread(self._c.generate_presigned_url, *a, **kw)
68
+ async def get_object(self, **kwargs) -> Dict[str, Any]:
69
+ """Retrieve object from IBM COS."""
70
+ return await asyncio.to_thread(self._c.get_object, **kwargs)
62
71
 
63
- # ---- cleanup -----------------------------------------------------------
72
+ async def head_object(self, **kwargs) -> Dict[str, Any]:
73
+ """Get object metadata from IBM COS."""
74
+ return await asyncio.to_thread(self._c.head_object, **kwargs)
75
+
76
+ async def delete_object(self, **kwargs) -> Dict[str, Any]:
77
+ """Delete object from IBM COS."""
78
+ return await asyncio.to_thread(self._c.delete_object, **kwargs)
79
+
80
+ async def list_objects_v2(self, **kwargs) -> Dict[str, Any]:
81
+ """List objects in IBM COS bucket."""
82
+ return await asyncio.to_thread(self._c.list_objects_v2, **kwargs)
83
+
84
+ async def head_bucket(self, **kwargs) -> Dict[str, Any]:
85
+ """Check if bucket exists in IBM COS."""
86
+ return await asyncio.to_thread(self._c.head_bucket, **kwargs)
87
+
88
+ async def generate_presigned_url(self, *args, **kwargs) -> str:
89
+ """Generate presigned URL for IBM COS object."""
90
+ return await asyncio.to_thread(self._c.generate_presigned_url, *args, **kwargs)
91
+
92
+ # ---- Additional operations for completeness ---------------------------
93
+ async def copy_object(self, **kwargs) -> Dict[str, Any]:
94
+ """Copy object within IBM COS."""
95
+ return await asyncio.to_thread(self._c.copy_object, **kwargs)
96
+
97
+ async def delete_objects(self, **kwargs) -> Dict[str, Any]:
98
+ """Delete multiple objects from IBM COS."""
99
+ return await asyncio.to_thread(self._c.delete_objects, **kwargs)
100
+
101
+ # ---- Cleanup -----------------------------------------------------------
64
102
  async def close(self):
103
+ """Close the underlying sync client."""
65
104
  await asyncio.to_thread(self._c.close)
66
105
 
67
106
 
@@ -69,6 +108,11 @@ class _AsyncIBMClient:
69
108
  def factory() -> Callable[[], AsyncContextManager]:
70
109
  """
71
110
  Return a zero-arg callable that yields an async-context-manager.
111
+
112
+ Returns
113
+ -------
114
+ Callable[[], AsyncContextManager]
115
+ Factory function that creates IBM COS IAM client context managers
72
116
  """
73
117
 
74
118
  @asynccontextmanager
@@ -36,6 +36,7 @@ class _MemoryS3Client:
36
36
  If None, creates isolated per-instance storage.
37
37
  """
38
38
  self._store: Dict[str, Dict[str, Any]] = shared_store if shared_store is not None else {}
39
+ self._is_shared_store = shared_store is not None
39
40
  self._lock = asyncio.Lock()
40
41
  self._closed = False
41
42
 
@@ -231,8 +232,11 @@ class _MemoryS3Client:
231
232
  async def close(self):
232
233
  """Clean up resources and mark client as closed."""
233
234
  if not self._closed:
234
- async with self._lock:
235
- self._store.clear()
235
+ # Only clear the store if it's NOT shared
236
+ # If it's shared, other clients may still need the data
237
+ if not self._is_shared_store:
238
+ async with self._lock:
239
+ self._store.clear()
236
240
  self._closed = True
237
241
 
238
242
  # ------------------------------------------------------------
@@ -253,6 +257,10 @@ class _MemoryS3Client:
253
257
  "total_objects": total_objects,
254
258
  "total_bytes": total_bytes,
255
259
  "closed": self._closed,
260
+ "is_shared_store": self._is_shared_store,
261
+ "store_id": id(self._store), # Memory address for debugging
262
+ "client_id": id(self), # Client instance ID
263
+ "store_keys": list(self._store.keys())[:5], # First 5 keys for debugging
256
264
  }
257
265
 
258
266
  @classmethod
@@ -263,17 +271,32 @@ class _MemoryS3Client:
263
271
 
264
272
  # ---- public factory -------------------------------------------------------
265
273
 
274
+ # Global shared storage for memory provider when used as default
275
+ _default_shared_store: Dict[str, Dict[str, Any]] = {}
276
+
266
277
  def factory(shared_store: Optional[Dict[str, Dict[str, Any]]] = None) -> Callable[[], AsyncContextManager]:
267
278
  """
268
279
  Return a **zero-arg** factory that yields an async-context client.
269
280
 
281
+ Key behavior for memory provider:
282
+ - If shared_store is provided, uses that specific storage
283
+ - If shared_store is None, ALWAYS uses the global shared storage
284
+ - This ensures all memory clients in the same process share data
285
+ - Prevents issues where ArtifactStore operations can't see each other's data
286
+
270
287
  Parameters
271
288
  ----------
272
289
  shared_store : dict, optional
273
290
  If provided, all clients created by this factory will share
274
- the same storage dict. Useful for testing scenarios where
275
- you need multiple clients to see the same data.
291
+ the same storage dict. If None, will use a global shared store
292
+ to ensure consistency across operations within the same process.
276
293
  """
294
+
295
+ # CRITICAL: Always use global shared storage when none specified
296
+ # This prevents the common issue where each ArtifactStore operation
297
+ # gets a different isolated storage and can't see each other's data
298
+ if shared_store is None:
299
+ shared_store = _default_shared_store
277
300
 
278
301
  @asynccontextmanager
279
302
  async def _ctx():
@@ -307,6 +330,11 @@ async def clear_all_memory_stores():
307
330
  Emergency cleanup function that clears all active memory stores.
308
331
  Useful for test teardown.
309
332
  """
333
+ # Clear the global shared store
334
+ global _default_shared_store
335
+ _default_shared_store.clear()
336
+
337
+ # Close all active instances
310
338
  instances = list(_MemoryS3Client._instances)
311
339
  for instance in instances:
312
340
  try:
chuk_artifacts/store.py CHANGED
@@ -633,4 +633,50 @@ class ArtifactStore:
633
633
  return self
634
634
 
635
635
  async def __aexit__(self, exc_type, exc_val, exc_tb):
636
- await self.close()
636
+ await self.close()
637
+
638
+ async def get_sandbox_info(self) -> Dict[str, Any]:
639
+ """
640
+ Get sandbox information and metadata.
641
+
642
+ Returns
643
+ -------
644
+ Dict[str, Any]
645
+ Dictionary containing sandbox information including:
646
+ - sandbox_id: The current sandbox identifier
647
+ - bucket: The storage bucket name
648
+ - storage_provider: The storage provider type
649
+ - session_provider: The session provider type
650
+ - session_ttl_hours: Default session TTL
651
+ - grid_prefix_pattern: The grid path pattern for this sandbox
652
+ - created_at: Timestamp of when this info was retrieved
653
+ """
654
+ from datetime import datetime
655
+
656
+ # Get session manager stats if available
657
+ session_stats = {}
658
+ try:
659
+ session_stats = self._session_manager.get_cache_stats()
660
+ except Exception:
661
+ pass # Session manager might not have stats
662
+
663
+ # Get storage stats if available
664
+ storage_stats = {}
665
+ try:
666
+ storage_stats = await self._admin.get_stats()
667
+ except Exception:
668
+ pass # Storage might not have stats
669
+
670
+ return {
671
+ "sandbox_id": self.sandbox_id,
672
+ "bucket": self.bucket,
673
+ "storage_provider": self._storage_provider_name,
674
+ "session_provider": self._session_provider_name,
675
+ "session_ttl_hours": self.session_ttl_hours,
676
+ "max_retries": self.max_retries,
677
+ "grid_prefix_pattern": self.get_session_prefix_pattern(),
678
+ "created_at": datetime.utcnow().isoformat() + "Z",
679
+ "session_stats": session_stats,
680
+ "storage_stats": storage_stats,
681
+ "closed": self._closed,
682
+ }