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.
- b1sl/b1sl/adapter_protocol.py +4 -4
- b1sl/b1sl/async_client.py +25 -0
- b1sl/b1sl/async_rest_adapter.py +30 -12
- b1sl/b1sl/base_adapter.py +34 -0
- b1sl/b1sl/batch/__init__.py +6 -0
- b1sl/b1sl/batch/_recording_adapter.py +67 -0
- b1sl/b1sl/batch/changeset.py +29 -0
- b1sl/b1sl/batch/client.py +124 -0
- b1sl/b1sl/batch/parser.py +120 -0
- b1sl/b1sl/batch/results.py +56 -0
- b1sl/b1sl/batch/serializer.py +71 -0
- b1sl/b1sl/client.py +25 -0
- b1sl/b1sl/config.py +3 -0
- b1sl/b1sl/models/base.py +78 -0
- b1sl/b1sl/models/result.py +3 -1
- b1sl/b1sl/pagination.py +54 -0
- b1sl/b1sl/resources/async_base.py +88 -2
- b1sl/b1sl/resources/base.py +119 -1
- b1sl/b1sl/resources/odata.py +135 -33
- b1sl/b1sl/rest_adapter.py +15 -12
- b1sl/b1sl/schemas/__init__.py +3 -0
- b1sl/b1sl/schemas/udf.py +79 -0
- b1sl_python-0.3.0.dist-info/METADATA +315 -0
- {b1sl_python-0.2.0.dist-info → b1sl_python-0.3.0.dist-info}/RECORD +26 -16
- b1sl_python-0.2.0.dist-info/METADATA +0 -205
- {b1sl_python-0.2.0.dist-info → b1sl_python-0.3.0.dist-info}/WHEEL +0 -0
- {b1sl_python-0.2.0.dist-info → b1sl_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
b1sl/b1sl/adapter_protocol.py
CHANGED
|
@@ -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.
|
b1sl/b1sl/async_rest_adapter.py
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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=
|
|
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,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)
|