chuk-artifacts 0.2.2__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/core.py CHANGED
@@ -101,6 +101,79 @@ class CoreStorageOperations:
101
101
  else:
102
102
  raise ProviderError(f"Storage failed: {e}") from e
103
103
 
104
+ async def update_file(
105
+ self,
106
+ artifact_id: str,
107
+ new_data: bytes,
108
+ *,
109
+ mime: Optional[str] = None,
110
+ summary: Optional[str] = None,
111
+ meta: Optional[Dict[str, Any]] = None,
112
+ filename: Optional[str] = None,
113
+ ttl: Optional[int] = None,
114
+ ) -> None:
115
+ """
116
+ Update the contents of an existing artifact.
117
+
118
+ Parameters
119
+ ----------
120
+ artifact_id : str
121
+ ID of the artifact to update.
122
+ new_data : bytes
123
+ New data to overwrite the existing artifact.
124
+ mime : Optional[str]
125
+ Optional new MIME type.
126
+ summary : Optional[str]
127
+ Optional new summary.
128
+ meta : Optional[Dict[str, Any]]
129
+ Optional updated metadata.
130
+ filename : Optional[str]
131
+ Optional new filename.
132
+ ttl : Optional[int]
133
+ Optional TTL to reset (if provided).
134
+ """
135
+ if self.artifact_store._closed:
136
+ raise ArtifactStoreError("Store is closed")
137
+
138
+ try:
139
+ record = await self._get_record(artifact_id)
140
+ key = record["key"]
141
+ session_id = record["session_id"]
142
+
143
+ # Overwrite in object storage
144
+ await self._store_with_retry(
145
+ new_data,
146
+ key,
147
+ mime or record["mime"],
148
+ filename or record.get("filename"),
149
+ session_id,
150
+ )
151
+
152
+ # Update metadata
153
+ record.update({
154
+ "mime": mime or record["mime"],
155
+ "summary": summary or record["summary"],
156
+ "meta": meta or record["meta"],
157
+ "filename": filename or record.get("filename"),
158
+ "bytes": len(new_data),
159
+ "sha256": hashlib.sha256(new_data).hexdigest(),
160
+ "updated_at": datetime.utcnow().isoformat() + "Z",
161
+ "ttl": ttl or record["ttl"],
162
+ })
163
+
164
+ if ttl is not None:
165
+ record["ttl"] = ttl
166
+
167
+ session_ctx_mgr = self.artifact_store._session_factory()
168
+ async with session_ctx_mgr as session:
169
+ await session.setex(artifact_id, record["ttl"], json.dumps(record))
170
+
171
+ logger.info("Artifact updated successfully", extra={"artifact_id": artifact_id})
172
+
173
+ except Exception as e:
174
+ logger.error(f"Update failed for artifact {artifact_id}: {e}")
175
+ raise ProviderError(f"Artifact update failed: {e}") from e
176
+
104
177
  async def retrieve(self, artifact_id: str) -> bytes:
105
178
  """Retrieve artifact data."""
106
179
  if self.artifact_store._closed:
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
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import json
11
11
  import logging
12
- from typing import Any, Dict, List, Optional, TYPE_CHECKING
12
+ from typing import Any, Dict, List, TYPE_CHECKING
13
13
 
14
14
  if TYPE_CHECKING:
15
15
  from .store import ArtifactStore
@@ -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
 
@@ -68,7 +69,7 @@ class MetadataOperations:
68
69
  try:
69
70
  artifacts = []
70
71
  # Use the session manager's canonical prefix instead of building our own
71
- prefix = self.artifact_store._session_manager.get_canonical_prefix(session_id)
72
+ prefix = self.artifact_store.get_canonical_prefix(session_id)
72
73
 
73
74
  storage_ctx_mgr = self.artifact_store._s3_factory()
74
75
  async with storage_ctx_mgr as s3:
@@ -82,7 +83,7 @@ class MetadataOperations:
82
83
  for obj in response.get('Contents', []):
83
84
  key = obj['Key']
84
85
  # Parse the grid key using chuk_sessions
85
- parsed = self.artifact_store._session_manager.parse_grid_key(key)
86
+ parsed = self.artifact_store.parse_grid_key(key)
86
87
  if parsed and parsed.get('artifact_id'):
87
88
  artifact_id = parsed['artifact_id']
88
89
  try:
@@ -93,7 +94,7 @@ class MetadataOperations:
93
94
 
94
95
  return artifacts[:limit]
95
96
 
96
- logger.warning(f"Storage provider doesn't support listing")
97
+ logger.warning("Storage provider doesn't support listing")
97
98
  return []
98
99
 
99
100
  except Exception as e:
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