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.
Files changed (40) hide show
  1. b1sl/b1sl/__init__.py +19 -1
  2. b1sl/b1sl/adapter_protocol.py +4 -4
  3. b1sl/b1sl/async_client.py +80 -0
  4. b1sl/b1sl/async_rest_adapter.py +39 -19
  5. b1sl/b1sl/base_adapter.py +78 -1
  6. b1sl/b1sl/batch/__init__.py +6 -0
  7. b1sl/b1sl/batch/_recording_adapter.py +70 -0
  8. b1sl/b1sl/batch/changeset.py +29 -0
  9. b1sl/b1sl/batch/client.py +124 -0
  10. b1sl/b1sl/batch/parser.py +120 -0
  11. b1sl/b1sl/batch/results.py +56 -0
  12. b1sl/b1sl/batch/serializer.py +82 -0
  13. b1sl/b1sl/client.py +81 -0
  14. b1sl/b1sl/config.py +3 -0
  15. b1sl/b1sl/exceptions/__init__.py +6 -0
  16. b1sl/b1sl/exceptions/exceptions.py +95 -0
  17. b1sl/b1sl/models/base.py +78 -0
  18. b1sl/b1sl/models/result.py +3 -1
  19. b1sl/b1sl/pagination.py +66 -0
  20. b1sl/b1sl/resources/async_base.py +121 -7
  21. b1sl/b1sl/resources/base.py +154 -6
  22. b1sl/b1sl/resources/crossjoin.py +542 -0
  23. b1sl/b1sl/resources/odata.py +173 -33
  24. b1sl/b1sl/resources/sql_queries.py +571 -0
  25. b1sl/b1sl/rest_adapter.py +24 -18
  26. b1sl/b1sl/schemas/__init__.py +3 -0
  27. b1sl/b1sl/schemas/udf.py +79 -0
  28. b1sl/contrib/mcp/__init__.py +108 -0
  29. b1sl/contrib/mcp/discovery.py +410 -0
  30. b1sl/contrib/mcp/formatters.py +182 -0
  31. b1sl/contrib/mcp/grammar.py +255 -0
  32. b1sl/contrib/mcp/odata_formatters.py +308 -0
  33. b1sl/contrib/mcp/odata_grammar.py +316 -0
  34. b1sl/contrib/mcp/odata_schemas.py +640 -0
  35. b1sl/contrib/mcp/schemas.py +151 -0
  36. b1sl_python-0.4.0.dist-info/METADATA +315 -0
  37. {b1sl_python-0.2.0.dist-info → b1sl_python-0.4.0.dist-info}/RECORD +39 -19
  38. {b1sl_python-0.2.0.dist-info → b1sl_python-0.4.0.dist-info}/WHEEL +1 -1
  39. b1sl_python-0.2.0.dist-info/METADATA +0 -205
  40. {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 SAPConcurrencyError
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",
@@ -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
 
@@ -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
- headers = self._build_headers(http_method, endpoint_path)
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=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=headers.get("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.get("odata.nextLink")
336
- if isinstance(data_out, dict)
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
- next_params=self._get_ep_params(data_out.get("odata.nextLink"))
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 SAPConcurrencyError
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,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,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