b1sl-python 0.1.2__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.
Files changed (38) hide show
  1. b1sl/b1sl/__init__.py +6 -10
  2. b1sl/b1sl/adapter_protocol.py +4 -4
  3. b1sl/b1sl/async_client.py +151 -81
  4. b1sl/b1sl/async_rest_adapter.py +40 -13
  5. b1sl/b1sl/base_adapter.py +60 -1
  6. b1sl/b1sl/batch/__init__.py +6 -0
  7. b1sl/b1sl/batch/_recording_adapter.py +67 -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 +71 -0
  13. b1sl/b1sl/client.py +247 -16
  14. b1sl/b1sl/config.py +3 -0
  15. b1sl/b1sl/exceptions/__init__.py +19 -0
  16. b1sl/b1sl/models/_generated/complex_types.py +261 -261
  17. b1sl/b1sl/models/_generated/entities/businesspartners.py +30 -30
  18. b1sl/b1sl/models/_generated/entities/finance.py +24 -24
  19. b1sl/b1sl/models/_generated/entities/general.py +225 -225
  20. b1sl/b1sl/models/_generated/entities/inventory.py +43 -43
  21. b1sl/b1sl/models/_generated/entities/production.py +19 -19
  22. b1sl/b1sl/models/_generated/entities/purchasing.py +3 -3
  23. b1sl/b1sl/models/_generated/entities/sales.py +16 -16
  24. b1sl/b1sl/models/base.py +89 -1
  25. b1sl/b1sl/models/result.py +3 -1
  26. b1sl/b1sl/pagination.py +54 -0
  27. b1sl/b1sl/resources/async_base.py +182 -6
  28. b1sl/b1sl/resources/base.py +153 -9
  29. b1sl/b1sl/resources/odata.py +197 -13
  30. b1sl/b1sl/rest_adapter.py +25 -13
  31. b1sl/b1sl/schemas/__init__.py +3 -0
  32. b1sl/b1sl/schemas/udf.py +79 -0
  33. b1sl_python-0.3.0.dist-info/METADATA +315 -0
  34. {b1sl_python-0.1.2.dist-info → b1sl_python-0.3.0.dist-info}/RECORD +36 -26
  35. b1sl/b1sl/resources/_generated/client_mixin.py +0 -3920
  36. b1sl_python-0.1.2.dist-info/METADATA +0 -205
  37. {b1sl_python-0.1.2.dist-info → b1sl_python-0.3.0.dist-info}/WHEEL +0 -0
  38. {b1sl_python-0.1.2.dist-info → b1sl_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
b1sl/b1sl/__init__.py CHANGED
@@ -5,6 +5,7 @@ b1sl.b1sl — SDK for SAP B1 Service Layer (OData).
5
5
  import logging
6
6
  import warnings
7
7
 
8
+ from b1sl.b1sl import entities
8
9
  from b1sl.b1sl.async_client import AsyncB1Client
9
10
  from b1sl.b1sl.async_rest_adapter import AsyncRestAdapter
10
11
  from b1sl.b1sl.base_adapter import HookContext, ObservabilityConfig
@@ -17,6 +18,11 @@ from b1sl.b1sl.resources.async_base import AsyncGenericResource
17
18
  from b1sl.b1sl.resources.base import GenericResource, ODataQuery
18
19
  from b1sl.b1sl.rest_adapter import RestAdapter
19
20
 
21
+ try:
22
+ from b1sl.b1sl import fields # type: ignore
23
+ except ImportError:
24
+ fields = None # type: ignore
25
+
20
26
  try:
21
27
  from pydantic import ArbitraryTypeWarning
22
28
 
@@ -27,16 +33,6 @@ except ImportError:
27
33
  # Standard library pattern: prevent "No handlers could be found"
28
34
  logging.getLogger("b1sl").addHandler(logging.NullHandler())
29
35
 
30
- try:
31
- from b1sl.b1sl import entities # type: ignore
32
- except ImportError:
33
- entities = None # type: ignore # Before code generation
34
-
35
- try:
36
- from b1sl.b1sl import fields # type: ignore
37
- except ImportError:
38
- fields = None # type: ignore
39
-
40
36
  __all__ = [
41
37
  "AsyncB1Client",
42
38
  "B1Client",
@@ -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,17 +8,13 @@ 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,
14
15
  )
15
- from b1sl.b1sl.models._generated.entities.finance import JournalEntry
16
- from b1sl.b1sl.models._generated.entities.general import Document, Payment, User
17
-
18
- # Models for typing convenience aliases
16
+ from b1sl.b1sl.models._generated.entities.general import Document
19
17
  from b1sl.b1sl.models._generated.entities.inventory import Item
20
- from b1sl.b1sl.models._generated.entities.production import ProductionOrder
21
- from b1sl.b1sl.models._generated.entities.sales import ServiceCall
22
18
  from b1sl.b1sl.models.base import B1Model
23
19
  from b1sl.b1sl.resources.async_base import AsyncGenericResource
24
20
  from b1sl.b1sl.resources.udo import AsyncUDOResource
@@ -35,6 +31,12 @@ class AsyncB1Client:
35
31
  AI Role: Recommended for modern web apps.
36
32
  Use 'async with AsyncB1Client(config) as b1:' to ensure session cleanup.
37
33
 
34
+ Concurrency-Elite Aliases (Elite Citizens):
35
+ Only entities with ETag support are exposed as direct properties.
36
+ This ensures state-safety and clear architectural boundaries.
37
+ Objects without ETag support must be accessed via 'get_resource()'
38
+ or 'udo()'.
39
+
38
40
  Example:
39
41
  async with AsyncB1Client(config) as b1:
40
42
  item = await b1.items.get("A0001")
@@ -59,9 +61,10 @@ class AsyncB1Client:
59
61
  version (str): API version (defaults to 'v2').
60
62
  session_id (str, optional): An existing B1SESSION cookie to reuse.
61
63
  """
64
+ self._logger = logger or logging.getLogger(f"b1sl.{self.__class__.__name__}")
62
65
  self._adapter = AsyncRestAdapter(
63
66
  config,
64
- logger=logger,
67
+ logger=self._logger,
65
68
  version=version,
66
69
  observability=observability,
67
70
  session_id=session_id,
@@ -72,9 +75,6 @@ class AsyncB1Client:
72
75
  def session_id(self) -> str | None:
73
76
  """
74
77
  Retrieves the current SAP session ID.
75
-
76
- Returns:
77
- str: B1SESSION cookie value or None.
78
78
  """
79
79
  return self._adapter.session_id
80
80
 
@@ -96,10 +96,33 @@ class AsyncB1Client:
96
96
 
97
97
  Note:
98
98
  Use ``with`` (sync CM), **not** ``async with``, even in async code.
99
- This is correct Python — the CM guards a state variable, not I/O.
100
99
  """
101
100
  return self._adapter.dry_run(enabled)
102
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
+
103
126
  async def connect(self) -> None:
104
127
  """
105
128
  Initializes the underlying HTTP client and logs in.
@@ -117,7 +140,6 @@ class AsyncB1Client:
117
140
  async def __aenter__(self) -> AsyncB1Client:
118
141
  """
119
142
  Entry point for the async context manager.
120
- Logins and prepares the session.
121
143
  """
122
144
  await self.connect()
123
145
  return self
@@ -125,7 +147,6 @@ class AsyncB1Client:
125
147
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
126
148
  """
127
149
  Exit point for the async context manager.
128
- Ensures logout and connection pool cleanup.
129
150
  """
130
151
  await self.aclose()
131
152
 
@@ -149,145 +170,194 @@ class AsyncB1Client:
149
170
  return DynamicResource(self._adapter)
150
171
 
151
172
  # --------------------------------------------------------------------------
152
- # Thin Aliases for common endpoints (Developer Experience)
173
+ # Concurrency-Elite Aliases (First-Class Citizens with ETag support)
153
174
  # --------------------------------------------------------------------------
154
175
 
176
+ # --- Master Data ---
177
+
155
178
  @property
156
179
  def items(self) -> "AsyncGenericResource[Item]":
157
- """Convenience alias for get_resource(Item, 'Items')."""
158
- from b1sl.b1sl.models._generated.entities.inventory import Item
159
-
180
+ """Access the 'Items' entity (supports ETags)."""
181
+ from b1sl.b1sl.models._generated.entities import Item
160
182
  return self.get_resource(Item, "Items")
161
183
 
162
184
  @property
163
185
  def business_partners(self) -> "AsyncGenericResource[BusinessPartner]":
164
- """Convenience alias for get_resource(BusinessPartner, 'BusinessPartners')."""
165
- from b1sl.b1sl.models._generated.entities.businesspartners import (
166
- BusinessPartner,
167
- )
168
-
186
+ """Access the 'BusinessPartners' entity (supports ETags)."""
187
+ from b1sl.b1sl.models._generated.entities import BusinessPartner
169
188
  return self.get_resource(BusinessPartner, "BusinessPartners")
170
189
 
171
190
  @property
172
- def invoices(self) -> "AsyncGenericResource[Document]":
173
- """Convenience alias for get_resource(Document, 'Invoices')."""
174
- from b1sl.b1sl.models._generated.entities.general import Document
191
+ def activities(self) -> "AsyncGenericResource[Activity]":
192
+ """Access the 'Activities' entity (supports ETags)."""
193
+ from b1sl.b1sl.models._generated.entities import Activity
194
+ return self.get_resource(Activity, "Activities")
175
195
 
176
- return self.get_resource(Document, "Invoices")
196
+ # --- Sales Documents ---
177
197
 
178
198
  @property
179
199
  def quotations(self) -> "AsyncGenericResource[Document]":
180
- """Convenience alias for get_resource(Document, 'Quotations')."""
200
+ """Access the 'Quotations' entity (supports ETags)."""
181
201
  from b1sl.b1sl.models._generated.entities.general import Document
182
-
183
202
  return self.get_resource(Document, "Quotations")
184
203
 
185
204
  @property
186
205
  def orders(self) -> "AsyncGenericResource[Document]":
187
- """Convenience alias for get_resource(Document, 'Orders')."""
206
+ """Access the 'Orders' entity (supports ETags)."""
188
207
  from b1sl.b1sl.models._generated.entities.general import Document
189
-
190
208
  return self.get_resource(Document, "Orders")
191
209
 
192
210
  @property
193
211
  def delivery_notes(self) -> "AsyncGenericResource[Document]":
194
- """Convenience alias for get_resource(Document, 'DeliveryNotes')."""
212
+ """Access the 'DeliveryNotes' entity (supports ETags)."""
195
213
  from b1sl.b1sl.models._generated.entities.general import Document
196
-
197
214
  return self.get_resource(Document, "DeliveryNotes")
198
215
 
199
216
  @property
200
- def purchase_orders(self) -> "AsyncGenericResource[Document]":
201
- """Convenience alias for get_resource(Document, 'PurchaseOrders')."""
217
+ def invoices(self) -> "AsyncGenericResource[Document]":
218
+ """Access the 'Invoices' entity (supports ETags)."""
202
219
  from b1sl.b1sl.models._generated.entities.general import Document
203
-
204
- return self.get_resource(Document, "PurchaseOrders")
220
+ return self.get_resource(Document, "Invoices")
205
221
 
206
222
  @property
207
- def purchase_delivery_notes(self) -> "AsyncGenericResource[Document]":
208
- """Convenience alias for get_resource(Document, 'PurchaseDeliveryNotes')."""
223
+ def returns(self) -> "AsyncGenericResource[Document]":
224
+ """Access the 'Returns' entity (supports ETags)."""
209
225
  from b1sl.b1sl.models._generated.entities.general import Document
226
+ return self.get_resource(Document, "Returns")
210
227
 
211
- return self.get_resource(Document, "PurchaseDeliveryNotes")
228
+ @property
229
+ def return_request(self) -> "AsyncGenericResource[Document]":
230
+ """Access the 'ReturnRequest' entity (supports ETags)."""
231
+ from b1sl.b1sl.models._generated.entities.general import Document
232
+ return self.get_resource(Document, "ReturnRequest")
212
233
 
213
234
  @property
214
- def purchase_invoices(self) -> "AsyncGenericResource[Document]":
215
- """Convenience alias for get_resource(Document, 'PurchaseInvoices')."""
235
+ def credit_notes(self) -> "AsyncGenericResource[Document]":
236
+ """Access the 'CreditNotes' entity (supports ETags)."""
216
237
  from b1sl.b1sl.models._generated.entities.general import Document
238
+ return self.get_resource(Document, "CreditNotes")
217
239
 
218
- return self.get_resource(Document, "PurchaseInvoices")
240
+ @property
241
+ def down_payments(self) -> "AsyncGenericResource[Document]":
242
+ """Access the 'DownPayments' entity (supports ETags)."""
243
+ from b1sl.b1sl.models._generated.entities.general import Document
244
+ return self.get_resource(Document, "DownPayments")
219
245
 
220
246
  @property
221
- def incoming_payments(self) -> "AsyncGenericResource[Payment]":
222
- """Convenience alias for get_resource(Payment, 'IncomingPayments')."""
223
- from b1sl.b1sl.models._generated.entities.general import Payment
247
+ def goods_return_request(self) -> "AsyncGenericResource[Document]":
248
+ """Access the 'GoodsReturnRequest' entity (supports ETags)."""
249
+ from b1sl.b1sl.models._generated.entities.general import Document
250
+ return self.get_resource(Document, "GoodsReturnRequest")
224
251
 
225
- return self.get_resource(Payment, "IncomingPayments")
252
+ # --- Purchasing Documents ---
226
253
 
227
254
  @property
228
- def vendor_payments(self) -> "AsyncGenericResource[Payment]":
229
- """Convenience alias for get_resource(Payment, 'VendorPayments')."""
230
- from b1sl.b1sl.models._generated.entities.general import Payment
255
+ def purchase_requests(self) -> "AsyncGenericResource[Document]":
256
+ """Access the 'PurchaseRequests' entity (supports ETags)."""
257
+ from b1sl.b1sl.models._generated.entities.general import Document
258
+ return self.get_resource(Document, "PurchaseRequests")
231
259
 
232
- return self.get_resource(Payment, "VendorPayments")
260
+ @property
261
+ def purchase_quotations(self) -> "AsyncGenericResource[Document]":
262
+ """Access the 'PurchaseQuotations' entity (supports ETags)."""
263
+ from b1sl.b1sl.models._generated.entities.general import Document
264
+ return self.get_resource(Document, "PurchaseQuotations")
233
265
 
234
266
  @property
235
- def users(self) -> "AsyncGenericResource[User]":
236
- """Convenience alias for get_resource(User, 'Users')."""
237
- from b1sl.b1sl.models._generated.entities.general import User
267
+ def purchase_orders(self) -> "AsyncGenericResource[Document]":
268
+ """Access the 'PurchaseOrders' entity (supports ETags)."""
269
+ from b1sl.b1sl.models._generated.entities.general import Document
270
+ return self.get_resource(Document, "PurchaseOrders")
238
271
 
239
- return self.get_resource(User, "Users")
272
+ @property
273
+ def purchase_delivery_notes(self) -> "AsyncGenericResource[Document]":
274
+ """Access the 'PurchaseDeliveryNotes' entity (supports ETags)."""
275
+ from b1sl.b1sl.models._generated.entities.general import Document
276
+ return self.get_resource(Document, "PurchaseDeliveryNotes")
240
277
 
241
- # --- Producción & Operaciones ---
278
+ @property
279
+ def purchase_invoices(self) -> "AsyncGenericResource[Document]":
280
+ """Access the 'PurchaseInvoices' entity (supports ETags)."""
281
+ from b1sl.b1sl.models._generated.entities.general import Document
282
+ return self.get_resource(Document, "PurchaseInvoices")
242
283
 
243
284
  @property
244
- def production_orders(self) -> "AsyncGenericResource[ProductionOrder]":
245
- """Convenience alias for get_resource(ProductionOrder, 'ProductionOrders')."""
246
- from b1sl.b1sl.models._generated.entities.production import ProductionOrder
285
+ def purchase_returns(self) -> "AsyncGenericResource[Document]":
286
+ """Access the 'PurchaseReturns' entity (supports ETags)."""
287
+ from b1sl.b1sl.models._generated.entities.general import Document
288
+ return self.get_resource(Document, "PurchaseReturns")
247
289
 
248
- return self.get_resource(ProductionOrder, "ProductionOrders")
290
+ @property
291
+ def purchase_credit_notes(self) -> "AsyncGenericResource[Document]":
292
+ """Access the 'PurchaseCreditNotes' entity (supports ETags)."""
293
+ from b1sl.b1sl.models._generated.entities.general import Document
294
+ return self.get_resource(Document, "PurchaseCreditNotes")
295
+
296
+ @property
297
+ def purchase_down_payments(self) -> "AsyncGenericResource[Document]":
298
+ """Access the 'PurchaseDownPayments' entity (supports ETags)."""
299
+ from b1sl.b1sl.models._generated.entities.general import Document
300
+ return self.get_resource(Document, "PurchaseDownPayments")
249
301
 
250
- # --- Contabilidad & Finanzas ---
302
+ # --- Inventory & Specialized ---
251
303
 
252
304
  @property
253
- def journal_entries(self) -> "AsyncGenericResource[JournalEntry]":
254
- """Convenience alias for get_resource(JournalEntry, 'JournalEntries')."""
255
- from b1sl.b1sl.models._generated.entities.finance import JournalEntry
305
+ def inventory_gen_entries(self) -> "AsyncGenericResource[Document]":
306
+ """Access the 'InventoryGenEntries' entity (supports ETags)."""
307
+ from b1sl.b1sl.models._generated.entities.general import Document
308
+ return self.get_resource(Document, "InventoryGenEntries")
256
309
 
257
- return self.get_resource(JournalEntry, "JournalEntries")
310
+ @property
311
+ def inventory_gen_exits(self) -> "AsyncGenericResource[Document]":
312
+ """Access the 'InventoryGenExits' entity (supports ETags)."""
313
+ from b1sl.b1sl.models._generated.entities.general import Document
314
+ return self.get_resource(Document, "InventoryGenExits")
258
315
 
259
- # --- Servicio Post-Venta ---
316
+ @property
317
+ def drafts(self) -> "AsyncGenericResource[Document]":
318
+ """Access the 'Drafts' entity (supports ETags)."""
319
+ from b1sl.b1sl.models._generated.entities.general import Document
320
+ return self.get_resource(Document, "Drafts")
260
321
 
261
322
  @property
262
- def service_calls(self) -> "AsyncGenericResource[ServiceCall]":
263
- """Convenience alias for get_resource(ServiceCall, 'ServiceCalls')."""
264
- from b1sl.b1sl.models._generated.entities.sales import ServiceCall
323
+ def additional_expenses(self) -> "AsyncGenericResource[B1Model]":
324
+ """Access the 'AdditionalExpenses' entity (supports ETags)."""
325
+ from b1sl.b1sl.models.base import B1Model
326
+ return self.get_resource(B1Model, "AdditionalExpenses")
265
327
 
266
- return self.get_resource(ServiceCall, "ServiceCalls")
328
+ # --- Correction marketing documents ---
267
329
 
268
- # --- CRM & Seguimiento ---
330
+ @property
331
+ def correction_invoice(self) -> "AsyncGenericResource[Document]":
332
+ """Access the 'CorrectionInvoice' entity (supports ETags)."""
333
+ from b1sl.b1sl.models._generated.entities.general import Document
334
+ return self.get_resource(Document, "CorrectionInvoice")
269
335
 
270
336
  @property
271
- def activities(self) -> "AsyncGenericResource[Activity]":
272
- """Convenience alias for get_resource(Activity, 'Activities')."""
273
- from b1sl.b1sl.models._generated.entities.businesspartners import Activity
337
+ def correction_invoice_reversal(self) -> "AsyncGenericResource[Document]":
338
+ """Access the 'CorrectionInvoiceReversal' entity (supports ETags)."""
339
+ from b1sl.b1sl.models._generated.entities.general import Document
340
+ return self.get_resource(Document, "CorrectionInvoiceReversal")
274
341
 
275
- return self.get_resource(Activity, "Activities")
342
+ @property
343
+ def correction_purchase_invoice(self) -> "AsyncGenericResource[Document]":
344
+ """Access the 'CorrectionPurchaseInvoice' entity (supports ETags)."""
345
+ from b1sl.b1sl.models._generated.entities.general import Document
346
+ return self.get_resource(Document, "CorrectionPurchaseInvoice")
347
+
348
+ @property
349
+ def correction_purchase_invoice_reversal(self) -> "AsyncGenericResource[Document]":
350
+ """Access the 'CorrectionPurchaseInvoiceReversal' entity (supports ETags)."""
351
+ from b1sl.b1sl.models._generated.entities.general import Document
352
+ return self.get_resource(Document, "CorrectionPurchaseInvoiceReversal")
276
353
 
277
354
  def udo(self, table_name: str) -> "AsyncUDOResource":
278
355
  """
279
356
  Asynchronously access a User Defined Object (UDO) or User Table.
280
357
 
281
358
  AI Role: Dynamic accessor for entities not pre-defined in the client.
282
-
283
- Args:
284
- table_name (str): The UDO name in SAP B1.
285
-
286
- Returns:
287
- AsyncUDOResource: A resource object bound to the UDO.
288
359
  """
289
360
  from b1sl.b1sl.resources.udo import AsyncUDOResource
290
-
291
361
  return AsyncUDOResource(adapter=self._adapter, table_name=table_name)
292
362
 
293
363
 
@@ -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()
@@ -275,7 +278,10 @@ class AsyncRestAdapter(BaseRestAdapter):
275
278
  self._raise_if_concurrency_error(
276
279
  e.response.status_code, sap_code, sap_msg, endpoint_path, body
277
280
  )
278
- raise B1Exception(f"SAP Error {sap_code}: {sap_msg}") from e
281
+
282
+ # Use specialized exception based on status code if available
283
+ exc_cls = _HTTP_STATUS_TO_EXC.get(e.response.status_code, B1Exception)
284
+ raise exc_cls(f"SAP Error {sap_code}: {sap_msg}", details=body) from e
279
285
  except Exception as e:
280
286
  exc_captured = e
281
287
  raise B1Exception(f"Request failed: {e}") from e
@@ -290,6 +296,10 @@ class AsyncRestAdapter(BaseRestAdapter):
290
296
  duration_ms = (time.perf_counter() - start_time) * 1000
291
297
  status_code = response.status_code if response is not None else None
292
298
 
299
+ # Prepare context extras
300
+ context_extras = dict(self._obs.context_extras)
301
+ context_extras["is_dry_run"] = self._dry_run_active and http_method in {"POST", "PATCH", "DELETE"} and not _is_login
302
+
293
303
  ctx = HookContext(
294
304
  req_id=req_id,
295
305
  http_method=http_method,
@@ -300,7 +310,9 @@ class AsyncRestAdapter(BaseRestAdapter):
300
310
  user=self._username,
301
311
  status_code=status_code,
302
312
  duration_ms=duration_ms,
303
- extra=dict(self._obs.context_extras),
313
+ payload=log_data if http_method in {"POST", "PATCH"} else None,
314
+ if_match=req_headers.get("If-Match"),
315
+ extra=context_extras,
304
316
  exc=exc_captured,
305
317
  )
306
318
 
@@ -338,18 +350,33 @@ class AsyncRestAdapter(BaseRestAdapter):
338
350
  f"HTTP Error {response.status_code if response else 'Unknown'}"
339
351
  )
340
352
 
341
- async def get(self, endpoint, ep_params=None, data=None):
353
+ async def get(self, endpoint, ep_params=None, data=None, headers=None):
342
354
  """Execute an asynchronous GET request."""
343
- return await self._do("GET", endpoint, ep_params, data)
355
+ return await self._do("GET", endpoint, ep_params, data, headers=headers)
344
356
 
345
- async def post(self, endpoint, ep_params=None, data=None):
357
+ async def post(self, endpoint, ep_params=None, data=None, headers=None):
346
358
  """Execute an asynchronous POST request."""
347
- return await self._do("POST", endpoint, ep_params, data)
359
+ return await self._do("POST", endpoint, ep_params, data, headers=headers)
348
360
 
349
- async def patch(self, endpoint, ep_params=None, data=None):
361
+ async def patch(self, endpoint, ep_params=None, data=None, headers=None):
350
362
  """Execute an asynchronous PATCH request."""
351
- return await self._do("PATCH", endpoint, ep_params, data)
363
+ return await self._do("PATCH", endpoint, ep_params, data, headers=headers)
352
364
 
353
- async def delete(self, endpoint, ep_params=None, data=None):
365
+ async def delete(self, endpoint, ep_params=None, data=None, headers=None):
354
366
  """Execute an asynchronous DELETE request."""
355
- 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
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
7
7
  if TYPE_CHECKING:
8
8
  from b1sl.b1sl.config import B1Config
9
9
  import asyncio
10
+ import json
10
11
  import logging
11
12
  import uuid
12
13
  from collections import OrderedDict
@@ -56,6 +57,8 @@ class HookContext:
56
57
  user: str
57
58
  status_code: Optional[int] # None if network exception occurred
58
59
  duration_ms: float
60
+ payload: Optional[Dict[str, Any]] = None # Redacted request body
61
+ if_match: Optional[str] = None # ETag sent in If-Match
59
62
  extra: Dict[str, Any] = field(default_factory=dict)
60
63
  exc: Optional[Exception] = None
61
64
 
@@ -75,6 +78,8 @@ class HookContext:
75
78
  "user": self.user,
76
79
  "status_code": self.status_code,
77
80
  "duration_ms": round(self.duration_ms, 3),
81
+ "payload": self.payload,
82
+ "if_match": self.if_match,
78
83
  **self.extra,
79
84
  }
80
85
 
@@ -180,12 +185,20 @@ class BaseRestAdapter:
180
185
  self._dry_run_var: ContextVar[bool] = ContextVar(
181
186
  f"dry_run_{id(self)}", default=config.dry_run
182
187
  )
188
+ self._b1s_schema_var: ContextVar[str | None] = ContextVar(
189
+ f"b1s_schema_{id(self)}", default=config.b1s_schema
190
+ )
183
191
 
184
192
  @property
185
193
  def _dry_run_active(self) -> bool:
186
194
  """Returns the current effective dry_run state for this task/thread."""
187
195
  return self._dry_run_var.get()
188
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
+
189
202
  @contextmanager
190
203
  def dry_run(self, enabled: bool = True):
191
204
  """
@@ -215,6 +228,28 @@ class BaseRestAdapter:
215
228
  finally:
216
229
  self._dry_run_var.reset(token)
217
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
+
218
253
  @classmethod
219
254
  def from_config(
220
255
  cls,
@@ -323,6 +358,10 @@ class BaseRestAdapter:
323
358
  # POST covers OData Actions (e.g. /BusinessPartners('C20000')/Cancel)
324
359
  headers["If-Match"] = cached_etag
325
360
 
361
+ active_schema = self._schema_active
362
+ if active_schema:
363
+ headers["B1S-Schema"] = active_schema
364
+
326
365
  if extra_headers:
327
366
  headers.update(extra_headers)
328
367
  return headers
@@ -390,6 +429,19 @@ class BaseRestAdapter:
390
429
 
391
430
  clean_endpoint = ctx.endpoint.lstrip("/")
392
431
  msg = f"[{ctx.req_id}][{ctx.user}] [{ctx.http_method} /{clean_endpoint}]{status_label} ({ctx.duration_ms:.1f}ms){slow_label}"
432
+
433
+ # In Dry Run or Debug mode, we might want to see the body in the main message
434
+ meta_info = []
435
+ if ctx.if_match:
436
+ meta_info.append(f"ETag: {ctx.if_match}")
437
+
438
+ if ctx.extra.get("is_dry_run") and ctx.payload:
439
+ payload_str = json.dumps(ctx.payload)
440
+ meta_info.append(f"Body: {payload_str}")
441
+
442
+ if meta_info:
443
+ msg += f" | {' | '.join(meta_info)}"
444
+
393
445
  self._logger.log(level, msg, extra=ctx.to_log_extra())
394
446
 
395
447
  # ── URL Helpers ──────────────────────────────────────────────────────── #
@@ -419,7 +471,13 @@ class BaseRestAdapter:
419
471
  return "unknown", http_fallback
420
472
 
421
473
  error_node = body.get("error")
422
- if not error_node or not isinstance(error_node, dict):
474
+ if not error_node:
475
+ return "unknown", http_fallback
476
+
477
+ if isinstance(error_node, str):
478
+ return "unknown", error_node
479
+
480
+ if not isinstance(error_node, dict):
423
481
  return "unknown", http_fallback
424
482
 
425
483
  sap_code = str(error_node.get("code", "unknown"))
@@ -435,3 +493,4 @@ class BaseRestAdapter:
435
493
  sap_message = http_fallback
436
494
 
437
495
  return sap_code, sap_message
496
+