b1sl-python 0.2.0__py3-none-any.whl → 0.3.0__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.
@@ -12,17 +12,17 @@ class RestAdapterProtocol(Protocol):
12
12
  """
13
13
 
14
14
  def get(
15
- self, endpoint: str, ep_params: dict | None = None, data: dict | None = None
15
+ self, endpoint: str, ep_params: dict | None = None, data: dict | None = None, headers: dict | None = None
16
16
  ) -> Result: ...
17
17
 
18
18
  def post(
19
- self, endpoint: str, ep_params: dict | None = None, data: dict | None = None
19
+ self, endpoint: str, ep_params: dict | None = None, data: dict | None = None, headers: dict | None = None
20
20
  ) -> Result: ...
21
21
 
22
22
  def patch(
23
- self, endpoint: str, ep_params: dict | None = None, data: dict | None = None
23
+ self, endpoint: str, ep_params: dict | None = None, data: dict | None = None, headers: dict | None = None
24
24
  ) -> Result: ...
25
25
 
26
26
  def delete(
27
- self, endpoint: str, ep_params: dict | None = None, data: dict | None = None
27
+ self, endpoint: str, ep_params: dict | None = None, data: dict | None = None, headers: dict | None = None
28
28
  ) -> Result: ...
b1sl/b1sl/async_client.py CHANGED
@@ -8,6 +8,7 @@ from b1sl.b1sl.base_adapter import ObservabilityConfig
8
8
  from b1sl.b1sl.config import B1Config
9
9
 
10
10
  if TYPE_CHECKING:
11
+ from b1sl.b1sl.batch.client import BatchClient
11
12
  from b1sl.b1sl.models._generated.entities.businesspartners import (
12
13
  Activity,
13
14
  BusinessPartner,
@@ -98,6 +99,30 @@ class AsyncB1Client:
98
99
  """
99
100
  return self._adapter.dry_run(enabled)
100
101
 
102
+ def with_schema(self, name: str):
103
+ """
104
+ Context manager to temporarily set the B1S-Schema header
105
+ **for the current asyncio.Task only** (task-safe via ContextVar).
106
+
107
+ Usage::
108
+
109
+ async with AsyncB1Client(config) as b1:
110
+ async with b1.with_schema("demo.schema"):
111
+ await b1.items.get("A0001")
112
+ """
113
+ return self._adapter.with_schema(name)
114
+
115
+ def batch(self) -> BatchClient:
116
+ """
117
+ Returns a context manager that groups multiple resource operations
118
+ into a single OData $batch HTTP request.
119
+
120
+ Use this for high-concurrency scenarios (bulk GETs) or transactional
121
+ integrity (atomic ChangeSets). See :class:`BatchClient` for details.
122
+ """
123
+ from b1sl.b1sl.batch.client import BatchClient
124
+ return BatchClient(self)
125
+
101
126
  async def connect(self) -> None:
102
127
  """
103
128
  Initializes the underlying HTTP client and logs in.
@@ -215,6 +215,7 @@ class AsyncRestAdapter(BaseRestAdapter):
215
215
  endpoint: str,
216
216
  ep_params=None,
217
217
  data=None,
218
+ headers: dict | None = None,
218
219
  _is_login: bool = False,
219
220
  _retry_once=True,
220
221
  ) -> Result:
@@ -240,7 +241,9 @@ class AsyncRestAdapter(BaseRestAdapter):
240
241
 
241
242
  try:
242
243
  # ── ETag: inject If-None-Match (GET) or If-Match (PATCH/DELETE/POST) ──
243
- headers = self._build_headers(http_method, endpoint_path)
244
+ req_headers = self._build_headers(http_method, endpoint_path)
245
+ if headers:
246
+ req_headers.update(headers)
244
247
 
245
248
  if self._dry_run_active and http_method in {"POST", "PATCH", "DELETE"} and not _is_login:
246
249
  self._logger.info(f"[{req_id}] [DRY RUN] Intercepting {http_method} {full_url}")
@@ -250,7 +253,7 @@ class AsyncRestAdapter(BaseRestAdapter):
250
253
  else:
251
254
  response = await self._client.request(
252
255
  method=http_method, url=full_url, params=ep_params, json=data,
253
- headers=headers,
256
+ headers=req_headers,
254
257
  )
255
258
 
256
259
  if response.status_code == 401 and _retry_once and not _is_login:
@@ -259,7 +262,7 @@ class AsyncRestAdapter(BaseRestAdapter):
259
262
  # Recursive call will handle its own finally block,
260
263
  # but we need to return here to avoid double-logging/hooking.
261
264
  return await self._do(
262
- http_method, endpoint, ep_params, data, _is_login, _retry_once=False
265
+ http_method, endpoint, ep_params, data, headers, _is_login, _retry_once=False
263
266
  )
264
267
 
265
268
  response.raise_for_status()
@@ -308,7 +311,7 @@ class AsyncRestAdapter(BaseRestAdapter):
308
311
  status_code=status_code,
309
312
  duration_ms=duration_ms,
310
313
  payload=log_data if http_method in {"POST", "PATCH"} else None,
311
- if_match=headers.get("If-Match"),
314
+ if_match=req_headers.get("If-Match"),
312
315
  extra=context_extras,
313
316
  exc=exc_captured,
314
317
  )
@@ -347,18 +350,33 @@ class AsyncRestAdapter(BaseRestAdapter):
347
350
  f"HTTP Error {response.status_code if response else 'Unknown'}"
348
351
  )
349
352
 
350
- async def get(self, endpoint, ep_params=None, data=None):
353
+ async def get(self, endpoint, ep_params=None, data=None, headers=None):
351
354
  """Execute an asynchronous GET request."""
352
- return await self._do("GET", endpoint, ep_params, data)
355
+ return await self._do("GET", endpoint, ep_params, data, headers=headers)
353
356
 
354
- async def post(self, endpoint, ep_params=None, data=None):
357
+ async def post(self, endpoint, ep_params=None, data=None, headers=None):
355
358
  """Execute an asynchronous POST request."""
356
- return await self._do("POST", endpoint, ep_params, data)
359
+ return await self._do("POST", endpoint, ep_params, data, headers=headers)
357
360
 
358
- async def patch(self, endpoint, ep_params=None, data=None):
361
+ async def patch(self, endpoint, ep_params=None, data=None, headers=None):
359
362
  """Execute an asynchronous PATCH request."""
360
- return await self._do("PATCH", endpoint, ep_params, data)
363
+ return await self._do("PATCH", endpoint, ep_params, data, headers=headers)
361
364
 
362
- async def delete(self, endpoint, ep_params=None, data=None):
365
+ async def delete(self, endpoint, ep_params=None, data=None, headers=None):
363
366
  """Execute an asynchronous DELETE request."""
364
- return await self._do("DELETE", endpoint, ep_params, data)
367
+ return await self._do("DELETE", endpoint, ep_params, data, headers=headers)
368
+
369
+ async def post_batch(self, body: str, headers: dict) -> httpx.Response:
370
+ """
371
+ Special method to send raw multipart content for $batch operations.
372
+ """
373
+ await self.ensure_session()
374
+ if not self._client:
375
+ raise B1Exception("AsyncRestAdapter not initialized.")
376
+
377
+ url = f"{self.raw_base_url}/$batch"
378
+ # Combine with session headers if necessary,
379
+ # although httpx already handles them via cookies.
380
+ response = await self._client.post(url, content=body, headers=headers)
381
+ response.raise_for_status()
382
+ return response
b1sl/b1sl/base_adapter.py CHANGED
@@ -185,12 +185,20 @@ class BaseRestAdapter:
185
185
  self._dry_run_var: ContextVar[bool] = ContextVar(
186
186
  f"dry_run_{id(self)}", default=config.dry_run
187
187
  )
188
+ self._b1s_schema_var: ContextVar[str | None] = ContextVar(
189
+ f"b1s_schema_{id(self)}", default=config.b1s_schema
190
+ )
188
191
 
189
192
  @property
190
193
  def _dry_run_active(self) -> bool:
191
194
  """Returns the current effective dry_run state for this task/thread."""
192
195
  return self._dry_run_var.get()
193
196
 
197
+ @property
198
+ def _schema_active(self) -> str | None:
199
+ """Returns the current effective schema state for this task/thread."""
200
+ return self._b1s_schema_var.get()
201
+
194
202
  @contextmanager
195
203
  def dry_run(self, enabled: bool = True):
196
204
  """
@@ -220,6 +228,28 @@ class BaseRestAdapter:
220
228
  finally:
221
229
  self._dry_run_var.reset(token)
222
230
 
231
+ @contextmanager
232
+ def with_schema(self, schema: str | None):
233
+ """
234
+ Context manager to temporarily override the B1S-Schema header
235
+ **for the current asyncio task / thread only**.
236
+
237
+ Usage::
238
+
239
+ # Use a specific schema for this block
240
+ with client.with_schema("demo.schema"):
241
+ await client.items.get("A0001")
242
+
243
+ Note:
244
+ Use ``with``, not ``async with`` — the context manager is
245
+ synchronous even in async code, which is correct and idiomatic.
246
+ """
247
+ token = self._b1s_schema_var.set(schema)
248
+ try:
249
+ yield
250
+ finally:
251
+ self._b1s_schema_var.reset(token)
252
+
223
253
  @classmethod
224
254
  def from_config(
225
255
  cls,
@@ -328,6 +358,10 @@ class BaseRestAdapter:
328
358
  # POST covers OData Actions (e.g. /BusinessPartners('C20000')/Cancel)
329
359
  headers["If-Match"] = cached_etag
330
360
 
361
+ active_schema = self._schema_active
362
+ if active_schema:
363
+ headers["B1S-Schema"] = active_schema
364
+
331
365
  if extra_headers:
332
366
  headers.update(extra_headers)
333
367
  return headers
@@ -0,0 +1,6 @@
1
+ """OData $batch support for the b1sl SDK."""
2
+
3
+ from .client import BatchClient
4
+ from .results import BatchResult, BatchResults
5
+
6
+ __all__ = ["BatchClient", "BatchResult", "BatchResults"]
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Type
6
+
7
+ from b1sl.b1sl.models.result import Result
8
+
9
+ if TYPE_CHECKING:
10
+ from b1sl.b1sl.models.base import B1Model
11
+
12
+ from .client import BatchClient
13
+
14
+ @dataclass
15
+ class PendingRequest:
16
+ """Represents an enqueued request within a batch."""
17
+ method: str
18
+ endpoint: str
19
+ data: dict | None = None
20
+ ep_params: dict | None = None
21
+ model_type: Type[B1Model] | None = None
22
+ changeset_id: str | None = None
23
+ content_id: str = field(default_factory=lambda: str(uuid.uuid4()))
24
+
25
+ class _RecordingAdapter:
26
+ """
27
+ Internal adapter that captures requests instead of executing them.
28
+ Complies with the RestAdapterProtocol asynchronously.
29
+ """
30
+ def __init__(self, batch_client: BatchClient):
31
+ self._batch = batch_client
32
+ self._current_model: Type[B1Model] | None = None
33
+
34
+ def _record(self, method: str, endpoint: str, ep_params: dict | None = None, data: dict | None = None):
35
+ """Captures the request and adds it to the client's queue."""
36
+ if method == "GET" and self._batch.active_changeset_id:
37
+ raise ValueError("OData Batch Error: GET operations are not allowed within a ChangeSet.")
38
+
39
+ req = PendingRequest(
40
+ method=method,
41
+ endpoint=endpoint,
42
+ ep_params=ep_params,
43
+ data=data,
44
+ model_type=self._current_model,
45
+ changeset_id=self._batch.active_changeset_id
46
+ )
47
+ self._batch._pending.append(req)
48
+
49
+ # Return a simulated Result so Pydantic doesn't crash
50
+ return Result(status_code=202, data={})
51
+
52
+ # Asynchronous implementation for AsyncGenericResource
53
+ async def get(self, endpoint, ep_params=None, data=None):
54
+ return self._record("GET", endpoint, ep_params, data)
55
+
56
+ async def post(self, endpoint, ep_params=None, data=None):
57
+ return self._record("POST", endpoint, ep_params, data)
58
+
59
+ async def patch(self, endpoint, ep_params=None, data=None):
60
+ return self._record("PATCH", endpoint, ep_params, data)
61
+
62
+ async def delete(self, endpoint, ep_params=None, data=None):
63
+ return self._record("DELETE", endpoint, ep_params, data)
64
+
65
+ # Synchronous implementation for GenericResource (compatibility)
66
+ def get_sync(self, endpoint, ep_params=None, data=None):
67
+ return self._record("GET", endpoint, ep_params, data)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from .client import BatchClient
8
+
9
+ class ChangeSetContext:
10
+ """
11
+ Manages the lifecycle of a ChangeSet in OData.
12
+ Ensures that operations are atomic and prohibits GETs.
13
+ """
14
+ def __init__(self, batch_client: BatchClient):
15
+ self._batch = batch_client
16
+ self._changeset_id = f"changeset_{uuid.uuid4()}"
17
+
18
+ def __enter__(self) -> BatchClient:
19
+ self._batch.active_changeset_id = self._changeset_id
20
+ return self._batch
21
+
22
+ def __exit__(self, exc_type, exc_val, exc_tb):
23
+ self._batch.active_changeset_id = None
24
+
25
+ async def __aenter__(self) -> BatchClient:
26
+ return self.__enter__()
27
+
28
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
29
+ return self.__exit__(exc_type, exc_val, exc_tb)
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import functools
5
+ import inspect
6
+ import re
7
+ import uuid
8
+ from typing import TYPE_CHECKING, Any, Callable
9
+
10
+ from ._recording_adapter import PendingRequest, _RecordingAdapter
11
+ from .changeset import ChangeSetContext
12
+ from .parser import BatchParser
13
+ from .results import BatchResults
14
+ from .serializer import BatchSerializer
15
+
16
+ if TYPE_CHECKING:
17
+ from b1sl.b1sl import AsyncB1Client, B1Client
18
+
19
+ class ResourceProxy:
20
+ """Recursive proxy for capturing batch requests."""
21
+ def __init__(self, target: Any, adapter: _RecordingAdapter, client: BatchClient):
22
+ self._target = target
23
+ self._adapter = adapter
24
+ self._client = client
25
+
26
+ def __getattr__(self, name: str) -> Any:
27
+ if name.startswith("_"):
28
+ return getattr(self._target, name)
29
+ attr = getattr(self._target, name)
30
+ if inspect.iscoroutinefunction(attr):
31
+ return self._wrap_async(attr)
32
+ if callable(attr):
33
+ return self._wrap_callable(attr)
34
+ return attr
35
+
36
+ def _wrap_async(self, method: Callable) -> Callable:
37
+ @functools.wraps(method)
38
+ async def wrapper(*args, **kwargs):
39
+ model = getattr(self._target, "model", None)
40
+ if model is None and hasattr(self._target, "_resource"):
41
+ model = getattr(self._target._resource, "model", None)
42
+ self._adapter._current_model = model
43
+ try:
44
+ await method(*args, **kwargs)
45
+ except (AttributeError, TypeError):
46
+ # Suppress infrastructure errors from the proxy mechanism;
47
+ # business-logic exceptions (e.g. ValueError for GET-in-ChangeSet)
48
+ # are intentionally allowed to propagate.
49
+ pass
50
+ return None
51
+ return wrapper
52
+
53
+ def _wrap_callable(self, method: Callable) -> Callable:
54
+ @functools.wraps(method)
55
+ def wrapper(*args, **kwargs):
56
+ result = method(*args, **kwargs)
57
+ if hasattr(result, "__dict__") or hasattr(result, "_adapter"):
58
+ if hasattr(result, "_adapter"):
59
+ setattr(result, "_adapter", self._adapter)
60
+ if hasattr(result, "_resource") and hasattr(result._resource, "_adapter"):
61
+ setattr(result._resource, "_adapter", self._adapter)
62
+ return ResourceProxy(result, self._adapter, self._client)
63
+ return result
64
+ return wrapper
65
+
66
+ class BatchClient:
67
+ """OData Batch Orchestrator."""
68
+ def __init__(self, b1_session: AsyncB1Client | B1Client):
69
+ self._b1 = b1_session
70
+ self._pending: list[PendingRequest] = []
71
+ self._adapter = _RecordingAdapter(self)
72
+ self.active_changeset_id: str | None = None
73
+ self._batch_boundary = f"batch_{uuid.uuid4()}"
74
+
75
+ def __getattr__(self, name: str) -> Any:
76
+ real_resource = getattr(self._b1, name)
77
+ new_resource = copy.copy(real_resource)
78
+ if hasattr(new_resource, "_adapter"):
79
+ new_resource._adapter = self._adapter
80
+ return ResourceProxy(new_resource, self._adapter, self)
81
+
82
+ def changeset(self) -> ChangeSetContext:
83
+ return ChangeSetContext(self)
84
+
85
+ def _extract_response_boundary(self, response_content_type: str) -> str | None:
86
+ """Extracts the boundary string from a SAP Content-Type response header."""
87
+ match = re.search(r"boundary=([^\s;]+)", response_content_type)
88
+ if match:
89
+ return match.group(1).strip().replace('"', '')
90
+ return None
91
+
92
+ async def execute(self) -> BatchResults:
93
+ """Dynamically serializes, sends, and parses the batch."""
94
+ if not self._pending:
95
+ return BatchResults([])
96
+
97
+ serializer = BatchSerializer(self._pending, self._batch_boundary)
98
+ body = serializer.serialize()
99
+
100
+ real_adapter = getattr(self._b1, "_adapter")
101
+ headers = {"Content-Type": f"multipart/mixed; boundary={self._batch_boundary}"}
102
+
103
+ response = await real_adapter.post_batch(body, headers)
104
+
105
+ # IMPORTANT: Use the boundary that SAP returns in its response
106
+ resp_ct = response.headers.get("Content-Type", "")
107
+ resp_boundary = self._extract_response_boundary(resp_ct)
108
+
109
+ # Fallback: use the request boundary if SAP omits it in the response
110
+ boundary_to_use = resp_boundary or self._batch_boundary
111
+
112
+ parser = BatchParser(response.text, boundary_to_use)
113
+ expected_models = [req.model_type for req in self._pending]
114
+ raw_results = parser.parse(expected_models)
115
+
116
+ return BatchResults(raw_results)
117
+
118
+ async def __aenter__(self) -> BatchClient:
119
+ return self
120
+
121
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
122
+ # Reset internal state to prevent accidental reuse of a consumed BatchClient.
123
+ self._pending.clear()
124
+ self.active_changeset_id = None
@@ -0,0 +1,120 @@
1
+ import json
2
+ import re
3
+ from typing import Type
4
+
5
+ from .results import BatchResult
6
+
7
+
8
+ class BatchParser:
9
+ """Parses multipart/mixed responses from SAP SL."""
10
+
11
+ def __init__(self, content: str, boundary: str):
12
+ self.content = content
13
+ self.boundary = boundary
14
+
15
+ def parse(self, expected_models: list[Type] | None = None) -> list[BatchResult]:
16
+ results = []
17
+ parts = self._split_parts(self.content, self.boundary)
18
+
19
+ # Original request index tracker
20
+ request_idx = 0
21
+
22
+ for part in parts:
23
+ if "Content-Type: multipart/mixed" in part:
24
+ # It's a ChangeSet. SAP returns its responses in a nested block.
25
+ sub_boundary = self._extract_boundary(part)
26
+ if sub_boundary:
27
+ sub_parts = self._split_parts(part, sub_boundary)
28
+ for sub_part in sub_parts:
29
+ model = expected_models[request_idx] if expected_models and request_idx < len(expected_models) else None
30
+ res = self._parse_single_http_response(sub_part, model, index=request_idx)
31
+ results.append(res)
32
+ request_idx += 1
33
+ continue
34
+
35
+ # Simple response outside of changeset
36
+ model = expected_models[request_idx] if expected_models and request_idx < len(expected_models) else None
37
+ res = self._parse_single_http_response(part, model, index=request_idx)
38
+ results.append(res)
39
+ request_idx += 1
40
+
41
+ return results
42
+
43
+ def _split_parts(self, content: str, boundary: str) -> list[str]:
44
+ """Divides the content into real parts by scanning delimiters."""
45
+ parts = []
46
+ current_part: list[str] = []
47
+ token = f"--{boundary}"
48
+ end_token = f"--{boundary}--"
49
+
50
+ in_part = False
51
+ lines = content.replace("\r\n", "\n").split("\n")
52
+
53
+ for line in lines:
54
+ trimmed = line.strip()
55
+ if trimmed == token:
56
+ if in_part and current_part:
57
+ parts.append("\n".join(current_part))
58
+ current_part = []
59
+ in_part = True
60
+ continue
61
+
62
+ if trimmed == end_token:
63
+ if in_part and current_part:
64
+ parts.append("\n".join(current_part))
65
+ current_part = []
66
+ in_part = False
67
+ continue
68
+
69
+ if in_part:
70
+ current_part.append(line)
71
+
72
+ if in_part and current_part:
73
+ parts.append("\n".join(current_part))
74
+
75
+ return parts
76
+
77
+ def _extract_boundary(self, content: str) -> str:
78
+ match = re.search(r"boundary=([^\s;]+)", content)
79
+ if match:
80
+ return match.group(1).strip().replace('"', '')
81
+ return ""
82
+
83
+ def _parse_single_http_response(
84
+ self, raw_http: str, model_type: Type | None = None, index: int = 0
85
+ ) -> BatchResult:
86
+ """Parses an individual HTTP response part."""
87
+ start_idx = raw_http.find("HTTP/1.1")
88
+ if start_idx == -1:
89
+ return BatchResult(status=500, error="Invalid part format")
90
+
91
+ message = raw_http[start_idx:]
92
+
93
+ status_match = re.search(r"HTTP/1\.1\s+(\d+)", message)
94
+ status = int(status_match.group(1)) if status_match else 500
95
+
96
+ body = None
97
+ msg_normalized = message.replace("\r\n", "\n")
98
+ header_body_split = re.split(r"\n\s*\n", msg_normalized, 1)
99
+
100
+ if len(header_body_split) > 1:
101
+ json_str = header_body_split[1].strip()
102
+ if json_str:
103
+ try:
104
+ body = json.loads(json_str)
105
+ except Exception:
106
+ body = json_str
107
+
108
+ error_msg = None
109
+ if status >= 400:
110
+ if isinstance(body, dict) and "error" in body:
111
+ err = body["error"]
112
+ if isinstance(err, dict):
113
+ msg = err.get("message")
114
+ error_msg = msg.get("value") if isinstance(msg, dict) else str(msg)
115
+ else:
116
+ error_msg = str(err)
117
+ if not error_msg:
118
+ error_msg = str(body)
119
+
120
+ return BatchResult(status=status, data=body, error=error_msg, model_type=model_type, index=index)
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any, Type
5
+
6
+ if TYPE_CHECKING:
7
+ from b1sl.b1sl.models.base import B1Model
8
+
9
+ @dataclass
10
+ class BatchResult:
11
+ """Individual result of an operation within a batch."""
12
+ status: int
13
+ data: Any = None
14
+ error: str | None = None
15
+ model_type: Type[B1Model] | None = None
16
+ index: int = 0 # Original position in the batch
17
+
18
+ @property
19
+ def ok(self) -> bool:
20
+ return 200 <= self.status < 300
21
+
22
+ @property
23
+ def entity(self) -> Any:
24
+ """Returns the parsed data as a Pydantic model if available."""
25
+ if self.ok and self.model_type and isinstance(self.data, (dict, list)):
26
+ if isinstance(self.data, list):
27
+ return [self.model_type.model_validate(item) for item in self.data]
28
+ # Special case: SAP sometimes returns the object in a 'value' node or directly
29
+ val = self.data.get("value", self.data) if isinstance(self.data, dict) else self.data
30
+ if isinstance(val, list):
31
+ return [self.model_type.model_validate(item) for item in val]
32
+ return self.model_type.model_validate(val)
33
+ return self.data
34
+
35
+ class BatchResults:
36
+ """Intelligent container for the results of a complete batch."""
37
+ def __init__(self, results: list[BatchResult]):
38
+ self.results = results
39
+
40
+ @property
41
+ def all_ok(self) -> bool:
42
+ return all(r.ok for r in self.results)
43
+
44
+ @property
45
+ def failed(self) -> list[BatchResult]:
46
+ """Returns only the failed operations, preserving their original index."""
47
+ return [r for r in self.results if not r.ok]
48
+
49
+ def __len__(self):
50
+ return len(self.results)
51
+
52
+ def __getitem__(self, idx: int) -> BatchResult:
53
+ return self.results[idx]
54
+
55
+ def __iter__(self):
56
+ return iter(self.results)