Name | coupa-async-client JSON |
Version |
0.1.0
JSON |
| download |
home_page | None |
Summary | Minimal async Coupa client with offset pagination, concurrency, and retries. |
upload_time | 2025-08-29 11:39:51 |
maintainer | None |
docs_url | None |
author | None |
requires_python | >=3.10 |
license | MIT |
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"
}