Skip to content

Commit 7b09311

Browse files
Abel Milashclaude
andcommitted
Add async Dataverse client with full test suite (~95% coverage)
Introduces AsyncDataverseClient and the complete aio/ async stack mirroring the sync client: _AsyncODataClient (CRUD, SQL, metadata, file upload, relationships), _AsyncBatchClient with _SyncResponseWrapper bridge, _AsyncHttpClient with aiohttp and identical retry/timeout logic, and all async operation namespaces (records, tables, query, files, batch, dataframe). Fixes a cold-start race in _bulk_fetch_picklists by adding asyncio.Lock (double-checked locking pattern) -- concurrent coroutines no longer issue redundant metadata fetches for the same table. Adds pytest-asyncio (asyncio_mode=auto) and aiohttp optional dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 656ed26 commit 7b09311

35 files changed

Lines changed: 10973 additions & 0 deletions

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,13 @@ dataverse-install-claude-skill = "PowerPlatform.Dataverse._skill_installer:main"
4343
dataverse-migrate = "tools.migrate_v0_to_v1:main"
4444

4545
[project.optional-dependencies]
46+
async = [
47+
"aiohttp>=3.9",
48+
]
4649
dev = [
4750
"pytest>=7.0.0",
4851
"pytest-cov>=4.0.0",
52+
"pytest-asyncio>=0.23.0",
4953
"black>=23.0.0",
5054
"isort>=5.12.0",
5155
"mypy>=1.0.0",
@@ -95,6 +99,7 @@ select = [
9599

96100
[tool.pytest.ini_options]
97101
testpaths = ["tests/unit"]
102+
asyncio_mode = "auto"
98103

99104
[tool.coverage.run]
100105
source = ["src/PowerPlatform"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
from .async_client import AsyncDataverseClient
5+
6+
__all__ = ["AsyncDataverseClient"]
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
from __future__ import annotations
5+
6+
from contextlib import asynccontextmanager
7+
from typing import AsyncIterator, Optional
8+
9+
import aiohttp
10+
from azure.core.credentials_async import AsyncTokenCredential
11+
12+
from .core._async_auth import _AsyncAuthManager
13+
from ..core.config import DataverseConfig
14+
from .data._async_odata import _AsyncODataClient
15+
from .operations.async_dataframe import AsyncDataFrameOperations
16+
from .operations.async_records import AsyncRecordOperations
17+
from .operations.async_query import AsyncQueryOperations
18+
from .operations.async_files import AsyncFileOperations
19+
from .operations.async_tables import AsyncTableOperations
20+
from .operations.async_batch import AsyncBatchOperations
21+
22+
23+
class AsyncDataverseClient:
24+
"""
25+
Async high-level client for Microsoft Dataverse operations.
26+
27+
This client provides a simple, stable async interface for interacting with
28+
Dataverse environments through the Web API. It handles authentication via
29+
Azure Identity and delegates HTTP operations to an internal
30+
:class:`~PowerPlatform.Dataverse.aio.data._async_odata._AsyncODataClient`.
31+
32+
Key capabilities:
33+
- OData CRUD operations: create, read, update, delete records
34+
- SQL queries: execute read-only SQL via Web API ``?sql`` parameter
35+
- Table metadata: create, inspect, and delete custom tables; create and delete columns
36+
- File uploads: upload files to file columns with chunking support
37+
38+
:param base_url: Your Dataverse environment URL, for example
39+
``"https://org.crm.dynamics.com"``. Trailing slash is automatically removed.
40+
:type base_url: :class:`str`
41+
:param credential: Azure async Identity credential for authentication.
42+
:type credential: ~azure.core.credentials_async.AsyncTokenCredential
43+
:param config: Optional configuration for language, timeouts, and retries.
44+
If not provided, defaults are loaded from :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+
.. note::
50+
The client lazily initializes its internal OData client on first use,
51+
allowing lightweight construction without immediate network calls.
52+
53+
.. note::
54+
All methods that communicate with the Dataverse Web API may raise
55+
:class:`~PowerPlatform.Dataverse.core.errors.HttpError` on non-successful
56+
HTTP responses (e.g. 401, 403, 404, 429, 500). Individual method
57+
docstrings document only domain-specific exceptions.
58+
59+
Operations are organized into namespaces:
60+
61+
- ``client.records`` -- create, update, delete, and get records (single or paginated queries)
62+
- ``client.query`` -- query and search operations
63+
- ``client.tables`` -- table and column metadata management
64+
- ``client.files`` -- file upload operations
65+
- ``client.dataframe`` -- pandas DataFrame wrappers for record CRUD
66+
- ``client.batch`` -- batch multiple operations into a single HTTP request
67+
68+
The client supports Python's async context manager protocol for automatic
69+
resource cleanup and HTTP connection pooling:
70+
71+
Example:
72+
**Recommended -- async context manager** (enables HTTP connection pooling)::
73+
74+
from azure.identity.aio import DefaultAzureCredential
75+
from PowerPlatform.Dataverse.aio.async_client import AsyncDataverseClient
76+
77+
credential = DefaultAzureCredential()
78+
79+
async with AsyncDataverseClient("https://org.crm.dynamics.com", credential) as client:
80+
record_id = await client.records.create("account", {"name": "Contoso Ltd"})
81+
await client.records.update("account", record_id, {"telephone1": "555-0100"})
82+
# Session closed, caches cleared automatically
83+
84+
**Manual lifecycle**::
85+
86+
client = AsyncDataverseClient("https://org.crm.dynamics.com", credential)
87+
try:
88+
record_id = await client.records.create("account", {"name": "Contoso Ltd"})
89+
finally:
90+
await client.aclose()
91+
"""
92+
93+
def __init__(
94+
self,
95+
base_url: str,
96+
credential: AsyncTokenCredential,
97+
config: Optional[DataverseConfig] = None,
98+
) -> None:
99+
self.auth = _AsyncAuthManager(credential)
100+
self._base_url = (base_url or "").rstrip("/")
101+
if not self._base_url:
102+
raise ValueError("base_url is required.")
103+
self._config = config or DataverseConfig.from_env()
104+
self._odata: Optional[_AsyncODataClient] = None
105+
self._session: Optional[aiohttp.ClientSession] = None
106+
self._closed: bool = False
107+
108+
# Operation namespaces
109+
self.records = AsyncRecordOperations(self)
110+
self.query = AsyncQueryOperations(self)
111+
self.tables = AsyncTableOperations(self)
112+
self.files = AsyncFileOperations(self)
113+
self.dataframe = AsyncDataFrameOperations(self)
114+
self.batch = AsyncBatchOperations(self)
115+
116+
def _get_odata(self) -> _AsyncODataClient:
117+
"""
118+
Get or create the internal async OData client instance.
119+
120+
This method implements lazy initialization of the low-level async OData
121+
client, deferring construction until the first API call.
122+
123+
:return: The lazily-initialized low-level async client.
124+
:rtype: ~PowerPlatform.Dataverse.aio.data._async_odata._AsyncODataClient
125+
"""
126+
if self._odata is None:
127+
self._odata = _AsyncODataClient(
128+
self.auth,
129+
self._base_url,
130+
self._config,
131+
session=self._session,
132+
)
133+
return self._odata
134+
135+
@asynccontextmanager
136+
async def _scoped_odata(self) -> AsyncIterator[_AsyncODataClient]:
137+
"""Async context manager yielding the low-level client with a correlation scope."""
138+
self._check_closed()
139+
od = self._get_odata()
140+
# _call_scope() is a sync context manager (just sets a context var — no I/O).
141+
with od._call_scope():
142+
yield od
143+
144+
# ---------------- Context manager / lifecycle ----------------
145+
146+
async def __aenter__(self) -> "AsyncDataverseClient":
147+
"""Enter the async context manager.
148+
149+
Creates an :class:`aiohttp.ClientSession` for HTTP connection pooling.
150+
All operations within the ``async with`` block reuse this session for
151+
better performance (TCP and TLS reuse).
152+
153+
:return: The client instance.
154+
:rtype: AsyncDataverseClient
155+
156+
:raises RuntimeError: If the client has been closed.
157+
"""
158+
self._check_closed()
159+
if self._session is None:
160+
self._session = aiohttp.ClientSession()
161+
return self
162+
163+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
164+
"""Exit the async context manager with cleanup.
165+
166+
Calls :meth:`aclose` to release resources. Exceptions are not
167+
suppressed.
168+
"""
169+
await self.aclose()
170+
171+
async def aclose(self) -> None:
172+
"""Close the async client and release resources.
173+
174+
Closes the HTTP session (if any), clears internal caches, and
175+
marks the client as closed. Safe to call multiple times. After
176+
closing, any operation will raise :class:`RuntimeError`.
177+
178+
Called automatically when using the client as an async context manager.
179+
180+
Example::
181+
182+
client = AsyncDataverseClient(base_url, credential)
183+
try:
184+
await client.records.create("account", {"name": "Contoso"})
185+
finally:
186+
await client.aclose()
187+
"""
188+
if self._closed:
189+
return
190+
if self._odata is not None:
191+
await self._odata.close()
192+
self._odata = None
193+
if self._session is not None:
194+
await self._session.close()
195+
self._session = None
196+
self._closed = True
197+
198+
def _check_closed(self) -> None:
199+
"""Raise :class:`RuntimeError` if the client has been closed."""
200+
if self._closed:
201+
raise RuntimeError("AsyncDataverseClient is closed")
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Async authentication helpers for Dataverse.
6+
7+
This module provides :class:`~PowerPlatform.Dataverse.aio.core._async_auth._AsyncAuthManager`,
8+
a thin wrapper over any Azure Identity ``AsyncTokenCredential`` for acquiring OAuth2 access
9+
tokens asynchronously, and reuses :class:`~PowerPlatform.Dataverse.core._auth._TokenPair` for
10+
storing the acquired token alongside its scope.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from azure.core.credentials_async import AsyncTokenCredential
16+
17+
from ...core._auth import _TokenPair
18+
19+
20+
class _AsyncAuthManager:
21+
"""
22+
Azure Identity-based async authentication manager for Dataverse.
23+
24+
:param credential: Azure Identity async credential implementation.
25+
:type credential: ~azure.core.credentials_async.AsyncTokenCredential
26+
:raises TypeError: If ``credential`` does not implement :class:`~azure.core.credentials_async.AsyncTokenCredential`.
27+
"""
28+
29+
def __init__(self, credential: AsyncTokenCredential) -> None:
30+
if not isinstance(credential, AsyncTokenCredential):
31+
raise TypeError("credential must implement azure.core.credentials_async.AsyncTokenCredential.")
32+
self.credential: AsyncTokenCredential = credential
33+
34+
async def _acquire_token(self, scope: str) -> _TokenPair:
35+
"""
36+
Acquire an access token asynchronously for the specified OAuth2 scope.
37+
38+
:param scope: OAuth2 scope string, typically ``"https://<org>.crm.dynamics.com/.default"``.
39+
:type scope: :class:`str`
40+
:return: Token pair containing the scope and access token.
41+
:rtype: ~PowerPlatform.Dataverse.core._auth._TokenPair
42+
:raises ~azure.core.exceptions.ClientAuthenticationError: If token acquisition fails.
43+
"""
44+
token = await self.credential.get_token(scope)
45+
return _TokenPair(resource=scope, access_token=token.token)

0 commit comments

Comments
 (0)