Skip to content

Commit f660dc5

Browse files
authored
Merge branch 'main' into feature/metadata
2 parents 9140589 + 5a395ec commit f660dc5

39 files changed

+8073
-94
lines changed

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,32 @@ This skill provides guidance for developers working on the PowerPlatform Dataver
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: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ The SDK supports Dataverse's native bulk operations: Pass lists to `create()`, `
3030
- Control page size with `page_size` parameter
3131
- Use `top` parameter to limit total records returned
3232

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

3538
### Import
@@ -105,6 +108,20 @@ for page in client.records.get(
105108
print(f"{account['name']} - {contact.get('fullname', 'N/A')}")
106109
```
107110

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

117134
#### Upsert Records
118-
Creates or updates records identified by alternate keys. Single item PATCH; multiple items `UpsertMultiple` bulk action.
135+
Creates or updates records identified by alternate keys. Single item -> PATCH; multiple items -> `UpsertMultiple` bulk action.
119136
> **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.
120137
```python
121138
from PowerPlatform.Dataverse.models.upsert import UpsertItem
@@ -157,6 +174,42 @@ client.records.delete("account", account_id)
157174
client.records.delete("account", [id1, id2, id3], use_bulk_delete=True)
158175
```
159176

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

162215
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.
@@ -416,6 +469,7 @@ except ValidationError as e:
416469
- Check filter/expand parameters use correct case
417470
- Verify column names exist and are spelled correctly
418471
- Ensure custom columns include customization prefix
472+
- 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.
419473

420474
## Best Practices
421475

@@ -428,7 +482,7 @@ except ValidationError as e:
428482
5. **Use production credentials** - ClientSecretCredential or CertificateCredential for unattended operations
429483
6. **Error handling** - Implement retry logic for transient errors (`e.is_transient`)
430484
7. **Always include customization prefix** for custom tables/columns
431-
8. **Use lowercase** - Generally using lowercase input won't go wrong, except for custom table/column naming
485+
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`)
432486
9. **Test in non-production environments** first
433487
10. **Use named constants** - Import cascade behavior constants from `PowerPlatform.Dataverse.common.constants`
434488

.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

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.1.0b7] - 2026-03-17
9+
10+
### Added
11+
- DataFrame namespace: `client.dataframe.get()`, `.create()`, `.update()`, `.delete()` for working with Dataverse records as pandas DataFrames and Series — no manual dict conversion required (#98)
12+
- Table metadata now includes `primary_name_attribute` and `primary_id_attribute` from `tables.create()` and `tables.get_info()` (#148)
13+
14+
### Changed
15+
- `pandas>=2.0.0` is now a required dependency (#98)
16+
17+
## [0.1.0b6] - 2026-03-12
18+
19+
### Added
20+
- Context manager support: `with DataverseClient(...) as client:` for automatic resource cleanup, HTTP connection pooling, and `close()` for explicit lifecycle management (#117)
21+
- Typed return models `Record`, `TableInfo`, and `ColumnInfo` for record and table metadata operations, replacing raw `Dict[str, Any]` returns with full backward compatibility (`result["key"]` still works) (#115)
22+
- Alternate key management: `client.tables.create_alternate_key()`, `client.tables.get_alternate_keys()`, `client.tables.delete_alternate_key()` with typed `AlternateKeyInfo` model (#126)
23+
24+
### Fixed
25+
- `@odata.bind` lookup bindings now preserve navigation property casing (e.g., `new_CustomerId@odata.bind`), fixing `400 Bad Request` errors on create/update/upsert with lookup fields (#137)
26+
- Reduced unnecessary HTTP round-trips on create/update/upsert when records contain `@odata.bind` keys (#137)
27+
- Single-record `get()` now lowercases `$select` column names consistently with multi-record queries (#137)
28+
829
## [0.1.0b5] - 2026-02-27
930

1031
### Fixed
@@ -70,6 +91,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7091
- Comprehensive error handling with specific exception types (`DataverseError`, `AuthenticationError`, etc.) (#22, #24)
7192
- HTTP retry logic with exponential backoff for resilient operations (#72)
7293

94+
[0.1.0b7]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b6...v0.1.0b7
95+
[0.1.0b6]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b5...v0.1.0b6
7396
[0.1.0b5]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b4...v0.1.0b5
7497
[0.1.0b4]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b3...v0.1.0b4
7598
[0.1.0b3]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b2...v0.1.0b3

CONTRIBUTING.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,41 @@ published release:
121121
# After publishing v0.1.0b4, bump to v0.1.0b5 on main
122122
# Update version in pyproject.toml
123123
# Commit directly to main: "Bump version to 0.1.0b5 for next development cycle"
124+
```
125+
126+
### Docstring Type Annotations (Microsoft Learn Compatibility)
127+
128+
This SDK's API reference is published on [Microsoft Learn](https://learn.microsoft.com). The Learn doc pipeline processes `:type:` and `:rtype:` Sphinx directives differently from standard Sphinx -- every word between `:class:` back-tick references is treated as a separate cross-reference (`<xref:word>`). For example:
129+
130+
```
131+
:rtype: :class:`list` of :class:`str`
132+
```
133+
134+
This produces a broken `<xref:of>` link because `of` is not a valid type.
135+
136+
**Rules for `:type:` and `:rtype:` directives:**
137+
138+
- Use **Python bracket notation** for generic types: `list[str]`, `dict[str, typing.Any]`, `list[dict]`
139+
- Use **`or`** (without `:class:`) for union types: `str or None`, `dict or list[dict]`
140+
- Use **bracket nesting** for complex types: `collections.abc.Iterable[list[dict]]`
141+
- `:class:` is fine for **single standalone types**: `` :class:`str` ``, `` :class:`bool` ``
142+
143+
**NEVER** use the following patterns -- the connector words (`of`, `mapping`, `to`) become broken `<xref:>` links on Learn:
144+
145+
```
146+
:class:`X` of :class:`Y`
147+
:class:`X` mapping :class:`Y` to :class:`Z`
148+
```
149+
150+
Correct:
151+
```
152+
:type data: dict or list[dict]
153+
:rtype: list[str]
154+
:type select: list[str] or None
155+
```
156+
157+
Wrong:
158+
```
159+
:type data: :class:`dict` or :class:`list` of :class:`dict`
160+
:rtype: :class:`list` of :class:`str`
124161
```

0 commit comments

Comments
 (0)