Skip to content

Commit 7233455

Browse files
Abel Milashclaude
andcommitted
Add async example scripts with interactive browser authentication
- Add examples/aio/_auth.py: AsyncInteractiveBrowserCredential wrapper that delegates get_token() to the sync InteractiveBrowserCredential via ThreadPoolExecutor, satisfying the AsyncTokenCredential protocol - Add examples/aio/basic/: installation_example.py, functional_testing.py - Add examples/aio/advanced/: walkthrough, batch, dataframe_operations, relationships, alternate_keys_upsert, fetchxml, sql_examples, prodev_quick_start, datascience_risk_assessment, file_upload - Fix prodev_quick_start: create tables sequentially (Dataverse holds a metadata customization lock per request; concurrent creates fail); retry cleanup loop to handle transient SQL deadlocks All 12 scripts validated end-to-end against a live Dataverse environment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d219a19 commit 7233455

16 files changed

Lines changed: 6053 additions & 0 deletions

examples/aio/__init__.py

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.

examples/aio/_auth.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Async credential helper for the async example scripts.
6+
7+
azure-identity's InteractiveBrowserCredential is only available in the sync
8+
namespace (azure.identity), not the async one (azure.identity.aio). This
9+
module wraps the sync credential so it satisfies the AsyncTokenCredential
10+
protocol required by AsyncDataverseClient.
11+
12+
Usage::
13+
14+
from _auth import AsyncInteractiveBrowserCredential
15+
16+
credential = AsyncInteractiveBrowserCredential()
17+
try:
18+
async with AsyncDataverseClient(org_url, credential) as client:
19+
...
20+
finally:
21+
await credential.close()
22+
"""
23+
24+
import asyncio
25+
from concurrent.futures import ThreadPoolExecutor
26+
27+
from azure.identity import InteractiveBrowserCredential
28+
29+
30+
class AsyncInteractiveBrowserCredential:
31+
"""
32+
Async wrapper around the sync InteractiveBrowserCredential.
33+
34+
get_token() is dispatched to a dedicated thread so the event loop stays
35+
free during the browser popup / token exchange. Subsequent calls hit the
36+
in-process token cache and return almost immediately.
37+
"""
38+
39+
def __init__(self, **kwargs):
40+
self._credential = InteractiveBrowserCredential(**kwargs)
41+
self._executor = ThreadPoolExecutor(max_workers=1)
42+
43+
async def get_token(self, *scopes, **kwargs):
44+
loop = asyncio.get_running_loop()
45+
return await loop.run_in_executor(
46+
self._executor,
47+
lambda: self._credential.get_token(*scopes, **kwargs),
48+
)
49+
50+
async def close(self):
51+
self._executor.shutdown(wait=False)
52+
53+
async def __aenter__(self):
54+
return self
55+
56+
async def __aexit__(self, *_):
57+
await self.close()

examples/aio/advanced/__init__.py

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: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT license.
4+
5+
"""
6+
PowerPlatform Dataverse Client - Async Alternate Keys & Upsert Example
7+
8+
Async equivalent of examples/advanced/alternate_keys_upsert.py.
9+
10+
Demonstrates the full workflow of creating alternate keys and using
11+
them for upsert operations:
12+
1. Create a custom table with columns
13+
2. Define an alternate key on a column
14+
3. Wait for the key index to become Active
15+
4. Upsert records using the alternate key
16+
5. Verify records were created/updated correctly
17+
6. Clean up
18+
19+
Prerequisites:
20+
pip install PowerPlatform-Dataverse-Client
21+
pip install azure-identity
22+
"""
23+
24+
import asyncio
25+
import sys
26+
27+
from PowerPlatform.Dataverse.aio.async_client import AsyncDataverseClient
28+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
29+
from pathlib import Path
30+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
31+
from _auth import AsyncInteractiveBrowserCredential
32+
33+
# --- Config ---
34+
TABLE_NAME = "new_AltKeyDemo"
35+
KEY_COLUMN = "new_externalid"
36+
KEY_NAME = "new_ExternalIdKey"
37+
BACKOFF_DELAYS = (0, 3, 10, 20, 35)
38+
39+
40+
# --- Helpers ---
41+
async def backoff(coro_fn, *, delays=BACKOFF_DELAYS):
42+
"""Retry *coro_fn* with exponential-ish backoff on any exception."""
43+
last = None
44+
total_delay = 0
45+
attempts = 0
46+
for d in delays:
47+
if d:
48+
await asyncio.sleep(d)
49+
total_delay += d
50+
attempts += 1
51+
try:
52+
result = await coro_fn()
53+
if attempts > 1:
54+
retry_count = attempts - 1
55+
print(f" [INFO] Backoff succeeded after {retry_count} retry(s); " f"waited {total_delay}s total.")
56+
return result
57+
except Exception as ex: # noqa: BLE001
58+
last = ex
59+
continue
60+
if last:
61+
if attempts:
62+
retry_count = max(attempts - 1, 0)
63+
print(f" [WARN] Backoff exhausted after {retry_count} retry(s); " f"waited {total_delay}s total.")
64+
raise last
65+
66+
67+
async def wait_for_key_active(client, table, key_name, max_wait=120):
68+
"""Poll get_alternate_keys until the key status is Active."""
69+
import time
70+
71+
start = time.time()
72+
while time.time() - start < max_wait:
73+
keys = await client.tables.get_alternate_keys(table)
74+
for k in keys:
75+
if k.schema_name == key_name:
76+
print(f" Key status: {k.status}")
77+
if k.status == "Active":
78+
return k
79+
if k.status == "Failed":
80+
raise RuntimeError(f"Alternate key index failed: {k.schema_name}")
81+
await asyncio.sleep(5)
82+
raise TimeoutError(f"Key {key_name} did not become Active within {max_wait}s")
83+
84+
85+
# --- Main ---
86+
async def main():
87+
"""Run the async alternate-keys & upsert E2E walkthrough."""
88+
print("PowerPlatform Dataverse Client - Async Alternate Keys & Upsert Example")
89+
print("=" * 70)
90+
print("This script demonstrates:")
91+
print(" - Creating a custom table with columns")
92+
print(" - Defining an alternate key on a column")
93+
print(" - Waiting for the key index to become Active")
94+
print(" - Upserting records via alternate key (create + update)")
95+
print(" - Verifying records and listing keys")
96+
print(" - Cleaning up (delete key, delete table)")
97+
print("=" * 70)
98+
99+
entered = input("Enter Dataverse org URL (e.g. https://yourorg.crm.dynamics.com): ").strip()
100+
if not entered:
101+
print("No URL entered; exiting.")
102+
sys.exit(1)
103+
104+
base_url = entered.rstrip("/")
105+
credential = AsyncInteractiveBrowserCredential()
106+
try:
107+
async with AsyncDataverseClient(base_url, credential) as client:
108+
109+
# ------------------------------------------------------------------
110+
# Step 1: Create table (skip if already exists)
111+
# ------------------------------------------------------------------
112+
print("\n1. Creating table...")
113+
table_info = await client.tables.get(TABLE_NAME)
114+
if table_info:
115+
print(f" Table already exists: {TABLE_NAME} (skipped)")
116+
else:
117+
table_info = await backoff(
118+
lambda: client.tables.create(
119+
TABLE_NAME,
120+
columns={
121+
KEY_COLUMN: "string",
122+
"new_ProductName": "string",
123+
"new_Price": "decimal",
124+
},
125+
)
126+
)
127+
print(f" Created: {table_info.get('table_schema_name', TABLE_NAME)}")
128+
await asyncio.sleep(10) # Wait for metadata propagation
129+
130+
# ------------------------------------------------------------------
131+
# Step 2: Create alternate key (skip if already exists)
132+
# ------------------------------------------------------------------
133+
print("\n2. Creating alternate key...")
134+
existing_keys = await client.tables.get_alternate_keys(TABLE_NAME)
135+
existing_key = next((k for k in existing_keys if k.schema_name == KEY_NAME), None)
136+
if existing_key:
137+
print(f" Alternate key already exists: {KEY_NAME} (skipped)")
138+
else:
139+
key_info = await backoff(
140+
lambda: client.tables.create_alternate_key(TABLE_NAME, KEY_NAME, [KEY_COLUMN.lower()])
141+
)
142+
print(f" Key created: {key_info.schema_name} (id={key_info.metadata_id})")
143+
144+
# ------------------------------------------------------------------
145+
# Step 3: Wait for key to become Active
146+
# ------------------------------------------------------------------
147+
print("\n3. Waiting for key index to become Active...")
148+
active_key = await wait_for_key_active(client, TABLE_NAME, KEY_NAME)
149+
print(f" Key is Active: {active_key.schema_name}")
150+
151+
# ------------------------------------------------------------------
152+
# Step 4: Upsert records (creates new)
153+
# ------------------------------------------------------------------
154+
print("\n4a. Upsert single record (PATCH, creates new)...")
155+
await client.records.upsert(
156+
TABLE_NAME,
157+
[
158+
UpsertItem(
159+
alternate_key={KEY_COLUMN.lower(): "EXT-001"},
160+
record={"new_productname": "Widget A", "new_price": 9.99},
161+
),
162+
],
163+
)
164+
print(" Upserted EXT-001 (single)")
165+
166+
print("\n4b. Upsert second record (single PATCH)...")
167+
await client.records.upsert(
168+
TABLE_NAME,
169+
[
170+
UpsertItem(
171+
alternate_key={KEY_COLUMN.lower(): "EXT-002"},
172+
record={"new_productname": "Widget B", "new_price": 19.99},
173+
),
174+
],
175+
)
176+
print(" Upserted EXT-002 (single)")
177+
178+
print("\n4c. Upsert multiple records (UpsertMultiple bulk)...")
179+
await client.records.upsert(
180+
TABLE_NAME,
181+
[
182+
UpsertItem(
183+
alternate_key={KEY_COLUMN.lower(): "EXT-003"},
184+
record={"new_productname": "Widget C", "new_price": 29.99},
185+
),
186+
UpsertItem(
187+
alternate_key={KEY_COLUMN.lower(): "EXT-004"},
188+
record={"new_productname": "Widget D", "new_price": 39.99},
189+
),
190+
],
191+
)
192+
print(" Upserted EXT-003, EXT-004 (bulk)")
193+
194+
# ------------------------------------------------------------------
195+
# Step 5a: Upsert single update (PATCH, record exists)
196+
# ------------------------------------------------------------------
197+
print("\n5a. Upsert single record (update existing via PATCH)...")
198+
await client.records.upsert(
199+
TABLE_NAME,
200+
[
201+
UpsertItem(
202+
alternate_key={KEY_COLUMN.lower(): "EXT-001"},
203+
record={"new_productname": "Widget A v2", "new_price": 12.99},
204+
),
205+
],
206+
)
207+
print(" Updated EXT-001 (single)")
208+
209+
# ------------------------------------------------------------------
210+
# Step 5b: Upsert multiple update (UpsertMultiple, records exist)
211+
# ------------------------------------------------------------------
212+
print("\n5b. Upsert multiple records (update existing via UpsertMultiple)...")
213+
await client.records.upsert(
214+
TABLE_NAME,
215+
[
216+
UpsertItem(
217+
alternate_key={KEY_COLUMN.lower(): "EXT-003"},
218+
record={"new_productname": "Widget C v2", "new_price": 31.99},
219+
),
220+
UpsertItem(
221+
alternate_key={KEY_COLUMN.lower(): "EXT-004"},
222+
record={"new_productname": "Widget D v2", "new_price": 41.99},
223+
),
224+
],
225+
)
226+
print(" Updated EXT-003, EXT-004 (bulk)")
227+
228+
# ------------------------------------------------------------------
229+
# Step 6: Verify
230+
# ------------------------------------------------------------------
231+
print("\n6. Verifying records...")
232+
async for record in client.records.list_pages(
233+
TABLE_NAME,
234+
select=["new_productname", "new_price", KEY_COLUMN.lower()],
235+
):
236+
for item in record:
237+
ext_id = item.get(KEY_COLUMN.lower(), "?")
238+
name = item.get("new_productname", "?")
239+
price = item.get("new_price", "?")
240+
print(f" {ext_id}: {name} @ ${price}")
241+
242+
# ------------------------------------------------------------------
243+
# Step 7: List alternate keys
244+
# ------------------------------------------------------------------
245+
print("\n7. Listing alternate keys...")
246+
keys = await client.tables.get_alternate_keys(TABLE_NAME)
247+
for k in keys:
248+
print(f" {k.schema_name}: columns={k.key_attributes}, status={k.status}")
249+
250+
# ------------------------------------------------------------------
251+
# Step 8: Cleanup
252+
# ------------------------------------------------------------------
253+
cleanup = input("\n8. Delete table and cleanup? (Y/n): ").strip() or "y"
254+
if cleanup.lower() in ("y", "yes"):
255+
try:
256+
# Delete alternate key first
257+
for k in keys:
258+
await client.tables.delete_alternate_key(TABLE_NAME, k.metadata_id)
259+
print(f" Deleted key: {k.schema_name}")
260+
await asyncio.sleep(5)
261+
await backoff(lambda: client.tables.delete(TABLE_NAME))
262+
print(f" Deleted table: {TABLE_NAME}")
263+
except Exception as e: # noqa: BLE001
264+
print(f" Cleanup error: {e}")
265+
else:
266+
print(" Table kept for inspection.")
267+
finally:
268+
await credential.close()
269+
270+
print("\nDone.")
271+
272+
273+
if __name__ == "__main__":
274+
asyncio.run(main())

0 commit comments

Comments
 (0)