Skip to content

Commit 7ce015e

Browse files
Samson Gebreclaude
andcommitted
Merge origin/main into batch branch: bring in files namespace and model reorganization
- Keep both client.files and client.batch namespaces - Accept origin/main's _list_entities with filter/select parameters - Fix imports: models.metadata was reorganized into relationship.py + labels.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 3034fcb + 6a3cb1b commit 7ce015e

31 files changed

Lines changed: 1999 additions & 397 deletions

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

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Use the PowerPlatform Dataverse Client Python SDK to interact with Microsoft Dat
2121
- `client.records` -- CRUD and OData queries
2222
- `client.query` -- query and search operations
2323
- `client.tables` -- table metadata, columns, and relationships
24+
- `client.files` -- file upload operations
2425
- `client.batch` -- batch multiple operations into a single HTTP request
2526

2627
### Bulk Operations
@@ -108,6 +109,40 @@ client.records.update("account", account_id, {"telephone1": "555-0200"})
108109
client.records.update("account", [id1, id2, id3], {"industry": "Technology"})
109110
```
110111

112+
#### Upsert Records
113+
Creates or updates records identified by alternate keys. Single item → PATCH; multiple items → `UpsertMultiple` bulk action.
114+
> **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.
115+
```python
116+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
117+
118+
# Single upsert
119+
client.records.upsert("account", [
120+
UpsertItem(
121+
alternate_key={"accountnumber": "ACC-001"},
122+
record={"name": "Contoso Ltd", "telephone1": "555-0100"},
123+
)
124+
])
125+
126+
# Bulk upsert (uses UpsertMultiple API automatically)
127+
client.records.upsert("account", [
128+
UpsertItem(alternate_key={"accountnumber": "ACC-001"}, record={"name": "Contoso Ltd"}),
129+
UpsertItem(alternate_key={"accountnumber": "ACC-002"}, record={"name": "Fabrikam Inc"}),
130+
])
131+
132+
# Composite alternate key
133+
client.records.upsert("account", [
134+
UpsertItem(
135+
alternate_key={"accountnumber": "ACC-001", "address1_postalcode": "98052"},
136+
record={"name": "Contoso Ltd"},
137+
)
138+
])
139+
140+
# Plain dict syntax (no import needed)
141+
client.records.upsert("account", [
142+
{"alternate_key": {"accountnumber": "ACC-001"}, "record": {"name": "Contoso Ltd"}}
143+
])
144+
```
145+
111146
#### Delete Records
112147
```python
113148
# Single delete
@@ -198,7 +233,7 @@ client.tables.delete("new_Product")
198233

199234
#### Create One-to-Many Relationship
200235
```python
201-
from PowerPlatform.Dataverse.models.metadata import (
236+
from PowerPlatform.Dataverse.models.relationship import (
202237
LookupAttributeMetadata,
203238
OneToManyRelationshipMetadata,
204239
Label,
@@ -230,7 +265,7 @@ print(f"Created lookup field: {result['lookup_schema_name']}")
230265

231266
#### Create Many-to-Many Relationship
232267
```python
233-
from PowerPlatform.Dataverse.models.metadata import ManyToManyRelationshipMetadata
268+
from PowerPlatform.Dataverse.models.relationship import ManyToManyRelationshipMetadata
234269

235270
relationship = ManyToManyRelationshipMetadata(
236271
schema_name="new_employee_project",
@@ -268,11 +303,11 @@ client.tables.delete_relationship(result["relationship_id"])
268303

269304
```python
270305
# Upload file to a file column
271-
client.upload_file(
272-
table_schema_name="account",
306+
client.files.upload(
307+
table="account",
273308
record_id=account_id,
274-
file_name_attribute="new_Document", # If the file column doesn't exist, it will be created automatically
275-
path="/path/to/document.pdf"
309+
file_column="new_Document", # If the file column doesn't exist, it will be created automatically
310+
path="/path/to/document.pdf",
276311
)
277312
```
278313

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Warn when ADO pipeline YAML file changes
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- ".azdo/ci-pr.yaml"
7+
8+
permissions:
9+
contents: read
10+
pull-requests: write
11+
12+
jobs:
13+
warn:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Emit warning in logs
17+
run: |
18+
echo "::warning file=.azdo/ci-pr.yaml::This PR changes .azdo/ci-pr.yaml. After merge, Azure DevOps may disable/require approval for the PR pipeline YAML until it is re-enabled/approved."
19+
20+
echo "ADO pipeline: DV-Python-SDK-PullRequest (definitionId=29922)"
21+
echo "https://dev.azure.com/dynamicscrm/OneCRM/_build?definitionId=29922"
22+
23+
- name: Add workflow summary
24+
run: |
25+
{
26+
echo "## ADO PR pipeline YAML change detected"
27+
echo ""
28+
echo "**File changed:** \`.azdo/ci-pr.yaml\`"
29+
echo ""
30+
echo "**Why this matters:** After this is merged, Azure DevOps may disable/require approval for the PR pipeline YAML."
31+
echo ""
32+
echo "**Action required (post-merge):** Re-enable / approve the updated YAML for:"
33+
echo "- **DV-Python-SDK-PullRequest** (definitionId=29922)"
34+
echo "- https://dev.azure.com/dynamicscrm/OneCRM/_build?definitionId=29922"
35+
echo ""
36+
echo "Then trigger a run to confirm PR validation works."
37+
} >> "$GITHUB_STEP_SUMMARY"
38+
39+
- name: Post resolvable PR review comment
40+
env:
41+
GH_TOKEN: ${{ github.token }}
42+
run: |
43+
jq -n \
44+
--arg sha "${{ github.event.pull_request.head.sha }}" \
45+
'{
46+
path: ".azdo/ci-pr.yaml",
47+
subject_type: "file",
48+
commit_id: $sha,
49+
body: "> [!WARNING]\n> **ADO PR pipeline YAML change detected**\n>\n> This PR modifies `.azdo/ci-pr.yaml`. After merge, Azure DevOps may disable or require approval for the PR validation pipeline.\n>\n> **Action required (post-merge):** Re-enable / approve the updated YAML for:\n> - **DV-Python-SDK-PullRequest** (definitionId=29922)\n> - https://dev.azure.com/dynamicscrm/OneCRM/_build?definitionId=29922\n>\n> Please resolve this comment after completing the post-merge steps."
50+
}' | \
51+
gh api \
52+
--method POST \
53+
-H "Accept: application/vnd.github+json" \
54+
/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments \
55+
--input -

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,52 @@ 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.0b4] - 2026-02-25
9+
10+
### Added
11+
- Operation namespaces: `client.records`, `client.query`, `client.tables`, `client.files` (#102)
12+
- Relationship management: `create_one_to_many_relationship`, `create_many_to_many_relationship`, `get_relationship`, `delete_relationship`, `create_lookup_field` with typed `RelationshipInfo` return model (#105, #114)
13+
- `client.records.upsert()` for upsert operations with alternate key support (#106)
14+
- `client.files.upload()` for file upload operations (#111)
15+
- `client.tables.list(filter=, select=)` parameters for filtering and projecting table metadata (#112)
16+
- Cascade behavior constants (`CASCADE_BEHAVIOR_CASCADE`, `CASCADE_BEHAVIOR_REMOVE_LINK`, etc.) and input models (`CascadeConfiguration`, `LookupAttributeMetadata`, `Label`, `LocalizedLabel`)
17+
18+
### Deprecated
19+
- All flat methods on `DataverseClient` (`create`, `update`, `delete`, `get`, `query_sql`, `upload_file`, etc.) now emit `DeprecationWarning` and delegate to the corresponding namespaced operations
20+
21+
## [0.1.0b3] - 2025-12-19
22+
23+
### Added
24+
- Client-side correlation ID and client request ID for request tracing (#70)
25+
- Unit tests for `DataverseClient` (#71)
26+
27+
### Changed
28+
- Standardized package versioning (#84)
29+
- Updated package link (#69)
30+
31+
### Fixed
32+
- Retry logic for examples (#72)
33+
- Removed double space formatting issue (#82)
34+
- Updated CI trigger to include main branch (#81)
35+
36+
## [0.1.0b2] - 2025-11-17
37+
38+
### Added
39+
- Enforce Black formatting across the codebase (#61, #62)
40+
- Python 3.14 support added to `pyproject.toml` (#55)
41+
42+
### Changed
43+
- Removed `pandas` dependency (#57)
44+
- Refactored SDK architecture and quality improvements (#55)
45+
- Prefixed table names with schema name for consistency (#51)
46+
- Updated docstrings across core modules (#54, #63)
47+
48+
### Fixed
49+
- Fixed `get` for single-select option set columns (#52)
50+
- Fixed example filename references and documentation URLs (#60)
51+
- Fixed API documentation link in examples (#64)
52+
- Fixed CI pipeline to use modern `pyproject.toml` dev dependencies (#56, #59)
53+
854
## [0.1.0b1] - 2025-11-14
955

1056
### Added
@@ -19,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1965
- Comprehensive error handling with specific exception types (`DataverseError`, `AuthenticationError`, etc.) (#22, #24)
2066
- HTTP retry logic with exponential backoff for resilient operations (#72)
2167

68+
[0.1.0b4]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b3...v0.1.0b4
2269
[0.1.0b3]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b2...v0.1.0b3
2370
[0.1.0b2]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/compare/v0.1.0b1...v0.1.0b2
2471
[0.1.0b1]: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/releases/tag/v0.1.0b1

CONTRIBUTING.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,16 @@ Brief summary of the release
109109
### Fixed
110110
- Bug fix 1 (#125)
111111
- Bug fix 2 (#126)
112+
```
113+
114+
**Post-Release Version Bump:**
115+
116+
After tagging and publishing a release, immediately bump the version on `main` to the next
117+
development target. This ensures builds from source are clearly distinguished from the
118+
published release:
119+
120+
```bash
121+
# After publishing v0.1.0b4, bump to v0.1.0b5 on main
122+
# Update both pyproject.toml and src/PowerPlatform/Dataverse/__version__.py
123+
# Commit directly to main: "Bump version to 0.1.0b5 for next development cycle"
112124
```

README.md

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
2323
- [Quick start](#quick-start)
2424
- [Basic CRUD operations](#basic-crud-operations)
2525
- [Bulk operations](#bulk-operations)
26+
- [Upsert operations](#upsert-operations)
2627
- [Query data](#query-data)
2728
- [Table management](#table-management)
2829
- [Relationship management](#relationship-management)
@@ -35,7 +36,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac
3536
## Key features
3637

3738
- **🔄 CRUD Operations**: Create, read, update, and delete records with support for bulk operations and automatic retry
38-
- **⚡ True Bulk Operations**: Automatically uses Dataverse's native `CreateMultiple`, `UpdateMultiple`, and `BulkDelete` Web API operations for maximum performance and transactional integrity
39+
- **⚡ True Bulk Operations**: Automatically uses Dataverse's native `CreateMultiple`, `UpdateMultiple`, `UpsertMultiple`, and `BulkDelete` Web API operations for maximum performance and transactional integrity
3940
- **📊 SQL Queries**: Execute read-only SQL queries via the Dataverse Web API `?sql=` parameter
4041
- **🏗️ Table Management**: Create, inspect, and delete custom tables and columns programmatically
4142
- **🔗 Relationship Management**: Create one-to-many and many-to-many relationships between tables with full metadata control
@@ -113,8 +114,8 @@ The SDK provides a simple, pythonic interface for Dataverse operations:
113114

114115
| Concept | Description |
115116
|---------|-------------|
116-
| **DataverseClient** | Main entry point; provides `records`, `query`, `tables`, and `batch` namespaces |
117-
| **Namespaces** | Operations are organized into `client.records` (CRUD & OData queries), `client.query` (query & search), `client.tables` (metadata), and `client.batch` (batch requests) |
117+
| **DataverseClient** | Main entry point; provides `records`, `query`, `tables`, `files`, and `batch` namespaces |
118+
| **Namespaces** | Operations are organized into `client.records` (CRUD & OData queries), `client.query` (query & search), `client.tables` (metadata), `client.files` (file uploads), and `client.batch` (batch requests) |
118119
| **Records** | Dataverse records represented as Python dictionaries with column schema names |
119120
| **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) |
120121
| **Bulk Operations** | Efficient bulk processing for multiple records with automatic optimization |
@@ -180,6 +181,57 @@ client.records.update("account", ids, {"industry": "Technology"})
180181
client.records.delete("account", ids, use_bulk_delete=True)
181182
```
182183

184+
### Upsert operations
185+
186+
Use `client.records.upsert()` to create or update records identified by alternate keys. When the
187+
key matches an existing record it is updated; otherwise the record is created. A single item uses
188+
a PATCH request; multiple items use the `UpsertMultiple` bulk action.
189+
190+
> **Prerequisite**: The table must have an **alternate key** configured in Dataverse for the
191+
> columns used in `alternate_key`. Alternate keys are defined in the table's metadata (Power Apps
192+
> maker portal → Table → Keys, or via the Dataverse API). Without a configured alternate key,
193+
> upsert requests will be rejected by Dataverse with a 400 error.
194+
195+
```python
196+
from PowerPlatform.Dataverse.models.upsert import UpsertItem
197+
198+
# Upsert a single record
199+
client.records.upsert("account", [
200+
UpsertItem(
201+
alternate_key={"accountnumber": "ACC-001"},
202+
record={"name": "Contoso Ltd", "telephone1": "555-0100"},
203+
)
204+
])
205+
206+
# Upsert multiple records (uses UpsertMultiple bulk action)
207+
client.records.upsert("account", [
208+
UpsertItem(
209+
alternate_key={"accountnumber": "ACC-001"},
210+
record={"name": "Contoso Ltd"},
211+
),
212+
UpsertItem(
213+
alternate_key={"accountnumber": "ACC-002"},
214+
record={"name": "Fabrikam Inc"},
215+
),
216+
])
217+
218+
# Composite alternate key (multiple columns identify the record)
219+
client.records.upsert("account", [
220+
UpsertItem(
221+
alternate_key={"accountnumber": "ACC-001", "address1_postalcode": "98052"},
222+
record={"name": "Contoso Ltd"},
223+
)
224+
])
225+
226+
# Plain dict syntax (no import needed)
227+
client.records.upsert("account", [
228+
{
229+
"alternate_key": {"accountnumber": "ACC-001"},
230+
"record": {"name": "Contoso Ltd"},
231+
}
232+
])
233+
```
234+
183235
### Query data
184236

185237
```python
@@ -267,13 +319,12 @@ client.tables.delete("new_Product")
267319
Create relationships between tables using the relationship API. For a complete working example, see [examples/advanced/relationships.py](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/relationships.py).
268320

269321
```python
270-
from PowerPlatform.Dataverse.models.metadata import (
322+
from PowerPlatform.Dataverse.models.relationship import (
271323
LookupAttributeMetadata,
272324
OneToManyRelationshipMetadata,
273325
ManyToManyRelationshipMetadata,
274-
Label,
275-
LocalizedLabel,
276326
)
327+
from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel
277328

278329
# Create a one-to-many relationship: Department (1) -> Employee (N)
279330
# This adds a "Department" lookup field to the Employee table
@@ -328,11 +379,11 @@ result = client.tables.create_lookup_field(
328379

329380
```python
330381
# Upload a file to a record
331-
client.upload_file(
332-
table_schema_name="account",
333-
record_id=account_id,
334-
file_name_attribute="new_Document", # If the file column doesn't exist, it will be created automatically
335-
path="/path/to/document.pdf"
382+
client.files.upload(
383+
"account",
384+
account_id,
385+
"new_Document", # If the file column doesn't exist, it will be created automatically
386+
"/path/to/document.pdf",
336387
)
337388
```
338389

0 commit comments

Comments
 (0)