Skip to content

Commit 324ec25

Browse files
Copilotsaurabhrb
andcommitted
Add async/await support to Dataverse SDK via inheritance-based AsyncDataverseClient
Co-authored-by: saurabhrb <32964911+saurabhrb@users.noreply.github.com>
1 parent 81ff852 commit 324ec25

12 files changed

Lines changed: 2966 additions & 0 deletions

File tree

architecture/async_design.md

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Async Architecture Design
2+
3+
## Design Philosophy
4+
5+
The async layer follows the **inheritance-based async pattern** used by the Azure SDK for Python:
6+
a parallel class hierarchy that inherits all pure-logic from the sync classes and overrides only
7+
the three blocking operations.
8+
9+
### Only 3 blocking operations get async overrides
10+
11+
| Blocking operation | Sync implementation | Async implementation |
12+
|---|---|---|
13+
| Token acquisition | `credential.get_token(scope)` | `await credential.get_token(scope)` |
14+
| HTTP I/O | `requests.request(...)` | `await aiohttp.ClientSession.request(...)` |
15+
| Sleep / backoff | `time.sleep(delay)` | `await asyncio.sleep(delay)` |
16+
17+
Everything else — URL building, OData serialization, key formatting, cache lookups,
18+
payload construction, error parsing — is **pure CPU logic** that runs in microseconds.
19+
These methods are inherited directly from the sync classes with no override needed.
20+
21+
## Class Hierarchy
22+
23+
```
24+
azure.core.credentials.TokenCredential
25+
_AuthManager._acquire_token(scope) → credential.get_token(scope)
26+
27+
azure.core.credentials_async.AsyncTokenCredential
28+
_AsyncAuthManager._acquire_token(scope) → await credential.get_token(scope)
29+
30+
_HttpClient._request(method, url, **kw) → requests.request(...)
31+
_ODataClient._raw_request(...)
32+
_ODataClient._request(...)
33+
... all sync CRUD / metadata methods
34+
35+
_AsyncHttpClient._request(method, url, **kw) → await aiohttp.ClientSession.request(...)
36+
_AsyncODataClient(inherits _ODataClient)
37+
override: _raw_request, _request, _headers, _merge_headers
38+
override: _create, _create_multiple, _update, _update_multiple, ...
39+
override: _entity_set_from_schema_name, _get, _get_multiple, ...
40+
inherited: _format_key, _build_alternate_key_str, _escape_odata_quotes, ...
41+
inherited: _attribute_payload, _label, _to_pascal, _normalize_cache_key, ...
42+
43+
DataverseClient
44+
records → RecordOperations (sync)
45+
query → QueryOperations (sync)
46+
tables → TableOperations (sync)
47+
files → FileOperations (sync)
48+
49+
AsyncDataverseClient
50+
records → AsyncRecordOperations (async)
51+
query → AsyncQueryOperations (async)
52+
tables → AsyncTableOperations (async)
53+
files → AsyncFileOperations (async)
54+
```
55+
56+
## File Map
57+
58+
| File | Purpose |
59+
|---|---|
60+
| `core/_auth.py` | Sync `_AuthManager` (unchanged) |
61+
| `core/_async_auth.py` | Async `_AsyncAuthManager` |
62+
| `core/_http.py` | Sync `_HttpClient` using `requests` (unchanged) |
63+
| `core/_async_http.py` | Async `_AsyncHttpClient` using `aiohttp` |
64+
| `data/_odata.py` | Sync `_ODataClient` (unchanged) |
65+
| `data/_async_odata.py` | Async `_AsyncODataClient` inheriting `_ODataClient` |
66+
| `client.py` | Sync `DataverseClient` (unchanged) |
67+
| `async_client.py` | Async `AsyncDataverseClient` |
68+
| `operations/records.py` | Sync `RecordOperations` (unchanged) |
69+
| `operations/async_records.py` | Async `AsyncRecordOperations` |
70+
| `operations/query.py` | Sync `QueryOperations` (unchanged) |
71+
| `operations/async_query.py` | Async `AsyncQueryOperations` |
72+
| `operations/tables.py` | Sync `TableOperations` (unchanged) |
73+
| `operations/async_tables.py` | Async `AsyncTableOperations` |
74+
| `operations/files.py` | Sync `FileOperations` (unchanged) |
75+
| `operations/async_files.py` | Async `AsyncFileOperations` |
76+
77+
## Async HTTP Response Wrapper
78+
79+
`_AsyncODataClient` depends on response objects that provide `.status_code`, `.headers`,
80+
`.text`, and `.json()` synchronously (matching the `requests.Response` interface used
81+
throughout `_ODataClient`).
82+
83+
`_AsyncResponse` achieves this by **eagerly reading the entire response body** when the
84+
aiohttp request completes. This is acceptable for Dataverse API responses (which are
85+
typically small JSON payloads). File uploads use streaming writes (not reads), so
86+
eager body reading does not affect upload performance.
87+
88+
## Async Generator for `_get_multiple`
89+
90+
The sync `_get_multiple` is a regular generator (`yield`). The async version is an
91+
**async generator** (`async def` with `yield`), enabling callers to iterate pages with
92+
`async for`:
93+
94+
```python
95+
async for page in od._get_multiple("account", filter="statecode eq 0"):
96+
for row in page:
97+
print(row["name"])
98+
```
99+
100+
At the public API level, `AsyncRecordOperations.get()` returns an async generator function:
101+
102+
```python
103+
pages = await client.records.get("account", filter="statecode eq 0")
104+
async for page in pages:
105+
for record in page:
106+
print(record["name"])
107+
```
108+
109+
## ContextVar Correlation IDs
110+
111+
`_CALL_SCOPE_CORRELATION_ID` is a `ContextVar`. Python's `ContextVar` is fully compatible
112+
with asyncio — each task gets its own copy of the context. The `_call_scope()` context
113+
manager (sync `@contextmanager`) is used inside `@asynccontextmanager` (`_scoped_odata`)
114+
via a regular `with` statement — this is safe because the ContextVar set/reset is
115+
instantaneous (no I/O).
116+
117+
## Usage Comparison
118+
119+
### Sync (existing code — unchanged)
120+
121+
```python
122+
from azure.identity import ClientSecretCredential
123+
from PowerPlatform.Dataverse.client import DataverseClient
124+
125+
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
126+
127+
with DataverseClient("https://org.crm.dynamics.com", credential) as client:
128+
guid = client.records.create("account", {"name": "Contoso"})
129+
record = client.records.get("account", guid)
130+
client.records.update("account", guid, {"telephone1": "555-0100"})
131+
client.records.delete("account", guid)
132+
```
133+
134+
### Async (new)
135+
136+
```python
137+
import asyncio
138+
from azure.identity.aio import ClientSecretCredential
139+
from PowerPlatform.Dataverse.async_client import AsyncDataverseClient
140+
141+
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
142+
143+
async def main():
144+
async with AsyncDataverseClient("https://org.crm.dynamics.com", credential) as client:
145+
guid = await client.records.create("account", {"name": "Contoso"})
146+
record = await client.records.get("account", guid)
147+
await client.records.update("account", guid, {"telephone1": "555-0100"})
148+
await client.records.delete("account", guid)
149+
150+
asyncio.run(main())
151+
```
152+
153+
## Installation
154+
155+
### Async support (optional dependency)
156+
157+
```bash
158+
pip install "PowerPlatform-Dataverse-Client[async]"
159+
```
160+
161+
This installs `aiohttp>=3.13.3` in addition to the core dependencies. The sync client
162+
continues to work without `aiohttp` installed.
163+
164+
## Migration Guide
165+
166+
Existing sync code does **not** need to change. Async support is purely additive:
167+
168+
1. Change import: `from PowerPlatform.Dataverse.client import DataverseClient`
169+
`from PowerPlatform.Dataverse.async_client import AsyncDataverseClient`
170+
171+
2. Use `async with` instead of `with`
172+
173+
3. Use async credentials: `azure.identity.aio.*` instead of `azure.identity.*`
174+
175+
4. Add `await` before every operation call
176+
177+
5. Use `async for` when iterating pages returned by `client.records.get(table, ...)`

examples/basic/async_example.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Basic async usage example for the Dataverse SDK."""
2+
3+
import asyncio
4+
5+
# Use an async Azure Identity credential
6+
# Install: pip install azure-identity
7+
from azure.identity.aio import InteractiveBrowserCredential
8+
9+
from PowerPlatform.Dataverse.async_client import AsyncDataverseClient
10+
11+
12+
async def main() -> None:
13+
credential = InteractiveBrowserCredential()
14+
15+
async with AsyncDataverseClient("https://org.crm.dynamics.com", credential) as client:
16+
# Create a single record
17+
guid = await client.records.create("account", {"name": "Contoso"})
18+
print(f"[OK] Created account: {guid}")
19+
20+
# Fetch the record back
21+
record = await client.records.get("account", guid, select=["name"])
22+
print(f"[OK] Name: {record['name']}")
23+
24+
# Update the record
25+
await client.records.update("account", guid, {"telephone1": "555-0100"})
26+
print("[OK] Updated telephone")
27+
28+
# Delete the record
29+
await client.records.delete("account", guid)
30+
print("[OK] Deleted account")
31+
32+
# SQL query (async)
33+
rows = await client.query.sql("SELECT TOP 5 name FROM account ORDER BY name")
34+
for row in rows:
35+
print(f" account: {row['name']}")
36+
37+
# Multi-record fetch with async pagination
38+
print("[INFO] Paging through active accounts:")
39+
async for page in await client.records.get(
40+
"account",
41+
filter="statecode eq 0",
42+
select=["name"],
43+
page_size=20,
44+
):
45+
for rec in page:
46+
print(f" {rec['name']}")
47+
48+
49+
if __name__ == "__main__":
50+
asyncio.run(main())

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,16 @@ dependencies = [
4141
dataverse-install-claude-skill = "PowerPlatform.Dataverse._skill_installer:main"
4242

4343
[project.optional-dependencies]
44+
async = ["aiohttp>=3.13.3"]
4445
dev = [
4546
"pytest>=7.0.0",
4647
"pytest-cov>=4.0.0",
48+
"pytest-asyncio>=0.23.0",
4749
"black>=23.0.0",
4850
"isort>=5.12.0",
4951
"mypy>=1.0.0",
5052
"ruff>=0.1.0",
53+
"aiohttp>=3.13.3",
5154
]
5255

5356
[tool.setuptools]
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Async Dataverse client.
6+
7+
:class:`~PowerPlatform.Dataverse.async_client.AsyncDataverseClient` mirrors the public API of
8+
:class:`~PowerPlatform.Dataverse.client.DataverseClient` with full ``async``/``await`` support.
9+
Existing sync code is completely unaffected; async support is opt-in via a separate import.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from contextlib import asynccontextmanager
15+
from typing import Any, AsyncIterator, Dict, List, Optional, Union
16+
17+
from azure.core.credentials_async import AsyncTokenCredential
18+
19+
from .core._async_auth import _AsyncAuthManager
20+
from .core.config import DataverseConfig
21+
from .data._async_odata import _AsyncODataClient
22+
from .operations.async_records import AsyncRecordOperations
23+
from .operations.async_query import AsyncQueryOperations
24+
from .operations.async_tables import AsyncTableOperations
25+
from .operations.async_files import AsyncFileOperations
26+
27+
__all__ = ["AsyncDataverseClient"]
28+
29+
30+
class AsyncDataverseClient:
31+
"""
32+
Async high-level client for Microsoft Dataverse operations.
33+
34+
Mirrors :class:`~PowerPlatform.Dataverse.client.DataverseClient` with ``async``/``await``
35+
support. All methods are ``async def`` and must be awaited.
36+
37+
:param base_url: Your Dataverse environment URL, for example
38+
``"https://org.crm.dynamics.com"``. Trailing slash is automatically removed.
39+
:type base_url: :class:`str`
40+
:param credential: Azure Identity async credential for authentication.
41+
:type credential: ~azure.core.credentials_async.AsyncTokenCredential
42+
:param config: Optional configuration for language, timeouts, and retries.
43+
If not provided, defaults are loaded from
44+
:meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`.
45+
:type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig or None
46+
47+
:raises ValueError: If ``base_url`` is missing or empty after trimming.
48+
49+
Operations are organized into namespaces:
50+
51+
- ``client.records`` -- create, update, delete, and get records (single or paginated)
52+
- ``client.query`` -- query and search operations
53+
- ``client.tables`` -- table and column metadata management
54+
- ``client.files`` -- file upload operations
55+
56+
The client supports Python's async context manager protocol::
57+
58+
from azure.identity.aio import ClientSecretCredential
59+
from PowerPlatform.Dataverse.async_client import AsyncDataverseClient
60+
61+
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
62+
63+
async with AsyncDataverseClient("https://org.crm.dynamics.com", credential) as client:
64+
guid = await client.records.create("account", {"name": "Contoso Ltd"})
65+
record = await client.records.get("account", guid, select=["name"])
66+
print(record["name"])
67+
await client.records.delete("account", guid)
68+
"""
69+
70+
def __init__(
71+
self,
72+
base_url: str,
73+
credential: AsyncTokenCredential,
74+
config: Optional[DataverseConfig] = None,
75+
) -> None:
76+
self.auth = _AsyncAuthManager(credential)
77+
self._base_url = (base_url or "").rstrip("/")
78+
if not self._base_url:
79+
raise ValueError("base_url is required.")
80+
self._config = config or DataverseConfig.from_env()
81+
self._odata: Optional[_AsyncODataClient] = None
82+
self._closed: bool = False
83+
84+
# Operation namespaces
85+
self.records = AsyncRecordOperations(self)
86+
self.query = AsyncQueryOperations(self)
87+
self.tables = AsyncTableOperations(self)
88+
self.files = AsyncFileOperations(self)
89+
90+
def _get_odata(self) -> _AsyncODataClient:
91+
"""Get or lazily create the internal async OData client."""
92+
if self._odata is None:
93+
self._odata = _AsyncODataClient(
94+
self.auth,
95+
self._base_url,
96+
self._config,
97+
)
98+
return self._odata
99+
100+
@asynccontextmanager
101+
async def _scoped_odata(self) -> AsyncIterator[_AsyncODataClient]:
102+
"""Yield the async OData client with an active correlation scope."""
103+
self._check_closed()
104+
od = self._get_odata()
105+
with od._call_scope():
106+
yield od
107+
108+
# ---------------- Context manager / lifecycle ----------------
109+
110+
async def __aenter__(self) -> AsyncDataverseClient:
111+
"""Enter the async context manager.
112+
113+
:return: The client instance.
114+
:rtype: AsyncDataverseClient
115+
:raises RuntimeError: If the client has been closed.
116+
"""
117+
self._check_closed()
118+
return self
119+
120+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
121+
"""Exit the async context manager and close the client."""
122+
await self.close()
123+
124+
async def close(self) -> None:
125+
"""Close the client and release resources.
126+
127+
Closes the underlying aiohttp session, clears caches, and marks the
128+
client as closed. Safe to call multiple times.
129+
130+
Called automatically when using the client as an async context manager.
131+
"""
132+
if self._closed:
133+
return
134+
if self._odata is not None:
135+
await self._odata.close()
136+
self._odata = None
137+
self._closed = True
138+
139+
def _check_closed(self) -> None:
140+
"""Raise :class:`RuntimeError` if the client has been closed."""
141+
if self._closed:
142+
raise RuntimeError("AsyncDataverseClient is closed")

0 commit comments

Comments
 (0)