Skip to content

Commit a4356ed

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/metadata
2 parents 479f0b1 + 2af249b commit a4356ed

30 files changed

Lines changed: 2183 additions & 356 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This skill provides guidance for developers working on the PowerPlatform Dataver
1313

1414
### API Design
1515

16-
1. **Public methods in operation namespaces** - New public methods go in the appropriate namespace module under `src/PowerPlatform/Dataverse/operations/` (`records.py`, `query.py`, `tables.py`). The `client.py` file exposes these via namespace properties (`client.records`, `client.query`, `client.tables`). Public types and constants live in their own modules (e.g., `models/metadata.py`, `common/constants.py`)
16+
1. **Public methods in operation namespaces** - New public methods go in the appropriate namespace module under `src/PowerPlatform/Dataverse/operations/` (`records.py`, `query.py`, `tables.py`). The `client.py` file exposes these via namespace properties (`client.records`, `client.query`, `client.tables`). Public types and constants live in their own modules (e.g., `models/table_info.py`, `common/constants.py`)
1717
2. **Every public method needs README example** - Public API methods must have examples in README.md
1818
3. **Reuse existing APIs** - Always check if an existing method can be used before making direct Web API calls
1919
4. **Update documentation** when adding features - Keep README and SKILL files (both copies) in sync

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ credential = AzureCliCredential()
5353
credential = ClientSecretCredential(tenant_id, client_id, client_secret)
5454
credential = CertificateCredential(tenant_id, client_id, cert_path)
5555

56-
# Create client (no trailing slash on URL!)
56+
# Create client with context manager (recommended -- enables HTTP connection pooling)
57+
# No trailing slash on URL!
58+
with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
59+
... # all operations here
60+
# Session closed, caches cleared automatically
61+
62+
# Or without context manager:
5763
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
5864
```
5965

@@ -239,7 +245,7 @@ info = client.tables.get("account", select=["DisplayName", "Description"])
239245

240246
#### List Columns
241247
```python
242-
from PowerPlatform.Dataverse.models.metadata import ColumnMetadata
248+
from PowerPlatform.Dataverse.models.table_info import ColumnInfo
243249

244250
columns = client.tables.get_columns("account")
245251
for col in columns:
@@ -261,7 +267,7 @@ if col:
261267

262268
#### Get Column Options (Picklist/Choice Values)
263269
```python
264-
from PowerPlatform.Dataverse.models.metadata import OptionSetInfo
270+
from PowerPlatform.Dataverse.models.table_info import OptionSetInfo
265271

266272
options = client.tables.get_column_options("account", "accountcategorycode")
267273
if options:

README.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ The SDK provides a simple, pythonic interface for Dataverse operations:
113113
| Concept | Description |
114114
|---------|-------------|
115115
| **DataverseClient** | Main entry point; provides `records`, `query`, `tables`, and `files` namespaces |
116+
| **Context Manager** | Use `with DataverseClient(...) as client:` for automatic cleanup and HTTP connection pooling |
116117
| **Namespaces** | Operations are organized into `client.records` (CRUD & OData queries), `client.query` (query & search), `client.tables` (metadata), and `client.files` (file uploads) |
117118
| **Records** | Dataverse records represented as Python dictionaries with column schema names |
118119
| **Schema names** | Use table schema names (`"account"`, `"new_MyTestTable"`) and column schema names (`"name"`, `"new_MyTestColumn"`). See: [Table definitions in Microsoft Dataverse](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/entity-metadata) |
@@ -131,17 +132,18 @@ from PowerPlatform.Dataverse.client import DataverseClient
131132

132133
# Connect to Dataverse
133134
credential = InteractiveBrowserCredential()
134-
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
135135

136-
# Create a contact
137-
contact_id = client.records.create("contact", {"firstname": "John", "lastname": "Doe"})
136+
with DataverseClient("https://yourorg.crm.dynamics.com", credential) as client:
137+
# Create a contact
138+
contact_id = client.records.create("contact", {"firstname": "John", "lastname": "Doe"})
138139

139-
# Read the contact back
140-
contact = client.records.get("contact", contact_id, select=["firstname", "lastname"])
141-
print(f"Created: {contact['firstname']} {contact['lastname']}")
140+
# Read the contact back
141+
contact = client.records.get("contact", contact_id, select=["firstname", "lastname"])
142+
print(f"Created: {contact['firstname']} {contact['lastname']}")
142143

143-
# Clean up
144-
client.records.delete("contact", contact_id)
144+
# Clean up
145+
client.records.delete("contact", contact_id)
146+
# Session closed, caches cleared automatically
145147
```
146148

147149
### Basic CRUD operations
@@ -514,7 +516,7 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio
514516

515517
When contributing new features to this SDK, please follow these guidelines:
516518

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

examples/advanced/file_upload.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,5 @@ def get_dataset_info(file_path: Path):
375375
except Exception as e: # noqa: BLE001
376376
print({"test_file_8mb_deleted": False, "error": str(e)})
377377

378+
client.close()
378379
print("Done.")

examples/advanced/relationships.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,6 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)):
108108

109109

110110
def main():
111-
# Initialize relationship IDs to None for cleanup safety
112-
rel_id_1 = None
113-
rel_id_2 = None
114-
rel_id_3 = None
115-
116111
print("=" * 80)
117112
print("Dataverse SDK - Relationship Management Example")
118113
print("=" * 80)
@@ -135,8 +130,16 @@ def main():
135130
credential = InteractiveBrowserCredential()
136131

137132
log_call(f"DataverseClient(base_url='{base_url}', credential=...)")
138-
client = DataverseClient(base_url=base_url, credential=credential)
139-
print(f"[OK] Connected to: {base_url}")
133+
with DataverseClient(base_url=base_url, credential=credential) as client:
134+
print(f"[OK] Connected to: {base_url}")
135+
_run_example(client)
136+
137+
138+
def _run_example(client):
139+
# Initialize relationship IDs to None for cleanup safety
140+
rel_id_1 = None
141+
rel_id_2 = None
142+
rel_id_3 = None
140143

141144
# ============================================================================
142145
# 2. CLEANUP PREVIOUS RUN (Idempotency)

examples/advanced/walkthrough.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,12 @@ def main():
8787
credential = InteractiveBrowserCredential()
8888

8989
log_call(f"DataverseClient(base_url='{base_url}', credential=...)")
90-
client = DataverseClient(base_url=base_url, credential=credential)
91-
print(f"[OK] Connected to: {base_url}")
90+
with DataverseClient(base_url=base_url, credential=credential) as client:
91+
print(f"[OK] Connected to: {base_url}")
92+
_run_walkthrough(client)
9293

94+
95+
def _run_walkthrough(client):
9396
# ============================================================================
9497
# 2. TABLE CREATION (METADATA)
9598
# ============================================================================

0 commit comments

Comments
 (0)