Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e7df299
Rebase refactoring on main (post GA API merge)
May 18, 2026
a4f69cd
Rebase async implementation on main (post GA API merge)
May 18, 2026
16084a1
Merge branch 'main' into users/abelmilash/async-phase2
May 19, 2026
c610af5
Remove non-async changes from async PR: revert migration tool path, r…
May 19, 2026
3113d45
Restore non-async files to match main: libcst in dev, query.py docstr…
May 19, 2026
9cb9d4a
Align async example scripts with sync counterparts
May 19, 2026
c4d5614
Add async client documentation to README and SDK use skill
May 20, 2026
774102a
Simplify async _async_odata.py: replace multi-pass comprehensions wit…
May 20, 2026
170b550
Fix black formatting in _async_odata.py; sync dev SKILL copies
May 20, 2026
836ca29
Fix azure.identity import: InteractiveBrowserCredential is not in aio…
May 20, 2026
7a360c1
Fix async credential examples: use ClientSecretCredential from azure.…
May 20, 2026
5c6bdc7
Use DefaultAzureCredential in async examples and docs
May 20, 2026
0d71bd3
Simplify async auth docs: single import with one-line note on interac…
May 20, 2026
255a798
Remove inline auth comment from async import examples
May 20, 2026
f37b9b4
Rename 'materialized' to 'response' in _async_http.py for clarity
May 20, 2026
58fe82e
Fix _async_batch.py: rename r to response, simplify remove_columns loop
May 20, 2026
bd74d02
Align async fetchxml JSON parsing with sync pattern
May 20, 2026
d738377
Reorder fetchxml() in async_query.py to match sync method order
May 20, 2026
e4680f8
Fix fetchxml() position in async_query.py: move above sql_columns
May 20, 2026
6c32c6b
Align async fetchxml() docstring and comments with sync
May 20, 2026
e37fe4d
Fix async example scripts: remove deprecated method calls and close s…
May 20, 2026
5fba6de
Fix async file_upload example: use _AsyncResponse._body instead of .c…
May 20, 2026
1a9d566
Fix async file_upload example: fix remaining .content references for …
May 20, 2026
51e653f
Add async concurrency benchmark and validation script
May 20, 2026
d7310be
Expand concurrency_benchmark.py docstring with per-test descriptions
May 20, 2026
cb0d711
Apply black formatting to concurrency_benchmark.py
May 20, 2026
da41fcc
Shorten per-test descriptions in concurrency_benchmark.py docstring
May 20, 2026
562e8b7
Rewrite per-test docstring entries as concise 3-sentence descriptions
May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .azdo/ci-pr.yaml
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Comment thread
abelmilash-msft marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

ADO PR pipeline YAML change detected

This PR modifies .azdo/ci-pr.yaml. After merge, Azure DevOps may disable or require approval for the PR validation pipeline.

Action required (post-merge): Re-enable / approve the updated YAML for:

Please resolve this comment after completing the post-merge steps.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

ADO PR pipeline YAML change detected

This PR modifies .azdo/ci-pr.yaml. After merge, Azure DevOps may disable or require approval for the PR validation pipeline.

Action required (post-merge): Re-enable / approve the updated YAML for:

Please resolve this comment after completing the post-merge steps.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

ADO PR pipeline YAML change detected

This PR modifies .azdo/ci-pr.yaml. After merge, Azure DevOps may disable or require approval for the PR validation pipeline.

Action required (post-merge): Re-enable / approve the updated YAML for:

Please resolve this comment after completing the post-merge steps.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

ADO PR pipeline YAML change detected

This PR modifies .azdo/ci-pr.yaml. After merge, Azure DevOps may disable or require approval for the PR validation pipeline.

Action required (post-merge): Re-enable / approve the updated YAML for:

Please resolve this comment after completing the post-merge steps.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

ADO PR pipeline YAML change detected

This PR modifies .azdo/ci-pr.yaml. After merge, Azure DevOps may disable or require approval for the PR validation pipeline.

Action required (post-merge): Re-enable / approve the updated YAML for:

Please resolve this comment after completing the post-merge steps.

Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Azure DevOps PR validation pipeline
# Matches GitHub Actions workflow for consistency

Expand Down Expand Up @@ -43,7 +43,7 @@
- script: |
python -m pip install --upgrade pip
python -m pip install flake8 black build diff-cover
python -m pip install -e .[dev]
python -m pip install -e .[dev,async]
displayName: 'Install dependencies'

- script: |
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/dataverse-sdk-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ This skill provides guidance for developers working on the PowerPlatform Dataver
4. **Update documentation** when adding features - Keep README and SKILL files (both copies) in sync
5. **Consider backwards compatibility** - Avoid breaking changes
6. **Internal vs public naming** - Modules, files, and functions not meant to be part of the public API must use a `_` prefix (e.g., `_odata.py`, `_relationships.py`). Files without the prefix (e.g., `constants.py`, `metadata.py`) are public and importable by SDK consumers
7. **Async client** - The SDK ships a full async client (`AsyncDataverseClient`) under `src/PowerPlatform/Dataverse/aio/`. When adding a feature to the sync client, add it to the async client too. The async operation namespaces mirror the sync ones: `aio/operations/async_records.py`, `async_query.py`, `async_tables.py`, `async_batch.py`, `async_files.py`. Pure logic (payload builders, URL construction) goes in `data/_odata_base.py` — inherited by both `_ODataClient` and `_AsyncODataClient` — so it only needs to be written once; HTTP-calling code goes in `data/_odata.py` (sync) or `aio/data/_async_odata.py` (async). Async tests live in `tests/unit/aio/` and async examples in `examples/aio/`. The `aiohttp` dependency is an optional extra (`pip install "PowerPlatform-Dataverse-Client[async]"`) — do not move it into the required `dependencies` list in `pyproject.toml`.

### Dataverse Property Naming Rules

Expand Down
109 changes: 109 additions & 0 deletions .claude/skills/dataverse-sdk-use/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,115 @@ except ValidationError as e:
10. **Test in non-production environments** first
11. **Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`

## Async Client

The SDK ships a full async client, `AsyncDataverseClient`, under `PowerPlatform.Dataverse.aio`. Requires the `[async]` extra: `pip install "PowerPlatform-Dataverse-Client[async]"`.

### Import
```python
from azure.identity.aio import DefaultAzureCredential
from PowerPlatform.Dataverse.aio.async_client import AsyncDataverseClient
```

### Client Initialization
```python
# Context manager (recommended -- closes session and clears caches automatically)
async with AsyncDataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
... # all operations here

# Standalone (call aclose() in a finally block)
client = AsyncDataverseClient("https://yourorg.crm.dynamics.com", credential)
try:
...
finally:
await client.aclose()
```

### CRUD Operations
Every sync method has an async equivalent -- add `await`:
```python
# Create
account_id = await client.records.create("account", {"name": "Contoso Ltd"})

# Read
account = await client.records.retrieve("account", account_id, select=["name", "telephone1"])

# Update
await client.records.update("account", account_id, {"telephone1": "555-0200"})

# Delete
await client.records.delete("account", account_id)

# Bulk create
ids = await client.records.create("account", [{"name": "A"}, {"name": "B"}])
```

### Query Builder
```python
from PowerPlatform.Dataverse.models.filters import col

# Collect all results
result = await (
client.query.builder("account")
.select("name", "telephone1")
.where(col("statecode") == 0)
.top(10)
.execute()
)
for record in result:
print(record["name"])

# Lazy page iteration (memory-efficient)
async for page in (
client.query.builder("account")
.select("name")
.page_size(500)
.execute_pages()
):
for record in page:
print(record["name"])

# SQL query
rows = await client.query.sql("SELECT TOP 5 name FROM account")

# FetchXML
xml = '<fetch top="5"><entity name="account"><attribute name="name"/></entity></fetch>'
rows = await client.query.fetchxml(xml).execute()
```

### Batch and Changesets
```python
# Plain batch
batch = client.batch.new()
batch.records.create("account", {"name": "Alpha"})
result = await batch.execute()

# Atomic changeset
batch = client.batch.new()
async with batch.changeset() as cs:
ref = cs.records.create("contact", {"firstname": "Alice"})
cs.records.update("account", account_id, {"primarycontactid@odata.bind": ref})
result = await batch.execute()
```

### DataFrame Operations
```python
import pandas as pd

# Query to DataFrame
result = await (
client.query.builder("account")
.select("name", "telephone1")
.where(col("statecode") == 0)
.execute()
)
df = result.to_dataframe()

# Create from DataFrame
new_accounts = pd.DataFrame([{"name": "Contoso"}, {"name": "Fabrikam"}])
ids = await client.dataframe.create("account", new_accounts)
```

## Additional Resources

Load these resources as needed during development:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
run: |
python -m pip install --upgrade pip
python -m pip install flake8 black build diff-cover
python -m pip install -e .[dev]
python -m pip install -e .[dev,async]

- name: Check format with black
run: |
Expand Down
106 changes: 101 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
- [Key features](#key-features)
- [Getting started](#getting-started)
- [Prerequisites](#prerequisites)
- [Install the package](#install-the-package)
- [Install the package](#install-the-package)
- [Authenticate the client](#authenticate-the-client)
- [Key concepts](#key-concepts)
- [Examples](#examples)
Expand All @@ -30,6 +30,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
- [Relationship management](#relationship-management)
- [File operations](#file-operations)
- [Batch operations](#batch-operations)
- [Async client](#async-client)
- [Next steps](#next-steps)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)
Expand All @@ -53,7 +54,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac

### Prerequisites

- **Python 3.10+** (3.10, 3.11, 3.12, 3.13 supported)
- **Python 3.10+** (3.10, 3.11, 3.12, 3.13 supported)
- **Microsoft Dataverse environment** with appropriate permissions
- **OAuth authentication configured** for your application

Expand Down Expand Up @@ -92,7 +93,7 @@ The client requires any Azure Identity `TokenCredential` implementation for OAut

```python
from azure.identity import (
InteractiveBrowserCredential,
InteractiveBrowserCredential,
ClientSecretCredential,
CertificateCredential,
AzureCliCredential
Expand All @@ -103,7 +104,7 @@ from PowerPlatform.Dataverse.client import DataverseClient
credential = InteractiveBrowserCredential() # Browser authentication
# credential = AzureCliCredential() # If logged in via 'az login'

# Production options
# Production options
# credential = ClientSecretCredential(tenant_id, client_id, client_secret)
# credential = CertificateCredential(tenant_id, client_id, cert_path)

Expand Down Expand Up @@ -783,6 +784,101 @@ result = batch.execute()

For a complete example see [examples/advanced/batch.py](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/batch.py).

## Async client

The SDK ships a full async client, `AsyncDataverseClient`, for use in async applications. It mirrors every operation of the sync client — the same namespaces (`records`, `query`, `tables`, `files`, `batch`), the same method signatures, and the same return types.

### Install

The async client requires `aiohttp`, which is an optional extra:

```bash
pip install "PowerPlatform-Dataverse-Client[async]"
```

### Quick start

```python
import asyncio
from azure.identity.aio import DefaultAzureCredential
from PowerPlatform.Dataverse.aio.async_client import AsyncDataverseClient

async def main():
async with DefaultAzureCredential() as credential:
async with AsyncDataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
# Create a contact
contact_id = await client.records.create("contact", {"firstname": "John", "lastname": "Doe"})

# Read it back
contact = await client.records.retrieve("contact", contact_id, select=["firstname", "lastname"])
print(f"Created: {contact['firstname']} {contact['lastname']}")

# Clean up
await client.records.delete("contact", contact_id)

asyncio.run(main())
```

> **Note:** `InteractiveBrowserCredential` from `azure.identity` is sync-only and cannot be used directly with the async client. See [examples/aio/_auth.py](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/aio/_auth.py) for an async wrapper.

### Standalone usage (without `async with`)

```python
client = AsyncDataverseClient("https://yourorg.crm.dynamics.com", credential)
try:
account_id = await client.records.create("account", {"name": "Contoso Ltd"})
finally:
await client.aclose()
```

### Query builder

The async query builder API is identical to the sync one:

```python
from PowerPlatform.Dataverse.models.filters import col

# Execute and collect all results
result = await (
client.query.builder("account")
.select("name", "telephone1")
.where(col("statecode") == 0)
.top(10)
.execute()
)
for record in result:
print(record["name"])

# Lazy page-by-page iteration (memory-efficient for large sets)
async for page in (
client.query.builder("account")
.select("name")
.page_size(500)
.execute_pages()
):
for record in page:
print(record["name"])
```

### Batch and changesets

```python
batch = client.batch.new()
batch.records.create("account", {"name": "Alpha"})
batch.records.create("account", {"name": "Beta"})
result = await batch.execute()
print(f"Created {len(list(result.entity_ids))} records")

# Atomic changeset
batch = client.batch.new()
async with batch.changeset() as cs:
ref = cs.records.create("contact", {"firstname": "Alice"})
cs.records.update("account", account_id, {"primarycontactid@odata.bind": ref})
result = await batch.execute()
```

See [examples/aio/](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/tree/main/examples/aio) for async equivalents of all sync examples.

## Next steps

### More sample code
Expand All @@ -808,7 +904,7 @@ For comprehensive information on Microsoft Dataverse and related technologies:
| Resource | Description |
|----------|-------------|
| **[Dataverse Developer Guide](https://learn.microsoft.com/power-apps/developer/data-platform/)** | Complete developer documentation for Microsoft Dataverse |
| **[Dataverse Web API Reference](https://learn.microsoft.com/power-apps/developer/data-platform/webapi/)** | Detailed Web API reference and examples |
| **[Dataverse Web API Reference](https://learn.microsoft.com/power-apps/developer/data-platform/webapi/)** | Detailed Web API reference and examples |
| **[Azure Identity for Python](https://learn.microsoft.com/python/api/overview/azure/identity-readme)** | Authentication library documentation and credential types |
| **[Power Platform Developer Center](https://learn.microsoft.com/power-platform/developer/)** | Broader Power Platform development resources |
| **[Dataverse SDK for .NET](https://learn.microsoft.com/power-apps/developer/data-platform/org-service/overview)** | Official .NET SDK for Microsoft Dataverse |
Expand Down
2 changes: 2 additions & 0 deletions examples/aio/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
57 changes: 57 additions & 0 deletions examples/aio/_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""
Async credential helper for the async example scripts.

azure-identity's InteractiveBrowserCredential is only available in the sync
namespace (azure.identity), not the async one (azure.identity.aio). This
module wraps the sync credential so it satisfies the AsyncTokenCredential
protocol required by AsyncDataverseClient.

Usage::

from _auth import AsyncInteractiveBrowserCredential

credential = AsyncInteractiveBrowserCredential()
try:
async with AsyncDataverseClient(org_url, credential) as client:
...
finally:
await credential.close()
"""

import asyncio
from concurrent.futures import ThreadPoolExecutor

from azure.identity import InteractiveBrowserCredential


class AsyncInteractiveBrowserCredential:
"""
Async wrapper around the sync InteractiveBrowserCredential.

get_token() is dispatched to a dedicated thread so the event loop stays
free during the browser popup / token exchange. Subsequent calls hit the
in-process token cache and return almost immediately.
"""

def __init__(self, **kwargs):
self._credential = InteractiveBrowserCredential(**kwargs)
self._executor = ThreadPoolExecutor(max_workers=1)

async def get_token(self, *scopes, **kwargs):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
self._executor,
lambda: self._credential.get_token(*scopes, **kwargs),
)

async def close(self):
self._executor.shutdown(wait=False)

async def __aenter__(self):
return self

async def __aexit__(self, *_):
await self.close()
4 changes: 4 additions & 0 deletions examples/aio/advanced/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""Advanced async examples showcasing complex Dataverse SDK features."""
Loading
Loading