b1sl-python 0.1.2__py3-none-any.whl → 0.2.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 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",
b1sl/b1sl/async_client.py CHANGED
@@ -12,13 +12,8 @@ if TYPE_CHECKING:
12
12
  Activity,
13
13
  BusinessPartner,
14
14
  )
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
15
+ from b1sl.b1sl.models._generated.entities.general import Document
19
16
  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
17
  from b1sl.b1sl.models.base import B1Model
23
18
  from b1sl.b1sl.resources.async_base import AsyncGenericResource
24
19
  from b1sl.b1sl.resources.udo import AsyncUDOResource
@@ -35,6 +30,12 @@ class AsyncB1Client:
35
30
  AI Role: Recommended for modern web apps.
36
31
  Use 'async with AsyncB1Client(config) as b1:' to ensure session cleanup.
37
32
 
33
+ Concurrency-Elite Aliases (Elite Citizens):
34
+ Only entities with ETag support are exposed as direct properties.
35
+ This ensures state-safety and clear architectural boundaries.
36
+ Objects without ETag support must be accessed via 'get_resource()'
37
+ or 'udo()'.
38
+
38
39
  Example:
39
40
  async with AsyncB1Client(config) as b1:
40
41
  item = await b1.items.get("A0001")
@@ -59,9 +60,10 @@ class AsyncB1Client:
59
60
  version (str): API version (defaults to 'v2').
60
61
  session_id (str, optional): An existing B1SESSION cookie to reuse.
61
62
  """
63
+ self._logger = logger or logging.getLogger(f"b1sl.{self.__class__.__name__}")
62
64
  self._adapter = AsyncRestAdapter(
63
65
  config,
64
- logger=logger,
66
+ logger=self._logger,
65
67
  version=version,
66
68
  observability=observability,
67
69
  session_id=session_id,
@@ -72,9 +74,6 @@ class AsyncB1Client:
72
74
  def session_id(self) -> str | None:
73
75
  """
74
76
  Retrieves the current SAP session ID.
75
-
76
- Returns:
77
- str: B1SESSION cookie value or None.
78
77
  """
79
78
  return self._adapter.session_id
80
79
 
@@ -96,7 +95,6 @@ class AsyncB1Client:
96
95
 
97
96
  Note:
98
97
  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
98
  """
101
99
  return self._adapter.dry_run(enabled)
102
100
 
@@ -117,7 +115,6 @@ class AsyncB1Client:
117
115
  async def __aenter__(self) -> AsyncB1Client:
118
116
  """
119
117
  Entry point for the async context manager.
120
- Logins and prepares the session.
121
118
  """
122
119
  await self.connect()
123
120
  return self
@@ -125,7 +122,6 @@ class AsyncB1Client:
125
122
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
126
123
  """
127
124
  Exit point for the async context manager.
128
- Ensures logout and connection pool cleanup.
129
125
  """
130
126
  await self.aclose()
131
127
 
@@ -149,145 +145,194 @@ class AsyncB1Client:
149
145
  return DynamicResource(self._adapter)
150
146
 
151
147
  # --------------------------------------------------------------------------
152
- # Thin Aliases for common endpoints (Developer Experience)
148
+ # Concurrency-Elite Aliases (First-Class Citizens with ETag support)
153
149
  # --------------------------------------------------------------------------
154
150
 
151
+ # --- Master Data ---
152
+
155
153
  @property
156
154
  def items(self) -> "AsyncGenericResource[Item]":
157
- """Convenience alias for get_resource(Item, 'Items')."""
158
- from b1sl.b1sl.models._generated.entities.inventory import Item
159
-
155
+ """Access the 'Items' entity (supports ETags)."""
156
+ from b1sl.b1sl.models._generated.entities import Item
160
157
  return self.get_resource(Item, "Items")
161
158
 
162
159
  @property
163
160
  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
-
161
+ """Access the 'BusinessPartners' entity (supports ETags)."""
162
+ from b1sl.b1sl.models._generated.entities import BusinessPartner
169
163
  return self.get_resource(BusinessPartner, "BusinessPartners")
170
164
 
171
165
  @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
166
+ def activities(self) -> "AsyncGenericResource[Activity]":
167
+ """Access the 'Activities' entity (supports ETags)."""
168
+ from b1sl.b1sl.models._generated.entities import Activity
169
+ return self.get_resource(Activity, "Activities")
175
170
 
176
- return self.get_resource(Document, "Invoices")
171
+ # --- Sales Documents ---
177
172
 
178
173
  @property
179
174
  def quotations(self) -> "AsyncGenericResource[Document]":
180
- """Convenience alias for get_resource(Document, 'Quotations')."""
175
+ """Access the 'Quotations' entity (supports ETags)."""
181
176
  from b1sl.b1sl.models._generated.entities.general import Document
182
-
183
177
  return self.get_resource(Document, "Quotations")
184
178
 
185
179
  @property
186
180
  def orders(self) -> "AsyncGenericResource[Document]":
187
- """Convenience alias for get_resource(Document, 'Orders')."""
181
+ """Access the 'Orders' entity (supports ETags)."""
188
182
  from b1sl.b1sl.models._generated.entities.general import Document
189
-
190
183
  return self.get_resource(Document, "Orders")
191
184
 
192
185
  @property
193
186
  def delivery_notes(self) -> "AsyncGenericResource[Document]":
194
- """Convenience alias for get_resource(Document, 'DeliveryNotes')."""
187
+ """Access the 'DeliveryNotes' entity (supports ETags)."""
195
188
  from b1sl.b1sl.models._generated.entities.general import Document
196
-
197
189
  return self.get_resource(Document, "DeliveryNotes")
198
190
 
199
191
  @property
200
- def purchase_orders(self) -> "AsyncGenericResource[Document]":
201
- """Convenience alias for get_resource(Document, 'PurchaseOrders')."""
192
+ def invoices(self) -> "AsyncGenericResource[Document]":
193
+ """Access the 'Invoices' entity (supports ETags)."""
202
194
  from b1sl.b1sl.models._generated.entities.general import Document
203
-
204
- return self.get_resource(Document, "PurchaseOrders")
195
+ return self.get_resource(Document, "Invoices")
205
196
 
206
197
  @property
207
- def purchase_delivery_notes(self) -> "AsyncGenericResource[Document]":
208
- """Convenience alias for get_resource(Document, 'PurchaseDeliveryNotes')."""
198
+ def returns(self) -> "AsyncGenericResource[Document]":
199
+ """Access the 'Returns' entity (supports ETags)."""
209
200
  from b1sl.b1sl.models._generated.entities.general import Document
201
+ return self.get_resource(Document, "Returns")
210
202
 
211
- return self.get_resource(Document, "PurchaseDeliveryNotes")
203
+ @property
204
+ def return_request(self) -> "AsyncGenericResource[Document]":
205
+ """Access the 'ReturnRequest' entity (supports ETags)."""
206
+ from b1sl.b1sl.models._generated.entities.general import Document
207
+ return self.get_resource(Document, "ReturnRequest")
212
208
 
213
209
  @property
214
- def purchase_invoices(self) -> "AsyncGenericResource[Document]":
215
- """Convenience alias for get_resource(Document, 'PurchaseInvoices')."""
210
+ def credit_notes(self) -> "AsyncGenericResource[Document]":
211
+ """Access the 'CreditNotes' entity (supports ETags)."""
216
212
  from b1sl.b1sl.models._generated.entities.general import Document
213
+ return self.get_resource(Document, "CreditNotes")
217
214
 
218
- return self.get_resource(Document, "PurchaseInvoices")
215
+ @property
216
+ def down_payments(self) -> "AsyncGenericResource[Document]":
217
+ """Access the 'DownPayments' entity (supports ETags)."""
218
+ from b1sl.b1sl.models._generated.entities.general import Document
219
+ return self.get_resource(Document, "DownPayments")
219
220
 
220
221
  @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
222
+ def goods_return_request(self) -> "AsyncGenericResource[Document]":
223
+ """Access the 'GoodsReturnRequest' entity (supports ETags)."""
224
+ from b1sl.b1sl.models._generated.entities.general import Document
225
+ return self.get_resource(Document, "GoodsReturnRequest")
226
+
227
+ # --- Purchasing Documents ---
224
228
 
225
- return self.get_resource(Payment, "IncomingPayments")
229
+ @property
230
+ def purchase_requests(self) -> "AsyncGenericResource[Document]":
231
+ """Access the 'PurchaseRequests' entity (supports ETags)."""
232
+ from b1sl.b1sl.models._generated.entities.general import Document
233
+ return self.get_resource(Document, "PurchaseRequests")
226
234
 
227
235
  @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
236
+ def purchase_quotations(self) -> "AsyncGenericResource[Document]":
237
+ """Access the 'PurchaseQuotations' entity (supports ETags)."""
238
+ from b1sl.b1sl.models._generated.entities.general import Document
239
+ return self.get_resource(Document, "PurchaseQuotations")
231
240
 
232
- return self.get_resource(Payment, "VendorPayments")
241
+ @property
242
+ def purchase_orders(self) -> "AsyncGenericResource[Document]":
243
+ """Access the 'PurchaseOrders' entity (supports ETags)."""
244
+ from b1sl.b1sl.models._generated.entities.general import Document
245
+ return self.get_resource(Document, "PurchaseOrders")
233
246
 
234
247
  @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
248
+ def purchase_delivery_notes(self) -> "AsyncGenericResource[Document]":
249
+ """Access the 'PurchaseDeliveryNotes' entity (supports ETags)."""
250
+ from b1sl.b1sl.models._generated.entities.general import Document
251
+ return self.get_resource(Document, "PurchaseDeliveryNotes")
238
252
 
239
- return self.get_resource(User, "Users")
253
+ @property
254
+ def purchase_invoices(self) -> "AsyncGenericResource[Document]":
255
+ """Access the 'PurchaseInvoices' entity (supports ETags)."""
256
+ from b1sl.b1sl.models._generated.entities.general import Document
257
+ return self.get_resource(Document, "PurchaseInvoices")
240
258
 
241
- # --- Producción & Operaciones ---
259
+ @property
260
+ def purchase_returns(self) -> "AsyncGenericResource[Document]":
261
+ """Access the 'PurchaseReturns' entity (supports ETags)."""
262
+ from b1sl.b1sl.models._generated.entities.general import Document
263
+ return self.get_resource(Document, "PurchaseReturns")
242
264
 
243
265
  @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
266
+ def purchase_credit_notes(self) -> "AsyncGenericResource[Document]":
267
+ """Access the 'PurchaseCreditNotes' entity (supports ETags)."""
268
+ from b1sl.b1sl.models._generated.entities.general import Document
269
+ return self.get_resource(Document, "PurchaseCreditNotes")
247
270
 
248
- return self.get_resource(ProductionOrder, "ProductionOrders")
271
+ @property
272
+ def purchase_down_payments(self) -> "AsyncGenericResource[Document]":
273
+ """Access the 'PurchaseDownPayments' entity (supports ETags)."""
274
+ from b1sl.b1sl.models._generated.entities.general import Document
275
+ return self.get_resource(Document, "PurchaseDownPayments")
249
276
 
250
- # --- Contabilidad & Finanzas ---
277
+ # --- Inventory & Specialized ---
251
278
 
252
279
  @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
280
+ def inventory_gen_entries(self) -> "AsyncGenericResource[Document]":
281
+ """Access the 'InventoryGenEntries' entity (supports ETags)."""
282
+ from b1sl.b1sl.models._generated.entities.general import Document
283
+ return self.get_resource(Document, "InventoryGenEntries")
256
284
 
257
- return self.get_resource(JournalEntry, "JournalEntries")
285
+ @property
286
+ def inventory_gen_exits(self) -> "AsyncGenericResource[Document]":
287
+ """Access the 'InventoryGenExits' entity (supports ETags)."""
288
+ from b1sl.b1sl.models._generated.entities.general import Document
289
+ return self.get_resource(Document, "InventoryGenExits")
258
290
 
259
- # --- Servicio Post-Venta ---
291
+ @property
292
+ def drafts(self) -> "AsyncGenericResource[Document]":
293
+ """Access the 'Drafts' entity (supports ETags)."""
294
+ from b1sl.b1sl.models._generated.entities.general import Document
295
+ return self.get_resource(Document, "Drafts")
260
296
 
261
297
  @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
298
+ def additional_expenses(self) -> "AsyncGenericResource[B1Model]":
299
+ """Access the 'AdditionalExpenses' entity (supports ETags)."""
300
+ from b1sl.b1sl.models.base import B1Model
301
+ return self.get_resource(B1Model, "AdditionalExpenses")
265
302
 
266
- return self.get_resource(ServiceCall, "ServiceCalls")
303
+ # --- Correction marketing documents ---
267
304
 
268
- # --- CRM & Seguimiento ---
305
+ @property
306
+ def correction_invoice(self) -> "AsyncGenericResource[Document]":
307
+ """Access the 'CorrectionInvoice' entity (supports ETags)."""
308
+ from b1sl.b1sl.models._generated.entities.general import Document
309
+ return self.get_resource(Document, "CorrectionInvoice")
269
310
 
270
311
  @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
312
+ def correction_invoice_reversal(self) -> "AsyncGenericResource[Document]":
313
+ """Access the 'CorrectionInvoiceReversal' entity (supports ETags)."""
314
+ from b1sl.b1sl.models._generated.entities.general import Document
315
+ return self.get_resource(Document, "CorrectionInvoiceReversal")
274
316
 
275
- return self.get_resource(Activity, "Activities")
317
+ @property
318
+ def correction_purchase_invoice(self) -> "AsyncGenericResource[Document]":
319
+ """Access the 'CorrectionPurchaseInvoice' entity (supports ETags)."""
320
+ from b1sl.b1sl.models._generated.entities.general import Document
321
+ return self.get_resource(Document, "CorrectionPurchaseInvoice")
322
+
323
+ @property
324
+ def correction_purchase_invoice_reversal(self) -> "AsyncGenericResource[Document]":
325
+ """Access the 'CorrectionPurchaseInvoiceReversal' entity (supports ETags)."""
326
+ from b1sl.b1sl.models._generated.entities.general import Document
327
+ return self.get_resource(Document, "CorrectionPurchaseInvoiceReversal")
276
328
 
277
329
  def udo(self, table_name: str) -> "AsyncUDOResource":
278
330
  """
279
331
  Asynchronously access a User Defined Object (UDO) or User Table.
280
332
 
281
333
  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
334
  """
289
335
  from b1sl.b1sl.resources.udo import AsyncUDOResource
290
-
291
336
  return AsyncUDOResource(adapter=self._adapter, table_name=table_name)
292
337
 
293
338
 
@@ -275,7 +275,10 @@ class AsyncRestAdapter(BaseRestAdapter):
275
275
  self._raise_if_concurrency_error(
276
276
  e.response.status_code, sap_code, sap_msg, endpoint_path, body
277
277
  )
278
- raise B1Exception(f"SAP Error {sap_code}: {sap_msg}") from e
278
+
279
+ # Use specialized exception based on status code if available
280
+ exc_cls = _HTTP_STATUS_TO_EXC.get(e.response.status_code, B1Exception)
281
+ raise exc_cls(f"SAP Error {sap_code}: {sap_msg}", details=body) from e
279
282
  except Exception as e:
280
283
  exc_captured = e
281
284
  raise B1Exception(f"Request failed: {e}") from e
@@ -290,6 +293,10 @@ class AsyncRestAdapter(BaseRestAdapter):
290
293
  duration_ms = (time.perf_counter() - start_time) * 1000
291
294
  status_code = response.status_code if response is not None else None
292
295
 
296
+ # Prepare context extras
297
+ context_extras = dict(self._obs.context_extras)
298
+ context_extras["is_dry_run"] = self._dry_run_active and http_method in {"POST", "PATCH", "DELETE"} and not _is_login
299
+
293
300
  ctx = HookContext(
294
301
  req_id=req_id,
295
302
  http_method=http_method,
@@ -300,7 +307,9 @@ class AsyncRestAdapter(BaseRestAdapter):
300
307
  user=self._username,
301
308
  status_code=status_code,
302
309
  duration_ms=duration_ms,
303
- extra=dict(self._obs.context_extras),
310
+ payload=log_data if http_method in {"POST", "PATCH"} else None,
311
+ if_match=headers.get("If-Match"),
312
+ extra=context_extras,
304
313
  exc=exc_captured,
305
314
  )
306
315
 
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
 
@@ -390,6 +395,19 @@ class BaseRestAdapter:
390
395
 
391
396
  clean_endpoint = ctx.endpoint.lstrip("/")
392
397
  msg = f"[{ctx.req_id}][{ctx.user}] [{ctx.http_method} /{clean_endpoint}]{status_label} ({ctx.duration_ms:.1f}ms){slow_label}"
398
+
399
+ # In Dry Run or Debug mode, we might want to see the body in the main message
400
+ meta_info = []
401
+ if ctx.if_match:
402
+ meta_info.append(f"ETag: {ctx.if_match}")
403
+
404
+ if ctx.extra.get("is_dry_run") and ctx.payload:
405
+ payload_str = json.dumps(ctx.payload)
406
+ meta_info.append(f"Body: {payload_str}")
407
+
408
+ if meta_info:
409
+ msg += f" | {' | '.join(meta_info)}"
410
+
393
411
  self._logger.log(level, msg, extra=ctx.to_log_extra())
394
412
 
395
413
  # ── URL Helpers ──────────────────────────────────────────────────────── #
@@ -419,7 +437,13 @@ class BaseRestAdapter:
419
437
  return "unknown", http_fallback
420
438
 
421
439
  error_node = body.get("error")
422
- if not error_node or not isinstance(error_node, dict):
440
+ if not error_node:
441
+ return "unknown", http_fallback
442
+
443
+ if isinstance(error_node, str):
444
+ return "unknown", error_node
445
+
446
+ if not isinstance(error_node, dict):
423
447
  return "unknown", http_fallback
424
448
 
425
449
  sap_code = str(error_node.get("code", "unknown"))
@@ -435,3 +459,4 @@ class BaseRestAdapter:
435
459
  sap_message = http_fallback
436
460
 
437
461
  return sap_code, sap_message
462
+