Skip to main content

Odyn is a python client for Business Central Web Services API.

Project description

Odyn

Async-first Python client for Microsoft Dynamics 365 Business Central on-premises OData Web Services. Returns native Polars DataFrames with built-in caching, rate limiting, retry logic, and a fluent query builder.

Scope: Odyn targets Business Central OData Web Services only. It is not designed for the standard Business Central API v2.0 endpoints.

Why Odyn

Integrating with Business Central Web Services means dealing with NTLM/Basic auth, manual OData query strings, pagination loops, and converting JSON blobs into something useful. Odyn handles all of that behind a single async call that returns a Polars DataFrame.

  • Async + sync — built on httpx; sync wrapper included for scripts and notebooks
  • Polars DataFrames — columnar, fast, zero-copy where possible
  • Fluent query builder — type-safe filters via the F singleton, method chaining, raw escape hatch
  • Parquet caching — local file cache with TTL, SHA256 keys, hit/miss stats
  • Resilience — exponential backoff with jitter, rate limiting via token bucket, concurrency control
  • Batch operations — concurrent chunked lookups with progress callbacks
  • Delta syncget_since() / get_before() for incremental loads
  • Streaming — page-by-page async iteration for large datasets
  • Hooks — plug in request/response observers for logging, metrics, or tracing

Requirements

  • Python 3.12+
  • httpx >= 0.28
  • polars >= 1.36
  • aiolimiter >= 1.2

Installation

pip install odyn

Or with uv:

uv add odyn

Quick Start

Async

import asyncio
from odyn import BCWebServiceClient, BasicAuth
from odyn.query import ODataQuery, F

async def main():
    async with BCWebServiceClient.create(
        server="https://bc-server:7048",
        instance="BC210",
        auth=BasicAuth("DOMAIN\\user", "password"),
        company="CRONUS International Ltd.",
    ) as client:
        # All customers as a Polars DataFrame
        customers = await client.get("customers")

        # Filtered query
        query = (
            ODataQuery()
            .select("No", "Name", "Balance_LCY")
            .filter(F.Balance_LCY > 1000)
            .filter(F.Blocked == False)
            .order_by("Balance_LCY desc")
            .top(50)
        )
        top_customers = await client.get("customers", query=query)

        # Single record by key
        customer = await client.get_by_key("customers", "C00010")

asyncio.run(main())

Sync

from odyn import BCWebServiceClientSync, BasicAuth

with BCWebServiceClientSync.create(
    server="https://bc-server:7048",
    instance="BC210",
    auth=BasicAuth("user", "password"),
    company="CRONUS",
) as client:
    df = client.get("customers")
    print(df)

API Key Authentication

from odyn import BCWebServiceClient, APIKeyAuth

auth = APIKeyAuth("my-secret-api-key")

# Or with a Bearer token in the Authorization header
auth = APIKeyAuth("my-key", header_name="Authorization", prefix="Bearer")

Query Builder

from odyn.query import ODataQuery, F

query = (
    ODataQuery()
    .select("No", "Name", "Balance_LCY")
    .filter(F.Status == "Active")          # eq
    .filter(F.Balance_LCY > 1000)          # gt
    .filter(F.Type.is_in(["Customer", "Vendor"]))  # IN via OR chain
    .expand("SalesLines")
    .order_by("Name asc")
    .top(100)
    .skip(50)
)

# Raw OData for functions not covered by the DSL
query = ODataQuery().filter_raw("contains(Name, 'Corp')")

# Combine expressions with & and |
expr = (F.Status == "Active") & (F.Balance_LCY > 0)
expr = (F.City == "London") | (F.City == "Berlin")

Caching

async with BCWebServiceClient.create(
    server="https://bc-server:7048",
    instance="BC210",
    auth=BasicAuth("user", "pass"),
    cache_dir="~/.cache/odyn",
    cache_ttl=3600,  # 1 hour
) as client:
    df = await client.get("customers")                   # cache miss — fetches from API
    df = await client.get("customers")                   # cache hit — reads Parquet
    df = await client.get("customers", use_cache=False)  # force refresh

    client.cleanup_cache()  # remove expired entries

Batch Operations

customer_ids = ["C001", "C002", ..., "C500"]

df = await client.get_batch(
    "customers",
    field="No",
    values=customer_ids,
    batch_size=50,
    select=["No", "Name", "Balance_LCY"],
)

Delta Sync

from datetime import datetime, timedelta, timezone

since = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
updated = await client.get_since("customers", since)

Streaming

async for page in client.get_stream("largeDataset"):
    process(page)  # each page is a Polars DataFrame

Documentation

Full documentation lives in docs/:

Guide Description
Getting Started Installation, prerequisites, first connection
Client Creating and configuring the client
Authentication BasicAuth, APIKeyAuth, custom headers
Query Builder Filters, select, expand, order, the F singleton
Caching ParquetCache, TTL, cache management
Sync Client Synchronous wrapper for non-async contexts
Advanced Hooks, streaming, batch ops, delta sync, concurrency
Exceptions Exception hierarchy and error handling
API Reference Every class, method, parameter, and type
Troubleshooting Common issues and solutions
LLM Context Single-file complete reference for AI assistants

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

odyn-0.5.0.tar.gz (31.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

odyn-0.5.0-py3-none-any.whl (35.4 kB view details)

Uploaded Python 3

File details

Details for the file odyn-0.5.0.tar.gz.

File metadata

  • Download URL: odyn-0.5.0.tar.gz
  • Upload date:
  • Size: 31.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for odyn-0.5.0.tar.gz
Algorithm Hash digest
SHA256 3bc9accfd4ff50fb69111da6bb8b983b0b663113afb020e64d4834ee1d16e103
MD5 9797e286574aff97cd0138b36f3e0379
BLAKE2b-256 c2b264a049c2bc68d5dfd8e29b8305c0a86f78735d09971e4d5647c938d9e5c4

See more details on using hashes here.

File details

Details for the file odyn-0.5.0-py3-none-any.whl.

File metadata

  • Download URL: odyn-0.5.0-py3-none-any.whl
  • Upload date:
  • Size: 35.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for odyn-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1a4c8bb8f372fc80cad5699d3dbbef3bbbb59d4bab4ad6f69e6ec2723465bfd0
MD5 15f2300662ae03e8b78b276e8f20aa31
BLAKE2b-256 4df784182dbaf3ec97b3d242202c413262667faf8a8338d61749b60ee62cc87d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page