Skip to content

Commit 83b9e68

Browse files
author
Samson Gebre
committed
Merge branch 'copilot/add-local-file-logging' of https://github.com/microsoft/PowerPlatform-DataverseClient-Python into copilot/add-local-file-logging
2 parents 448b150 + 33802d9 commit 83b9e68

60 files changed

Lines changed: 18330 additions & 435 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.azdo/ci-pr.yaml

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ extends:
4242

4343
- script: |
4444
python -m pip install --upgrade pip
45-
python -m pip install flake8 black build
45+
python -m pip install flake8 black build diff-cover
4646
python -m pip install -e .[dev]
4747
displayName: 'Install dependencies'
4848
@@ -60,18 +60,30 @@ extends:
6060
- script: |
6161
python -m build
6262
displayName: 'Build package'
63-
63+
6464
- script: |
6565
python -m pip install dist/*.whl
6666
displayName: 'Install wheel'
67-
67+
6868
- script: |
69-
pytest
69+
PYTHONPATH=src pytest --junitxml=test-results.xml --cov --cov-report=xml
7070
displayName: 'Test with pytest'
71-
71+
72+
- script: |
73+
git fetch origin main
74+
diff-cover coverage.xml --compare-branch=origin/main --fail-under=90
75+
displayName: 'Diff coverage (90% for new changes)'
76+
7277
- task: PublishTestResults@2
7378
condition: succeededOrFailed()
7479
inputs:
75-
testResultsFiles: '**/test-*.xml'
80+
testResultsFiles: '**/test-results.xml'
7681
testRunTitle: 'Python 3.12'
7782
displayName: 'Publish test results'
83+
84+
- task: PublishCodeCoverageResults@2
85+
condition: succeededOrFailed()
86+
inputs:
87+
summaryFileLocation: '**/coverage.xml'
88+
pathToSources: '$(Build.SourcesDirectory)/src'
89+
displayName: 'Publish code coverage'

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,39 @@ 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`, `batch.py`). The `client.py` file exposes these via namespace properties (`client.records`, `client.query`, `client.tables`, `client.batch`). Public types and constants live in their own modules (e.g., `models/metadata.py`, `models/batch.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
2020
5. **Consider backwards compatibility** - Avoid breaking changes
2121
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
2222

23+
### Dataverse Property Naming Rules
24+
25+
Dataverse uses two different naming conventions for properties. Getting this wrong causes 400 errors that are hard to debug.
26+
27+
| Property type | Name convention | Example | When used |
28+
|---|---|---|---|
29+
| **Structural** (columns) | LogicalName (always lowercase) | `new_name`, `new_priority` | `$select`, `$filter`, `$orderby`, record payload keys |
30+
| **Navigation** (relationships / lookups) | Navigation Property Name (usually SchemaName, PascalCase, case-sensitive) | `new_CustomerId`, `new_AgentId` | `$expand`, `@odata.bind` annotation keys |
31+
32+
Navigation property names are case-sensitive and must match the entity's `$metadata`. Using the logical name instead of the navigation property name results in 400 Bad Request errors.
33+
34+
**Critical rule:** The OData parser validates `@odata.bind` property names **case-sensitively** against declared navigation properties. Lowercasing `new_CustomerId@odata.bind` to `new_customerid@odata.bind` causes: `ODataException: An undeclared property 'new_customerid' which only has property annotations...`
35+
36+
**SDK implementation:**
37+
38+
- `_lowercase_keys()` lowercases all keys EXCEPT those containing `@odata.` (preserves navigation property casing in `@odata.bind` keys)
39+
- `_lowercase_list()` lowercases `$select` and `$orderby` params (structural properties)
40+
- `$expand` params are passed as-is (navigation properties, PascalCase)
41+
- `_convert_labels_to_ints()` skips `@odata.` keys entirely (they are annotations, not attributes)
42+
43+
**When adding new code that processes record dicts or builds query parameters:**
44+
45+
- Always use `_lowercase_keys()` for record payloads. Never manually call `.lower()` on all keys
46+
- Never lowercase `$expand` values or `@odata.bind` key prefixes
47+
- If iterating record keys, skip keys containing `@odata.` when doing attribute-level operations
48+
2349
### Code Style
2450

2551
6. **No emojis** - Do not use emoji in code, comments, or output
@@ -28,3 +54,35 @@ This skill provides guidance for developers working on the PowerPlatform Dataver
2854
9. **Document public APIs** - Add Sphinx-style docstrings with examples for public methods
2955
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.
3056
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]`.
57+
58+
### Docstring Type Annotations (Microsoft Learn Compatibility)
59+
60+
This SDK's API reference is published on Microsoft Learn. The Learn doc pipeline parses `:type:` and `:rtype:` directives differently from standard Sphinx -- every word between `:class:` references is treated as a separate cross-reference (`<xref:word>`). Using Sphinx-style `:class:\`list\` of :class:\`str\`` produces broken `<xref:of>` links on Learn.
61+
62+
**Rules for `:type:` and `:rtype:` directives:**
63+
64+
- Use Python bracket notation for generic types: `list[str]`, `dict[str, typing.Any]`, `list[dict]`
65+
- Use `or` (without `:class:`) for union types: `str or None`, `dict or list[dict]`
66+
- Use bracket nesting for complex types: `collections.abc.Iterable[list[dict]]`
67+
- Use `~` prefix for SDK types to show short name: `list[~PowerPlatform.Dataverse.models.record.Record]`
68+
- `:class:` is fine for single standalone types: `:class:\`str\``, `:class:\`bool\``
69+
70+
**Never** use `:class:\`X\` of :class:\`Y\`` or `:class:\`X\` mapping :class:\`Y\` to :class:\`Z\`` -- the words `of`, `mapping`, `to` become broken `<xref:>` links.
71+
72+
**Correct examples:**
73+
74+
```rst
75+
:type data: dict or list[dict]
76+
:rtype: list[str]
77+
:rtype: collections.abc.Iterable[list[~PowerPlatform.Dataverse.models.record.Record]]
78+
:type select: list[str] or None
79+
:type columns: dict[str, typing.Any]
80+
```
81+
82+
**Wrong examples (NEVER use):**
83+
84+
```rst
85+
:type data: :class:`dict` or :class:`list` of :class:`dict`
86+
:rtype: :class:`list` of :class:`str`
87+
:type columns: :class:`dict` mapping :class:`str` to :class:`typing.Any`
88+
```

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

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Use the PowerPlatform Dataverse Client Python SDK to interact with Microsoft Dat
2222
- `client.query` -- query and search operations
2323
- `client.tables` -- table metadata, columns, and relationships
2424
- `client.files` -- file upload operations
25+
- `client.batch` -- batch multiple operations into a single HTTP request
2526

2627
### Bulk Operations
2728
The SDK supports Dataverse's native bulk operations: Pass lists to `create()`, `update()` for automatic bulk processing, for `delete()`, set `use_bulk_delete` when passing lists to use bulk operation
@@ -30,6 +31,9 @@ The SDK supports Dataverse's native bulk operations: Pass lists to `create()`, `
3031
- Control page size with `page_size` parameter
3132
- Use `top` parameter to limit total records returned
3233

34+
### DataFrame Support
35+
- DataFrame operations are accessed via the `client.dataframe` namespace: `client.dataframe.get()`, `client.dataframe.create()`, `client.dataframe.update()`, `client.dataframe.delete()`
36+
3337
## Common Operations
3438

3539
### Import
@@ -105,6 +109,20 @@ for page in client.records.get(
105109
print(f"{account['name']} - {contact.get('fullname', 'N/A')}")
106110
```
107111

112+
#### Create Records with Lookup Bindings (@odata.bind)
113+
```python
114+
# Set lookup fields using @odata.bind with PascalCase navigation property names
115+
# CORRECT: use the navigation property name (case-sensitive, must match $metadata)
116+
guid = client.records.create("new_ticket", {
117+
"new_name": "TKT-001",
118+
"new_CustomerId@odata.bind": f"/new_customers({customer_id})",
119+
"new_AgentId@odata.bind": f"/new_agents({agent_id})",
120+
})
121+
122+
# WRONG: lowercase navigation property causes 400 error
123+
# "new_customerid@odata.bind" -> ODataException: undeclared property 'new_customerid'
124+
```
125+
108126
#### Update Records
109127
```python
110128
# Single update
@@ -115,7 +133,7 @@ client.records.update("account", [id1, id2, id3], {"industry": "Technology"})
115133
```
116134

117135
#### Upsert Records
118-
Creates or updates records identified by alternate keys. Single item PATCH; multiple items `UpsertMultiple` bulk action.
136+
Creates or updates records identified by alternate keys. Single item -> PATCH; multiple items -> `UpsertMultiple` bulk action.
119137
> **Prerequisite**: The table must have an alternate key configured in Dataverse for the columns used in `alternate_key`. Without it, Dataverse will reject the request with a 400 error.
120138
```python
121139
from PowerPlatform.Dataverse.models.upsert import UpsertItem
@@ -157,6 +175,42 @@ client.records.delete("account", account_id)
157175
client.records.delete("account", [id1, id2, id3], use_bulk_delete=True)
158176
```
159177

178+
### DataFrame Operations
179+
180+
The SDK provides DataFrame wrappers for all CRUD operations via the `client.dataframe` namespace, using pandas DataFrames and Series as input/output.
181+
182+
```python
183+
import pandas as pd
184+
185+
# Query records -- returns a single DataFrame
186+
df = client.dataframe.get("account", filter="statecode eq 0", select=["name"])
187+
print(f"Got {len(df)} rows")
188+
189+
# Limit results with top for large tables
190+
df = client.dataframe.get("account", select=["name"], top=100)
191+
192+
# Fetch single record as one-row DataFrame
193+
df = client.dataframe.get("account", record_id=account_id, select=["name"])
194+
195+
# Create records from a DataFrame (returns a Series of GUIDs)
196+
new_accounts = pd.DataFrame([
197+
{"name": "Contoso", "telephone1": "555-0100"},
198+
{"name": "Fabrikam", "telephone1": "555-0200"},
199+
])
200+
new_accounts["accountid"] = client.dataframe.create("account", new_accounts)
201+
202+
# Update records from a DataFrame (id_column identifies the GUID column)
203+
new_accounts["telephone1"] = ["555-0199", "555-0299"]
204+
client.dataframe.update("account", new_accounts, id_column="accountid")
205+
206+
# Clear a field by setting clear_nulls=True (by default, NaN/None fields are skipped)
207+
df = pd.DataFrame([{"accountid": "guid-1", "websiteurl": None}])
208+
client.dataframe.update("account", df, id_column="accountid", clear_nulls=True)
209+
210+
# Delete records by passing a Series of GUIDs
211+
client.dataframe.delete("account", new_accounts["accountid"])
212+
```
213+
160214
### SQL Queries
161215

162216
SQL queries are **read-only** and support limited SQL syntax. A single SELECT statement with optional WHERE, TOP (integer literal), ORDER BY (column names only), and a simple table alias after FROM is supported. But JOIN and subqueries may not be. Refer to the Dataverse documentation for the current feature set.
@@ -196,6 +250,7 @@ table_info = client.tables.create(
196250
#### Supported Column Types
197251
Types on the same line map to the same exact format under the hood
198252
- `"string"` or `"text"` - Single line of text
253+
- `"memo"` or `"multiline"` - Multiple lines of text (4000 character default)
199254
- `"int"` or `"integer"` - Whole number
200255
- `"decimal"` or `"money"` - Decimal number
201256
- `"float"` or `"double"` - Floating point number
@@ -316,6 +371,50 @@ client.files.upload(
316371
)
317372
```
318373

374+
### Batch Operations
375+
376+
Use `client.batch` to send multiple operations in one HTTP request. All batch methods return `None`; results arrive via `BatchResult` after `execute()`.
377+
378+
```python
379+
# Build a batch request
380+
batch = client.batch.new()
381+
batch.records.create("account", {"name": "Contoso"})
382+
batch.records.update("account", account_id, {"telephone1": "555-0100"})
383+
batch.records.get("account", account_id, select=["name"])
384+
batch.query.sql("SELECT TOP 5 name FROM account")
385+
386+
result = batch.execute()
387+
for item in result.responses:
388+
if item.is_success:
389+
print(f"[OK] {item.status_code} entity_id={item.entity_id}")
390+
if item.data:
391+
# GET responses populate item.data with the parsed JSON record
392+
print(item.data.get("name"))
393+
else:
394+
print(f"[ERR] {item.status_code}: {item.error_message}")
395+
396+
# Transactional changeset (all succeed or roll back)
397+
with batch.changeset() as cs:
398+
ref = cs.records.create("contact", {"firstname": "Alice"})
399+
cs.records.update("account", account_id, {"primarycontactid@odata.bind": ref})
400+
401+
# Continue on error
402+
result = batch.execute(continue_on_error=True)
403+
print(f"Succeeded: {len(result.succeeded)}, Failed: {len(result.failed)}")
404+
```
405+
406+
**BatchResult properties:**
407+
- `result.responses` -- list of `BatchItemResponse` in submission order
408+
- `result.succeeded` -- responses with 2xx status codes
409+
- `result.failed` -- responses with non-2xx status codes
410+
- `result.has_errors` -- True if any response failed
411+
- `result.entity_ids` -- GUIDs from OData-EntityId headers (creates and updates)
412+
413+
**Batch limitations:**
414+
- Maximum 1000 operations per batch
415+
- Paginated `records.get()` (without `record_id`) is not supported in batch
416+
- `flush_cache()` is not supported in batch
417+
319418
## Error Handling
320419

321420
The SDK provides structured exceptions with detailed error information:
@@ -359,6 +458,7 @@ except ValidationError as e:
359458
- Check filter/expand parameters use correct case
360459
- Verify column names exist and are spelled correctly
361460
- Ensure custom columns include customization prefix
461+
- For `@odata.bind` errors ("undeclared property"): the navigation property name before `@odata.bind` is case-sensitive and must match the entity's `$metadata` exactly (e.g., `new_CustomerId@odata.bind` for custom lookups, `parentaccountid@odata.bind` for system lookups). The SDK preserves `@odata.bind` key casing.
362462

363463
## Best Practices
364464

@@ -371,7 +471,7 @@ except ValidationError as e:
371471
5. **Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
372472
6. **Error handling** - Implement retry logic for transient errors (`e.is_transient`)
373473
7. **Always include customization prefix** for custom tables/columns
374-
8. **Use lowercase** - Generally using lowercase input won't go wrong, except for custom table/column naming
474+
8. **Use lowercase for column names, match `$metadata` for navigation properties** - Column names in `$select`/`$filter`/record payloads use lowercase LogicalNames. Navigation properties in `$expand` and `@odata.bind` keys are case-sensitive and must match the entity's `$metadata` (PascalCase for custom lookups like `new_CustomerId`, lowercase for system lookups like `parentaccountid`)
375475
9. **Test in non-production environments** first
376476
10. **Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
377477

.github/workflows/python-package.yml

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ jobs:
1818

1919
steps:
2020
- uses: actions/checkout@v4
21+
with:
22+
fetch-depth: 0
2123

2224
- name: Set up Python 3.12
2325
uses: actions/setup-python@v5
@@ -27,7 +29,7 @@ jobs:
2729
- name: Install dependencies
2830
run: |
2931
python -m pip install --upgrade pip
30-
python -m pip install flake8 black build
32+
python -m pip install flake8 black build diff-cover
3133
python -m pip install -e .[dev]
3234
3335
- name: Check format with black
@@ -44,11 +46,30 @@ jobs:
4446
- name: Build package
4547
run: |
4648
python -m build
47-
49+
4850
- name: Install wheel
4951
run: |
5052
python -m pip install dist/*.whl
51-
53+
5254
- name: Test with pytest
5355
run: |
54-
pytest
56+
PYTHONPATH=src pytest --junitxml=test-results.xml --cov --cov-report=xml
57+
58+
- name: Diff coverage (90% for new changes)
59+
run: |
60+
git fetch origin ${{ github.base_ref }}
61+
diff-cover coverage.xml --compare-branch=origin/${{ github.base_ref }} --fail-under=90
62+
63+
- name: Upload test results
64+
if: always()
65+
uses: actions/upload-artifact@v4
66+
with:
67+
name: test-results
68+
path: test-results.xml
69+
70+
- name: Upload coverage report
71+
if: always()
72+
uses: actions/upload-artifact@v4
73+
with:
74+
name: coverage-report
75+
path: coverage.xml

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ Thumbs.db
2525

2626
# Claude local settings
2727
.claude/*.local.json
28+
.claude/*.local.md

0 commit comments

Comments
 (0)