| name | b1sl-sdk |
| description | Modern, async-first Python SDK for SAP Business One Service Layer (b1sl-python). Use this skill to interact with SAP B1 entities (Items, Business Partners, Orders, Invoices, and 100+ more) using type-safe Pydantic v2 models, a fluent OData query builder with operator overloading, automatic session management, and structured observability. Covers installation, configuration, async client patterns, query building, UDF handling, and the metadata generation pipeline.
|
SAP B1 Python SDK (b1sl)
Overview
b1sl-python is a metadata-driven, async-first SDK for SAP Business One Service Layer.
It automates the entire model and resource layer by parsing SAP's OData metadata, delivering
full type safety, IDE autocompletion, and production-grade session management.
- PyPI:
b1sl-python
- Repository: operator-ita/b1sl-python
- Verified Baseline: Service Layer 1.27 (SAP 10.0 FP 2405)
- Protocol: OData V4 (
v2 endpoint)
- Minimum for ETags: Service Layer 1.21+
Installation
uv add b1sl-python
pip install b1sl-python
uv add "b1sl-python[django]"
uv add "b1sl-python[generator]"
Configuration
The SDK uses a hierarchical, environment-agnostic configuration system.
Required Environment Variables
B1SL_BASE_URL=https://your-server:50000
B1SL_USERNAME=manager
B1SL_PASSWORD=your_password
B1SL_COMPANY_DB=SBODEMOUS
B1SL_ENV=dev
B1SL_DRY_RUN=0
Loading Config
from b1sl.b1sl import B1Environment, B1Config
env = B1Environment.load()
config = env.config
config = B1Config.from_env()
Environments
B1SL_ENV | Log Format | Use Case |
|---|
dev (default) | Human-readable | Local development |
test | Human-readable | CI / test isolation |
prod | Structured JSON | Production observability pipelines |
| Dry Run | intercepted | B1SL_DRY_RUN=1. Intercepts POST/PATCH/DELETE. |
Never store B1SL_PASSWORD in configs/*.json. Only non-sensitive test data IDs belong there.
Temporary Dry Run (Context Manager)
You can toggle Dry Run mode temporarily for a specific block of code using the dry_run() context manager available in both sync and async clients:
with b1.dry_run():
await b1.items.create(new_item)
with b1.dry_run(enabled=False):
await b1.items.update(item)
❗ Critical Guidelines: Flat Namespace & Enums
Always use the flat public namespace for models and enums to ensure clean code and IDE support. Never import from _generated internal paths.
from b1sl.b1sl import entities as en
from b1sl.b1sl.fields import Item, Order
from b1sl.b1sl.resources.odata import F
new_item = en.Item(item_code="A100", item_name="New Item")
The recommended client for all production use cases.
Basic Usage
import asyncio
from b1sl.b1sl import AsyncB1Client, B1Config
async def main():
config = B1Config.from_env()
async with AsyncB1Client(config) as b1:
item = await b1.items.get("A0001")
print(f"[{item.item_code}] {item.item_name}")
asyncio.run(main())
The async with block handles POST /Logout automatically — even on exceptions.
Manual Lifecycle (Long-running services)
client = AsyncB1Client(config)
await client.connect()
await client.aclose()
Top 16 Canonical Aliases
| Category | Aliases |
|---|
| Master Data | items, business_partners, users |
| Sales | quotations, orders, delivery_notes, invoices, incoming_payments |
| Purchasing | purchase_orders, purchase_delivery_notes, purchase_invoices, vendor_payments |
| Operations | production_orders, journal_entries, service_calls, activities |
Dynamic Access (Any Endpoint)
from b1sl.b1sl.models._generated.entities.inventory import ItemWarehouseInfo
whse_resource = b1.get_resource(ItemWarehouseInfo, "ItemWarehouseInfo")
data = await whse_resource.get("A0001")
Custom Client Alias (Enterprise Pattern)
from b1sl.b1sl import AsyncB1Client
from b1sl.b1sl.resources.async_base import AsyncGenericResource
from b1sl.b1sl.models._generated.entities.inventory import ItemWarehouseInfo
class MyB1Client(AsyncB1Client):
@property
def warehouses(self) -> AsyncGenericResource[ItemWarehouseInfo]:
return self.get_resource(ItemWarehouseInfo, "ItemWarehouseInfo")
High Concurrency with asyncio.gather
async with AsyncB1Client(config) as b1:
codes = ["A0001", "A0002", "A0003"]
items = await asyncio.gather(*[b1.items.get(c) for c in codes])
The SDK uses a shared httpx.AsyncClient and an asyncio.Lock to prevent session floods.
Key Async Features
- 401 Auto-Retry: Expired sessions are transparently renewed and the original request is retried once.
- Session Hydration: Reuse an existing
B1SESSION token across serverless functions or Temporal activities.
- Optimistic Concurrency (ETags): Automated ETag handling with smart cache invalidation on
412 conflicts.
CRUD Operations (Master Data & Transactions)
The SDK provides a consistent set of methods for interacting with resources.
Create (POST)
Instantiate a model and pass it to the .create() method.
from b1sl.b1sl import entities as en
new_item = en.Item(item_code="A0001", item_name="New Item")
await b1.items.create(new_item)
Read (GET)
Fetch by ID or check for existence.
if await b1.items.exists("A0001"):
pass
total = await b1.items.count()
Optimistic Concurrency (ETags)
The SDK manages ETags behind the scenes. Every model instance has a .etag property.
item = await b1.items.get("P001")
print(item.etag)
Update (PATCH) - The "Surgical Delta" Pattern
Best Practice: Never resubmit a full object. Only send the fields you want to change.
delta = en.Item(item_name="Updated Name")
await b1.items.update("A0001", delta)
Delete (DELETE)
await b1.items.delete("A0001")
Transparent Pagination Streams
When dealing with large datasets, SAP Service Layer automatically paginates results. The SDK provides a .stream() method to transparently handle these pages using Python generators.
Usage
Available on any resource or builder.
from b1sl.b1sl.fields import Item
async for item in b1.items.filter(Item.quantity_on_stock > 0).stream(page_size=100):
process(item)
for item in b1.items.filter(Item.quantity_on_stock > 5).stream():
process(item)
Configuration
page_size: Controls B1S-PageSize header (HTTP efficiency).
max_pages: Safety limit on number of HTTP requests.
.top(N): Hard global limit on total items yielded across all pages.
Common Patterns
- Progress:
total = await b1.items.count(); async for i in b1.items.stream(): ...
- Collect:
items = [i async for i in b1.items.stream()]
- Safety:
.stream(max_pages=5)
Guarantee
The SDK ensures that all query parameters ($filter, $select, etc.) are re-applied to every subsequent page fetch, even if SAP omits them in the nextLink.
OData $batch Operations (Performance & Atomicity)
The SDK supports grouping multiple operations into a single HTTP request using a Proxy-based recording pattern.
Use Case
- High Concurrency: Fetching hundreds of records using generic queries in one Go.
- Transaction Integrity: Ensuring multiple creates/updates succeed or fail together as a unit.
Basic Pattern
async with b1.batch() as batch:
await batch.items.top(1).execute()
async with batch.changeset() as cs:
await cs.items.create(en.Item(item_code="B1001"))
await cs.orders.create(new_order)
results = await batch.execute()
Result Analysis
Results are flattened and indexed according to their original enqueueing order.
if results.all_ok:
print(f"Operation 0 found {len(results[0].entity)} items")
print(f"New Item Code: {results[2].entity.item_code}")
else:
for r in results.failed:
print(f"Op {r.index} failed: {r.error}")
Error Handling & Atomicity
- Partial Success: Top-level operations are independent. If one fails, others still succeed.
- Atomic ChangeSets: If one operation inside a
changeset() fails, the entire ChangeSet is rolled back.
- No Exceptions:
batch.execute() returns results even on failure. Use results.all_ok or results.failed.
- Sync parity:
B1Client.batch() works identically with plain with blocks and a sync execute().
- Dry Run aware: under
with b1.dry_run():, batch.execute() returns synthesized per-op 204s without sending anything to SAP.
[!IMPORTANT]
OData Rule: GET operations are not permitted inside a changeset() block. The SDK will raise a ValueError if this is attempted.
Error Handling
The SDK maps Service Layer HTTP errors to specialized Python exceptions for cleaner flow control:
B1NotFoundError: Resource missing (404).
B1ValidationError: Bad request or validation failure (400).
SAPConcurrencyError: ETag version mismatch (412).
B1AuthError: Authentication or session failure (401).
B1Exception: Base class for all SDK-specific errors.
Pattern: Safe Existence Check
Instead of catching 404s manually, use the .exists() helper:
if await b1.items.exists("A0001"):
pass
Pattern: Defensive Error Parsing
The SDK handles cases where SAP returns string-based error nodes instead of dictionaries, ensuring e.details is always safe to inspect if it contains valid JSON.
FastAPI Integration
from fastapi import FastAPI
from contextlib import asynccontextmanager
from b1sl.b1sl import AsyncB1Client, B1Config
b1_client: AsyncB1Client | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global b1_client
b1_client = AsyncB1Client(B1Config.from_env())
await b1_client.connect()
yield
await b1_client.aclose()
app = FastAPI(lifespan=lifespan)
@app.get("/items/{item_code}")
async def get_item(item_code: str):
return await b1_client.items.get(item_code)
SQL Queries
Execute stored SQL definitions via the SQLQueries endpoint. Use client.sql_queries (Elite alias) on both sync and async clients.
Running a stored query
from b1sl.b1sl import B1Client, B1Config
with B1Client(B1Config.from_env()) as client:
result = client.sql_queries.run("sql04")
print(f"{len(result)} rows, has_more={result.has_more}")
result = client.sql_queries.run("sql01", docTotal=100.0, docPartner="C001")
for row in client.sql_queries.run_stream("sql04", page_size=50, max_pages=10):
process(row)
Typed rows via Pydantic
from pydantic import BaseModel
class ItemRow(BaseModel):
ItemCode: str
OnHand: float | None = None
result = client.sql_queries.run("sql04")
typed = result.to_pydantic(ItemRow)
print(typed[0].ItemCode)
Async client
async with AsyncB1Client(config) as b1:
result = await b1.sql_queries.run("sql04")
async for row in b1.sql_queries.run_stream("sql04", page_size=50):
await process(row)
Error handling
| SAP code | Exception | Cause |
|---|
"702" | B1SqlNotAllowedError | Table not in b1s_sqltable.conf allowlist |
"703" | B1SqlNotAllowedError | Column in ColumnExcludeList |
"704" | B1SqlParamError | Wrong parameter name, count, or type |
Both B1SqlNotAllowedError and B1SqlParamError are subclasses of B1ValidationError.
from b1sl.b1sl.exceptions.exceptions import B1SqlNotAllowedError, B1SqlParamError
try:
result = client.sql_queries.run("sql04", wrongParam=1)
except B1SqlNotAllowedError as e:
print(f"Table/column blocked [{e.sap_code}]: {e}")
except B1SqlParamError as e:
print(f"Parameter error: {e}")
Reference: docs/reference/sl/sql-queries.md
SQL Query Composer (MCP / AI Agent helpers)
b1sl.contrib.mcp provides grammar constants and prompt helpers that let an
LLM (Claude, GPT, etc.) generate valid SAP SL SQL without hallucinating
unsupported constructs.
The problem
LLMs frequently write SQL that SAP SL silently rejects:
| LLM writes | SL response |
|---|
CASE WHEN … END | parse error |
COALESCE(col, 0) | parse error |
WITH cte AS (…) | parse error |
ROW_NUMBER() OVER (…) | parse error |
FROM (SELECT …) AS sub | parse error |
Ground the LLM before asking it to write SQL
from b1sl.contrib.mcp.grammar import sql_grammar_system_prompt
system = sql_grammar_system_prompt()
system_no_tables = sql_grammar_system_prompt(include_tables=False)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": "Write SQL to get all items with stock > 0"},
]
Grammar constants for validation
from b1sl.contrib.mcp import (
SUPPORTED_KEYWORDS,
SUPPORTED_FUNCTIONS,
UNSUPPORTED_COMMON,
)
sql_upper = generated_sql.upper()
violations = [kw for kw in UNSUPPORTED_COMMON if kw in sql_upper]
if violations:
raise ValueError(f"Unsupported constructs: {violations}")
Full MCP agent loop: describe → generate → store → run
from b1sl.contrib.mcp.grammar import sql_grammar_system_prompt
from b1sl.contrib.mcp.schemas import sql_query_tool_definition
from b1sl.contrib.mcp.formatters import format_sql_result
system_prompt = sql_grammar_system_prompt()
with B1Client(config) as b1:
b1.sql_queries.create(en.SQLQuery(
sql_code="agent_items_low_stock",
sql_name="Agent: Items with low stock",
sql_text='SELECT "ItemCode", "ItemName", "OnHand" FROM "OITM" WHERE "OnHand" < :threshold',
param_list="threshold",
))
info = b1.sql_queries.describe("agent_items_low_stock")
tool = sql_query_tool_definition(info)
result = b1.sql_queries.run("agent_items_low_stock", threshold=10)
context = format_sql_result(result, title="Low Stock Items")
Key SQL rules to embed in your prompts
| Rule | Detail |
|---|
| Write unquoted identifiers | SL normalises ItemCode → "ItemCode" (HANA) / [ItemCode] (MSSQL) |
| Always alias columns | Aliases become JSON keys — SELECT ItemCode as Code → {"Code": "…"} |
Use :paramName for params | WHERE "DocTotal" > :docTotal — pass at run(code, docTotal=100) |
ISNULL = IFNULL | Both are cross-backend; SL normalises transparently |
| No subquery aliases | FROM (SELECT …) AS sub is NOT supported |
OData Query Builder
Fluent, type-safe interface. No string concatenation needed. Lead with the Static Field Constants (b1sl.b1sl.fields); reach for the raw F proxy only for UDFs and dynamic names.
Field Referencing Styles
| Style | Variable | Import | Case | Autocomplete |
|---|
| Static (recommended) | Item, Order, etc. | from b1sl.b1sl.fields import Item, Order | Pythonic snake_case | ✅ Full |
| Dynamic (UDFs / raw) | F | from b1sl.b1sl.resources.odata import F | SAP CamelCase, verbatim | ❌ None |
The static constants carry the metadata-verified SAP names — including
irregular spellings a naive conversion gets wrong (service_call_id →
ServiceCallID, bplid → BPLID). A typo on a constant raises
AttributeError immediately; a typo through F becomes a live SAP -1000
error at runtime. Entity-set aliases mirror entities: fields.Order,
fields.Invoice, … resolve to DocumentFields.
Basic Examples
from b1sl.b1sl.fields import Item
from b1sl.b1sl.resources.odata import F
results = await b1.items.filter(Item.quantity_on_stock > 0).execute()
results = await b1.items.filter(F.U_Categoria == "MRO").execute()
Never import the module as F (from b1sl.b1sl import fields as F): that
shadows the raw proxy and the two are not interchangeable.
Operator Reference
| Python Operator | OData Equivalent | Example |
|---|
== | eq | Item.item_code == 'A001' |
!= | ne | BusinessPartner.card_code != 'C001' |
> | gt | Item.quantity_on_stock > 0 |
>= | ge | Order.doc_total >= 100.5 |
& | and | (A) & (B) |
| | or | (A) | (B) |
~ | not | ~(A) |
IMPORTANT: Parentheses are mandatory for logical composition: (A) & (B).
String Functions
await b1.items.filter(Item.item_name.contains("Cheese")).execute()
Expansions (Surgical)
from b1sl.b1sl.fields import ServiceCall, BusinessPartner
sc = await client.service_calls.by_id(1).expand({
ServiceCall.business_partner: [BusinessPartner.card_code, BusinessPartner.card_name]
}).execute()
sc = await client.service_calls.by_id(1).select(
ServiceCall.subject,
ServiceCall.business_partner / BusinessPartner.card_code,
).expand([ServiceCall.business_partner]).execute()
Terminal Methods
| Method | Source | Returns | Behavior |
|---|
.execute() | Builder | PaginatedResult[T] | T | Executes query, returns one page (list-like, with next_params / has_more) or single object. |
.list() | Resource | PaginatedResult[T] | One page with pagination metadata; pass params=page.next_params for the next page. |
.stream() | Either | Generator | Transparent. Fetches every page until exhaustion. |
.first() | Builder | T | None | Adds $top=1, executed, returns first or None. |
Interaction Patterns
| Style | Tooling | Discovery | Case | Best For |
|---|
| Pythonic | fields | ✅ Full IDE | snake_case | Default for every metadata field. |
| Dynamic | F Proxy | ❌ None | CamelCase | UDFs, runtime-built names, generic tools. |
| Hybrid | fields + raw strings | Mixed | Mixed | Custom tables, advanced OData. |
UDF (User-Defined Field) Handling
The core SDK follows a "Vanilla" policy — U_* fields are excluded from generated models to maintain version stability. Three patterns cover UDF access:
Pattern A — Dynamic .udfs Mapping (Recommended)
The most professional way to handle UDFs. Provides a protected namespace on every model.
item = b1.items.get("C100")
item.udfs["U_Color"] = "Vibrant Red"
current_color = item.udfs["U_Color"]
new_bp = en.BusinessPartner(
card_code="C2000",
udfs={"U_Priority": "High"}
)
[!IMPORTANT]
The .udfs mapping strictly enforces the U_ prefix. Attempting to access or set a non-UDF field via this proxy will raise a KeyError.
Pattern B — Typed UDFs (for heavy UDF users)
Declare UDFs as first-class fields in the Override system:
from pydantic import Field
from .._generated.entities.inventory import Item as _Item
class Item(_Item):
my_color: str | None = Field(None, alias="U_RealColor")
[!NOTE]
Legacy code may read UDFs via model.get("U_Color") (possible because
B1Model uses extra="allow"). Discouraged: it does not enforce the U_
prefix, so a typo silently reads a core SAP field. Use .udfs instead —
it supports the full Mapping API, including .get(key, default).
Pattern C — Dynamic Schema & Validation (Advanced)
Discovery and validation using the metadata-driven UDFSchema container.
schema = await b1.business_partners.get_udf_schema()
if "U_Age" in schema:
print(f"U_Age info: {schema['U_Age'].description}")
try:
payload = schema.validate_and_dump({"U_Age": 25, "U_Color": "Red"})
await b1.business_partners.update(card_code, {"udfs": payload})
except Exception as e:
print(f"Validation failed: {e}")
[!TIP]
Use validate_and_dump when building UIs or integrations where incoming raw data needs to be verified against the current SAP environment's schema before submission.
Architecture Layers
src/b1sl/b1sl/
├── models/
│ ├── _generated/ # AUTO-GENERATED — NEVER edit manually
│ ├── _overrides/ # Handcrafted extensions (calculated props, UDFs)
│ └── entities/ # Public facade — blend of generated + overrides
├── resources/
│ ├── _generated/ # Auto-generated service classes (CRUD + actions)
│ └── async_base.py # AsyncGenericResource base
└── fields/ # Typed OData field constants (fields.Item.item_code, ...)
Key rules:
_generated/ is read-only. All structural changes go through the generator.
- Imports for consumers should always come from
b1sl.b1sl.entities or b1sl.b1sl.fields.
B1Model provides universal: boolean coercion (tYES/tNO→bool), date parsing (/Date(ms)/→ISO), and null filtering on .to_api_payload().
Metadata Generation Pipeline
Used when updating models for a new SAP version or adding new entities.
./scripts/generate_models.sh <version>
Source files (place in metadata/<version>/):
| File | Source |
|---|
metadata_document.xml | GET /b1s/v2/$metadata |
service_document.json | GET /b1s/v2/ |
service_layer_api_reference.html | SAP API Reference page |
Use .real.xml / .real.json suffixes for local production metadata — they are git-ignored and take precedence over generic files.
Django Integration
from b1sl.b1sl import B1Config, B1Client
config = B1Config.from_django_settings()
client = B1Client(config)
from b1sl.b1sl.adapter import get_rest_adapter
adapter = get_rest_adapter()
Resources