Skip to content

Commit e97d669

Browse files
authored
Merge branch 'main' into users/vikasrathee/supported_versions
2 parents e06706f + 1fa3c5d commit e97d669

8 files changed

Lines changed: 185 additions & 36 deletions

File tree

.claude/skills/dataverse-sdk-use/SKILL.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ relationship = OneToManyRelationshipMetadata(
427427
)
428428

429429
result = client.tables.create_one_to_many_relationship(lookup, relationship)
430-
print(f"Created lookup field: {result['lookup_schema_name']}")
430+
print(f"Created lookup field: {result.lookup_schema_name}")
431431
```
432432

433433
#### Create Many-to-Many Relationship
@@ -441,7 +441,7 @@ relationship = ManyToManyRelationshipMetadata(
441441
)
442442

443443
result = client.tables.create_many_to_many_relationship(relationship)
444-
print(f"Created: {result['relationship_schema_name']}")
444+
print(f"Created: {result.relationship_schema_name}")
445445
```
446446

447447
#### Convenience Method for Lookup Fields
@@ -460,10 +460,10 @@ result = client.tables.create_lookup_field(
460460
# Get relationship metadata
461461
rel = client.tables.get_relationship("new_Department_Employee")
462462
if rel:
463-
print(f"Found: {rel['SchemaName']}")
463+
print(f"Found: {rel.relationship_schema_name}")
464464

465465
# Delete relationship
466-
client.tables.delete_relationship(result["relationship_id"])
466+
client.tables.delete_relationship(result.relationship_id)
467467
```
468468

469469
### File Operations
@@ -589,6 +589,8 @@ except ValidationError as e:
589589

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

592+
> **Note:** snippets in this section are fragments. Every `await` line assumes it lives inside an `async def main(): ...` body with `client` and `credential` already constructed (see the Client Initialization block for the wrapper). Outside an async function, `await` is a `SyntaxError`.
593+
592594
### Import
593595
```python
594596
from azure.identity.aio import DefaultAzureCredential
@@ -597,6 +599,8 @@ from PowerPlatform.Dataverse.aio import AsyncDataverseClient
597599

598600
### Client Initialization
599601
```python
602+
# given: credential constructed (e.g. DefaultAzureCredential())
603+
600604
# Context manager (recommended -- closes session and clears caches automatically)
601605
async with AsyncDataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
602606
... # all operations here
@@ -612,6 +616,8 @@ finally:
612616
### CRUD Operations
613617
Every sync method has an async equivalent -- add `await`:
614618
```python
619+
# given: client is an open AsyncDataverseClient
620+
615621
# Create
616622
account_id = await client.records.create("account", {"name": "Contoso Ltd"})
617623

@@ -630,6 +636,7 @@ ids = await client.records.create("account", [{"name": "A"}, {"name": "B"}])
630636

631637
### Query Builder
632638
```python
639+
# given: client is an open AsyncDataverseClient
633640
from PowerPlatform.Dataverse.models.filters import col
634641

635642
# Collect all results
@@ -663,6 +670,8 @@ rows = await client.query.fetchxml(xml).execute()
663670

664671
### Batch and Changesets
665672
```python
673+
# given: client is open; account_id from an earlier records.create
674+
666675
# Plain batch
667676
batch = client.batch.new()
668677
batch.records.create("account", {"name": "Alpha"})
@@ -678,6 +687,7 @@ result = await batch.execute()
678687

679688
### DataFrame Operations
680689
```python
690+
# given: client is an open AsyncDataverseClient
681691
import pandas as pd
682692

683693
# Query to DataFrame

README.md

Lines changed: 108 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,19 @@ from PowerPlatform.Dataverse.client import DataverseClient
104104
credential = InteractiveBrowserCredential() # Browser authentication
105105
# credential = AzureCliCredential() # If logged in via 'az login'
106106

107-
# Production options
108-
# credential = ClientSecretCredential(tenant_id, client_id, client_secret)
107+
# For Production options (service principal / app-only auth)
108+
# credential = ClientSecretCredential(
109+
# tenant_id="...", # ID of the service principal's tenant. Also called its "directory" ID.
110+
# client_id="...", # The service principal's client ID
111+
# client_secret="...", # Client secret value generated for the app (store in Key Vault / env var)
112+
# )
109113
# credential = CertificateCredential(tenant_id, client_id, cert_path)
110114

111115
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
112116
```
117+
Ref: https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity?view=azure-python
113118

114-
> **Complete authentication setup**: See **[Use OAuth with Dataverse](https://learn.microsoft.com/power-apps/developer/data-platform/authenticate-oauth)** for app registration, all credential types, and security configuration.
119+
> **Set up service principal authentication**: To use `ClientSecretCredential` or `CertificateCredential` you must first register an Azure AD app and grant it access to your Dataverse environment as an application user. See **[Use OAuth with Dataverse](https://learn.microsoft.com/power-apps/developer/data-platform/authenticate-oauth)** (covers app registration, obtaining `tenant_id` / `client_id` / `client_secret`, all credential types, and security configuration).
115120
116121
## Key concepts
117122

@@ -180,6 +185,8 @@ client.records.update("account", account_id, {"telephone1": "555-0199"})
180185
client.records.delete("account", account_id)
181186
```
182187

188+
> **Deprecation note (migration from beta):** `client.records.get()` is deprecated and emits a `DeprecationWarning`. Use `client.records.retrieve(table, record_id)` for single-record reads (returns `None` on 404 instead of raising) and `client.records.list(table, filter=...)` / `client.records.list_pages(...)` for multi-record queries. Return types differ from the beta `get()`, so the codemod flags these for manual review rather than rewriting them — run `dataverse-migrate` (see [Query data](#query-data)) to locate every call site.
189+
183190
### Bulk operations
184191

185192
```python
@@ -192,7 +199,7 @@ payloads = [
192199
ids = client.records.create("account", payloads)
193200

194201
# Bulk update (broadcast same change to all)
195-
client.records.update("account", ids, {"industry": "Technology"})
202+
client.records.update("account", ids, {"exchangerate": 1})
196203

197204
# Bulk delete
198205
client.records.delete("account", ids, use_bulk_delete=True)
@@ -209,6 +216,26 @@ a PATCH request; multiple items use the `UpsertMultiple` bulk action.
209216
> maker portal → Table → Keys, or via the Dataverse API). Without a configured alternate key,
210217
> upsert requests will be rejected by Dataverse with a 400 error.
211218
219+
Set up the key once before running the upsert examples:
220+
221+
```python
222+
# One-time setup for the examples below: make accountnumber an alternate key on account
223+
key = client.tables.create_alternate_key(
224+
"account",
225+
"account_accountnumber_ak",
226+
["accountnumber"],
227+
display_name="Account Number",
228+
)
229+
print(f"Created key {key.schema_name} ({key.metadata_id}), status={key.status}")
230+
231+
# Optional: check key status (useful right after creation; status transitions Pending -> Active)
232+
for k in client.tables.get_alternate_keys("account"):
233+
if k.schema_name == "account_accountnumber_ak":
234+
print(f"{k.schema_name}: {k.status}")
235+
```
236+
237+
Upsert usage
238+
212239
```python
213240
from PowerPlatform.Dataverse.models import UpsertItem
214241

@@ -332,12 +359,9 @@ print(f"Got {len(df)} accounts")
332359
```python
333360
# Comparison filters using col() expressions
334361
query = (client.query.builder("contact")
335-
.where(col("statecode") == 0) # statecode eq 0
336-
.where(col("revenue") > 1000000) # revenue gt 1000000
337-
.where(col("name").contains("Corp")) # contains(name, 'Corp')
338-
.where(col("statecode").in_([0, 1])) # Microsoft.Dynamics.CRM.In(...)
339-
.where(col("revenue").between(100000, 500000)) # revenue ge 100000 and revenue le 500000
340-
.where(col("telephone1").is_null()) # telephone1 eq null
362+
.where(col("email").contains("outlook.com")) # contains(email from domain, 'outlook.com')
363+
.where(col("creditlimit").between(10000, 50000)) # credit limit ge 10000 and revenue le 50000
364+
.where(col("telephone1").is_null()) # telephone1 eq null
341365
)
342366
```
343367

@@ -458,11 +482,15 @@ results = (client.query.builder("account")
458482
.where(col("statecode") == 0)
459483
.count()
460484
.execute())
485+
print(len(results)) # QueryResult is sized — use len() to get the count
461486

462487
# Via records.list() — count=True adds $count=true to the OData request
463488
results = client.records.list("account", filter="statecode eq 0", count=True)
489+
print(len(results))
464490
```
465491

492+
> **Accessing the count:** `QueryResult` is iterable and sized — call `len(results)` to get the number of records. There is no separate `.count` or `.total_count` attribute. Because the client auto-paginates, `len(results)` reflects every matching row fetched; the server's raw `@odata.count` annotation is not surfaced as a standalone field.
493+
466494
**FetchXML queries** -- `client.query.fetchxml()` returns an inert `FetchXmlQuery` object; no HTTP request is made until you call `.execute()` or `.execute_pages()`:
467495

468496
```python
@@ -636,7 +664,7 @@ relationship = OneToManyRelationshipMetadata(
636664
)
637665

638666
result = client.tables.create_one_to_many_relationship(lookup, relationship)
639-
print(f"Created lookup field: {result['lookup_schema_name']}")
667+
print(f"Created lookup field: {result.lookup_schema_name}")
640668

641669
# Create a many-to-many relationship: Employee (N) <-> Project (N)
642670
# Employees work on multiple projects; projects have multiple team members
@@ -647,25 +675,25 @@ m2m_relationship = ManyToManyRelationshipMetadata(
647675
)
648676

649677
result = client.tables.create_many_to_many_relationship(m2m_relationship)
650-
print(f"Created M:N relationship: {result['relationship_schema_name']}")
678+
print(f"Created M:N relationship: {result.relationship_schema_name}")
651679

652680
# Query relationship metadata
653681
rel = client.tables.get_relationship("new_Department_Employee")
654682
if rel:
655-
print(f"Found: {rel['SchemaName']}")
683+
print(f"Found: {rel.relationship_schema_name}")
656684

657685
# List all relationships
658686
rels = client.tables.list_relationships()
659687
for rel in rels:
660-
print(f"{rel['SchemaName']} ({rel.get('@odata.type')})")
688+
print(f"{rel['SchemaName']} ({rel.get('RelationshipType')})")
661689

662690
# List relationships for a specific table (one-to-many + many-to-one + many-to-many)
663691
account_rels = client.tables.list_table_relationships("account")
664692
for rel in account_rels:
665-
print(f"{rel['SchemaName']} -> {rel.get('@odata.type')}")
693+
print(f"{rel['SchemaName']} -> {rel.get('RelationshipType')}")
666694

667695
# Delete a relationship
668-
client.tables.delete_relationship(result['relationship_id'])
696+
client.tables.delete_relationship(result.relationship_id)
669697
```
670698

671699
For simpler scenarios, use the convenience method:
@@ -757,6 +785,13 @@ for item in result.failed:
757785
print(f"[ERR] {item.status_code}: {item.error_message}")
758786
```
759787

788+
> `continue_on_error=True` only affects how Dataverse handles per-operation
789+
> failures on the server. Client-side errors raised before the batch is sent
790+
> — such as `ValidationError` (e.g. exceeding the 1000-operation limit) or
791+
> `MetadataError` from metadata pre-resolution (`tables.delete`,
792+
> `tables.add_columns`, `tables.remove_columns`) — are still raised as
793+
> exceptions and must be handled with `try`/`except`.
794+
760795
**DataFrame integration** -- feed pandas DataFrames directly into a batch:
761796

762797
```python
@@ -787,6 +822,8 @@ For a complete example see [examples/advanced/batch.py](https://github.com/micro
787822

788823
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.
789824

825+
> **Async snippets below are fragments.** Every example after `### Quick start` assumes it is nested inside an `async def main(): ...` body, with `client` and `credential` already constructed as shown in Quick start. Copying a fragment into a top-level `.py` file will raise `SyntaxError: 'await' outside function`. See [examples/aio/](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/tree/main/examples/aio) for full runnable scripts.
826+
790827
### Install
791828

792829
The async client requires `aiohttp`, which is an optional extra:
@@ -799,10 +836,13 @@ pip install "PowerPlatform-Dataverse-Client[async]"
799836

800837
```python
801838
import asyncio
802-
from azure.identity.aio import DefaultAzureCredential
839+
from azure.identity import InteractiveBrowserCredential
803840
from PowerPlatform.Dataverse.aio import AsyncDataverseClient
804841

805842
async def main():
843+
# Connect to Dataverse
844+
credential = InteractiveBrowserCredential()
845+
806846
async with DefaultAzureCredential() as credential:
807847
async with AsyncDataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
808848
# Create a contact
@@ -821,6 +861,7 @@ asyncio.run(main())
821861
### Standalone usage (without `async with`)
822862

823863
```python
864+
# given: credential constructed as in Quick start (e.g. DefaultAzureCredential())
824865
client = AsyncDataverseClient("https://yourorg.crm.dynamics.com", credential)
825866
try:
826867
account_id = await client.records.create("account", {"name": "Contoso Ltd"})
@@ -833,6 +874,7 @@ finally:
833874
The async query builder API is identical to the sync one:
834875

835876
```python
877+
# given: client is an open AsyncDataverseClient
836878
from PowerPlatform.Dataverse.models.filters import col
837879

838880
# Execute and collect all results
@@ -860,6 +902,7 @@ async for page in (
860902
### Batch and changesets
861903

862904
```python
905+
# given: client is open; account_id is the GUID returned by an earlier records.create
863906
batch = client.batch.new()
864907
batch.records.create("account", {"name": "Alpha"})
865908
batch.records.create("account", {"name": "Beta"})
@@ -908,24 +951,64 @@ For comprehensive information on Microsoft Dataverse and related technologies:
908951

909952
## Troubleshooting
910953

911-
### General
954+
### Exception hierarchy
955+
956+
The SDK raises structured exceptions that all inherit from a common base, `DataverseError`. Catching the base class is the safest fallback; catch the specific subclasses when you need to react differently to validation, metadata, SQL, or HTTP failures.
957+
958+
```
959+
Exception
960+
└── DataverseError # Base class for every SDK-raised error
961+
├── ValidationError # Client-side input validation failed
962+
├── MetadataError # Table/column/relationship metadata problem
963+
├── SQLParseError # SQL query could not be parsed
964+
└── HttpError # Dataverse Web API returned a non-success status
965+
```
966+
967+
All classes are importable from `PowerPlatform.Dataverse.core.errors` (or re-exported from `PowerPlatform.Dataverse.core`).
912968

913-
The client raises structured exceptions for different error scenarios:
969+
| Exception | When it is raised | Typical examples |
970+
|-----------|-------------------|------------------|
971+
| **`DataverseError`** | Base class. Catch it to handle any SDK-originated failure in one block. | Fallback `except` clause. |
972+
| **`ValidationError`** | Client-side argument validation fails **before** a request is sent. | Empty/`None` table name, missing primary key, non-string SQL, invalid batch payload, unsupported column type in `create_table`. |
973+
| **`MetadataError`** | A metadata lookup or definition operation fails — usually an unknown or invalid table, column, or relationship. | Unknown logical name passed to `batch.create/update/delete`, `tables.create_column`, `relationships.create_*`, or `tables.delete`. |
974+
| **`SQLParseError`** | A SQL string passed to `client.query.sql(...)` cannot be parsed into a valid SELECT. | Unsupported SQL syntax, write statements (`INSERT`/`UPDATE`/`DELETE`), malformed queries. |
975+
| **`HttpError`** | The Dataverse Web API responded with a non-2xx status. Exposes `status_code`, `service_error_code`, `correlation_id`, `service_request_id`, `retry_after`, and `is_transient` (set for 408/429/503/504). | 401 (auth), 403 (permissions), 404 (record/table not found), 412 (concurrency/ETag), 429 (throttling), 5xx (server). |
976+
977+
> **Note on timeouts and network errors.** Low-level network failures from the underlying `httpx` client are **not** wrapped by the SDK and surface as their original `httpx` exceptions — most commonly `httpx.ReadTimeout`, `httpx.ConnectTimeout`, and `httpx.TimeoutException` (their common base) on slow endpoints such as `relationships.list()` or large queries, and `httpx.ConnectError`/`httpx.NetworkError` for connectivity issues. Catch `httpx.HTTPError` to cover all of them, or `httpx.TimeoutException` for timeouts specifically. The async client (`PowerPlatform.Dataverse.aio`) surfaces `aiohttp.ClientError` and `asyncio.TimeoutError` analogously.
978+
979+
### General
914980

915981
```python
982+
import httpx
916983
from PowerPlatform.Dataverse.client import DataverseClient
917-
from PowerPlatform.Dataverse.core.errors import HttpError, ValidationError
984+
from PowerPlatform.Dataverse.core.errors import (
985+
DataverseError,
986+
HttpError,
987+
MetadataError,
988+
SQLParseError,
989+
ValidationError,
990+
)
918991

919992
try:
920993
client.records.retrieve("account", "invalid-id")
994+
except ValidationError as e:
995+
print(f"Validation error: {e.message} (subcode={e.subcode})")
996+
except MetadataError as e:
997+
print(f"Metadata error: {e.message} (subcode={e.subcode})")
998+
except SQLParseError as e:
999+
print(f"SQL parse error: {e.message}")
9211000
except HttpError as e:
9221001
print(f"HTTP {e.status_code}: {e.message}")
923-
print(f"Error code: {e.code}")
924-
print(f"Subcode: {e.subcode}")
1002+
print(f"Code: {e.code} Subcode: {e.subcode}")
1003+
print(f"Service request id: {e.details.get('service_request_id')}")
9251004
if e.is_transient:
926-
print("This error may be retryable")
927-
except ValidationError as e:
928-
print(f"Validation error: {e.message}")
1005+
print(f"Transient — retry after {e.details.get('retry_after')}s")
1006+
except httpx.TimeoutException as e:
1007+
# ReadTimeout / ConnectTimeout / WriteTimeout from the underlying transport
1008+
print(f"Request timed out: {e}")
1009+
except DataverseError as e:
1010+
# Catch-all for any other SDK-raised error
1011+
print(f"Dataverse error [{e.code}]: {e.message}")
9291012
```
9301013

9311014
### Authentication issues

examples/aio/advanced/walkthrough.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,16 @@ async def _run_walkthrough(client):
265265
record_ids = [r.get("new_walkthroughdemoid")[:8] + "..." for r in page]
266266
print(f" Page {page_num}: {len(page)} records - IDs: {record_ids}")
267267

268+
log_call(
269+
"async for record in await client.query.builder(...).top(5).execute() — QueryResult supports async iteration"
270+
)
271+
print("Iterating a QueryResult with `async for` (top 5 by quantity)...")
272+
top_result = await backoff(
273+
lambda: client.query.builder(table_name).order_by("new_Quantity", descending=True).top(5).execute()
274+
)
275+
async for record in top_result:
276+
print(f" - Qty={record.get('new_quantity')} Title={record.get('new_title')}")
277+
268278
# ============================================================================
269279
# 7. QUERYBUILDER - FLUENT QUERIES
270280
# ============================================================================

src/PowerPlatform/Dataverse/aio/operations/async_tables.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ async def create_lookup_field(
561561
required=True,
562562
cascade_delete=CASCADE_BEHAVIOR_REMOVE_LINK,
563563
)
564-
print(f"Created lookup: {result['lookup_schema_name']}")
564+
print(f"Created lookup: {result.lookup_schema_name}")
565565
"""
566566
async with self._client._scoped_odata() as od:
567567
lookup, relationship = od._build_lookup_field_models(

0 commit comments

Comments
 (0)