Skip to main content

Minimal async Coupa client with offset pagination, concurrency, and retries.

Project description

coupa-async-client

Minimal async Coupa API client (OAuth2 client-credentials, offset pagination with concurrency & retries). No persistence. You pass resource, params, fields, offsets; it yields JSON.

Install

pip install coupa-async-client

Environment

Set via shell or .env:

CLIENT_ID=your_client_id
CLIENT_SECRET=your_client_secret
SCOPES=scope1,scope2           # optional; comma or space separated
BASE_URL=https://yourcompany.coupahost.com/api
TOKEN_URL=https://yourcompany.coupahost.com/oauth2/token

Quickstart (Async)

import os, asyncio
from coupa_async_client import CoupaAsyncClient

BASE_URL  = os.getenv("BASE_URL",  "https://yourcompany.coupahost.com/api")
TOKEN_URL = os.getenv("TOKEN_URL", "https://yourcompany.coupahost.com/oauth2/token")

async def main():
    async with CoupaAsyncClient(
        base_url=BASE_URL,
        token_url=TOKEN_URL,
        client_id=os.environ["CLIENT_ID"],
        client_secret=os.environ["CLIENT_SECRET"],
        scopes=[s for s in os.getenv("SCOPES", "").replace(",", " ").split() if s],
        default_page_size=50,
        default_concurrent=20,
    ) as client:
        params = {
            "updated-at[gt_or_eq]": "2025-08-14T00:00:00-04:00",
            "updated-at[lt]"     : "2025-08-15T00:00:00-04:00",
        }
        fields = '["id","created-at","status"]'
        async for item in client.iter_items("approvals", params=params, fields=fields):
            print(item["id"], item.get("status"))

if __name__ == "__main__":
    asyncio.run(main())

Quickstart (Sync)

import os
from coupa_async_client import CoupaClient

with CoupaClient(
    base_url=os.environ["BASE_URL"],
    token_url=os.environ["TOKEN_URL"],
    client_id=os.environ["CLIENT_ID"],
    client_secret=os.environ["CLIENT_SECRET"],
    scopes=[s for s in os.getenv("SCOPES", "").replace(",", " ").split() if s],
    default_page_size=50,
) as client:
    params = {"status[eq]": "approved"}
    fields = '["id","status","created-at"]'
    for item in client.items("approvals", params=params, fields=fields):
        print(item["id"], item["status"])

Concepts

Resource: collection path (e.g., approvals, requisitions, invoices).

Params (filters): dict added to query string. Use field[operator]=value. Nested: parent[child][op].

Fields: JSON string defining projection/expansion (forwarded as-is).

Offset pagination: client iterates offsets in batches, concurrent (async) or sequential (sync); stops when a whole batch is empty (unless until_empty=False).

Fields (projection/expansion)

# simple
fields = '["id","status","created-at"]'

# relationship expansion
fields = (
    '["id","status",'
    '{"approver":["id","name","login"]},'
    '{"approved_by":["id","fullname"]}]'
)

Coupa filter operators

Build them as entries in params:

Operator Meaning URL fragment example params example
(default) equals /purchase_orders?id=100 {"id": "100"}
contains substring (not datetime) /suppliers?name[contains]=.com {"name[contains]": ".com"}
starts_with prefix (not datetime) /budget_lines?notes[starts_with]=San%20Francisco {"notes[starts_with]": "San Francisco"}
ends_with suffix (not datetime) /items?name[ends_with]=Gray {"name[ends_with]": "Gray"}
gt greater than /purchase_orders?version[gt]=1 {"version[gt]": "1"}
lt less than /suppliers?updated-at[lt]=2010-01-15 {"updated-at[lt]": "2010-01-15"}
gt_or_eq greater or equal /purchase_orders?version[gt_or_eq]=3 {"version[gt_or_eq]": "3"}
lt_or_eq less or equal /purchase_orders?version[lt_or_eq]=1 {"version[lt_or_eq]": "1"}
not_eq not equal /purchase_orders?status[not_eq]=active {"status[not_eq]": "active"}
in any of (CSV list) /invoices?account-type[name][in]=SAP100,SAP200 {"account-type[name][in]": "SAP100,SAP200"}
not_in exclude any of (CSV list) /invoices?status[not_in]=ap_hold,booking_hold {"status[not_in]": "ap_hold,booking_hold"}
blank blank value (true/false) /suppliers?po-email[blank]=true {"po-email[blank]": "true"}

Use ISO-8601 with timezone for datetime filters (e.g., 2025-08-15T00:00:00-04:00).

Offset pagination

# scan a fixed offset window
async for page in client.iter_pages(
    "requisitions",
    params={"status[eq]": "approved"},
    fields='["id","created-at"]',
    offset_start=0,
    offset_end=10_000,   # exclusive
    page_size=50,
    concurrent=20,
    until_empty=False,
):
    ...

# stop automatically when a full batch is empty (default)
async for item in client.iter_items(
    "suppliers",
    params={"name[contains]": ".com"},
    fields='["id","name","updated-at"]',
):
    ...

Error handling & retries

OAuth2 token errors raise a custom CoupaAuthError with status + short response snippet.

Requests retry on 429 (honoring Retry-After) and transient 5xx with exponential jitter backoff.

Async batches skip failed offsets for that batch; the “stop-on-empty” condition triggers only if no errors and all pages are empty.

Performance tuning

Start with default_concurrent=10–30 (async); adjust to your Coupa limits.

Keep page_size aligned with the server-side page size/limit if available.

Use until_empty=False to sweep a fixed offset range regardless of empties.

Reduce concurrency if you frequently hit 429.

CSV example (outside the library)

import csv, os, asyncio
from coupa_async_client import CoupaAsyncClient

async def main():
    async with CoupaAsyncClient(
        base_url=os.environ["BASE_URL"],
        token_url=os.environ["TOKEN_URL"],
        client_id=os.environ["CLIENT_ID"],
        client_secret=os.environ["CLIENT_SECRET"],
        scopes=[s for s in os.getenv("SCOPES", "").replace(",", " ").split() if s],
        default_concurrent=30,
    ) as client:
        params = {"status[eq]": "approved"}
        fields = '["id","status","created-at"]'
        with open("requisitions.csv", "w", newline="", encoding="utf-8") as f:
            w = csv.DictWriter(f, fieldnames=["id","status","created-at"])
            w.writeheader()
            async for row in client.iter_items("requisitions", params=params, fields=fields):
                w.writerow({k: row.get(k) for k in w.fieldnames})

if __name__ == "__main__":
    asyncio.run(main())

Contributing

Keep it generic: no persistence, no endpoint-specific mappers.

Add type hints/docstrings. Run your linter/formatter if configured.

Open issues/PRs with clear reproduction steps.

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

coupa_async_client-0.1.0.tar.gz (6.7 kB view details)

Uploaded Source

Built Distribution

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

coupa_async_client-0.1.0-py3-none-any.whl (10.2 kB view details)

Uploaded Python 3

File details

Details for the file coupa_async_client-0.1.0.tar.gz.

File metadata

  • Download URL: coupa_async_client-0.1.0.tar.gz
  • Upload date:
  • Size: 6.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.12.9

File hashes

Hashes for coupa_async_client-0.1.0.tar.gz
Algorithm Hash digest
SHA256 a6a598da0071183b531f2bcefcf5316ca06a1da11a1bf208328595a4a60089e7
MD5 e7cd14633bef5883a5aba68d4dc9c349
BLAKE2b-256 70f78fabb0ceb1b4779db805cc96db8e8e2ea7700990c6f80c1cf9865869141f

See more details on using hashes here.

Provenance

The following attestation bundles were made for coupa_async_client-0.1.0.tar.gz:

Publisher: release.yml on RodrigoMufatto/coupa_async_client

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file coupa_async_client-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for coupa_async_client-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 36fad82fcfd58e283a0134347c4856f472505031e2adbeebdbc42f34d597f1a9
MD5 932b043b2cbb8b86aa443390907b122e
BLAKE2b-256 74179bfff2d69bffdede0630465bb4dae9c911f799685c2a96c7a5773cc34d93

See more details on using hashes here.

Provenance

The following attestation bundles were made for coupa_async_client-0.1.0-py3-none-any.whl:

Publisher: release.yml on RodrigoMufatto/coupa_async_client

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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