|
| 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") |
0 commit comments