coupa-async-client


Namecoupa-async-client JSON
Version 0.1.0 PyPI version JSON
download
home_pageNone
SummaryMinimal async Coupa client with offset pagination, concurrency, and retries.
upload_time2025-08-29 11:39:51
maintainerNone
docs_urlNone
authorNone
requires_python>=3.10
licenseMIT
keywords api async client coupa httpx pagination
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage No coveralls.
            # 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
```bash
pip install coupa-async-client
```
## Environment

Set via shell or .env:

```ini
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)

```python
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)
```python
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)

```python
# 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

```python
# 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)

```python
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
            

Raw data

            {
    "_id": null,
    "home_page": null,
    "name": "coupa-async-client",
    "maintainer": null,
    "docs_url": null,
    "requires_python": ">=3.10",
    "maintainer_email": null,
    "keywords": "api, async, client, coupa, httpx, pagination",
    "author": null,
    "author_email": "Rodrigo Mufatto <rodrigom3317@gmail.com>",
    "download_url": "https://files.pythonhosted.org/packages/70/f7/8fabb0ceb1b4779db805cc96db8e8e2ea7700990c6f80c1cf9865869141f/coupa_async_client-0.1.0.tar.gz",
    "platform": null,
    "description": "# coupa-async-client\n\nMinimal async Coupa API client (OAuth2 client-credentials, offset pagination with concurrency & retries).\n**No persistence.** You pass `resource`, `params`, `fields`, offsets; it yields JSON.\n\n## Install\n```bash\npip install coupa-async-client\n```\n## Environment\n\nSet via shell or .env:\n\n```ini\nCLIENT_ID=your_client_id\nCLIENT_SECRET=your_client_secret\nSCOPES=scope1,scope2           # optional; comma or space separated\nBASE_URL=https://yourcompany.coupahost.com/api\nTOKEN_URL=https://yourcompany.coupahost.com/oauth2/token\n```\n## Quickstart (Async)\n\n```python\nimport os, asyncio\nfrom coupa_async_client import CoupaAsyncClient\n\nBASE_URL  = os.getenv(\"BASE_URL\",  \"https://yourcompany.coupahost.com/api\")\nTOKEN_URL = os.getenv(\"TOKEN_URL\", \"https://yourcompany.coupahost.com/oauth2/token\")\n\nasync def main():\n    async with CoupaAsyncClient(\n        base_url=BASE_URL,\n        token_url=TOKEN_URL,\n        client_id=os.environ[\"CLIENT_ID\"],\n        client_secret=os.environ[\"CLIENT_SECRET\"],\n        scopes=[s for s in os.getenv(\"SCOPES\", \"\").replace(\",\", \" \").split() if s],\n        default_page_size=50,\n        default_concurrent=20,\n    ) as client:\n        params = {\n            \"updated-at[gt_or_eq]\": \"2025-08-14T00:00:00-04:00\",\n            \"updated-at[lt]\"     : \"2025-08-15T00:00:00-04:00\",\n        }\n        fields = '[\"id\",\"created-at\",\"status\"]'\n        async for item in client.iter_items(\"approvals\", params=params, fields=fields):\n            print(item[\"id\"], item.get(\"status\"))\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n## Quickstart (Sync)\n```python\nimport os\nfrom coupa_async_client import CoupaClient\n\nwith CoupaClient(\n    base_url=os.environ[\"BASE_URL\"],\n    token_url=os.environ[\"TOKEN_URL\"],\n    client_id=os.environ[\"CLIENT_ID\"],\n    client_secret=os.environ[\"CLIENT_SECRET\"],\n    scopes=[s for s in os.getenv(\"SCOPES\", \"\").replace(\",\", \" \").split() if s],\n    default_page_size=50,\n) as client:\n    params = {\"status[eq]\": \"approved\"}\n    fields = '[\"id\",\"status\",\"created-at\"]'\n    for item in client.items(\"approvals\", params=params, fields=fields):\n        print(item[\"id\"], item[\"status\"])\n```\n\n## Concepts\n\nResource: collection path (e.g., approvals, requisitions, invoices).\n\nParams (filters): dict added to query string. Use field[operator]=value. Nested: parent[child][op].\n\nFields: JSON string defining projection/expansion (forwarded as-is).\n\nOffset pagination: client iterates offsets in batches, concurrent (async) or sequential (sync); stops when a whole batch is empty (unless until_empty=False).\n\n## Fields (projection/expansion)\n\n```python\n# simple\nfields = '[\"id\",\"status\",\"created-at\"]'\n\n# relationship expansion\nfields = (\n    '[\"id\",\"status\",'\n    '{\"approver\":[\"id\",\"name\",\"login\"]},'\n    '{\"approved_by\":[\"id\",\"fullname\"]}]'\n)\n```\n\n## Coupa filter operators\n\nBuild them as entries in params:\n\n| Operator      | Meaning                   | URL fragment example                               | `params` example                              |\n| ------------- | ------------------------- | -------------------------------------------------- | --------------------------------------------- |\n| *(default)*   | equals                    | `/purchase_orders?id=100`                          | `{\"id\": \"100\"}`                               |\n| `contains`    | substring (not datetime)  | `/suppliers?name[contains]=.com`                   | `{\"name[contains]\": \".com\"}`                  |\n| `starts_with` | prefix (not datetime)     | `/budget_lines?notes[starts_with]=San%20Francisco` | `{\"notes[starts_with]\": \"San Francisco\"}`     |\n| `ends_with`   | suffix (not datetime)     | `/items?name[ends_with]=Gray`                      | `{\"name[ends_with]\": \"Gray\"}`                 |\n| `gt`          | greater than              | `/purchase_orders?version[gt]=1`                   | `{\"version[gt]\": \"1\"}`                        |\n| `lt`          | less than                 | `/suppliers?updated-at[lt]=2010-01-15`             | `{\"updated-at[lt]\": \"2010-01-15\"}`            |\n| `gt_or_eq`    | greater or equal          | `/purchase_orders?version[gt_or_eq]=3`             | `{\"version[gt_or_eq]\": \"3\"}`                  |\n| `lt_or_eq`    | less or equal             | `/purchase_orders?version[lt_or_eq]=1`             | `{\"version[lt_or_eq]\": \"1\"}`                  |\n| `not_eq`      | not equal                 | `/purchase_orders?status[not_eq]=active`           | `{\"status[not_eq]\": \"active\"}`                |\n| `in`          | any of (CSV list)         | `/invoices?account-type[name][in]=SAP100,SAP200`   | `{\"account-type[name][in]\": \"SAP100,SAP200\"}` |\n| `not_in`      | exclude any of (CSV list) | `/invoices?status[not_in]=ap_hold,booking_hold`    | `{\"status[not_in]\": \"ap_hold,booking_hold\"}`  |\n| `blank`       | blank value (true/false)  | `/suppliers?po-email[blank]=true`                  | `{\"po-email[blank]\": \"true\"}`                 |\n\nUse ISO-8601 with timezone for datetime filters (e.g., 2025-08-15T00:00:00-04:00).\n\n## Offset pagination\n\n```python\n# scan a fixed offset window\nasync for page in client.iter_pages(\n    \"requisitions\",\n    params={\"status[eq]\": \"approved\"},\n    fields='[\"id\",\"created-at\"]',\n    offset_start=0,\n    offset_end=10_000,   # exclusive\n    page_size=50,\n    concurrent=20,\n    until_empty=False,\n):\n    ...\n\n# stop automatically when a full batch is empty (default)\nasync for item in client.iter_items(\n    \"suppliers\",\n    params={\"name[contains]\": \".com\"},\n    fields='[\"id\",\"name\",\"updated-at\"]',\n):\n    ...\n```\n\n## Error handling & retries\n\nOAuth2 token errors raise a custom CoupaAuthError with status + short response snippet.\n\nRequests retry on 429 (honoring Retry-After) and transient 5xx with exponential jitter backoff.\n\nAsync batches skip failed offsets for that batch; the \u201cstop-on-empty\u201d condition triggers only if no errors and all pages are empty.\n\n## Performance tuning\n\nStart with default_concurrent=10\u201330 (async); adjust to your Coupa limits.\n\nKeep page_size aligned with the server-side page size/limit if available.\n\nUse until_empty=False to sweep a fixed offset range regardless of empties.\n\nReduce concurrency if you frequently hit 429.\n\n## CSV example (outside the library)\n\n```python\nimport csv, os, asyncio\nfrom coupa_async_client import CoupaAsyncClient\n\nasync def main():\n    async with CoupaAsyncClient(\n        base_url=os.environ[\"BASE_URL\"],\n        token_url=os.environ[\"TOKEN_URL\"],\n        client_id=os.environ[\"CLIENT_ID\"],\n        client_secret=os.environ[\"CLIENT_SECRET\"],\n        scopes=[s for s in os.getenv(\"SCOPES\", \"\").replace(\",\", \" \").split() if s],\n        default_concurrent=30,\n    ) as client:\n        params = {\"status[eq]\": \"approved\"}\n        fields = '[\"id\",\"status\",\"created-at\"]'\n        with open(\"requisitions.csv\", \"w\", newline=\"\", encoding=\"utf-8\") as f:\n            w = csv.DictWriter(f, fieldnames=[\"id\",\"status\",\"created-at\"])\n            w.writeheader()\n            async for row in client.iter_items(\"requisitions\", params=params, fields=fields):\n                w.writerow({k: row.get(k) for k in w.fieldnames})\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n## Contributing\n\nKeep it generic: no persistence, no endpoint-specific mappers.\n\nAdd type hints/docstrings. Run your linter/formatter if configured.\n\nOpen issues/PRs with clear reproduction steps.\n\n## License\n\nMIT",
    "bugtrack_url": null,
    "license": "MIT",
    "summary": "Minimal async Coupa client with offset pagination, concurrency, and retries.",
    "version": "0.1.0",
    "project_urls": {
        "Repository": "https://github.com/RodrigoMufatto/coupa_async_client"
    },
    "split_keywords": [
        "api",
        " async",
        " client",
        " coupa",
        " httpx",
        " pagination"
    ],
    "urls": [
        {
            "comment_text": null,
            "digests": {
                "blake2b_256": "74179bfff2d69bffdede0630465bb4dae9c911f799685c2a96c7a5773cc34d93",
                "md5": "932b043b2cbb8b86aa443390907b122e",
                "sha256": "36fad82fcfd58e283a0134347c4856f472505031e2adbeebdbc42f34d597f1a9"
            },
            "downloads": -1,
            "filename": "coupa_async_client-0.1.0-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "932b043b2cbb8b86aa443390907b122e",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": ">=3.10",
            "size": 10151,
            "upload_time": "2025-08-29T11:39:50",
            "upload_time_iso_8601": "2025-08-29T11:39:50.066037Z",
            "url": "https://files.pythonhosted.org/packages/74/17/9bfff2d69bffdede0630465bb4dae9c911f799685c2a96c7a5773cc34d93/coupa_async_client-0.1.0-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": null,
            "digests": {
                "blake2b_256": "70f78fabb0ceb1b4779db805cc96db8e8e2ea7700990c6f80c1cf9865869141f",
                "md5": "e7cd14633bef5883a5aba68d4dc9c349",
                "sha256": "a6a598da0071183b531f2bcefcf5316ca06a1da11a1bf208328595a4a60089e7"
            },
            "downloads": -1,
            "filename": "coupa_async_client-0.1.0.tar.gz",
            "has_sig": false,
            "md5_digest": "e7cd14633bef5883a5aba68d4dc9c349",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": ">=3.10",
            "size": 6739,
            "upload_time": "2025-08-29T11:39:51",
            "upload_time_iso_8601": "2025-08-29T11:39:51.042797Z",
            "url": "https://files.pythonhosted.org/packages/70/f7/8fabb0ceb1b4779db805cc96db8e8e2ea7700990c6f80c1cf9865869141f/coupa_async_client-0.1.0.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2025-08-29 11:39:51",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "RodrigoMufatto",
    "github_project": "coupa_async_client",
    "github_not_found": true,
    "lcname": "coupa-async-client"
}
        
Elapsed time: 1.68365s