b1sl-python 0.2.0__py3-none-any.whl → 0.4.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/__init__.py +19 -1
- b1sl/b1sl/adapter_protocol.py +4 -4
- b1sl/b1sl/async_client.py +80 -0
- b1sl/b1sl/async_rest_adapter.py +39 -19
- b1sl/b1sl/base_adapter.py +78 -1
- b1sl/b1sl/batch/__init__.py +6 -0
- b1sl/b1sl/batch/_recording_adapter.py +70 -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 +82 -0
- b1sl/b1sl/client.py +81 -0
- b1sl/b1sl/config.py +3 -0
- b1sl/b1sl/exceptions/__init__.py +6 -0
- b1sl/b1sl/exceptions/exceptions.py +95 -0
- b1sl/b1sl/models/base.py +78 -0
- b1sl/b1sl/models/result.py +3 -1
- b1sl/b1sl/pagination.py +66 -0
- b1sl/b1sl/resources/async_base.py +121 -7
- b1sl/b1sl/resources/base.py +154 -6
- b1sl/b1sl/resources/crossjoin.py +542 -0
- b1sl/b1sl/resources/odata.py +173 -33
- b1sl/b1sl/resources/sql_queries.py +571 -0
- b1sl/b1sl/rest_adapter.py +24 -18
- b1sl/b1sl/schemas/__init__.py +3 -0
- b1sl/b1sl/schemas/udf.py +79 -0
- b1sl/contrib/mcp/__init__.py +108 -0
- b1sl/contrib/mcp/discovery.py +410 -0
- b1sl/contrib/mcp/formatters.py +182 -0
- b1sl/contrib/mcp/grammar.py +255 -0
- b1sl/contrib/mcp/odata_formatters.py +308 -0
- b1sl/contrib/mcp/odata_grammar.py +316 -0
- b1sl/contrib/mcp/odata_schemas.py +640 -0
- b1sl/contrib/mcp/schemas.py +151 -0
- b1sl_python-0.4.0.dist-info/METADATA +315 -0
- {b1sl_python-0.2.0.dist-info → b1sl_python-0.4.0.dist-info}/RECORD +39 -19
- {b1sl_python-0.2.0.dist-info → b1sl_python-0.4.0.dist-info}/WHEEL +1 -1
- b1sl_python-0.2.0.dist-info/METADATA +0 -205
- {b1sl_python-0.2.0.dist-info → b1sl_python-0.4.0.dist-info}/licenses/LICENSE +0 -0
b1sl/b1sl/__init__.py
CHANGED
|
@@ -13,9 +13,20 @@ from b1sl.b1sl.client import B1Client
|
|
|
13
13
|
from b1sl.b1sl.config import B1Config
|
|
14
14
|
from b1sl.b1sl.config_manager import B1Environment
|
|
15
15
|
from b1sl.b1sl.environment import B1Env
|
|
16
|
-
from b1sl.b1sl.exceptions.exceptions import
|
|
16
|
+
from b1sl.b1sl.exceptions.exceptions import (
|
|
17
|
+
B1SqlNotAllowedError,
|
|
18
|
+
B1SqlParamError,
|
|
19
|
+
B1SqlSyntaxError,
|
|
20
|
+
SAPConcurrencyError,
|
|
21
|
+
)
|
|
17
22
|
from b1sl.b1sl.resources.async_base import AsyncGenericResource
|
|
18
23
|
from b1sl.b1sl.resources.base import GenericResource, ODataQuery
|
|
24
|
+
from b1sl.b1sl.resources.sql_queries import (
|
|
25
|
+
DEFAULT_ACCESSIBLE_TABLES,
|
|
26
|
+
KNOWN_INACCESSIBLE_PATTERNS,
|
|
27
|
+
SQLQueryInfo,
|
|
28
|
+
SQLRunResult,
|
|
29
|
+
)
|
|
19
30
|
from b1sl.b1sl.rest_adapter import RestAdapter
|
|
20
31
|
|
|
21
32
|
try:
|
|
@@ -42,9 +53,16 @@ __all__ = [
|
|
|
42
53
|
"RestAdapter",
|
|
43
54
|
"B1Env",
|
|
44
55
|
"SAPConcurrencyError",
|
|
56
|
+
"B1SqlSyntaxError",
|
|
57
|
+
"B1SqlNotAllowedError",
|
|
58
|
+
"B1SqlParamError",
|
|
45
59
|
"AsyncGenericResource",
|
|
46
60
|
"GenericResource",
|
|
47
61
|
"ODataQuery",
|
|
62
|
+
"DEFAULT_ACCESSIBLE_TABLES",
|
|
63
|
+
"KNOWN_INACCESSIBLE_PATTERNS",
|
|
64
|
+
"SQLQueryInfo",
|
|
65
|
+
"SQLRunResult",
|
|
48
66
|
"entities",
|
|
49
67
|
"fields",
|
|
50
68
|
"HookContext",
|
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,
|
|
@@ -16,6 +17,11 @@ if TYPE_CHECKING:
|
|
|
16
17
|
from b1sl.b1sl.models._generated.entities.inventory import Item
|
|
17
18
|
from b1sl.b1sl.models.base import B1Model
|
|
18
19
|
from b1sl.b1sl.resources.async_base import AsyncGenericResource
|
|
20
|
+
from b1sl.b1sl.resources.crossjoin import (
|
|
21
|
+
AsyncCrossJoinQueryBuilder,
|
|
22
|
+
AsyncQueryServiceBuilder,
|
|
23
|
+
)
|
|
24
|
+
from b1sl.b1sl.resources.sql_queries import AsyncSQLQueriesResource
|
|
19
25
|
from b1sl.b1sl.resources.udo import AsyncUDOResource
|
|
20
26
|
|
|
21
27
|
|
|
@@ -98,6 +104,30 @@ class AsyncB1Client:
|
|
|
98
104
|
"""
|
|
99
105
|
return self._adapter.dry_run(enabled)
|
|
100
106
|
|
|
107
|
+
def with_schema(self, name: str):
|
|
108
|
+
"""
|
|
109
|
+
Context manager to temporarily set the B1S-Schema header
|
|
110
|
+
**for the current asyncio.Task only** (task-safe via ContextVar).
|
|
111
|
+
|
|
112
|
+
Usage::
|
|
113
|
+
|
|
114
|
+
async with AsyncB1Client(config) as b1:
|
|
115
|
+
async with b1.with_schema("demo.schema"):
|
|
116
|
+
await b1.items.get("A0001")
|
|
117
|
+
"""
|
|
118
|
+
return self._adapter.with_schema(name)
|
|
119
|
+
|
|
120
|
+
def batch(self) -> BatchClient:
|
|
121
|
+
"""
|
|
122
|
+
Returns a context manager that groups multiple resource operations
|
|
123
|
+
into a single OData $batch HTTP request.
|
|
124
|
+
|
|
125
|
+
Use this for high-concurrency scenarios (bulk GETs) or transactional
|
|
126
|
+
integrity (atomic ChangeSets). See :class:`BatchClient` for details.
|
|
127
|
+
"""
|
|
128
|
+
from b1sl.b1sl.batch.client import BatchClient
|
|
129
|
+
return BatchClient(self)
|
|
130
|
+
|
|
101
131
|
async def connect(self) -> None:
|
|
102
132
|
"""
|
|
103
133
|
Initializes the underlying HTTP client and logs in.
|
|
@@ -326,6 +356,15 @@ class AsyncB1Client:
|
|
|
326
356
|
from b1sl.b1sl.models._generated.entities.general import Document
|
|
327
357
|
return self.get_resource(Document, "CorrectionPurchaseInvoiceReversal")
|
|
328
358
|
|
|
359
|
+
# --- SQL Queries ---
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def sql_queries(self) -> "AsyncSQLQueriesResource":
|
|
363
|
+
"""Access the 'SQLQueries' endpoint (supports ETags, run() / run_stream())."""
|
|
364
|
+
from b1sl.b1sl.resources.sql_queries import AsyncSQLQueriesResource
|
|
365
|
+
resource = AsyncSQLQueriesResource(self._adapter)
|
|
366
|
+
return resource
|
|
367
|
+
|
|
329
368
|
def udo(self, table_name: str) -> "AsyncUDOResource":
|
|
330
369
|
"""
|
|
331
370
|
Asynchronously access a User Defined Object (UDO) or User Table.
|
|
@@ -335,4 +374,45 @@ class AsyncB1Client:
|
|
|
335
374
|
from b1sl.b1sl.resources.udo import AsyncUDOResource
|
|
336
375
|
return AsyncUDOResource(adapter=self._adapter, table_name=table_name)
|
|
337
376
|
|
|
377
|
+
def crossjoin(self, *entities: str) -> "AsyncCrossJoinQueryBuilder":
|
|
378
|
+
"""Build an async ``$crossjoin`` query (SAP HANA only, B1 9.2 patch 07+).
|
|
379
|
+
|
|
380
|
+
At least 2 entity names are required. A bare crossjoin without
|
|
381
|
+
``$expand`` or ``$apply`` will raise ``ValueError`` before the request.
|
|
382
|
+
|
|
383
|
+
Example::
|
|
384
|
+
|
|
385
|
+
rows = await (
|
|
386
|
+
client.crossjoin("Orders", "BusinessPartners")
|
|
387
|
+
.expand({"Orders": ["DocEntry", "DocNum"], "BusinessPartners": ["CardCode"]})
|
|
388
|
+
.filter("Orders/CardCode eq BusinessPartners/CardCode")
|
|
389
|
+
.execute()
|
|
390
|
+
)
|
|
391
|
+
"""
|
|
392
|
+
from b1sl.b1sl.resources.crossjoin import AsyncCrossJoinQueryBuilder
|
|
393
|
+
|
|
394
|
+
return AsyncCrossJoinQueryBuilder(self._adapter, *entities)
|
|
395
|
+
|
|
396
|
+
def query_service(self, query_path: str) -> "AsyncQueryServiceBuilder":
|
|
397
|
+
"""Async ``QueryService_PostQuery`` row-level filter (SAP HANA only, B1 9.2 PL11+).
|
|
398
|
+
|
|
399
|
+
Example::
|
|
400
|
+
|
|
401
|
+
rows = await (
|
|
402
|
+
client.query_service("$crossjoin(Orders,Orders/DocumentLines)")
|
|
403
|
+
.expand({
|
|
404
|
+
"Orders": ["DocEntry", "DocNum"],
|
|
405
|
+
"Orders/DocumentLines": ["ItemCode", "LineNum"],
|
|
406
|
+
})
|
|
407
|
+
.filter(
|
|
408
|
+
"Orders/DocEntry eq Orders/DocumentLines/DocEntry"
|
|
409
|
+
" and Orders/DocumentLines/ItemCode eq 'WIDGET'"
|
|
410
|
+
)
|
|
411
|
+
.execute()
|
|
412
|
+
)
|
|
413
|
+
"""
|
|
414
|
+
from b1sl.b1sl.resources.crossjoin import AsyncQueryServiceBuilder
|
|
415
|
+
|
|
416
|
+
return AsyncQueryServiceBuilder(self._adapter, query_path)
|
|
417
|
+
|
|
338
418
|
|
b1sl/b1sl/async_rest_adapter.py
CHANGED
|
@@ -16,6 +16,7 @@ from b1sl.b1sl.exceptions.exceptions import (
|
|
|
16
16
|
B1ValidationError,
|
|
17
17
|
)
|
|
18
18
|
from b1sl.b1sl.models.result import Result
|
|
19
|
+
from b1sl.b1sl.pagination import extract_next_link
|
|
19
20
|
|
|
20
21
|
_HTTP_STATUS_TO_EXC: dict[int, type] = {
|
|
21
22
|
400: B1ValidationError,
|
|
@@ -215,6 +216,7 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
215
216
|
endpoint: str,
|
|
216
217
|
ep_params=None,
|
|
217
218
|
data=None,
|
|
219
|
+
headers: dict | None = None,
|
|
218
220
|
_is_login: bool = False,
|
|
219
221
|
_retry_once=True,
|
|
220
222
|
) -> Result:
|
|
@@ -240,7 +242,9 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
240
242
|
|
|
241
243
|
try:
|
|
242
244
|
# ── ETag: inject If-None-Match (GET) or If-Match (PATCH/DELETE/POST) ──
|
|
243
|
-
|
|
245
|
+
req_headers = self._build_headers(http_method, endpoint_path)
|
|
246
|
+
if headers:
|
|
247
|
+
req_headers.update(headers)
|
|
244
248
|
|
|
245
249
|
if self._dry_run_active and http_method in {"POST", "PATCH", "DELETE"} and not _is_login:
|
|
246
250
|
self._logger.info(f"[{req_id}] [DRY RUN] Intercepting {http_method} {full_url}")
|
|
@@ -250,7 +254,7 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
250
254
|
else:
|
|
251
255
|
response = await self._client.request(
|
|
252
256
|
method=http_method, url=full_url, params=ep_params, json=data,
|
|
253
|
-
headers=
|
|
257
|
+
headers=req_headers,
|
|
254
258
|
)
|
|
255
259
|
|
|
256
260
|
if response.status_code == 401 and _retry_once and not _is_login:
|
|
@@ -259,7 +263,7 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
259
263
|
# Recursive call will handle its own finally block,
|
|
260
264
|
# but we need to return here to avoid double-logging/hooking.
|
|
261
265
|
return await self._do(
|
|
262
|
-
http_method, endpoint, ep_params, data, _is_login, _retry_once=False
|
|
266
|
+
http_method, endpoint, ep_params, data, headers, _is_login, _retry_once=False
|
|
263
267
|
)
|
|
264
268
|
|
|
265
269
|
response.raise_for_status()
|
|
@@ -275,7 +279,10 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
275
279
|
self._raise_if_concurrency_error(
|
|
276
280
|
e.response.status_code, sap_code, sap_msg, endpoint_path, body
|
|
277
281
|
)
|
|
278
|
-
|
|
282
|
+
self._raise_if_sql_error(
|
|
283
|
+
e.response.status_code, sap_code, sap_msg, body
|
|
284
|
+
)
|
|
285
|
+
|
|
279
286
|
# Use specialized exception based on status code if available
|
|
280
287
|
exc_cls = _HTTP_STATUS_TO_EXC.get(e.response.status_code, B1Exception)
|
|
281
288
|
raise exc_cls(f"SAP Error {sap_code}: {sap_msg}", details=body) from e
|
|
@@ -308,7 +315,7 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
308
315
|
status_code=status_code,
|
|
309
316
|
duration_ms=duration_ms,
|
|
310
317
|
payload=log_data if http_method in {"POST", "PATCH"} else None,
|
|
311
|
-
if_match=
|
|
318
|
+
if_match=req_headers.get("If-Match"),
|
|
312
319
|
extra=context_extras,
|
|
313
320
|
exc=exc_captured,
|
|
314
321
|
)
|
|
@@ -332,13 +339,11 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
332
339
|
status_code=response.status_code,
|
|
333
340
|
message=response.reason_phrase,
|
|
334
341
|
data=data_out,
|
|
335
|
-
next_link=data_out
|
|
336
|
-
|
|
342
|
+
next_link=extract_next_link(data_out) if isinstance(data_out, dict) else None,
|
|
343
|
+
next_params=self._get_ep_params(extract_next_link(data_out))
|
|
344
|
+
if isinstance(data_out, dict) and extract_next_link(data_out)
|
|
337
345
|
else None,
|
|
338
|
-
|
|
339
|
-
if isinstance(data_out, dict) and data_out.get("odata.nextLink")
|
|
340
|
-
else None,
|
|
341
|
-
metadata=data_out.get("odata.metadata")
|
|
346
|
+
metadata=(data_out.get("@odata.context") or data_out.get("odata.metadata"))
|
|
342
347
|
if isinstance(data_out, dict)
|
|
343
348
|
else None,
|
|
344
349
|
)
|
|
@@ -347,18 +352,33 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
347
352
|
f"HTTP Error {response.status_code if response else 'Unknown'}"
|
|
348
353
|
)
|
|
349
354
|
|
|
350
|
-
async def get(self, endpoint, ep_params=None, data=None):
|
|
355
|
+
async def get(self, endpoint, ep_params=None, data=None, headers=None):
|
|
351
356
|
"""Execute an asynchronous GET request."""
|
|
352
|
-
return await self._do("GET", endpoint, ep_params, data)
|
|
357
|
+
return await self._do("GET", endpoint, ep_params, data, headers=headers)
|
|
353
358
|
|
|
354
|
-
async def post(self, endpoint, ep_params=None, data=None):
|
|
359
|
+
async def post(self, endpoint, ep_params=None, data=None, headers=None):
|
|
355
360
|
"""Execute an asynchronous POST request."""
|
|
356
|
-
return await self._do("POST", endpoint, ep_params, data)
|
|
361
|
+
return await self._do("POST", endpoint, ep_params, data, headers=headers)
|
|
357
362
|
|
|
358
|
-
async def patch(self, endpoint, ep_params=None, data=None):
|
|
363
|
+
async def patch(self, endpoint, ep_params=None, data=None, headers=None):
|
|
359
364
|
"""Execute an asynchronous PATCH request."""
|
|
360
|
-
return await self._do("PATCH", endpoint, ep_params, data)
|
|
365
|
+
return await self._do("PATCH", endpoint, ep_params, data, headers=headers)
|
|
361
366
|
|
|
362
|
-
async def delete(self, endpoint, ep_params=None, data=None):
|
|
367
|
+
async def delete(self, endpoint, ep_params=None, data=None, headers=None):
|
|
363
368
|
"""Execute an asynchronous DELETE request."""
|
|
364
|
-
return await self._do("DELETE", endpoint, ep_params, data)
|
|
369
|
+
return await self._do("DELETE", endpoint, ep_params, data, headers=headers)
|
|
370
|
+
|
|
371
|
+
async def post_batch(self, body: str, headers: dict) -> httpx.Response:
|
|
372
|
+
"""
|
|
373
|
+
Special method to send raw multipart content for $batch operations.
|
|
374
|
+
"""
|
|
375
|
+
await self.ensure_session()
|
|
376
|
+
if not self._client:
|
|
377
|
+
raise B1Exception("AsyncRestAdapter not initialized.")
|
|
378
|
+
|
|
379
|
+
url = f"{self.raw_base_url}/$batch"
|
|
380
|
+
# Combine with session headers if necessary,
|
|
381
|
+
# although httpx already handles them via cookies.
|
|
382
|
+
response = await self._client.post(url, content=body, headers=headers)
|
|
383
|
+
response.raise_for_status()
|
|
384
|
+
return response
|
b1sl/b1sl/base_adapter.py
CHANGED
|
@@ -16,7 +16,12 @@ from datetime import datetime
|
|
|
16
16
|
from typing import Any, Callable, Dict, List, Optional
|
|
17
17
|
from urllib.parse import parse_qs, urlencode, urlparse
|
|
18
18
|
|
|
19
|
-
from b1sl.b1sl.exceptions.exceptions import
|
|
19
|
+
from b1sl.b1sl.exceptions.exceptions import (
|
|
20
|
+
B1SqlNotAllowedError,
|
|
21
|
+
B1SqlParamError,
|
|
22
|
+
B1SqlSyntaxError,
|
|
23
|
+
SAPConcurrencyError,
|
|
24
|
+
)
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
@dataclass
|
|
@@ -185,12 +190,20 @@ class BaseRestAdapter:
|
|
|
185
190
|
self._dry_run_var: ContextVar[bool] = ContextVar(
|
|
186
191
|
f"dry_run_{id(self)}", default=config.dry_run
|
|
187
192
|
)
|
|
193
|
+
self._b1s_schema_var: ContextVar[str | None] = ContextVar(
|
|
194
|
+
f"b1s_schema_{id(self)}", default=config.b1s_schema
|
|
195
|
+
)
|
|
188
196
|
|
|
189
197
|
@property
|
|
190
198
|
def _dry_run_active(self) -> bool:
|
|
191
199
|
"""Returns the current effective dry_run state for this task/thread."""
|
|
192
200
|
return self._dry_run_var.get()
|
|
193
201
|
|
|
202
|
+
@property
|
|
203
|
+
def _schema_active(self) -> str | None:
|
|
204
|
+
"""Returns the current effective schema state for this task/thread."""
|
|
205
|
+
return self._b1s_schema_var.get()
|
|
206
|
+
|
|
194
207
|
@contextmanager
|
|
195
208
|
def dry_run(self, enabled: bool = True):
|
|
196
209
|
"""
|
|
@@ -220,6 +233,28 @@ class BaseRestAdapter:
|
|
|
220
233
|
finally:
|
|
221
234
|
self._dry_run_var.reset(token)
|
|
222
235
|
|
|
236
|
+
@contextmanager
|
|
237
|
+
def with_schema(self, schema: str | None):
|
|
238
|
+
"""
|
|
239
|
+
Context manager to temporarily override the B1S-Schema header
|
|
240
|
+
**for the current asyncio task / thread only**.
|
|
241
|
+
|
|
242
|
+
Usage::
|
|
243
|
+
|
|
244
|
+
# Use a specific schema for this block
|
|
245
|
+
with client.with_schema("demo.schema"):
|
|
246
|
+
await client.items.get("A0001")
|
|
247
|
+
|
|
248
|
+
Note:
|
|
249
|
+
Use ``with``, not ``async with`` — the context manager is
|
|
250
|
+
synchronous even in async code, which is correct and idiomatic.
|
|
251
|
+
"""
|
|
252
|
+
token = self._b1s_schema_var.set(schema)
|
|
253
|
+
try:
|
|
254
|
+
yield
|
|
255
|
+
finally:
|
|
256
|
+
self._b1s_schema_var.reset(token)
|
|
257
|
+
|
|
223
258
|
@classmethod
|
|
224
259
|
def from_config(
|
|
225
260
|
cls,
|
|
@@ -328,6 +363,10 @@ class BaseRestAdapter:
|
|
|
328
363
|
# POST covers OData Actions (e.g. /BusinessPartners('C20000')/Cancel)
|
|
329
364
|
headers["If-Match"] = cached_etag
|
|
330
365
|
|
|
366
|
+
active_schema = self._schema_active
|
|
367
|
+
if active_schema:
|
|
368
|
+
headers["B1S-Schema"] = active_schema
|
|
369
|
+
|
|
331
370
|
if extra_headers:
|
|
332
371
|
headers.update(extra_headers)
|
|
333
372
|
return headers
|
|
@@ -369,6 +408,44 @@ class BaseRestAdapter:
|
|
|
369
408
|
details=response_body,
|
|
370
409
|
)
|
|
371
410
|
|
|
411
|
+
@staticmethod
|
|
412
|
+
def _raise_if_sql_error(
|
|
413
|
+
status_code: int,
|
|
414
|
+
sap_code: str,
|
|
415
|
+
sap_message: str,
|
|
416
|
+
response_body: dict | None,
|
|
417
|
+
) -> None:
|
|
418
|
+
"""Convert SAP 400 responses with SQL-specific codes into typed exceptions.
|
|
419
|
+
|
|
420
|
+
SAP error codes handled:
|
|
421
|
+
- ``"702"`` / ``"703"`` → ``B1SqlNotAllowedError`` (table or column blocked)
|
|
422
|
+
- ``"704"`` → ``B1SqlParamError`` (missing, extra, or mis-typed parameter)
|
|
423
|
+
|
|
424
|
+
Called from ``_do`` in both sync and async adapters, right before the
|
|
425
|
+
generic ``B1ValidationError`` fallback, so callers can catch the
|
|
426
|
+
granular subclass or the broader ``B1ValidationError``.
|
|
427
|
+
|
|
428
|
+
Note: SAP returns ``code`` as a JSON string (e.g. ``"702"``), not an int.
|
|
429
|
+
"""
|
|
430
|
+
if status_code != 400:
|
|
431
|
+
return
|
|
432
|
+
if sap_code == B1SqlSyntaxError.SAP_ERROR_CODE:
|
|
433
|
+
raise B1SqlSyntaxError(
|
|
434
|
+
f"SAP SQL syntax error: {sap_message}",
|
|
435
|
+
details=response_body,
|
|
436
|
+
)
|
|
437
|
+
if sap_code in ("702", "703"):
|
|
438
|
+
raise B1SqlNotAllowedError(
|
|
439
|
+
f"SAP SQL allowlist violation [{sap_code}]: {sap_message}",
|
|
440
|
+
sap_code=sap_code,
|
|
441
|
+
details=response_body,
|
|
442
|
+
)
|
|
443
|
+
if sap_code == B1SqlParamError.SAP_ERROR_CODE:
|
|
444
|
+
raise B1SqlParamError(
|
|
445
|
+
f"SAP SQL parameter error: {sap_message}",
|
|
446
|
+
details=response_body,
|
|
447
|
+
)
|
|
448
|
+
|
|
372
449
|
def _redact_data(self, data: dict | None) -> dict:
|
|
373
450
|
"""Mask sensitive information in data dictionaries before logging."""
|
|
374
451
|
return {k: "***" if k == "Password" else v for k, v in (data or {}).items()}
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
headers: dict | None = None
|
|
22
|
+
model_type: Type[B1Model] | None = None
|
|
23
|
+
changeset_id: str | None = None
|
|
24
|
+
content_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
25
|
+
|
|
26
|
+
class _RecordingAdapter:
|
|
27
|
+
"""
|
|
28
|
+
Internal adapter that captures requests instead of executing them.
|
|
29
|
+
Complies with the RestAdapterProtocol asynchronously.
|
|
30
|
+
"""
|
|
31
|
+
def __init__(self, batch_client: BatchClient):
|
|
32
|
+
self._batch = batch_client
|
|
33
|
+
self._current_model: Type[B1Model] | None = None
|
|
34
|
+
|
|
35
|
+
def _record(self, method: str, endpoint: str, ep_params: dict | None = None,
|
|
36
|
+
data: dict | None = None, headers: dict | None = None):
|
|
37
|
+
"""Captures the request and adds it to the client's queue."""
|
|
38
|
+
if method == "GET" and self._batch.active_changeset_id:
|
|
39
|
+
raise ValueError("OData Batch Error: GET operations are not allowed within a ChangeSet.")
|
|
40
|
+
|
|
41
|
+
req = PendingRequest(
|
|
42
|
+
method=method,
|
|
43
|
+
endpoint=endpoint,
|
|
44
|
+
ep_params=ep_params,
|
|
45
|
+
data=data,
|
|
46
|
+
headers=headers,
|
|
47
|
+
model_type=self._current_model,
|
|
48
|
+
changeset_id=self._batch.active_changeset_id
|
|
49
|
+
)
|
|
50
|
+
self._batch._pending.append(req)
|
|
51
|
+
|
|
52
|
+
# Return a simulated Result so Pydantic doesn't crash
|
|
53
|
+
return Result(status_code=202, data={})
|
|
54
|
+
|
|
55
|
+
# Asynchronous implementation for AsyncGenericResource
|
|
56
|
+
async def get(self, endpoint, ep_params=None, data=None, headers=None):
|
|
57
|
+
return self._record("GET", endpoint, ep_params, data, headers)
|
|
58
|
+
|
|
59
|
+
async def post(self, endpoint, ep_params=None, data=None, headers=None):
|
|
60
|
+
return self._record("POST", endpoint, ep_params, data, headers)
|
|
61
|
+
|
|
62
|
+
async def patch(self, endpoint, ep_params=None, data=None, headers=None):
|
|
63
|
+
return self._record("PATCH", endpoint, ep_params, data, headers)
|
|
64
|
+
|
|
65
|
+
async def delete(self, endpoint, ep_params=None, data=None, headers=None):
|
|
66
|
+
return self._record("DELETE", endpoint, ep_params, data, headers)
|
|
67
|
+
|
|
68
|
+
# Synchronous implementation for GenericResource (compatibility)
|
|
69
|
+
def get_sync(self, endpoint, ep_params=None, data=None, headers=None):
|
|
70
|
+
return self._record("GET", endpoint, ep_params, data, headers)
|
|
@@ -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
|