Skip to content

Commit 4996de3

Browse files
tpellissier-msfttpellissierclaude
authored
Add operation namespaces (client.records, client.query, client.tables) (microsoft#102)
* Add operation namespaces (client.records, client.query, client.tables) Reorganize SDK public API into three operation namespaces per the SDK Redesign Summary. New namespace methods use cleaner signatures (keyword-only params, @overload for single/bulk, renamed table params) while all existing flat methods (client.create, client.get, etc.) continue to work unchanged with deprecation warnings that point to the new equivalents. Key changes: - records.create() returns str for single, list[str] for bulk - query.get() handles paginated multi-record queries - tables.create() uses solution= and primary_column= keyword args - 40 new unit tests (75 total, all passing) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix PR review comments: docstrings, error docs, and create() validation - Document type aliases in tables.create() columns param (#4) - Clarify SQL description in client namespace summary (microsoft#8) - Add :raises: for HttpError, ValidationError, ValueError across all operation methods (microsoft#6) - Fix query.sql() to document ValidationError instead of incorrect SQLParseError - Restore post-call validation in records.create() matching original client.create() behavior (microsoft#9) - Improve deprecated get() docstring with explicit guidance on when to use records.get() vs query.get() (microsoft#10) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Use :class: role in :type:/:rtype: directives for Sphinx cross-references Aligns with existing codebase convention and ensures clickable hyperlinks in generated API documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: tpellissier <tpellissier@microsoft.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent da93d9f commit 4996de3

16 files changed

Lines changed: 1636 additions & 237 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
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. **All public methods in client.py** - Public API methods must be in client.py
16+
1. **Public API 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`)
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
@@ -25,5 +25,5 @@ This skill provides guidance for developers working on the PowerPlatform Dataver
2525
7. **Standardize output format** - Use `[INFO]`, `[WARN]`, `[ERR]`, `[OK]` prefixes for console output
2626
8. **No noqa comments** - Do not add `# noqa: BLE001` or similar linter suppression comments
2727
9. **Document public APIs** - Add Sphinx-style docstrings with examples for public methods
28-
10. **Define __all__ in module files, not __init__.py** - Use `__all__` to control exports in the actual module file (e.g., errors.py), not in `__init__.py`.
29-
11. **Run black before committing** - Always run `python -m black <changed files>` before committing. CI will reject unformatted code. Config is in `pyproject.toml` under `[tool.black]`.
28+
10. **Define __all__ in module files** - Each module declares its own exports via `__all__` (e.g., `errors.py` defines `__all__ = ["HttpError", ...]`). Package `__init__.py` files should not re-export or redefine another module's `__all__`; they use `__all__ = []` to indicate no star-import exports.
29+
11. **Run black before committing** - Always run `python -m black <changed files>` before committing. CI will reject unformatted code. Config is in `pyproject.toml` under `[tool.black]`.

README.md

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,10 @@ The SDK provides a simple, pythonic interface for Dataverse operations:
109109

110110
| Concept | Description |
111111
|---------|-------------|
112-
| **DataverseClient** | Main entry point for all operations with environment connection |
112+
| **DataverseClient** | Main entry point; provides `records`, `query`, and `tables` namespaces |
113+
| **Namespaces** | Operations are organized into `client.records` (CRUD), `client.query` (queries), and `client.tables` (metadata) |
113114
| **Records** | Dataverse records represented as Python dictionaries with column schema names |
114-
| **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) |
115+
| **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) |
115116
| **Bulk Operations** | Efficient bulk processing for multiple records with automatic optimization |
116117
| **Paging** | Automatic handling of large result sets with iterators |
117118
| **Structured Errors** | Detailed exception hierarchy with retry guidance and diagnostic information |
@@ -130,32 +131,31 @@ credential = InteractiveBrowserCredential()
130131
client = DataverseClient("https://yourorg.crm.dynamics.com", credential)
131132

132133
# Create a contact
133-
contact_id = client.create("contact", {"firstname": "John", "lastname": "Doe"})[0]
134+
contact_id = client.records.create("contact", {"firstname": "John", "lastname": "Doe"})
134135

135136
# Read the contact back
136-
contact = client.get("contact", contact_id, select=["firstname", "lastname"])
137+
contact = client.records.get("contact", contact_id, select=["firstname", "lastname"])
137138
print(f"Created: {contact['firstname']} {contact['lastname']}")
138139

139140
# Clean up
140-
client.delete("contact", contact_id)
141+
client.records.delete("contact", contact_id)
141142
```
142143

143144
### Basic CRUD operations
144145

145146
```python
146147
# Create a record
147-
account_ids = client.create("account", {"name": "Contoso Ltd"})
148-
account_id = account_ids[0]
148+
account_id = client.records.create("account", {"name": "Contoso Ltd"})
149149

150150
# Read a record
151-
account = client.get("account", account_id)
151+
account = client.records.get("account", account_id)
152152
print(account["name"])
153153

154154
# Update a record
155-
client.update("account", account_id, {"telephone1": "555-0199"})
155+
client.records.update("account", account_id, {"telephone1": "555-0199"})
156156

157157
# Delete a record
158-
client.delete("account", account_id)
158+
client.records.delete("account", account_id)
159159
```
160160

161161
### Bulk operations
@@ -167,45 +167,43 @@ payloads = [
167167
{"name": "Company B"},
168168
{"name": "Company C"}
169169
]
170-
ids = client.create("account", payloads)
170+
ids = client.records.create("account", payloads)
171171

172172
# Bulk update (broadcast same change to all)
173-
client.update("account", ids, {"industry": "Technology"})
173+
client.records.update("account", ids, {"industry": "Technology"})
174174

175175
# Bulk delete
176-
client.delete("account", ids, use_bulk_delete=True)
176+
client.records.delete("account", ids, use_bulk_delete=True)
177177
```
178178

179179
### Query data
180180

181181
```python
182182
# SQL query (read-only)
183-
results = client.query_sql(
183+
results = client.query.sql(
184184
"SELECT TOP 10 accountid, name FROM account WHERE statecode = 0"
185185
)
186186
for record in results:
187187
print(record["name"])
188188

189189
# OData query with paging
190190
# Note: filter and expand parameters are case sensitive
191-
pages = client.get(
191+
for page in client.query.get(
192192
"account",
193193
select=["accountid", "name"], # select is case-insensitive (automatically lowercased)
194194
filter="statecode eq 0", # filter must use lowercase logical names (not transformed)
195-
top=100
196-
)
197-
for page in pages:
195+
top=100,
196+
):
198197
for record in page:
199198
print(record["name"])
200199

201200
# Query with navigation property expansion (case-sensitive!)
202-
pages = client.get(
201+
for page in client.query.get(
203202
"account",
204203
select=["name"],
205204
expand=["primarycontactid"], # Navigation property names are case-sensitive
206-
filter="statecode eq 0" # Column names must be lowercase logical names
207-
)
208-
for page in pages:
205+
filter="statecode eq 0", # Column names must be lowercase logical names
206+
):
209207
for account in page:
210208
contact = account.get("primarycontactid", {})
211209
print(f"{account['name']} - Contact: {contact.get('fullname', 'N/A')}")
@@ -220,41 +218,41 @@ for page in pages:
220218

221219
```python
222220
# Create a custom table, including the customization prefix value in the schema names for the table and columns.
223-
table_info = client.create_table("new_Product", {
221+
table_info = client.tables.create("new_Product", {
224222
"new_Code": "string",
225-
"new_Price": "decimal",
223+
"new_Price": "decimal",
226224
"new_Active": "bool"
227225
})
228226

229227
# Create with custom primary column name and solution assignment
230-
table_info = client.create_table(
231-
table_schema_name="new_Product",
228+
table_info = client.tables.create(
229+
"new_Product",
232230
columns={
233231
"new_Code": "string",
234232
"new_Price": "decimal"
235233
},
236-
solution_unique_name="MyPublisher", # Optional: add to specific solution
237-
primary_column_schema_name="new_ProductName" # Optional: custom primary column (default is "{customization prefix value}_Name")
234+
solution="MyPublisher", # Optional: add to specific solution
235+
primary_column="new_ProductName", # Optional: custom primary column (default is "{customization prefix value}_Name")
238236
)
239237

240238
# Get table information
241-
info = client.get_table_info("new_Product")
239+
info = client.tables.get("new_Product")
242240
print(f"Logical name: {info['table_logical_name']}")
243241
print(f"Entity set: {info['entity_set_name']}")
244242

245243
# List all tables
246-
tables = client.list_tables()
244+
tables = client.tables.list()
247245
for table in tables:
248246
print(table)
249247

250248
# Add columns to existing table (columns must include customization prefix value)
251-
client.create_columns("new_Product", {"new_Category": "string"})
249+
client.tables.add_columns("new_Product", {"new_Category": "string"})
252250

253251
# Remove columns
254-
client.delete_columns("new_Product", ["new_Category"])
252+
client.tables.remove_columns("new_Product", ["new_Category"])
255253

256254
# Clean up
257-
client.delete_table("new_Product")
255+
client.tables.delete("new_Product")
258256
```
259257

260258
> **Important**: All custom column names must include the customization prefix value (e.g., `"new_"`).
@@ -311,7 +309,7 @@ from PowerPlatform.Dataverse.client import DataverseClient
311309
from PowerPlatform.Dataverse.core.errors import HttpError, ValidationError
312310

313311
try:
314-
client.get("account", "invalid-id")
312+
client.records.get("account", "invalid-id")
315313
except HttpError as e:
316314
print(f"HTTP {e.status_code}: {e.message}")
317315
print(f"Error code: {e.code}")
@@ -335,7 +333,7 @@ For optimal performance in production environments:
335333

336334
| Best Practice | Description |
337335
|---------------|-------------|
338-
| **Bulk Operations** | Pass lists to `create()`, `update()` for automatic bulk processing, for `delete()`, set `use_bulk_delete` when passing lists to use bulk operation |
336+
| **Bulk Operations** | Pass lists to `records.create()`, `records.update()` for automatic bulk processing, for `records.delete()`, set `use_bulk_delete` when passing lists to use bulk operation |
339337
| **Select Fields** | Specify `select` parameter to limit returned columns and reduce payload size |
340338
| **Page Size Control** | Use `top` and `page_size` parameters to control memory usage |
341339
| **Connection Reuse** | Reuse `DataverseClient` instances across operations |
@@ -367,7 +365,7 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio
367365

368366
When contributing new features to this SDK, please follow these guidelines:
369367

370-
1. **All public methods in client.py** - Public API methods must be defined in [client.py](src/PowerPlatform/Dataverse/client.py)
368+
1. **Public API in operation namespaces** - New public methods go in the appropriate namespace module under [operations/](src/PowerPlatform/Dataverse/operations/)
371369
2. **Add README example for public methods** - Add usage examples to this README for public API methods
372370
3. **Document public APIs** - Include Sphinx-style docstrings with parameter descriptions and examples for all public methods
373371
4. **Update documentation** when adding features - Keep README and SKILL files (note that each skill has 2 copies) in sync

examples/advanced/file_upload.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,12 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)):
185185

186186
def ensure_table():
187187
# Check by schema
188-
existing = backoff(lambda: client.get_table_info(TABLE_SCHEMA_NAME))
188+
existing = backoff(lambda: client.tables.get(TABLE_SCHEMA_NAME))
189189
if existing:
190190
print({"table": TABLE_SCHEMA_NAME, "existed": True})
191191
return existing
192-
log(f"client.create_table('{TABLE_SCHEMA_NAME}', schema={{'new_Title': 'string'}})")
193-
info = backoff(lambda: client.create_table(TABLE_SCHEMA_NAME, {"new_Title": "string"}))
192+
log(f"client.tables.create('{TABLE_SCHEMA_NAME}', schema={{'new_Title': 'string'}})")
193+
info = backoff(lambda: client.tables.create(TABLE_SCHEMA_NAME, {"new_Title": "string"}))
194194
print({"table": TABLE_SCHEMA_NAME, "existed": False, "metadata_id": info.get("metadata_id")})
195195
return info
196196

@@ -213,12 +213,8 @@ def ensure_table():
213213
record_id = None
214214
try:
215215
payload = {name_attr: "File Sample Record"}
216-
log(f"client.create('{table_schema_name}', payload)")
217-
created_ids = backoff(lambda: client.create(table_schema_name, payload))
218-
if isinstance(created_ids, list) and created_ids:
219-
record_id = created_ids[0]
220-
else:
221-
raise RuntimeError("Unexpected create return; expected list[str] with at least one GUID")
216+
log(f"client.records.create('{table_schema_name}', payload)")
217+
record_id = backoff(lambda: client.records.create(table_schema_name, payload))
222218
print({"record_created": True, "id": record_id, "table schema name": table_schema_name})
223219
except Exception as e: # noqa: BLE001
224220
print({"record_created": False, "error": str(e)})
@@ -386,8 +382,8 @@ def get_dataset_info(file_path: Path):
386382
# --------------------------- Cleanup ---------------------------
387383
if cleanup_record and record_id:
388384
try:
389-
log(f"client.delete('{table_schema_name}', '{record_id}')")
390-
backoff(lambda: client.delete(table_schema_name, record_id))
385+
log(f"client.records.delete('{table_schema_name}', '{record_id}')")
386+
backoff(lambda: client.records.delete(table_schema_name, record_id))
391387
print({"record_deleted": True})
392388
except Exception as e: # noqa: BLE001
393389
print({"record_deleted": False, "error": str(e)})
@@ -396,8 +392,8 @@ def get_dataset_info(file_path: Path):
396392

397393
if cleanup_table:
398394
try:
399-
log(f"client.delete_table('{TABLE_SCHEMA_NAME}')")
400-
backoff(lambda: client.delete_table(TABLE_SCHEMA_NAME))
395+
log(f"client.tables.delete('{TABLE_SCHEMA_NAME}')")
396+
backoff(lambda: client.tables.delete(TABLE_SCHEMA_NAME))
401397
print({"table_deleted": True})
402398
except Exception as e: # noqa: BLE001
403399
print({"table_deleted": False, "error": str(e)})

0 commit comments

Comments
 (0)