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.
- b1sl/b1sl/__init__.py +6 -10
- b1sl/b1sl/adapter_protocol.py +4 -4
- b1sl/b1sl/async_client.py +151 -81
- b1sl/b1sl/async_rest_adapter.py +40 -13
- b1sl/b1sl/base_adapter.py +60 -1
- b1sl/b1sl/batch/__init__.py +6 -0
- b1sl/b1sl/batch/_recording_adapter.py +67 -0
- b1sl/b1sl/batch/changeset.py +29 -0
- b1sl/b1sl/batch/client.py +124 -0
- b1sl/b1sl/batch/parser.py +120 -0
- b1sl/b1sl/batch/results.py +56 -0
- b1sl/b1sl/batch/serializer.py +71 -0
- b1sl/b1sl/client.py +247 -16
- b1sl/b1sl/config.py +3 -0
- b1sl/b1sl/exceptions/__init__.py +19 -0
- b1sl/b1sl/models/_generated/complex_types.py +261 -261
- b1sl/b1sl/models/_generated/entities/businesspartners.py +30 -30
- b1sl/b1sl/models/_generated/entities/finance.py +24 -24
- b1sl/b1sl/models/_generated/entities/general.py +225 -225
- b1sl/b1sl/models/_generated/entities/inventory.py +43 -43
- b1sl/b1sl/models/_generated/entities/production.py +19 -19
- b1sl/b1sl/models/_generated/entities/purchasing.py +3 -3
- b1sl/b1sl/models/_generated/entities/sales.py +16 -16
- b1sl/b1sl/models/base.py +89 -1
- b1sl/b1sl/models/result.py +3 -1
- b1sl/b1sl/pagination.py +54 -0
- b1sl/b1sl/resources/async_base.py +182 -6
- b1sl/b1sl/resources/base.py +153 -9
- b1sl/b1sl/resources/odata.py +197 -13
- b1sl/b1sl/rest_adapter.py +25 -13
- b1sl/b1sl/schemas/__init__.py +3 -0
- b1sl/b1sl/schemas/udf.py +79 -0
- b1sl_python-0.3.0.dist-info/METADATA +315 -0
- {b1sl_python-0.1.2.dist-info → b1sl_python-0.3.0.dist-info}/RECORD +36 -26
- b1sl/b1sl/resources/_generated/client_mixin.py +0 -3920
- b1sl_python-0.1.2.dist-info/METADATA +0 -205
- {b1sl_python-0.1.2.dist-info → b1sl_python-0.3.0.dist-info}/WHEEL +0 -0
- {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",
|
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,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.
|
|
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=
|
|
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
|
-
#
|
|
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
|
-
"""
|
|
158
|
-
from b1sl.b1sl.models._generated.entities
|
|
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
|
-
"""
|
|
165
|
-
from b1sl.b1sl.models._generated.entities
|
|
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
|
|
173
|
-
"""
|
|
174
|
-
from b1sl.b1sl.models._generated.entities
|
|
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
|
-
|
|
196
|
+
# --- Sales Documents ---
|
|
177
197
|
|
|
178
198
|
@property
|
|
179
199
|
def quotations(self) -> "AsyncGenericResource[Document]":
|
|
180
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
|
201
|
-
"""
|
|
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
|
|
208
|
-
"""
|
|
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
|
-
|
|
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
|
|
215
|
-
"""
|
|
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
|
-
|
|
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
|
|
222
|
-
"""
|
|
223
|
-
from b1sl.b1sl.models._generated.entities.general import
|
|
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
|
-
|
|
252
|
+
# --- Purchasing Documents ---
|
|
226
253
|
|
|
227
254
|
@property
|
|
228
|
-
def
|
|
229
|
-
"""
|
|
230
|
-
from b1sl.b1sl.models._generated.entities.general import
|
|
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
|
-
|
|
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
|
|
236
|
-
"""
|
|
237
|
-
from b1sl.b1sl.models._generated.entities.general import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
245
|
-
"""
|
|
246
|
-
from b1sl.b1sl.models._generated.entities.
|
|
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
|
-
|
|
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
|
-
# ---
|
|
302
|
+
# --- Inventory & Specialized ---
|
|
251
303
|
|
|
252
304
|
@property
|
|
253
|
-
def
|
|
254
|
-
"""
|
|
255
|
-
from b1sl.b1sl.models._generated.entities.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
"""
|
|
264
|
-
from b1sl.b1sl.models.
|
|
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
|
-
|
|
328
|
+
# --- Correction marketing documents ---
|
|
267
329
|
|
|
268
|
-
|
|
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
|
|
272
|
-
"""
|
|
273
|
-
from b1sl.b1sl.models._generated.entities.
|
|
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
|
-
|
|
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
|
|
b1sl/b1sl/async_rest_adapter.py
CHANGED
|
@@ -215,6 +215,7 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
215
215
|
endpoint: str,
|
|
216
216
|
ep_params=None,
|
|
217
217
|
data=None,
|
|
218
|
+
headers: dict | None = None,
|
|
218
219
|
_is_login: bool = False,
|
|
219
220
|
_retry_once=True,
|
|
220
221
|
) -> Result:
|
|
@@ -240,7 +241,9 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
240
241
|
|
|
241
242
|
try:
|
|
242
243
|
# ── ETag: inject If-None-Match (GET) or If-Match (PATCH/DELETE/POST) ──
|
|
243
|
-
|
|
244
|
+
req_headers = self._build_headers(http_method, endpoint_path)
|
|
245
|
+
if headers:
|
|
246
|
+
req_headers.update(headers)
|
|
244
247
|
|
|
245
248
|
if self._dry_run_active and http_method in {"POST", "PATCH", "DELETE"} and not _is_login:
|
|
246
249
|
self._logger.info(f"[{req_id}] [DRY RUN] Intercepting {http_method} {full_url}")
|
|
@@ -250,7 +253,7 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
250
253
|
else:
|
|
251
254
|
response = await self._client.request(
|
|
252
255
|
method=http_method, url=full_url, params=ep_params, json=data,
|
|
253
|
-
headers=
|
|
256
|
+
headers=req_headers,
|
|
254
257
|
)
|
|
255
258
|
|
|
256
259
|
if response.status_code == 401 and _retry_once and not _is_login:
|
|
@@ -259,7 +262,7 @@ class AsyncRestAdapter(BaseRestAdapter):
|
|
|
259
262
|
# Recursive call will handle its own finally block,
|
|
260
263
|
# but we need to return here to avoid double-logging/hooking.
|
|
261
264
|
return await self._do(
|
|
262
|
-
http_method, endpoint, ep_params, data, _is_login, _retry_once=False
|
|
265
|
+
http_method, endpoint, ep_params, data, headers, _is_login, _retry_once=False
|
|
263
266
|
)
|
|
264
267
|
|
|
265
268
|
response.raise_for_status()
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
|