From 5cb9a7a7750f1a9611872558c1f0fb23257ae730 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Tue, 21 Apr 2026 21:33:05 -0700 Subject: [PATCH 01/18] Add module-level exports via __all__ in package __init__.py files Populate __all__ in models, core, and operations package __init__.py files so public symbols are importable directly from the package namespace. Update all user-facing examples, README, and skill docs to use the new shorter import paths. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 19 ++-- docs/spec-module-level-exports.md | 101 ++++++++++++++++++ examples/advanced/alternate_keys_upsert.py | 2 +- examples/advanced/relationships.py | 9 +- examples/advanced/walkthrough.py | 5 +- examples/basic/functional_testing.py | 13 +-- examples/basic/installation_example.py | 9 +- .../claude_skill/dataverse-sdk-use/SKILL.md | 18 ++-- src/PowerPlatform/Dataverse/core/__init__.py | 14 ++- .../Dataverse/models/__init__.py | 83 ++++++++++++-- .../Dataverse/operations/__init__.py | 34 +++++- 11 files changed, 260 insertions(+), 47 deletions(-) create mode 100644 docs/spec-module-level-exports.md diff --git a/README.md b/README.md index 7f0c1409..2fe5bc64 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ a PATCH request; multiple items use the `UpsertMultiple` bulk action. > upsert requests will be rejected by Dataverse with a 400 error. ```python -from PowerPlatform.Dataverse.models.upsert import UpsertItem +from PowerPlatform.Dataverse.models import UpsertItem # Upsert a single record client.records.upsert("account", [ @@ -318,7 +318,7 @@ query = (client.query.builder("contact") For complex logic (OR, NOT, grouping), use the composable expression tree with `where()`: ```python -from PowerPlatform.Dataverse.models.filters import eq, gt, filter_in, between +from PowerPlatform.Dataverse.models import eq, gt, filter_in, between # OR conditions: (statecode = 0 OR statecode = 1) AND revenue > 100k for record in (client.query.builder("account") @@ -351,7 +351,7 @@ for record in (client.query.builder("account") **Nested expand with options** -- expand navigation properties with `$select`, `$filter`, `$orderby`, and `$top`: ```python -from PowerPlatform.Dataverse.models.query_builder import ExpandOption +from PowerPlatform.Dataverse.models import ExpandOption # Expand related tasks with filtering and sorting for record in (client.query.builder("account") @@ -449,12 +449,14 @@ client.tables.delete("new_Product") 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). ```python -from PowerPlatform.Dataverse.models.relationship import ( +from PowerPlatform.Dataverse.models import ( + CascadeConfiguration, + Label, + LocalizedLabel, LookupAttributeMetadata, - OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, + OneToManyRelationshipMetadata, ) -from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel # Create a one-to-many relationship: Department (1) -> Employee (N) # This adds a "Department" lookup field to the Employee table @@ -639,7 +641,7 @@ The client raises structured exceptions for different error scenarios: ```python from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.errors import HttpError, ValidationError +from PowerPlatform.Dataverse.core import HttpError, ValidationError try: client.records.get("account", "invalid-id") @@ -679,8 +681,7 @@ Enable file-based HTTP logging to capture all requests and responses for debuggi ```python from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.config import DataverseConfig -from PowerPlatform.Dataverse.core.log_config import LogConfig +from PowerPlatform.Dataverse.core import DataverseConfig, LogConfig log_cfg = LogConfig( log_folder="./my_logs", # Directory for log files (created if missing) diff --git a/docs/spec-module-level-exports.md b/docs/spec-module-level-exports.md new file mode 100644 index 00000000..9c1482c6 --- /dev/null +++ b/docs/spec-module-level-exports.md @@ -0,0 +1,101 @@ +# Spec: Support Module-Level Exports via `__all__` + +## Goal + +Populate the `__all__` lists in each package-level `__init__.py` so that public symbols +are re-exported at the package level. Users will be able to import from the package +namespace directly rather than reaching into submodules. + +**Before:** +```python +from PowerPlatform.Dataverse.models.record import Record +from PowerPlatform.Dataverse.core.errors import DataverseError +``` + +**After:** +```python +from PowerPlatform.Dataverse.models import Record +from PowerPlatform.Dataverse.core import DataverseError +``` + +--- + +## Current Status + +`__all__` is already defined in every individual module (e.g. `models/filters.py`, +`core/errors.py`, `operations/records.py`), but all package-level `__init__.py` files +have empty exports: + +| Package `__init__.py` | Current `__all__` | +|---|---| +| `PowerPlatform.Dataverse.models` | `[]` | +| `PowerPlatform.Dataverse.operations` | `[]` | +| `PowerPlatform.Dataverse.core` | `[]` | +| `PowerPlatform.Dataverse.data` | `[]` | + +--- + +## The Challenge: Documentation Duplication Risk + +The public API docs on Microsoft Learn are auto-generated from the installed package. +The concern is that re-exporting a class in `__init__.py` could cause it to appear +twice in the docs — once at its definition location (e.g. `operations.records.RecordOperations`) +and again at the package level (e.g. `operations.RecordOperations`). + +**What we need to verify before merging:** +- [ ] Confirm with the team how the doc pipeline works and run a test build to check + for duplicate entries. + +--- + +## What Needs to Change + +### `models/__init__.py` +Re-export from: +- `models.query_builder` → `QueryBuilder`, `QueryParams`, `ExpandOption` +- `models.filters` → `eq`, `ne`, `gt`, `lt`, `ge`, `le`, `contains`, `startswith`, `endswith`, `filter_in`, `between`, `and_`, `or_`, `not_` +- `models.batch` → `BatchItemResponse`, `BatchResult` +- `models.record` → `Record` +- `models.table_info` → `TableInfo`, `ColumnInfo`, `AlternateKeyInfo` +- `models.relationship` → `OneToManyRelationship`, `ManyToManyRelationship`, `RelationshipInfo` (etc.) +- `models.upsert` → `UpsertItem` +- `models.labels` → `LocalizedLabel`, `Label` + +### `core/__init__.py` +Re-export from: +- `core.errors` → `DataverseError`, `HttpError`, `ValidationError`, `MetadataError`, `SQLParseError` +- `core.log_config` → `LogConfig` + +### `operations/__init__.py` +Re-export from: +- `operations.records` → `RecordOperations` +- `operations.tables` → `TableOperations` +- `operations.query` → `QueryOperations` +- `operations.batch` → `BatchOperations`, `BatchRecordOperations`, `BatchTableOperations` +- `operations.dataframe` → `DataFrameOperations` +- `operations.files` → `FileOperations` + +### `data/__init__.py` +No change — all submodules are internal (`_`-prefixed); `__all__` stays empty. + +--- + +## Benefits + +1. **Cleaner import paths** — users write `from PowerPlatform.Dataverse.models import Record` + instead of navigating submodule paths. + +2. **IDE discoverability** — autocompletion on `PowerPlatform.Dataverse.models.` surfaces + all public types immediately; users do not need to know submodule names. + +3. **No broken imports during refactoring** — if we ever rename or reorganise an internal + submodule, users' import paths stay the same as long as the `__init__.py` re-exports + are kept. Without this, any internal restructuring is a breaking change for users. + +4. **Wildcard imports work correctly** — currently `from PowerPlatform.Dataverse.models import *` + imports nothing, because `__all__ = []`. Once populated, wildcard imports pick up all + intended public symbols as defined by Python's module documentation. + +5. **Follows industry convention** — NumPy, pandas, and requests all expose their public + API at the package level via `__all__` in `__init__.py`. Aligning with this pattern + makes the SDK feel familiar to experienced Python users. diff --git a/examples/advanced/alternate_keys_upsert.py b/examples/advanced/alternate_keys_upsert.py index 67e8a43e..485e6fc7 100644 --- a/examples/advanced/alternate_keys_upsert.py +++ b/examples/advanced/alternate_keys_upsert.py @@ -23,7 +23,7 @@ import time from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models.upsert import UpsertItem +from PowerPlatform.Dataverse.models import UpsertItem from azure.identity import InteractiveBrowserCredential # type: ignore # --- Config --- diff --git a/examples/advanced/relationships.py b/examples/advanced/relationships.py index e768ead1..d6ccf16b 100644 --- a/examples/advanced/relationships.py +++ b/examples/advanced/relationships.py @@ -20,13 +20,14 @@ import time from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models.relationship import ( +from PowerPlatform.Dataverse.models import ( + CascadeConfiguration, + Label, + LocalizedLabel, LookupAttributeMetadata, - OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, - CascadeConfiguration, + OneToManyRelationshipMetadata, ) -from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel from PowerPlatform.Dataverse.common.constants import ( CASCADE_BEHAVIOR_NO_CASCADE, CASCADE_BEHAVIOR_REMOVE_LINK, diff --git a/examples/advanced/walkthrough.py b/examples/advanced/walkthrough.py index 5e4d0a4e..706ca71b 100644 --- a/examples/advanced/walkthrough.py +++ b/examples/advanced/walkthrough.py @@ -25,9 +25,8 @@ from enum import IntEnum from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.errors import MetadataError -from PowerPlatform.Dataverse.models.filters import eq, gt, between -from PowerPlatform.Dataverse.models.query_builder import ExpandOption +from PowerPlatform.Dataverse.core import MetadataError +from PowerPlatform.Dataverse.models import ExpandOption, between, eq, gt import requests diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index 1ea0d5f0..2d5cb784 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -32,19 +32,20 @@ # Import SDK components (assumes installation is already validated) from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError -from PowerPlatform.Dataverse.models.relationship import ( +from PowerPlatform.Dataverse.core import HttpError, MetadataError +from PowerPlatform.Dataverse.models import ( + CascadeConfiguration, + Label, + LocalizedLabel, LookupAttributeMetadata, - OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, - CascadeConfiguration, + OneToManyRelationshipMetadata, + UpsertItem, ) -from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel from PowerPlatform.Dataverse.common.constants import ( CASCADE_BEHAVIOR_NO_CASCADE, CASCADE_BEHAVIOR_REMOVE_LINK, ) -from PowerPlatform.Dataverse.models.upsert import UpsertItem from azure.identity import InteractiveBrowserCredential diff --git a/examples/basic/installation_example.py b/examples/basic/installation_example.py index 98189e58..1b15f625 100644 --- a/examples/basic/installation_example.py +++ b/examples/basic/installation_example.py @@ -60,10 +60,7 @@ from typing import Optional from datetime import datetime -from PowerPlatform.Dataverse.operations.records import RecordOperations -from PowerPlatform.Dataverse.operations.query import QueryOperations -from PowerPlatform.Dataverse.operations.tables import TableOperations -from PowerPlatform.Dataverse.operations.files import FileOperations +from PowerPlatform.Dataverse.operations import FileOperations, QueryOperations, RecordOperations, TableOperations def validate_imports(): @@ -81,11 +78,11 @@ def validate_imports(): print(f" [OK] Client class: PowerPlatform.Dataverse.client.DataverseClient") # Test submodule imports - from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError + from PowerPlatform.Dataverse.core import HttpError, MetadataError print(f" [OK] Core errors: HttpError, MetadataError") - from PowerPlatform.Dataverse.core.config import DataverseConfig + from PowerPlatform.Dataverse.core import DataverseConfig print(f" [OK] Core config: DataverseConfig") diff --git a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md index 72677468..327b930d 100644 --- a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md +++ b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md @@ -136,7 +136,7 @@ client.records.update("account", [id1, id2, id3], {"industry": "Technology"}) Creates or updates records identified by alternate keys. Single item -> PATCH; multiple items -> `UpsertMultiple` bulk action. > **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. ```python -from PowerPlatform.Dataverse.models.upsert import UpsertItem +from PowerPlatform.Dataverse.models import UpsertItem # Single upsert client.records.upsert("account", [ @@ -293,12 +293,12 @@ client.tables.delete("new_Product") #### Create One-to-Many Relationship ```python -from PowerPlatform.Dataverse.models.relationship import ( - LookupAttributeMetadata, - OneToManyRelationshipMetadata, +from PowerPlatform.Dataverse.models import ( + CascadeConfiguration, Label, LocalizedLabel, - CascadeConfiguration, + LookupAttributeMetadata, + OneToManyRelationshipMetadata, ) from PowerPlatform.Dataverse.common.constants import CASCADE_BEHAVIOR_REMOVE_LINK @@ -325,7 +325,7 @@ print(f"Created lookup field: {result['lookup_schema_name']}") #### Create Many-to-Many Relationship ```python -from PowerPlatform.Dataverse.models.relationship import ManyToManyRelationshipMetadata +from PowerPlatform.Dataverse.models import ManyToManyRelationshipMetadata relationship = ManyToManyRelationshipMetadata( schema_name="new_employee_project", @@ -420,12 +420,12 @@ print(f"Succeeded: {len(result.succeeded)}, Failed: {len(result.failed)}") The SDK provides structured exceptions with detailed error information: ```python -from PowerPlatform.Dataverse.core.errors import ( +from PowerPlatform.Dataverse.core import ( DataverseError, HttpError, - ValidationError, MetadataError, - SQLParseError + SQLParseError, + ValidationError, ) from PowerPlatform.Dataverse.client import DataverseClient diff --git a/src/PowerPlatform/Dataverse/core/__init__.py b/src/PowerPlatform/Dataverse/core/__init__.py index 79454f5b..6edf4921 100644 --- a/src/PowerPlatform/Dataverse/core/__init__.py +++ b/src/PowerPlatform/Dataverse/core/__init__.py @@ -8,4 +8,16 @@ configuration, HTTP client, and error handling. """ -__all__ = [] +from .config import DataverseConfig +from .errors import DataverseError, HttpError, MetadataError, SQLParseError, ValidationError +from .log_config import LogConfig + +__all__ = [ + "DataverseConfig", + "DataverseError", + "HttpError", + "LogConfig", + "MetadataError", + "SQLParseError", + "ValidationError", +] diff --git a/src/PowerPlatform/Dataverse/models/__init__.py b/src/PowerPlatform/Dataverse/models/__init__.py index dc10a4c0..e8a5a5a0 100644 --- a/src/PowerPlatform/Dataverse/models/__init__.py +++ b/src/PowerPlatform/Dataverse/models/__init__.py @@ -9,11 +9,82 @@ - :class:`~PowerPlatform.Dataverse.models.query_builder.QueryBuilder`: Fluent query builder. - :mod:`~PowerPlatform.Dataverse.models.filters`: Composable OData filter expressions. - :class:`~PowerPlatform.Dataverse.models.upsert.UpsertItem`: Upsert operation item. - -Import directly from the specific module, e.g.:: - - from PowerPlatform.Dataverse.models.query_builder import QueryBuilder - from PowerPlatform.Dataverse.models.filters import eq, gt """ -__all__ = [] +from .batch import BatchItemResponse, BatchResult +from .filters import ( + FilterExpression, + between, + contains, + endswith, + eq, + filter_in, + ge, + gt, + is_not_null, + is_null, + le, + lt, + ne, + not_between, + not_in, + raw, + startswith, +) +from .labels import Label, LocalizedLabel +from .query_builder import ExpandOption, QueryBuilder, QueryParams +from .record import Record +from .relationship import ( + CascadeConfiguration, + LookupAttributeMetadata, + ManyToManyRelationshipMetadata, + OneToManyRelationshipMetadata, + RelationshipInfo, +) +from .table_info import AlternateKeyInfo, ColumnInfo, TableInfo +from .upsert import UpsertItem + +__all__ = [ + # batch + "BatchItemResponse", + "BatchResult", + # filters + "FilterExpression", + "between", + "contains", + "endswith", + "eq", + "filter_in", + "ge", + "gt", + "is_not_null", + "is_null", + "le", + "lt", + "ne", + "not_between", + "not_in", + "raw", + "startswith", + # labels + "Label", + "LocalizedLabel", + # query builder + "ExpandOption", + "QueryBuilder", + "QueryParams", + # record + "Record", + # relationship + "CascadeConfiguration", + "LookupAttributeMetadata", + "ManyToManyRelationshipMetadata", + "OneToManyRelationshipMetadata", + "RelationshipInfo", + # table info + "AlternateKeyInfo", + "ColumnInfo", + "TableInfo", + # upsert + "UpsertItem", +] diff --git a/src/PowerPlatform/Dataverse/operations/__init__.py b/src/PowerPlatform/Dataverse/operations/__init__.py index 19c8a9e5..e7e001cf 100644 --- a/src/PowerPlatform/Dataverse/operations/__init__.py +++ b/src/PowerPlatform/Dataverse/operations/__init__.py @@ -8,6 +8,36 @@ SDK operations into logical groups: records, query, and tables. """ -from typing import List +from .batch import ( + BatchDataFrameOperations, + BatchOperations, + BatchQueryOperations, + BatchRecordOperations, + BatchRequest, + BatchTableOperations, + ChangeSet, + ChangeSetRecordOperations, +) +from .dataframe import DataFrameOperations +from .files import FileOperations +from .query import QueryOperations +from .records import RecordOperations +from .tables import TableOperations -__all__: List[str] = [] +__all__ = [ + # batch + "BatchDataFrameOperations", + "BatchOperations", + "BatchQueryOperations", + "BatchRecordOperations", + "BatchRequest", + "BatchTableOperations", + "ChangeSet", + "ChangeSetRecordOperations", + # other operations + "DataFrameOperations", + "FileOperations", + "QueryOperations", + "RecordOperations", + "TableOperations", +] From 8b4f454b03af040066d1824abf79f3f1adfac60d Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Tue, 21 Apr 2026 22:17:44 -0700 Subject: [PATCH 02/18] Fix RelationshipInfo subscript access in relationships example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelationshipInfo is a dataclass — use attribute access (.relationship_schema_name, .lookup_schema_name, .relationship_id) instead of dict subscript. Co-Authored-By: Claude Sonnet 4.6 --- examples/advanced/relationships.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/advanced/relationships.py b/examples/advanced/relationships.py index d6ccf16b..59942c14 100644 --- a/examples/advanced/relationships.py +++ b/examples/advanced/relationships.py @@ -237,11 +237,11 @@ def _run_example(client): ) ) - print(f"[OK] Created relationship: {result['relationship_schema_name']}") - print(f" Lookup field: {result['lookup_schema_name']}") - print(f" Relationship ID: {result['relationship_id']}") + print(f"[OK] Created relationship: {result.relationship_schema_name}") + print(f" Lookup field: {result.lookup_schema_name}") + print(f" Relationship ID: {result.relationship_id}") - rel_id_1 = result["relationship_id"] + rel_id_1 = result.relationship_id # ============================================================================ # 5. CREATE LOOKUP FIELD (Convenience Method) @@ -266,10 +266,10 @@ def _run_example(client): ) ) - print(f"[OK] Created lookup using convenience method: {result2['lookup_schema_name']}") - print(f" Relationship: {result2['relationship_schema_name']}") + print(f"[OK] Created lookup using convenience method: {result2.lookup_schema_name}") + print(f" Relationship: {result2.relationship_schema_name}") - rel_id_2 = result2["relationship_id"] + rel_id_2 = result2.relationship_id # ============================================================================ # 6. CREATE MANY-TO-MANY RELATIONSHIP @@ -293,10 +293,10 @@ def _run_example(client): ) ) - print(f"[OK] Created M:N relationship: {result3['relationship_schema_name']}") - print(f" Relationship ID: {result3['relationship_id']}") + print(f"[OK] Created M:N relationship: {result3.relationship_schema_name}") + print(f" Relationship ID: {result3.relationship_id}") - rel_id_3 = result3["relationship_id"] + rel_id_3 = result3.relationship_id # ============================================================================ # 7. QUERY RELATIONSHIP METADATA From 36da289d9e87c4339293b6297aea97b28949c6a9 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Tue, 21 Apr 2026 22:36:32 -0700 Subject: [PATCH 03/18] Fix remaining RelationshipInfo attribute access in relationships example Replace all remaining .get('Key') dict-style calls with proper attribute access on RelationshipInfo dataclass fields. Co-Authored-By: Claude Sonnet 4.6 --- examples/advanced/relationships.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/advanced/relationships.py b/examples/advanced/relationships.py index 59942c14..eae76225 100644 --- a/examples/advanced/relationships.py +++ b/examples/advanced/relationships.py @@ -43,7 +43,7 @@ def delete_relationship_if_exists(client, schema_name): """Delete a relationship by schema name if it exists.""" rel = client.tables.get_relationship(schema_name) if rel: - rel_id = rel.get("MetadataId") + rel_id = rel.relationship_id if rel_id: client.tables.delete_relationship(rel_id) print(f" (Cleaned up existing relationship: {schema_name})") @@ -309,10 +309,10 @@ def _run_example(client): rel_metadata = client.tables.get_relationship("new_Department_Employee") if rel_metadata: - print(f"[OK] Found relationship: {rel_metadata.get('SchemaName')}") - print(f" Type: {rel_metadata.get('@odata.type')}") - print(f" Referenced Entity: {rel_metadata.get('ReferencedEntity')}") - print(f" Referencing Entity: {rel_metadata.get('ReferencingEntity')}") + print(f"[OK] Found relationship: {rel_metadata.relationship_schema_name}") + print(f" Type: {rel_metadata.relationship_type}") + print(f" Referenced Entity: {rel_metadata.referenced_entity}") + print(f" Referencing Entity: {rel_metadata.referencing_entity}") else: print(" Relationship not found") @@ -320,10 +320,10 @@ def _run_example(client): m2m_metadata = client.tables.get_relationship("new_employee_project") if m2m_metadata: - print(f"[OK] Found relationship: {m2m_metadata.get('SchemaName')}") - print(f" Type: {m2m_metadata.get('@odata.type')}") - print(f" Entity 1: {m2m_metadata.get('Entity1LogicalName')}") - print(f" Entity 2: {m2m_metadata.get('Entity2LogicalName')}") + print(f"[OK] Found relationship: {m2m_metadata.relationship_schema_name}") + print(f" Type: {m2m_metadata.relationship_type}") + print(f" Entity 1: {m2m_metadata.entity1_logical_name}") + print(f" Entity 2: {m2m_metadata.entity2_logical_name}") else: print(" Relationship not found") From 01562a2764c5a0c71f6d175d57912313b16da5ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:54:30 +0000 Subject: [PATCH 04/18] Add unit tests for operations package-level exports Agent-Logs-Url: https://github.com/microsoft/PowerPlatform-DataverseClient-Python/sessions/2358d075-c005-43e4-a521-290267f8a424 Co-authored-by: abelmilash-msft <258686066+abelmilash-msft@users.noreply.github.com> --- tests/unit/test_operations_package_exports.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/unit/test_operations_package_exports.py diff --git a/tests/unit/test_operations_package_exports.py b/tests/unit/test_operations_package_exports.py new file mode 100644 index 00000000..2616f590 --- /dev/null +++ b/tests/unit/test_operations_package_exports.py @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import unittest + +from PowerPlatform.Dataverse import operations +from PowerPlatform.Dataverse.operations import ( + BatchDataFrameOperations, + BatchOperations, + BatchQueryOperations, + BatchRecordOperations, + BatchRequest, + BatchTableOperations, + ChangeSet, + ChangeSetRecordOperations, + DataFrameOperations, + FileOperations, + QueryOperations, + RecordOperations, + TableOperations, +) + + +class TestOperationsPackageExports(unittest.TestCase): + """Tests for package-level exports in PowerPlatform.Dataverse.operations.""" + + def test_package_level_imports_work(self): + """Expected operation namespace classes are importable from package root.""" + self.assertIs(operations.RecordOperations, RecordOperations) + self.assertIs(operations.QueryOperations, QueryOperations) + self.assertIs(operations.TableOperations, TableOperations) + self.assertIs(operations.FileOperations, FileOperations) + self.assertIs(operations.DataFrameOperations, DataFrameOperations) + + self.assertIs(operations.BatchOperations, BatchOperations) + self.assertIs(operations.BatchRecordOperations, BatchRecordOperations) + self.assertIs(operations.BatchQueryOperations, BatchQueryOperations) + self.assertIs(operations.BatchTableOperations, BatchTableOperations) + self.assertIs(operations.BatchDataFrameOperations, BatchDataFrameOperations) + self.assertIs(operations.BatchRequest, BatchRequest) + self.assertIs(operations.ChangeSet, ChangeSet) + self.assertIs(operations.ChangeSetRecordOperations, ChangeSetRecordOperations) + + def test_all_exports_include_expected_symbols(self): + """__all__ should expose the package-level operation symbols.""" + expected_exports = { + "BatchDataFrameOperations", + "BatchOperations", + "BatchQueryOperations", + "BatchRecordOperations", + "BatchRequest", + "BatchTableOperations", + "ChangeSet", + "ChangeSetRecordOperations", + "DataFrameOperations", + "FileOperations", + "QueryOperations", + "RecordOperations", + "TableOperations", + } + self.assertEqual(set(operations.__all__), expected_exports) From 49a27a3a31d00c40d4ff2df78a6042367770c126 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Wed, 22 Apr 2026 13:55:02 -0700 Subject: [PATCH 05/18] Add export tests for package-level __all__ symbols Add test_package_exports.py: 3 tests verifying every symbol in __all__ is importable from PowerPlatform.Dataverse.{core,models,operations}. Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_package_exports.py | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/unit/test_package_exports.py diff --git a/tests/unit/test_package_exports.py b/tests/unit/test_package_exports.py new file mode 100644 index 00000000..401a12e6 --- /dev/null +++ b/tests/unit/test_package_exports.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests that every symbol in __all__ is importable from each package namespace.""" + +import unittest + + +class TestCoreExports(unittest.TestCase): + """Every name in PowerPlatform.Dataverse.core.__all__ must be importable.""" + + def test_all_symbols_importable(self): + import PowerPlatform.Dataverse.core as m + + for name in m.__all__: + self.assertTrue(hasattr(m, name), f"{name!r} is in __all__ but missing from PowerPlatform.Dataverse.core") + + +class TestModelsExports(unittest.TestCase): + """Every name in PowerPlatform.Dataverse.models.__all__ must be importable.""" + + def test_all_symbols_importable(self): + import PowerPlatform.Dataverse.models as m + + for name in m.__all__: + self.assertTrue(hasattr(m, name), f"{name!r} is in __all__ but missing from PowerPlatform.Dataverse.models") + + +class TestOperationsExports(unittest.TestCase): + """Every name in PowerPlatform.Dataverse.operations.__all__ must be importable.""" + + def test_all_symbols_importable(self): + import PowerPlatform.Dataverse.operations as m + + for name in m.__all__: + self.assertTrue( + hasattr(m, name), f"{name!r} is in __all__ but missing from PowerPlatform.Dataverse.operations" + ) + + +if __name__ == "__main__": + unittest.main() From 691a4e0684870bb22643605b6a408fc8194370a0 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Wed, 22 Apr 2026 13:59:02 -0700 Subject: [PATCH 06/18] Consolidate export tests into single file - Merge Copilot's assertIs identity checks into test_package_exports.py - Add equivalent identity tests for core and models - Remove test_operations_package_exports.py (Copilot's file) Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_operations_package_exports.py | 61 ------------- tests/unit/test_package_exports.py | 89 ++++++++++++++++++- 2 files changed, 88 insertions(+), 62 deletions(-) delete mode 100644 tests/unit/test_operations_package_exports.py diff --git a/tests/unit/test_operations_package_exports.py b/tests/unit/test_operations_package_exports.py deleted file mode 100644 index 2616f590..00000000 --- a/tests/unit/test_operations_package_exports.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -import unittest - -from PowerPlatform.Dataverse import operations -from PowerPlatform.Dataverse.operations import ( - BatchDataFrameOperations, - BatchOperations, - BatchQueryOperations, - BatchRecordOperations, - BatchRequest, - BatchTableOperations, - ChangeSet, - ChangeSetRecordOperations, - DataFrameOperations, - FileOperations, - QueryOperations, - RecordOperations, - TableOperations, -) - - -class TestOperationsPackageExports(unittest.TestCase): - """Tests for package-level exports in PowerPlatform.Dataverse.operations.""" - - def test_package_level_imports_work(self): - """Expected operation namespace classes are importable from package root.""" - self.assertIs(operations.RecordOperations, RecordOperations) - self.assertIs(operations.QueryOperations, QueryOperations) - self.assertIs(operations.TableOperations, TableOperations) - self.assertIs(operations.FileOperations, FileOperations) - self.assertIs(operations.DataFrameOperations, DataFrameOperations) - - self.assertIs(operations.BatchOperations, BatchOperations) - self.assertIs(operations.BatchRecordOperations, BatchRecordOperations) - self.assertIs(operations.BatchQueryOperations, BatchQueryOperations) - self.assertIs(operations.BatchTableOperations, BatchTableOperations) - self.assertIs(operations.BatchDataFrameOperations, BatchDataFrameOperations) - self.assertIs(operations.BatchRequest, BatchRequest) - self.assertIs(operations.ChangeSet, ChangeSet) - self.assertIs(operations.ChangeSetRecordOperations, ChangeSetRecordOperations) - - def test_all_exports_include_expected_symbols(self): - """__all__ should expose the package-level operation symbols.""" - expected_exports = { - "BatchDataFrameOperations", - "BatchOperations", - "BatchQueryOperations", - "BatchRecordOperations", - "BatchRequest", - "BatchTableOperations", - "ChangeSet", - "ChangeSetRecordOperations", - "DataFrameOperations", - "FileOperations", - "QueryOperations", - "RecordOperations", - "TableOperations", - } - self.assertEqual(set(operations.__all__), expected_exports) diff --git a/tests/unit/test_package_exports.py b/tests/unit/test_package_exports.py index 401a12e6..7f9141f8 100644 --- a/tests/unit/test_package_exports.py +++ b/tests/unit/test_package_exports.py @@ -1,7 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Tests that every symbol in __all__ is importable from each package namespace.""" +"""Tests that every symbol in __all__ is importable from each package namespace, +and that re-exported objects are identical to their originals.""" import unittest @@ -15,6 +16,27 @@ def test_all_symbols_importable(self): for name in m.__all__: self.assertTrue(hasattr(m, name), f"{name!r} is in __all__ but missing from PowerPlatform.Dataverse.core") + def test_identity(self): + """Re-exported objects are the same objects as their source definitions.""" + import PowerPlatform.Dataverse.core as m + from PowerPlatform.Dataverse.core.config import DataverseConfig + from PowerPlatform.Dataverse.core.errors import ( + DataverseError, + HttpError, + MetadataError, + SQLParseError, + ValidationError, + ) + from PowerPlatform.Dataverse.core.log_config import LogConfig + + self.assertIs(m.DataverseConfig, DataverseConfig) + self.assertIs(m.DataverseError, DataverseError) + self.assertIs(m.HttpError, HttpError) + self.assertIs(m.MetadataError, MetadataError) + self.assertIs(m.SQLParseError, SQLParseError) + self.assertIs(m.ValidationError, ValidationError) + self.assertIs(m.LogConfig, LogConfig) + class TestModelsExports(unittest.TestCase): """Every name in PowerPlatform.Dataverse.models.__all__ must be importable.""" @@ -25,6 +47,38 @@ def test_all_symbols_importable(self): for name in m.__all__: self.assertTrue(hasattr(m, name), f"{name!r} is in __all__ but missing from PowerPlatform.Dataverse.models") + def test_identity(self): + """Re-exported objects are the same objects as their source definitions.""" + import PowerPlatform.Dataverse.models as m + from PowerPlatform.Dataverse.models.batch import BatchItemResponse, BatchResult + from PowerPlatform.Dataverse.models.query_builder import ExpandOption, QueryBuilder, QueryParams + from PowerPlatform.Dataverse.models.record import Record + from PowerPlatform.Dataverse.models.relationship import ( + CascadeConfiguration, + LookupAttributeMetadata, + ManyToManyRelationshipMetadata, + OneToManyRelationshipMetadata, + RelationshipInfo, + ) + from PowerPlatform.Dataverse.models.table_info import AlternateKeyInfo, ColumnInfo, TableInfo + from PowerPlatform.Dataverse.models.upsert import UpsertItem + + self.assertIs(m.BatchItemResponse, BatchItemResponse) + self.assertIs(m.BatchResult, BatchResult) + self.assertIs(m.ExpandOption, ExpandOption) + self.assertIs(m.QueryBuilder, QueryBuilder) + self.assertIs(m.QueryParams, QueryParams) + self.assertIs(m.Record, Record) + self.assertIs(m.CascadeConfiguration, CascadeConfiguration) + self.assertIs(m.LookupAttributeMetadata, LookupAttributeMetadata) + self.assertIs(m.ManyToManyRelationshipMetadata, ManyToManyRelationshipMetadata) + self.assertIs(m.OneToManyRelationshipMetadata, OneToManyRelationshipMetadata) + self.assertIs(m.RelationshipInfo, RelationshipInfo) + self.assertIs(m.AlternateKeyInfo, AlternateKeyInfo) + self.assertIs(m.ColumnInfo, ColumnInfo) + self.assertIs(m.TableInfo, TableInfo) + self.assertIs(m.UpsertItem, UpsertItem) + class TestOperationsExports(unittest.TestCase): """Every name in PowerPlatform.Dataverse.operations.__all__ must be importable.""" @@ -37,6 +91,39 @@ def test_all_symbols_importable(self): hasattr(m, name), f"{name!r} is in __all__ but missing from PowerPlatform.Dataverse.operations" ) + def test_identity(self): + """Re-exported objects are the same objects as their source definitions.""" + import PowerPlatform.Dataverse.operations as m + from PowerPlatform.Dataverse.operations.batch import ( + BatchDataFrameOperations, + BatchOperations, + BatchQueryOperations, + BatchRecordOperations, + BatchRequest, + BatchTableOperations, + ChangeSet, + ChangeSetRecordOperations, + ) + from PowerPlatform.Dataverse.operations.dataframe import DataFrameOperations + from PowerPlatform.Dataverse.operations.files import FileOperations + from PowerPlatform.Dataverse.operations.query import QueryOperations + from PowerPlatform.Dataverse.operations.records import RecordOperations + from PowerPlatform.Dataverse.operations.tables import TableOperations + + self.assertIs(m.BatchDataFrameOperations, BatchDataFrameOperations) + self.assertIs(m.BatchOperations, BatchOperations) + self.assertIs(m.BatchQueryOperations, BatchQueryOperations) + self.assertIs(m.BatchRecordOperations, BatchRecordOperations) + self.assertIs(m.BatchRequest, BatchRequest) + self.assertIs(m.BatchTableOperations, BatchTableOperations) + self.assertIs(m.ChangeSet, ChangeSet) + self.assertIs(m.ChangeSetRecordOperations, ChangeSetRecordOperations) + self.assertIs(m.DataFrameOperations, DataFrameOperations) + self.assertIs(m.FileOperations, FileOperations) + self.assertIs(m.QueryOperations, QueryOperations) + self.assertIs(m.RecordOperations, RecordOperations) + self.assertIs(m.TableOperations, TableOperations) + if __name__ == "__main__": unittest.main() From 5b37c2f706311644d7f953315e2a75df800f3697 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Wed, 22 Apr 2026 14:38:48 -0700 Subject: [PATCH 07/18] Improve docstrings in test_package_exports.py Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_package_exports.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_package_exports.py b/tests/unit/test_package_exports.py index 7f9141f8..ab8b101e 100644 --- a/tests/unit/test_package_exports.py +++ b/tests/unit/test_package_exports.py @@ -8,9 +8,14 @@ class TestCoreExports(unittest.TestCase): - """Every name in PowerPlatform.Dataverse.core.__all__ must be importable.""" + """Verify package-level exports for PowerPlatform.Dataverse.core. + + Checks that every symbol in __all__ is reachable from the package namespace + and that each re-export is the identical object as its source definition. + """ def test_all_symbols_importable(self): + """Every name listed in __all__ is accessible as an attribute of the package.""" import PowerPlatform.Dataverse.core as m for name in m.__all__: @@ -39,9 +44,14 @@ def test_identity(self): class TestModelsExports(unittest.TestCase): - """Every name in PowerPlatform.Dataverse.models.__all__ must be importable.""" + """Verify package-level exports for PowerPlatform.Dataverse.models. + + Checks that every symbol in __all__ is reachable from the package namespace + and that each re-export is the identical object as its source definition. + """ def test_all_symbols_importable(self): + """Every name listed in __all__ is accessible as an attribute of the package.""" import PowerPlatform.Dataverse.models as m for name in m.__all__: @@ -81,9 +91,14 @@ def test_identity(self): class TestOperationsExports(unittest.TestCase): - """Every name in PowerPlatform.Dataverse.operations.__all__ must be importable.""" + """Verify package-level exports for PowerPlatform.Dataverse.operations. + + Checks that every symbol in __all__ is reachable from the package namespace + and that each re-export is the identical object as its source definition. + """ def test_all_symbols_importable(self): + """Every name listed in __all__ is accessible as an attribute of the package.""" import PowerPlatform.Dataverse.operations as m for name in m.__all__: From e1e5f7861b918de9987ffbcfaba412b6d2e149bc Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Mon, 18 May 2026 14:38:35 -0700 Subject: [PATCH 08/18] Drop group-label comments from __all__ lists The inline comments (# batch, # filters, etc.) restate what the import statements above already show. Per repo convention, comments should explain why, not what. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/PowerPlatform/Dataverse/models/__init__.py | 10 ---------- src/PowerPlatform/Dataverse/operations/__init__.py | 2 -- 2 files changed, 12 deletions(-) diff --git a/src/PowerPlatform/Dataverse/models/__init__.py b/src/PowerPlatform/Dataverse/models/__init__.py index 651ef3e9..e418ffb4 100644 --- a/src/PowerPlatform/Dataverse/models/__init__.py +++ b/src/PowerPlatform/Dataverse/models/__init__.py @@ -35,38 +35,28 @@ from .upsert import UpsertItem __all__ = [ - # batch "BatchItemResponse", "BatchResult", - # fetchxml "FetchXmlQuery", - # filters (typed builder; deprecated factories are not re-exported) "ColumnProxy", "FilterExpression", "col", "raw", - # labels "Label", "LocalizedLabel", - # protocol "DataverseModel", - # query builder "ExpandOption", "QueryBuilder", "QueryParams", - # record "QueryResult", "Record", - # relationship "CascadeConfiguration", "LookupAttributeMetadata", "ManyToManyRelationshipMetadata", "OneToManyRelationshipMetadata", "RelationshipInfo", - # table info "AlternateKeyInfo", "ColumnInfo", "TableInfo", - # upsert "UpsertItem", ] diff --git a/src/PowerPlatform/Dataverse/operations/__init__.py b/src/PowerPlatform/Dataverse/operations/__init__.py index e7e001cf..d16f7fb9 100644 --- a/src/PowerPlatform/Dataverse/operations/__init__.py +++ b/src/PowerPlatform/Dataverse/operations/__init__.py @@ -25,7 +25,6 @@ from .tables import TableOperations __all__ = [ - # batch "BatchDataFrameOperations", "BatchOperations", "BatchQueryOperations", @@ -34,7 +33,6 @@ "BatchTableOperations", "ChangeSet", "ChangeSetRecordOperations", - # other operations "DataFrameOperations", "FileOperations", "QueryOperations", From 9cdb6687c983544354abaa513fd71e6fc6b64ecb Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Mon, 18 May 2026 14:47:42 -0700 Subject: [PATCH 09/18] Add setup.py shim for tools that require it Most build-time tools use pyproject.toml directly, but some legacy documentation generators look for setup.py. The shim reads version from VERSION.txt or PackageVersion env var for build pipelines. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 setup.py diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..c46ba05d --- /dev/null +++ b/setup.py @@ -0,0 +1,14 @@ +from os import environ, path +from setuptools import setup + +# Try to read from VERSION.txt file first, fall back to environment variable +version_file = path.join(path.dirname(__file__), "VERSION.txt") +if path.exists(version_file): + with open(version_file, "r", encoding="utf-8") as f: + package_version = f.read().strip() +else: + package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, +) From 85742b2656fd69d37c3a2cc27dcaaa430f98ca19 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Mon, 18 May 2026 14:58:16 -0700 Subject: [PATCH 10/18] Complete __all__ coverage in core/config and export tests - Add __all__ to core/config.py for explicit public API - Re-export OperationContext from core package (matches public usage) - Fill identity-check gaps in test_package_exports: covers all 8 core symbols and all 24 models symbols (was 7 and 15) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/PowerPlatform/Dataverse/core/__init__.py | 3 +- src/PowerPlatform/Dataverse/core/config.py | 2 ++ tests/unit/test_package_exports.py | 32 ++++++++++++++------ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/PowerPlatform/Dataverse/core/__init__.py b/src/PowerPlatform/Dataverse/core/__init__.py index 6edf4921..b3e61864 100644 --- a/src/PowerPlatform/Dataverse/core/__init__.py +++ b/src/PowerPlatform/Dataverse/core/__init__.py @@ -8,7 +8,7 @@ configuration, HTTP client, and error handling. """ -from .config import DataverseConfig +from .config import DataverseConfig, OperationContext from .errors import DataverseError, HttpError, MetadataError, SQLParseError, ValidationError from .log_config import LogConfig @@ -18,6 +18,7 @@ "HttpError", "LogConfig", "MetadataError", + "OperationContext", "SQLParseError", "ValidationError", ] diff --git a/src/PowerPlatform/Dataverse/core/config.py b/src/PowerPlatform/Dataverse/core/config.py index ed161048..ef71db52 100644 --- a/src/PowerPlatform/Dataverse/core/config.py +++ b/src/PowerPlatform/Dataverse/core/config.py @@ -18,6 +18,8 @@ if TYPE_CHECKING: from .log_config import LogConfig +__all__ = ["DataverseConfig", "OperationContext"] + # key=value pairs separated by semicolons. # Keys: alphanumeric, hyphens, underscores. # Values: alphanumeric, hyphens, underscores, dots, slashes. diff --git a/tests/unit/test_package_exports.py b/tests/unit/test_package_exports.py index ab8b101e..9c510285 100644 --- a/tests/unit/test_package_exports.py +++ b/tests/unit/test_package_exports.py @@ -24,7 +24,7 @@ def test_all_symbols_importable(self): def test_identity(self): """Re-exported objects are the same objects as their source definitions.""" import PowerPlatform.Dataverse.core as m - from PowerPlatform.Dataverse.core.config import DataverseConfig + from PowerPlatform.Dataverse.core.config import DataverseConfig, OperationContext from PowerPlatform.Dataverse.core.errors import ( DataverseError, HttpError, @@ -37,10 +37,11 @@ def test_identity(self): self.assertIs(m.DataverseConfig, DataverseConfig) self.assertIs(m.DataverseError, DataverseError) self.assertIs(m.HttpError, HttpError) + self.assertIs(m.LogConfig, LogConfig) self.assertIs(m.MetadataError, MetadataError) + self.assertIs(m.OperationContext, OperationContext) self.assertIs(m.SQLParseError, SQLParseError) self.assertIs(m.ValidationError, ValidationError) - self.assertIs(m.LogConfig, LogConfig) class TestModelsExports(unittest.TestCase): @@ -61,8 +62,12 @@ def test_identity(self): """Re-exported objects are the same objects as their source definitions.""" import PowerPlatform.Dataverse.models as m from PowerPlatform.Dataverse.models.batch import BatchItemResponse, BatchResult + from PowerPlatform.Dataverse.models.fetchxml_query import FetchXmlQuery + from PowerPlatform.Dataverse.models.filters import ColumnProxy, FilterExpression, col, raw + from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel + from PowerPlatform.Dataverse.models.protocol import DataverseModel from PowerPlatform.Dataverse.models.query_builder import ExpandOption, QueryBuilder, QueryParams - from PowerPlatform.Dataverse.models.record import Record + from PowerPlatform.Dataverse.models.record import QueryResult, Record from PowerPlatform.Dataverse.models.relationship import ( CascadeConfiguration, LookupAttributeMetadata, @@ -73,21 +78,30 @@ def test_identity(self): from PowerPlatform.Dataverse.models.table_info import AlternateKeyInfo, ColumnInfo, TableInfo from PowerPlatform.Dataverse.models.upsert import UpsertItem + self.assertIs(m.AlternateKeyInfo, AlternateKeyInfo) self.assertIs(m.BatchItemResponse, BatchItemResponse) self.assertIs(m.BatchResult, BatchResult) - self.assertIs(m.ExpandOption, ExpandOption) - self.assertIs(m.QueryBuilder, QueryBuilder) - self.assertIs(m.QueryParams, QueryParams) - self.assertIs(m.Record, Record) self.assertIs(m.CascadeConfiguration, CascadeConfiguration) + self.assertIs(m.ColumnInfo, ColumnInfo) + self.assertIs(m.ColumnProxy, ColumnProxy) + self.assertIs(m.DataverseModel, DataverseModel) + self.assertIs(m.ExpandOption, ExpandOption) + self.assertIs(m.FetchXmlQuery, FetchXmlQuery) + self.assertIs(m.FilterExpression, FilterExpression) + self.assertIs(m.Label, Label) + self.assertIs(m.LocalizedLabel, LocalizedLabel) self.assertIs(m.LookupAttributeMetadata, LookupAttributeMetadata) self.assertIs(m.ManyToManyRelationshipMetadata, ManyToManyRelationshipMetadata) self.assertIs(m.OneToManyRelationshipMetadata, OneToManyRelationshipMetadata) + self.assertIs(m.QueryBuilder, QueryBuilder) + self.assertIs(m.QueryParams, QueryParams) + self.assertIs(m.QueryResult, QueryResult) + self.assertIs(m.Record, Record) self.assertIs(m.RelationshipInfo, RelationshipInfo) - self.assertIs(m.AlternateKeyInfo, AlternateKeyInfo) - self.assertIs(m.ColumnInfo, ColumnInfo) self.assertIs(m.TableInfo, TableInfo) self.assertIs(m.UpsertItem, UpsertItem) + self.assertIs(m.col, col) + self.assertIs(m.raw, raw) class TestOperationsExports(unittest.TestCase): From 23cf7ae9001a88e3c9b95ec8585c4602143ab130 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Wed, 20 May 2026 19:26:00 -0700 Subject: [PATCH 11/18] Empty package __all__ lists to prevent doc-tool duplicate pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc generator (autoapi/docfx) was creating duplicate documentation pages for every re-exported symbol — one at the original submodule path (e.g. models.record.QueryResult) and one at the package root path (e.g. models.QueryResult). This caused ~17 cross-reference warnings in the Microsoft Learn doc build because xrefs in docstrings became ambiguous between the two paths. Emptying __all__ stops the doc tool from documenting the re-exports while keeping all package-root imports working (Python does not consult __all__ for regular 'from package import Symbol' imports). Tests updated to assert __all__ is empty and to verify each expected symbol is still importable from the package namespace. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/PowerPlatform/Dataverse/core/__init__.py | 11 +- .../Dataverse/models/__init__.py | 27 +--- .../Dataverse/operations/__init__.py | 16 +-- tests/unit/test_package_exports.py | 125 ++++++++++++++---- 4 files changed, 100 insertions(+), 79 deletions(-) diff --git a/src/PowerPlatform/Dataverse/core/__init__.py b/src/PowerPlatform/Dataverse/core/__init__.py index b3e61864..8958b427 100644 --- a/src/PowerPlatform/Dataverse/core/__init__.py +++ b/src/PowerPlatform/Dataverse/core/__init__.py @@ -12,13 +12,4 @@ from .errors import DataverseError, HttpError, MetadataError, SQLParseError, ValidationError from .log_config import LogConfig -__all__ = [ - "DataverseConfig", - "DataverseError", - "HttpError", - "LogConfig", - "MetadataError", - "OperationContext", - "SQLParseError", - "ValidationError", -] +__all__: list[str] = [] diff --git a/src/PowerPlatform/Dataverse/models/__init__.py b/src/PowerPlatform/Dataverse/models/__init__.py index e418ffb4..10b8e9a0 100644 --- a/src/PowerPlatform/Dataverse/models/__init__.py +++ b/src/PowerPlatform/Dataverse/models/__init__.py @@ -34,29 +34,4 @@ from .table_info import AlternateKeyInfo, ColumnInfo, TableInfo from .upsert import UpsertItem -__all__ = [ - "BatchItemResponse", - "BatchResult", - "FetchXmlQuery", - "ColumnProxy", - "FilterExpression", - "col", - "raw", - "Label", - "LocalizedLabel", - "DataverseModel", - "ExpandOption", - "QueryBuilder", - "QueryParams", - "QueryResult", - "Record", - "CascadeConfiguration", - "LookupAttributeMetadata", - "ManyToManyRelationshipMetadata", - "OneToManyRelationshipMetadata", - "RelationshipInfo", - "AlternateKeyInfo", - "ColumnInfo", - "TableInfo", - "UpsertItem", -] +__all__: list[str] = [] diff --git a/src/PowerPlatform/Dataverse/operations/__init__.py b/src/PowerPlatform/Dataverse/operations/__init__.py index d16f7fb9..84be3cca 100644 --- a/src/PowerPlatform/Dataverse/operations/__init__.py +++ b/src/PowerPlatform/Dataverse/operations/__init__.py @@ -24,18 +24,4 @@ from .records import RecordOperations from .tables import TableOperations -__all__ = [ - "BatchDataFrameOperations", - "BatchOperations", - "BatchQueryOperations", - "BatchRecordOperations", - "BatchRequest", - "BatchTableOperations", - "ChangeSet", - "ChangeSetRecordOperations", - "DataFrameOperations", - "FileOperations", - "QueryOperations", - "RecordOperations", - "TableOperations", -] +__all__: list[str] = [] diff --git a/tests/unit/test_package_exports.py b/tests/unit/test_package_exports.py index 9c510285..efa3bd55 100644 --- a/tests/unit/test_package_exports.py +++ b/tests/unit/test_package_exports.py @@ -1,25 +1,92 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Tests that every symbol in __all__ is importable from each package namespace, -and that re-exported objects are identical to their originals.""" +"""Tests for package-level imports. + +The three sub-packages (``core``, ``models``, ``operations``) deliberately keep +``__all__`` empty to avoid creating duplicate doc-tool entries for re-exported +symbols. Symbols are still importable from the package namespace because +Python's ``from package import Symbol`` does not consult ``__all__``. + +These tests verify: +1. ``__all__`` is empty (locks in the design decision). +2. Every expected public symbol is still importable from the package namespace. +3. Each imported symbol is the same object as its source definition. +""" import unittest +CORE_EXPECTED = [ + "DataverseConfig", + "DataverseError", + "HttpError", + "LogConfig", + "MetadataError", + "OperationContext", + "SQLParseError", + "ValidationError", +] + +MODELS_EXPECTED = [ + "AlternateKeyInfo", + "BatchItemResponse", + "BatchResult", + "CascadeConfiguration", + "ColumnInfo", + "ColumnProxy", + "DataverseModel", + "ExpandOption", + "FetchXmlQuery", + "FilterExpression", + "Label", + "LocalizedLabel", + "LookupAttributeMetadata", + "ManyToManyRelationshipMetadata", + "OneToManyRelationshipMetadata", + "QueryBuilder", + "QueryParams", + "QueryResult", + "Record", + "RelationshipInfo", + "TableInfo", + "UpsertItem", + "col", + "raw", +] + +OPERATIONS_EXPECTED = [ + "BatchDataFrameOperations", + "BatchOperations", + "BatchQueryOperations", + "BatchRecordOperations", + "BatchRequest", + "BatchTableOperations", + "ChangeSet", + "ChangeSetRecordOperations", + "DataFrameOperations", + "FileOperations", + "QueryOperations", + "RecordOperations", + "TableOperations", +] + + class TestCoreExports(unittest.TestCase): - """Verify package-level exports for PowerPlatform.Dataverse.core. + """Verify package-level imports for PowerPlatform.Dataverse.core.""" + + def test_all_is_empty(self): + """``__all__`` is deliberately empty to avoid doc-tool duplication.""" + import PowerPlatform.Dataverse.core as m - Checks that every symbol in __all__ is reachable from the package namespace - and that each re-export is the identical object as its source definition. - """ + self.assertEqual(m.__all__, []) - def test_all_symbols_importable(self): - """Every name listed in __all__ is accessible as an attribute of the package.""" + def test_expected_symbols_importable(self): + """Every expected public symbol is reachable from the package namespace.""" import PowerPlatform.Dataverse.core as m - for name in m.__all__: - self.assertTrue(hasattr(m, name), f"{name!r} is in __all__ but missing from PowerPlatform.Dataverse.core") + for name in CORE_EXPECTED: + self.assertTrue(hasattr(m, name), f"{name!r} not importable from PowerPlatform.Dataverse.core") def test_identity(self): """Re-exported objects are the same objects as their source definitions.""" @@ -45,18 +112,20 @@ def test_identity(self): class TestModelsExports(unittest.TestCase): - """Verify package-level exports for PowerPlatform.Dataverse.models. + """Verify package-level imports for PowerPlatform.Dataverse.models.""" + + def test_all_is_empty(self): + """``__all__`` is deliberately empty to avoid doc-tool duplication.""" + import PowerPlatform.Dataverse.models as m - Checks that every symbol in __all__ is reachable from the package namespace - and that each re-export is the identical object as its source definition. - """ + self.assertEqual(m.__all__, []) - def test_all_symbols_importable(self): - """Every name listed in __all__ is accessible as an attribute of the package.""" + def test_expected_symbols_importable(self): + """Every expected public symbol is reachable from the package namespace.""" import PowerPlatform.Dataverse.models as m - for name in m.__all__: - self.assertTrue(hasattr(m, name), f"{name!r} is in __all__ but missing from PowerPlatform.Dataverse.models") + for name in MODELS_EXPECTED: + self.assertTrue(hasattr(m, name), f"{name!r} not importable from PowerPlatform.Dataverse.models") def test_identity(self): """Re-exported objects are the same objects as their source definitions.""" @@ -105,20 +174,20 @@ def test_identity(self): class TestOperationsExports(unittest.TestCase): - """Verify package-level exports for PowerPlatform.Dataverse.operations. + """Verify package-level imports for PowerPlatform.Dataverse.operations.""" + + def test_all_is_empty(self): + """``__all__`` is deliberately empty to avoid doc-tool duplication.""" + import PowerPlatform.Dataverse.operations as m - Checks that every symbol in __all__ is reachable from the package namespace - and that each re-export is the identical object as its source definition. - """ + self.assertEqual(m.__all__, []) - def test_all_symbols_importable(self): - """Every name listed in __all__ is accessible as an attribute of the package.""" + def test_expected_symbols_importable(self): + """Every expected public symbol is reachable from the package namespace.""" import PowerPlatform.Dataverse.operations as m - for name in m.__all__: - self.assertTrue( - hasattr(m, name), f"{name!r} is in __all__ but missing from PowerPlatform.Dataverse.operations" - ) + for name in OPERATIONS_EXPECTED: + self.assertTrue(hasattr(m, name), f"{name!r} not importable from PowerPlatform.Dataverse.operations") def test_identity(self): """Re-exported objects are the same objects as their source definitions.""" From 753e36373383fc415b6ea2dd48da3161835dd909 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Wed, 20 May 2026 19:26:18 -0700 Subject: [PATCH 12/18] Fix Sphinx cross-references in docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleans up ~17 doc-build warnings caused by short-form, wrong-path, or unresolvable references in docstrings: - operations/records.py: qualify :class:`QueryResult` and :class:`FilterExpression` - operations/batch.py: qualify :attr:`BatchResult.responses` and :class:`FilterExpression`; replace unresolvable :attr: refs to instance attributes with plain backticks - operations/tables.py: fully qualify :attr:`AlternateKeyInfo.status` - models/fetchxml_query.py: correct wrong path (models.fetchxml_query.QueryResult → models.record.QueryResult) - core/log_config.py: qualify :class:`LogConfig` self-reference - client.py: replace reference to private _ODataClient with plain text Combined with the empty __all__ change, expected to reduce Microsoft Learn doc-build warnings from 38 to ~4 (the remaining 4 are auto-generated :mod: references to models.record in toc.yml that need a doc-repo-side fix). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/PowerPlatform/Dataverse/client.py | 2 +- src/PowerPlatform/Dataverse/core/log_config.py | 2 +- .../Dataverse/models/fetchxml_query.py | 6 +++--- src/PowerPlatform/Dataverse/operations/batch.py | 12 ++++++------ src/PowerPlatform/Dataverse/operations/records.py | 14 +++++++------- src/PowerPlatform/Dataverse/operations/tables.py | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 12ceaaac..60267d7e 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -27,7 +27,7 @@ class DataverseClient: This client provides a simple, stable interface for interacting with Dataverse environments through the Web API. It handles authentication via Azure Identity and delegates HTTP operations - to an internal :class:`~PowerPlatform.Dataverse.data._odata._ODataClient`. + to an internal OData client. Key capabilities: - OData CRUD operations: create, read, update, delete records diff --git a/src/PowerPlatform/Dataverse/core/log_config.py b/src/PowerPlatform/Dataverse/core/log_config.py index 73100703..e3d254ca 100644 --- a/src/PowerPlatform/Dataverse/core/log_config.py +++ b/src/PowerPlatform/Dataverse/core/log_config.py @@ -4,7 +4,7 @@ """ Local file logging configuration for Dataverse SDK HTTP diagnostics. -Provides :class:`LogConfig`, an opt-in configuration for writing request/response +Provides :class:`~PowerPlatform.Dataverse.core.log_config.LogConfig`, an opt-in configuration for writing request/response traces to ``.log`` files with automatic header redaction and timestamped filenames. """ diff --git a/src/PowerPlatform/Dataverse/models/fetchxml_query.py b/src/PowerPlatform/Dataverse/models/fetchxml_query.py index a1f43883..5f62e1e4 100644 --- a/src/PowerPlatform/Dataverse/models/fetchxml_query.py +++ b/src/PowerPlatform/Dataverse/models/fetchxml_query.py @@ -52,7 +52,7 @@ def __init__(self, xml: str, entity_name: str, client: "DataverseClient") -> Non self._client = client def execute(self) -> QueryResult: - """Execute the FetchXML query and return all results as a :class:`QueryResult`. + """Execute the FetchXML query and return all results as a :class:`~PowerPlatform.Dataverse.models.record.QueryResult`. Blocking — fetches all pages upfront and holds every record in memory before returning. Simple for small-to-medium result sets; use :meth:`execute_pages` @@ -72,7 +72,7 @@ def execute(self) -> QueryResult: return QueryResult(all_records) def execute_pages(self) -> Iterator[QueryResult]: - """Lazily yield one :class:`QueryResult` per HTTP page. + """Lazily yield one :class:`~PowerPlatform.Dataverse.models.record.QueryResult` per HTTP page. Streaming — each iteration fires one HTTP request and yields one page. Prefer over :meth:`execute` when: @@ -84,7 +84,7 @@ def execute_pages(self) -> Iterator[QueryResult]: One-shot — do not iterate more than once. - :return: Iterator of per-page :class:`QueryResult` objects. + :return: Iterator of per-page :class:`~PowerPlatform.Dataverse.models.record.QueryResult` objects. :rtype: Iterator[:class:`~PowerPlatform.Dataverse.models.record.QueryResult`] Example:: diff --git a/src/PowerPlatform/Dataverse/operations/batch.py b/src/PowerPlatform/Dataverse/operations/batch.py index aa5d8391..ff9e9546 100644 --- a/src/PowerPlatform/Dataverse/operations/batch.py +++ b/src/PowerPlatform/Dataverse/operations/batch.py @@ -72,7 +72,7 @@ class ChangeSetRecordOperations: create/update/delete). Only write operations are allowed — GET is not permitted inside a changeset. - Do not instantiate directly; use :attr:`ChangeSet.records`. + Do not instantiate directly; use ``ChangeSet.records``. """ def __init__(self, cs_internal: _ChangeSet) -> None: @@ -136,7 +136,7 @@ class ChangeSet: A transactional group of single-record write operations. All operations succeed or are rolled back together. Use as a context - manager or call :attr:`records` to add operations directly. + manager or call ``records`` to add operations directly. Do not instantiate directly; use :meth:`BatchRequest.changeset`. @@ -412,7 +412,7 @@ def list( :param table: Table schema name (e.g. ``"account"``). :type table: :class:`str` - :param filter: Optional OData ``$filter`` expression or :class:`FilterExpression`. + :param filter: Optional OData ``$filter`` expression or :class:`~PowerPlatform.Dataverse.models.filters.FilterExpression`. :type filter: str or FilterExpression or None :param select: Optional list of column logical names to include. :type select: list[str] or None @@ -574,7 +574,7 @@ def add_columns(self, table: str, columns: Dict[str, Any]) -> None: Add column-create operations to the batch (one per column). The table's ``MetadataId`` is resolved at execute time. Each column - produces one entry in :attr:`BatchResult.responses`. + produces one entry in :attr:`~PowerPlatform.Dataverse.models.batch.BatchResult.responses`. :param table: Schema name of the target table. :type table: :class:`str` @@ -589,7 +589,7 @@ def remove_columns(self, table: str, columns: Union[str, List[str]]) -> None: The table's ``MetadataId`` and each column's ``MetadataId`` are resolved at execute time. Each column produces one entry in - :attr:`BatchResult.responses`. + :attr:`~PowerPlatform.Dataverse.models.batch.BatchResult.responses`. :param table: Schema name of the target table. :type table: :class:`str` @@ -926,7 +926,7 @@ class BatchRequest: Builder for constructing and executing a Dataverse OData ``$batch`` request. Obtain via :meth:`BatchOperations.new` (``client.batch.new()``). Add operations - through :attr:`records`, :attr:`tables`, :attr:`query`, and :attr:`dataframe`, + through ``records``, ``tables``, ``query``, and ``dataframe``, optionally group writes into a :meth:`changeset`, then call :meth:`execute`. diff --git a/src/PowerPlatform/Dataverse/operations/records.py b/src/PowerPlatform/Dataverse/operations/records.py index c9c66119..70f05e25 100644 --- a/src/PowerPlatform/Dataverse/operations/records.py +++ b/src/PowerPlatform/Dataverse/operations/records.py @@ -548,14 +548,14 @@ def list( count: bool = False, include_annotations: Optional[str] = None, ) -> QueryResult: - """Fetch multiple records and return them as a :class:`QueryResult`. + """Fetch multiple records and return them as a :class:`~PowerPlatform.Dataverse.models.record.QueryResult`. GA replacement for ``records.get(table, filter=...)``. All pages are - collected eagerly and returned as a single :class:`QueryResult`. + collected eagerly and returned as a single :class:`~PowerPlatform.Dataverse.models.record.QueryResult`. :param table: Schema name of the table (e.g. ``"account"``). :type table: :class:`str` - :param filter: Optional OData filter string or :class:`FilterExpression`. + :param filter: Optional OData filter string or :class:`~PowerPlatform.Dataverse.models.filters.FilterExpression`. :type filter: str or FilterExpression or None :param select: Optional list of column logical names to include. :type select: list[str] or None @@ -572,7 +572,7 @@ def list( :param include_annotations: OData annotation pattern for the ``Prefer: odata.include-annotations`` header, or ``None``. :type include_annotations: :class:`str` or None - :return: All matching records collected into a :class:`QueryResult`. + :return: All matching records collected into a :class:`~PowerPlatform.Dataverse.models.record.QueryResult`. :rtype: :class:`~PowerPlatform.Dataverse.models.record.QueryResult` Example:: @@ -622,7 +622,7 @@ def list_pages( count: bool = False, include_annotations: Optional[str] = None, ) -> Iterator[QueryResult]: - """Lazily yield one :class:`QueryResult` per HTTP page. + """Lazily yield one :class:`~PowerPlatform.Dataverse.models.record.QueryResult` per HTTP page. Streaming counterpart to :meth:`list`. Each iteration triggers one network request via ``@odata.nextLink``. One-shot — do not iterate @@ -630,7 +630,7 @@ def list_pages( :param table: Schema name of the table (e.g. ``"account"``). :type table: :class:`str` - :param filter: Optional OData filter string or :class:`FilterExpression`. + :param filter: Optional OData filter string or :class:`~PowerPlatform.Dataverse.models.filters.FilterExpression`. :type filter: str or FilterExpression or None :param select: Optional list of column logical names to include. :type select: list[str] or None @@ -647,7 +647,7 @@ def list_pages( :param include_annotations: OData annotation pattern for the ``Prefer: odata.include-annotations`` header, or ``None``. :type include_annotations: :class:`str` or None - :return: Iterator of per-page :class:`QueryResult` objects. + :return: Iterator of per-page :class:`~PowerPlatform.Dataverse.models.record.QueryResult` objects. :rtype: Iterator[:class:`~PowerPlatform.Dataverse.models.record.QueryResult`] Example:: diff --git a/src/PowerPlatform/Dataverse/operations/tables.py b/src/PowerPlatform/Dataverse/operations/tables.py index 857b05e4..df9f8404 100644 --- a/src/PowerPlatform/Dataverse/operations/tables.py +++ b/src/PowerPlatform/Dataverse/operations/tables.py @@ -593,7 +593,7 @@ def create_alternate_key( Alternate keys allow upsert operations to identify records by one or more columns instead of the primary GUID. After creation the key is - queued for index building; its :attr:`~AlternateKeyInfo.status` will + queued for index building; its :attr:`~PowerPlatform.Dataverse.models.table_info.AlternateKeyInfo.status` will transition from ``"Pending"`` to ``"Active"`` once the index is ready. :param table: Schema name of the table (e.g. ``"new_Product"``). From f6f41db0a755298e3743943a2a6510fa81f832c9 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Wed, 20 May 2026 23:17:32 -0700 Subject: [PATCH 13/18] Remove setup.py and spec-module-level-exports.md from PR These files were experimental scaffolding for doc-generation testing that is no longer needed for the PR's scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/spec-module-level-exports.md | 101 ------------------------------ setup.py | 14 ----- 2 files changed, 115 deletions(-) delete mode 100644 docs/spec-module-level-exports.md delete mode 100644 setup.py diff --git a/docs/spec-module-level-exports.md b/docs/spec-module-level-exports.md deleted file mode 100644 index 9c1482c6..00000000 --- a/docs/spec-module-level-exports.md +++ /dev/null @@ -1,101 +0,0 @@ -# Spec: Support Module-Level Exports via `__all__` - -## Goal - -Populate the `__all__` lists in each package-level `__init__.py` so that public symbols -are re-exported at the package level. Users will be able to import from the package -namespace directly rather than reaching into submodules. - -**Before:** -```python -from PowerPlatform.Dataverse.models.record import Record -from PowerPlatform.Dataverse.core.errors import DataverseError -``` - -**After:** -```python -from PowerPlatform.Dataverse.models import Record -from PowerPlatform.Dataverse.core import DataverseError -``` - ---- - -## Current Status - -`__all__` is already defined in every individual module (e.g. `models/filters.py`, -`core/errors.py`, `operations/records.py`), but all package-level `__init__.py` files -have empty exports: - -| Package `__init__.py` | Current `__all__` | -|---|---| -| `PowerPlatform.Dataverse.models` | `[]` | -| `PowerPlatform.Dataverse.operations` | `[]` | -| `PowerPlatform.Dataverse.core` | `[]` | -| `PowerPlatform.Dataverse.data` | `[]` | - ---- - -## The Challenge: Documentation Duplication Risk - -The public API docs on Microsoft Learn are auto-generated from the installed package. -The concern is that re-exporting a class in `__init__.py` could cause it to appear -twice in the docs — once at its definition location (e.g. `operations.records.RecordOperations`) -and again at the package level (e.g. `operations.RecordOperations`). - -**What we need to verify before merging:** -- [ ] Confirm with the team how the doc pipeline works and run a test build to check - for duplicate entries. - ---- - -## What Needs to Change - -### `models/__init__.py` -Re-export from: -- `models.query_builder` → `QueryBuilder`, `QueryParams`, `ExpandOption` -- `models.filters` → `eq`, `ne`, `gt`, `lt`, `ge`, `le`, `contains`, `startswith`, `endswith`, `filter_in`, `between`, `and_`, `or_`, `not_` -- `models.batch` → `BatchItemResponse`, `BatchResult` -- `models.record` → `Record` -- `models.table_info` → `TableInfo`, `ColumnInfo`, `AlternateKeyInfo` -- `models.relationship` → `OneToManyRelationship`, `ManyToManyRelationship`, `RelationshipInfo` (etc.) -- `models.upsert` → `UpsertItem` -- `models.labels` → `LocalizedLabel`, `Label` - -### `core/__init__.py` -Re-export from: -- `core.errors` → `DataverseError`, `HttpError`, `ValidationError`, `MetadataError`, `SQLParseError` -- `core.log_config` → `LogConfig` - -### `operations/__init__.py` -Re-export from: -- `operations.records` → `RecordOperations` -- `operations.tables` → `TableOperations` -- `operations.query` → `QueryOperations` -- `operations.batch` → `BatchOperations`, `BatchRecordOperations`, `BatchTableOperations` -- `operations.dataframe` → `DataFrameOperations` -- `operations.files` → `FileOperations` - -### `data/__init__.py` -No change — all submodules are internal (`_`-prefixed); `__all__` stays empty. - ---- - -## Benefits - -1. **Cleaner import paths** — users write `from PowerPlatform.Dataverse.models import Record` - instead of navigating submodule paths. - -2. **IDE discoverability** — autocompletion on `PowerPlatform.Dataverse.models.` surfaces - all public types immediately; users do not need to know submodule names. - -3. **No broken imports during refactoring** — if we ever rename or reorganise an internal - submodule, users' import paths stay the same as long as the `__init__.py` re-exports - are kept. Without this, any internal restructuring is a breaking change for users. - -4. **Wildcard imports work correctly** — currently `from PowerPlatform.Dataverse.models import *` - imports nothing, because `__all__ = []`. Once populated, wildcard imports pick up all - intended public symbols as defined by Python's module documentation. - -5. **Follows industry convention** — NumPy, pandas, and requests all expose their public - API at the package level via `__all__` in `__init__.py`. Aligning with this pattern - makes the SDK feel familiar to experienced Python users. diff --git a/setup.py b/setup.py deleted file mode 100644 index c46ba05d..00000000 --- a/setup.py +++ /dev/null @@ -1,14 +0,0 @@ -from os import environ, path -from setuptools import setup - -# Try to read from VERSION.txt file first, fall back to environment variable -version_file = path.join(path.dirname(__file__), "VERSION.txt") -if path.exists(version_file): - with open(version_file, "r", encoding="utf-8") as f: - package_version = f.read().strip() -else: - package_version = environ.get("PackageVersion", "0.0.0") - -setup( - version=package_version, -) From 034828f0e39382220f51bdc628ba1b0d1cb69014 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Wed, 20 May 2026 23:21:15 -0700 Subject: [PATCH 14/18] Apply black formatting Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/test_package_exports.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_package_exports.py b/tests/unit/test_package_exports.py index efa3bd55..bf118c30 100644 --- a/tests/unit/test_package_exports.py +++ b/tests/unit/test_package_exports.py @@ -16,7 +16,6 @@ import unittest - CORE_EXPECTED = [ "DataverseConfig", "DataverseError", From 20311f6baf3434a7ac34b84e5cf79df2482f2128 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Thu, 21 May 2026 12:12:53 -0700 Subject: [PATCH 15/18] Fix remaining docfx warnings in query_builder docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change :meth: cross-references to plain backtick formatting for the 'build' method. The doc tool was failing to resolve :meth:`build` and :meth:`QueryBuilder.build` because 'build' is defined on the private '_QueryBuilderBase' parent class — autoapi/docfx can't link to private classes. Plain backticks (``build()``) render the same in HTML but skip cross-reference resolution. Expected impact on Microsoft Learn doc build: 3 warnings -> 0 or 1 (the only potentially remaining warning is the auto-generated 'inherits from _QueryBuilderBase' reference in QueryBuilder's page, which can only be eliminated by renaming the private class). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/PowerPlatform/Dataverse/models/query_builder.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PowerPlatform/Dataverse/models/query_builder.py b/src/PowerPlatform/Dataverse/models/query_builder.py index bb2664fe..0ddd2782 100644 --- a/src/PowerPlatform/Dataverse/models/query_builder.py +++ b/src/PowerPlatform/Dataverse/models/query_builder.py @@ -74,7 +74,7 @@ class QueryParams(TypedDict, total=False): - """Typed dictionary returned by :meth:`QueryBuilder.build`. + """Typed dictionary returned by ``QueryBuilder.build()``. Provides IDE autocomplete when passing build results to ``client.records.list()`` manually. @@ -187,7 +187,7 @@ class _QueryBuilderBase: Holds all query state and chaining methods (``select``, ``where``, ``order_by``, ``top``, ``page_size``, ``count``, ``expand``, ``include_annotations``, ``include_formatted_values``) and - :meth:`build`. + ``build()``. Subclasses add execution: :class:`QueryBuilder` for sync clients, :class:`~PowerPlatform.Dataverse.aio.models.async_query_builder.AsyncQueryBuilder` @@ -451,7 +451,7 @@ class QueryBuilder(_QueryBuilderBase): """Fluent interface for building and executing OData queries against a sync client. Provides method chaining for constructing complex queries with - composable filter expressions. Can be used standalone (via :meth:`build`) + composable filter expressions. Can be used standalone (via ``build()``) or bound to a client (via :meth:`execute`). :param table: Table schema name to query. @@ -483,7 +483,7 @@ def execute(self, *, by_page=_BY_PAGE_UNSET) -> Union[QueryResult, Iterator[Quer This method is only available when the QueryBuilder was created via ``client.query.builder(table)``. Standalone ``QueryBuilder`` - instances should use :meth:`build` to get parameters and pass them + instances should use ``build()`` to get parameters and pass them to ``client.records.list()`` manually. At least one of ``select()``, ``where()``, or ``top()`` must be From 0a8c76c9a40ca640044bfe299fe0a51af306781c Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Thu, 21 May 2026 23:17:59 -0700 Subject: [PATCH 16/18] Revert example, README, and skill import changes to reduce PR diff Co-Authored-By: Claude Sonnet 4.6 --- README.md | 19 +++++++++---------- examples/advanced/alternate_keys_upsert.py | 2 +- examples/advanced/relationships.py | 9 ++++----- examples/advanced/walkthrough.py | 5 +++-- examples/basic/functional_testing.py | 13 ++++++------- examples/basic/installation_example.py | 9 ++++++--- .../claude_skill/dataverse-sdk-use/SKILL.md | 18 +++++++++--------- 7 files changed, 38 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 32140be1..21abb591 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ a PATCH request; multiple items use the `UpsertMultiple` bulk action. > upsert requests will be rejected by Dataverse with a 400 error. ```python -from PowerPlatform.Dataverse.models import UpsertItem +from PowerPlatform.Dataverse.models.upsert import UpsertItem # Upsert a single record client.records.upsert("account", [ @@ -346,7 +346,7 @@ query = (client.query.builder("contact") For complex logic (OR, NOT, grouping), compose expressions with `&`, `|`, `~`: ```python -from PowerPlatform.Dataverse.models import col +from PowerPlatform.Dataverse.models.filters import col # OR conditions: (statecode = 0 OR statecode = 1) AND revenue > 100k for record in (client.query.builder("account") @@ -397,7 +397,7 @@ if record: **Nested expand with options** -- expand navigation properties with `$select`, `$filter`, `$orderby`, and `$top`: ```python -from PowerPlatform.Dataverse.models import ExpandOption +from PowerPlatform.Dataverse.models.query_builder import ExpandOption # Expand related tasks with filtering and sorting for record in (client.query.builder("account") @@ -614,14 +614,12 @@ client.tables.delete("new_Product") 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). ```python -from PowerPlatform.Dataverse.models import ( - CascadeConfiguration, - Label, - LocalizedLabel, +from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, - ManyToManyRelationshipMetadata, OneToManyRelationshipMetadata, + ManyToManyRelationshipMetadata, ) +from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel # Create a one-to-many relationship: Department (1) -> Employee (N) # This adds a "Department" lookup field to the Employee table @@ -823,7 +821,7 @@ The client raises structured exceptions for different error scenarios: ```python from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core import HttpError, ValidationError +from PowerPlatform.Dataverse.core.errors import HttpError, ValidationError try: client.records.retrieve("account", "invalid-id") @@ -864,7 +862,8 @@ Enable file-based HTTP logging to capture all requests and responses for debuggi ```python from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core import DataverseConfig, LogConfig +from PowerPlatform.Dataverse.core.config import DataverseConfig +from PowerPlatform.Dataverse.core.log_config import LogConfig log_cfg = LogConfig( log_folder="./my_logs", # Directory for log files (created if missing) diff --git a/examples/advanced/alternate_keys_upsert.py b/examples/advanced/alternate_keys_upsert.py index ca574fa5..3248282a 100644 --- a/examples/advanced/alternate_keys_upsert.py +++ b/examples/advanced/alternate_keys_upsert.py @@ -23,7 +23,7 @@ import time from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models import UpsertItem +from PowerPlatform.Dataverse.models.upsert import UpsertItem from azure.identity import InteractiveBrowserCredential # type: ignore # --- Config --- diff --git a/examples/advanced/relationships.py b/examples/advanced/relationships.py index eae76225..c0a8baa1 100644 --- a/examples/advanced/relationships.py +++ b/examples/advanced/relationships.py @@ -20,14 +20,13 @@ import time from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models import ( - CascadeConfiguration, - Label, - LocalizedLabel, +from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, - ManyToManyRelationshipMetadata, OneToManyRelationshipMetadata, + ManyToManyRelationshipMetadata, + CascadeConfiguration, ) +from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel from PowerPlatform.Dataverse.common.constants import ( CASCADE_BEHAVIOR_NO_CASCADE, CASCADE_BEHAVIOR_REMOVE_LINK, diff --git a/examples/advanced/walkthrough.py b/examples/advanced/walkthrough.py index 5e6533e4..d2cc4ff9 100644 --- a/examples/advanced/walkthrough.py +++ b/examples/advanced/walkthrough.py @@ -25,8 +25,9 @@ from enum import IntEnum from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core import MetadataError -from PowerPlatform.Dataverse.models import ExpandOption, col +from PowerPlatform.Dataverse.core.errors import MetadataError +from PowerPlatform.Dataverse.models.filters import col +from PowerPlatform.Dataverse.models.query_builder import ExpandOption import requests diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index b1c77fb0..e482f4a1 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -33,20 +33,19 @@ # Import SDK components (assumes installation is already validated) from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core import HttpError, MetadataError -from PowerPlatform.Dataverse.models import ( - CascadeConfiguration, - Label, - LocalizedLabel, +from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError +from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, - ManyToManyRelationshipMetadata, OneToManyRelationshipMetadata, - UpsertItem, + ManyToManyRelationshipMetadata, + CascadeConfiguration, ) +from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel from PowerPlatform.Dataverse.common.constants import ( CASCADE_BEHAVIOR_NO_CASCADE, CASCADE_BEHAVIOR_REMOVE_LINK, ) +from PowerPlatform.Dataverse.models.upsert import UpsertItem from azure.identity import InteractiveBrowserCredential diff --git a/examples/basic/installation_example.py b/examples/basic/installation_example.py index 255b6ace..61da149b 100644 --- a/examples/basic/installation_example.py +++ b/examples/basic/installation_example.py @@ -60,7 +60,10 @@ from typing import Optional from datetime import datetime -from PowerPlatform.Dataverse.operations import FileOperations, QueryOperations, RecordOperations, TableOperations +from PowerPlatform.Dataverse.operations.records import RecordOperations +from PowerPlatform.Dataverse.operations.query import QueryOperations +from PowerPlatform.Dataverse.operations.tables import TableOperations +from PowerPlatform.Dataverse.operations.files import FileOperations def validate_imports(): @@ -78,11 +81,11 @@ def validate_imports(): print(f" [OK] Client class: PowerPlatform.Dataverse.client.DataverseClient") # Test submodule imports - from PowerPlatform.Dataverse.core import HttpError, MetadataError + from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError print(f" [OK] Core errors: HttpError, MetadataError") - from PowerPlatform.Dataverse.core import DataverseConfig + from PowerPlatform.Dataverse.core.config import DataverseConfig print(f" [OK] Core config: DataverseConfig") diff --git a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md index a0dbb307..d25815d7 100644 --- a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md +++ b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md @@ -212,7 +212,7 @@ client.records.update("account", [id1, id2, id3], {"industry": "Technology"}) Creates or updates records identified by alternate keys. Single item -> PATCH; multiple items -> `UpsertMultiple` bulk action. > **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. ```python -from PowerPlatform.Dataverse.models import UpsertItem +from PowerPlatform.Dataverse.models.upsert import UpsertItem # Single upsert client.records.upsert("account", [ @@ -403,12 +403,12 @@ client.tables.delete("new_Product") #### Create One-to-Many Relationship ```python -from PowerPlatform.Dataverse.models import ( - CascadeConfiguration, - Label, - LocalizedLabel, +from PowerPlatform.Dataverse.models.relationship import ( LookupAttributeMetadata, OneToManyRelationshipMetadata, + Label, + LocalizedLabel, + CascadeConfiguration, ) from PowerPlatform.Dataverse.common.constants import CASCADE_BEHAVIOR_REMOVE_LINK @@ -435,7 +435,7 @@ print(f"Created lookup field: {result['lookup_schema_name']}") #### Create Many-to-Many Relationship ```python -from PowerPlatform.Dataverse.models import ManyToManyRelationshipMetadata +from PowerPlatform.Dataverse.models.relationship import ManyToManyRelationshipMetadata relationship = ManyToManyRelationshipMetadata( schema_name="new_employee_project", @@ -532,12 +532,12 @@ print(f"Succeeded: {len(result.succeeded)}, Failed: {len(result.failed)}") The SDK provides structured exceptions with detailed error information: ```python -from PowerPlatform.Dataverse.core import ( +from PowerPlatform.Dataverse.core.errors import ( DataverseError, HttpError, - MetadataError, - SQLParseError, ValidationError, + MetadataError, + SQLParseError ) from PowerPlatform.Dataverse.client import DataverseClient From 0146b6b9cfcbb8d7fbb12cbe6bb9ae263af576e7 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Thu, 21 May 2026 23:59:41 -0700 Subject: [PATCH 17/18] Update remaining deep imports to module-level imports in docstrings and examples Co-Authored-By: Claude Sonnet 4.6 --- README.md | 19 +- examples/advanced/alternate_keys_upsert.py | 2 +- examples/advanced/chunking_verification.py | 413 ++++++++++++++++++ examples/advanced/dataframe_operations.py | 2 +- .../advanced/datascience_risk_assessment.py | 2 +- examples/advanced/fetchxml.py | 2 +- examples/advanced/prodev_quick_start.py | 2 +- examples/advanced/relationships.py | 9 +- examples/advanced/sql_examples.py | 4 +- examples/advanced/walkthrough.py | 5 +- examples/basic/functional_testing.py | 13 +- examples/basic/installation_example.py | 9 +- .../claude_skill/dataverse-sdk-use/SKILL.md | 18 +- src/PowerPlatform/Dataverse/models/filters.py | 6 +- .../Dataverse/models/query_builder.py | 14 +- .../Dataverse/operations/batch.py | 2 +- .../Dataverse/operations/query.py | 6 +- .../Dataverse/operations/records.py | 4 +- .../Dataverse/operations/tables.py | 4 +- 19 files changed, 474 insertions(+), 62 deletions(-) create mode 100644 examples/advanced/chunking_verification.py diff --git a/README.md b/README.md index 21abb591..32140be1 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ a PATCH request; multiple items use the `UpsertMultiple` bulk action. > upsert requests will be rejected by Dataverse with a 400 error. ```python -from PowerPlatform.Dataverse.models.upsert import UpsertItem +from PowerPlatform.Dataverse.models import UpsertItem # Upsert a single record client.records.upsert("account", [ @@ -346,7 +346,7 @@ query = (client.query.builder("contact") For complex logic (OR, NOT, grouping), compose expressions with `&`, `|`, `~`: ```python -from PowerPlatform.Dataverse.models.filters import col +from PowerPlatform.Dataverse.models import col # OR conditions: (statecode = 0 OR statecode = 1) AND revenue > 100k for record in (client.query.builder("account") @@ -397,7 +397,7 @@ if record: **Nested expand with options** -- expand navigation properties with `$select`, `$filter`, `$orderby`, and `$top`: ```python -from PowerPlatform.Dataverse.models.query_builder import ExpandOption +from PowerPlatform.Dataverse.models import ExpandOption # Expand related tasks with filtering and sorting for record in (client.query.builder("account") @@ -614,12 +614,14 @@ client.tables.delete("new_Product") 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). ```python -from PowerPlatform.Dataverse.models.relationship import ( +from PowerPlatform.Dataverse.models import ( + CascadeConfiguration, + Label, + LocalizedLabel, LookupAttributeMetadata, - OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, + OneToManyRelationshipMetadata, ) -from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel # Create a one-to-many relationship: Department (1) -> Employee (N) # This adds a "Department" lookup field to the Employee table @@ -821,7 +823,7 @@ The client raises structured exceptions for different error scenarios: ```python from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.errors import HttpError, ValidationError +from PowerPlatform.Dataverse.core import HttpError, ValidationError try: client.records.retrieve("account", "invalid-id") @@ -862,8 +864,7 @@ Enable file-based HTTP logging to capture all requests and responses for debuggi ```python from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.config import DataverseConfig -from PowerPlatform.Dataverse.core.log_config import LogConfig +from PowerPlatform.Dataverse.core import DataverseConfig, LogConfig log_cfg = LogConfig( log_folder="./my_logs", # Directory for log files (created if missing) diff --git a/examples/advanced/alternate_keys_upsert.py b/examples/advanced/alternate_keys_upsert.py index 3248282a..ca574fa5 100644 --- a/examples/advanced/alternate_keys_upsert.py +++ b/examples/advanced/alternate_keys_upsert.py @@ -23,7 +23,7 @@ import time from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models.upsert import UpsertItem +from PowerPlatform.Dataverse.models import UpsertItem from azure.identity import InteractiveBrowserCredential # type: ignore # --- Config --- diff --git a/examples/advanced/chunking_verification.py b/examples/advanced/chunking_verification.py new file mode 100644 index 00000000..fd20a378 --- /dev/null +++ b/examples/advanced/chunking_verification.py @@ -0,0 +1,413 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Chunking verification for CreateMultiple / UpdateMultiple / UpsertMultiple. + +Tests auto-chunking at every boundary relative to _MULTIPLE_BATCH_SIZE (B = 1000): + - 0 records (no-op) + - 1 record (well below B) + - B-1 (just under one full chunk) + - B (exactly one chunk) + - B+1 (spills into a second chunk) + - 2*B (exactly two full chunks) + - 2*B+1 (spills into a third chunk) + +For update, both broadcast (one patch for all IDs) and paired (per-record patches) are tested. + +Prerequisites: +- pip install PowerPlatform-Dataverse-Client +- pip install azure-identity +""" + +import argparse +import time +from azure.identity import InteractiveBrowserCredential +from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.core import MetadataError +from PowerPlatform.Dataverse.models import UpsertItem + +B = 1000 # Must match _MULTIPLE_BATCH_SIZE in _odata.py + +SIZES = [0, 1, B - 1, B, B + 1, 2 * B, 2 * B + 1] + +TABLE = "new_ChunkingVerification" + +# Global pass/fail counters +_pass = 0 +_fail = 0 + + +# Simple logging helper (mirrors walkthrough style) +def log_call(description): + print(f"\n-> {description}") + + +def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): + last = None + total_delay = 0 + attempts = 0 + for d in delays: + if d: + time.sleep(d) + total_delay += d + attempts += 1 + try: + result = op() + if attempts > 1: + retry_count = attempts - 1 + print(f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total.") + return result + except Exception as ex: # noqa: BLE001 + last = ex + continue + if last: + if attempts: + retry_count = max(attempts - 1, 0) + print(f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total.") + raise last + + +def check(condition, msg): + global _pass, _fail + if condition: + _pass += 1 + print(f"[OK] {msg}") + else: + _fail += 1 + print(f"[FAIL] {msg}") + + +def timed(op): + """Run op(), return (result, elapsed_seconds).""" + t0 = time.time() + result = op() + return result, round(time.time() - t0, 2) + + +def count_records(client, pk_attr, filter_expr=None): + """Return total record count by paging with minimal field selection.""" + total = 0 + kwargs = {"select": [pk_attr]} + if filter_expr: + kwargs["filter"] = filter_expr + for page in client.records.get(TABLE, **kwargs): + total += len(page) + return total + + +def delete_all(client, pk_attr): + """Delete all records in the test table via synchronous $batch chunks. + + Uses $batch (not BulkDelete) so the table is guaranteed empty on return. + BulkDelete is asynchronous — it returns before records are removed, which + would corrupt record counts in subsequent test iterations. + """ + log_call(f"delete_all: fetching IDs from {TABLE}") + ids = [] + for page in client.records.get(TABLE, select=[pk_attr]): + ids.extend(r[pk_attr] for r in page) + if not ids: + print(f"[OK] {TABLE} is already empty.") + return + log_call(f"delete_all: deleting {len(ids)} records via $batch (chunks of {B})") + for i in range(0, len(ids), B): + chunk = ids[i : i + B] + batch = client.batch.new() + for record_id in chunk: + batch.records.delete(TABLE, record_id) + backoff(lambda b=batch: b.execute(continue_on_error=True)) + print(f" chunk {i // B + 1}: deleted {len(chunk)} records") + print(f"[OK] Deleted {len(ids)} records from {TABLE}.") + + +def make_records(n, *, marker="create"): + """Build n record payloads with a unique marker and sequential index.""" + return [{"new_Label": f"{marker}-{i}", "new_Index": i} for i in range(n)] + + +def make_upsert_items(n, *, marker="upsert"): + """Build n UpsertItems using new_Code as the alternate key.""" + return [ + UpsertItem( + alternate_key={"new_code": f"{marker}-{i}"}, + record={"new_Label": f"{marker}-label-{i}", "new_Index": i}, + ) + for i in range(n) + ] + + +def expected_chunks(n): + """Return the number of chunks n records will be split into.""" + return max(1, -(-n // B)) if n > 0 else 0 # ceiling division + + +# --------------------------------------------------------------------------- +# Test sections +# --------------------------------------------------------------------------- + + +def test_create_multiple(client, pk_attr): + print("\n" + "=" * 80) + print("CREATE MULTIPLE — boundary sizes") + print("=" * 80) + + for n in SIZES: + print(f"\n-- n={n} ({expected_chunks(n)} chunk(s) expected) --") + delete_all(client, pk_attr) + + if n == 0: + log_call(f"client.records.create('{TABLE}', []) # empty list — no HTTP call expected") + actual = count_records(client, pk_attr) + check(actual == 0, f"n={n:5d}: server count=0 (empty create is a no-op)") + continue + + records = make_records(n) + log_call(f"client.records.create('{TABLE}', [{n} records]) # {expected_chunks(n)} chunk(s)") + ids, elapsed = timed(lambda r=records: backoff(lambda: client.records.create(TABLE, r))) + print(f" create returned {len(ids)} IDs in {elapsed}s") + + log_call(f"count_records (expected {n})") + actual = count_records(client, pk_attr) + + check(len(ids) == n, f"n={n:5d}: IDs returned={len(ids)} (expected {n}) [{elapsed}s]") + check(actual == n, f"n={n:5d}: server count={actual} (expected {n})") + + +def test_update_multiple_broadcast(client, pk_attr): + print("\n" + "=" * 80) + print("UPDATE MULTIPLE — broadcast (same patch for all IDs)") + print("=" * 80) + + for n in SIZES: + print(f"\n-- n={n} ({expected_chunks(n)} chunk(s) expected) --") + if n == 0: + print("[OK] n=0: skipped (no records to update)") + continue + + delete_all(client, pk_attr) + records = make_records(n) + log_call(f"client.records.create('{TABLE}', [{n} records]) # seed") + ids, elapsed = timed(lambda r=records: backoff(lambda: client.records.create(TABLE, r))) + print(f" seeded {len(ids)} records in {elapsed}s") + + log_call( + f"client.records.update('{TABLE}', [{n} IDs], {{'new_Index': 9999}}) # broadcast, {expected_chunks(n)} chunk(s)" + ) + _, elapsed = timed(lambda i=ids: backoff(lambda: client.records.update(TABLE, i, {"new_Index": 9999}))) + print(f" update completed in {elapsed}s") + + log_call("count_records(filter='new_index eq 9999')") + updated = count_records(client, pk_attr, filter_expr="new_index eq 9999") + check(updated == n, f"n={n:5d}: {updated}/{n} records have new_Index=9999 [{elapsed}s]") + + +def test_update_multiple_paired(client, pk_attr): + print("\n" + "=" * 80) + print("UPDATE MULTIPLE — paired (per-record patches)") + print("=" * 80) + + for n in SIZES: + print(f"\n-- n={n} ({expected_chunks(n)} chunk(s) expected) --") + if n == 0: + print("[OK] n=0: skipped (no records to update)") + continue + + delete_all(client, pk_attr) + records = make_records(n) + log_call(f"client.records.create('{TABLE}', [{n} records]) # seed") + ids, elapsed = timed(lambda r=records: backoff(lambda: client.records.create(TABLE, r))) + print(f" seeded {len(ids)} records in {elapsed}s") + + patches = [{"new_Index": n - 1 - i} for i in range(n)] + log_call(f"client.records.update('{TABLE}', [{n} IDs], [{n} patches]) # paired, {expected_chunks(n)} chunk(s)") + _, elapsed = timed(lambda i=ids, p=patches: backoff(lambda: client.records.update(TABLE, i, p))) + print(f" update completed in {elapsed}s") + + log_call("sum new_index across all records (expect n*(n-1)/2)") + total_index = 0 + for page in client.records.get(TABLE, select=["new_index"]): + total_index += sum(r.get("new_index", 0) for r in page) + expected_sum = n * (n - 1) // 2 + check( + total_index == expected_sum, + f"n={n:5d}: index sum={total_index} (expected {expected_sum}) [{elapsed}s]", + ) + + +def test_upsert_multiple(client, pk_attr): + print("\n" + "=" * 80) + print("UPSERT MULTIPLE — insert then update via alternate key") + print("=" * 80) + + for n in SIZES: + print(f"\n-- n={n} ({expected_chunks(n)} chunk(s) expected) --") + if n == 0: + print("[OK] n=0: skipped (no records to upsert)") + continue + + delete_all(client, pk_attr) + items = make_upsert_items(n) + + log_call(f"client.records.upsert('{TABLE}', [{n} items]) # insert pass, {expected_chunks(n)} chunk(s)") + _, elapsed = timed(lambda i=items: backoff(lambda: client.records.upsert(TABLE, i))) + print(f" first upsert completed in {elapsed}s") + after_insert = count_records(client, pk_attr) + check(after_insert == n, f"n={n:5d}: {after_insert}/{n} records after insert pass [{elapsed}s]") + + update_items = [ + UpsertItem( + alternate_key={"new_code": f"upsert-{i}"}, + record={"new_Label": f"upsert-updated-{i}", "new_Index": i + 1000}, + ) + for i in range(n) + ] + + log_call( + f"client.records.upsert('{TABLE}', [{n} items]) # update pass (same keys), {expected_chunks(n)} chunk(s)" + ) + _, elapsed = timed(lambda i=update_items: backoff(lambda: client.records.upsert(TABLE, i))) + print(f" second upsert completed in {elapsed}s") + + after_update = count_records(client, pk_attr) + check( + after_update == n, f"n={n:5d}: {after_update}/{n} records after update pass (no duplicates) [{elapsed}s]" + ) + + log_call("count_records(filter='new_index gt 999') # verify values updated") + updated_count = count_records(client, pk_attr, filter_expr="new_index gt 999") + check(updated_count == n, f"n={n:5d}: {updated_count}/{n} records have updated new_Index (>999) [{elapsed}s]") + + +# --------------------------------------------------------------------------- +# Setup / teardown +# --------------------------------------------------------------------------- + + +def setup_table(client): + """Create table and alternate key; return the primary ID attribute name.""" + print("\n" + "=" * 80) + print("SETUP") + print("=" * 80) + + log_call(f"client.tables.get('{TABLE}')") + table_info = backoff(lambda: client.tables.get(TABLE)) + + if table_info: + print(f"[OK] Table already exists: {TABLE}") + else: + log_call(f"client.tables.create('{TABLE}', {{...}})") + table_info = backoff( + lambda: client.tables.create( + TABLE, + { + "new_Label": "string", + "new_Index": "int", + "new_Code": "string", + }, + ) + ) + print(f"[OK] Created table: {TABLE}") + + pk_attr = table_info.primary_id_attribute + print(f"[OK] Primary ID attribute: {pk_attr}") + + log_call(f"delete_all (clear any leftovers from a previous run)") + delete_all(client, pk_attr) + + # TODO: alternate key + upsert tests are commented out because index creation + # can take several minutes on Dataverse. Uncomment to run upsert verification. + # log_call(f"client.tables.add_alternate_key('{TABLE}', 'new_ChunkCodeKey', ['new_code'])") + # try: + # backoff(lambda: client.tables.add_alternate_key(TABLE, "new_ChunkCodeKey", ["new_code"])) + # print("[OK] Added alternate key on new_Code") + # except Exception as ex: # noqa: BLE001 + # print(f"[OK] Alternate key already exists (skipped): {ex}") + # + # log_call("client.tables.get_alternate_keys # poll until Active (index build can take several minutes)") + # deadline = time.time() + 600 + # while time.time() < deadline: + # keys = backoff(lambda: client.tables.get_alternate_keys(TABLE)) + # print(f" all keys: {[(k.schema_name, k.status) for k in keys]}") + # match = next((k for k in keys if k.schema_name.lower() == "new_chunkcodekey"), None) + # status = match.status if match else "missing" + # print(f" new_ChunkCodeKey status: {status}") + # if status == "Active": + # break + # if status == "Failed": + # raise RuntimeError("Alternate key index creation failed — check Dataverse solution health.") + # time.sleep(15) + # else: + # raise RuntimeError("Timed out waiting for alternate key to become Active (>600s).") + + print("[OK] Setup complete.") + return pk_attr + + +def teardown_table(client): + print("\n" + "=" * 80) + print("TEARDOWN") + print("=" * 80) + + log_call(f"client.tables.delete('{TABLE}')") + try: + backoff(lambda: client.tables.delete(TABLE)) + print(f"[OK] Deleted table: {TABLE}") + except MetadataError as ex: + if "not found" in str(ex).lower(): + print(f"[OK] Table already removed: {TABLE}") + else: + raise + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main(): + print("=" * 80) + print("Chunking Verification") + print(f"B = {B} (_MULTIPLE_BATCH_SIZE) Sizes: {SIZES}") + print("=" * 80) + print() + print("NOTE: The first API call opens a browser for authentication.") + print("Table creation and alternate key indexing can take several minutes.") + print("=" * 80) + + base_url = "https://aurorabapenv642a3.crmtest.dynamics.com" + print(f"Using org URL: {base_url}") + + log_call("InteractiveBrowserCredential()") + credential = InteractiveBrowserCredential() + + log_call(f"DataverseClient(base_url='{base_url}', credential=...)") + with DataverseClient(base_url=base_url, credential=credential) as client: + print(f"[OK] Connected to: {base_url}") + + pk_attr = setup_table(client) + try: + test_create_multiple(client, pk_attr) + test_update_multiple_broadcast(client, pk_attr) + test_update_multiple_paired(client, pk_attr) + # test_upsert_multiple(client, pk_attr) # TODO: requires alternate key (see setup_table) + finally: + input("\n[PAUSE] Validate table in Maker Portal, then press Enter to proceed with cleanup...") + delete_all(client, pk_attr) + teardown_table(client) + + print("\n" + "=" * 80) + print("RESULTS") + print("=" * 80) + total = _pass + _fail + print(f" Passed: {_pass}/{total}") + print(f" Failed: {_fail}/{total}") + if _fail == 0: + print(" All checks passed.") + else: + print(" Some checks FAILED — review [FAIL] lines above.") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/examples/advanced/dataframe_operations.py b/examples/advanced/dataframe_operations.py index 0a51b4c7..d71eac34 100644 --- a/examples/advanced/dataframe_operations.py +++ b/examples/advanced/dataframe_operations.py @@ -19,7 +19,7 @@ from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models.filters import col, raw +from PowerPlatform.Dataverse.models import col, raw def main(): diff --git a/examples/advanced/datascience_risk_assessment.py b/examples/advanced/datascience_risk_assessment.py index 80dafdc4..dbbd456d 100644 --- a/examples/advanced/datascience_risk_assessment.py +++ b/examples/advanced/datascience_risk_assessment.py @@ -50,7 +50,7 @@ from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models.filters import col, raw +from PowerPlatform.Dataverse.models import col, raw # -- Optional imports (graceful degradation if not installed) ------ diff --git a/examples/advanced/fetchxml.py b/examples/advanced/fetchxml.py index d4ac1e50..28751bf9 100644 --- a/examples/advanced/fetchxml.py +++ b/examples/advanced/fetchxml.py @@ -29,7 +29,7 @@ from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.errors import MetadataError +from PowerPlatform.Dataverse.core import MetadataError import requests # --------------------------------------------------------------------------- diff --git a/examples/advanced/prodev_quick_start.py b/examples/advanced/prodev_quick_start.py index d06e058f..223f61ae 100644 --- a/examples/advanced/prodev_quick_start.py +++ b/examples/advanced/prodev_quick_start.py @@ -56,7 +56,7 @@ from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models.filters import col +from PowerPlatform.Dataverse.models import col # -- Table schema names -- # Uses the standard 'new_' publisher prefix (default Dataverse publisher). diff --git a/examples/advanced/relationships.py b/examples/advanced/relationships.py index c0a8baa1..eae76225 100644 --- a/examples/advanced/relationships.py +++ b/examples/advanced/relationships.py @@ -20,13 +20,14 @@ import time from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.models.relationship import ( +from PowerPlatform.Dataverse.models import ( + CascadeConfiguration, + Label, + LocalizedLabel, LookupAttributeMetadata, - OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, - CascadeConfiguration, + OneToManyRelationshipMetadata, ) -from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel from PowerPlatform.Dataverse.common.constants import ( CASCADE_BEHAVIOR_NO_CASCADE, CASCADE_BEHAVIOR_REMOVE_LINK, diff --git a/examples/advanced/sql_examples.py b/examples/advanced/sql_examples.py index 372a3567..fc041ac8 100644 --- a/examples/advanced/sql_examples.py +++ b/examples/advanced/sql_examples.py @@ -46,7 +46,7 @@ import pandas as pd from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.errors import MetadataError +from PowerPlatform.Dataverse.core import MetadataError import requests # --------------------------------------------------------------------------- @@ -322,7 +322,7 @@ def _run_examples(client): "infrastructure. Specify columns explicitly instead.\n" "Use client.query.sql_columns('account') to discover column names." ) - from PowerPlatform.Dataverse.core.errors import ValidationError as _VE + from PowerPlatform.Dataverse.core import ValidationError as _VE try: client.query.sql(f"SELECT * FROM {parent_table}") diff --git a/examples/advanced/walkthrough.py b/examples/advanced/walkthrough.py index d2cc4ff9..5e6533e4 100644 --- a/examples/advanced/walkthrough.py +++ b/examples/advanced/walkthrough.py @@ -25,9 +25,8 @@ from enum import IntEnum from azure.identity import InteractiveBrowserCredential from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.errors import MetadataError -from PowerPlatform.Dataverse.models.filters import col -from PowerPlatform.Dataverse.models.query_builder import ExpandOption +from PowerPlatform.Dataverse.core import MetadataError +from PowerPlatform.Dataverse.models import ExpandOption, col import requests diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index e482f4a1..b1c77fb0 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -33,19 +33,20 @@ # Import SDK components (assumes installation is already validated) from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError -from PowerPlatform.Dataverse.models.relationship import ( +from PowerPlatform.Dataverse.core import HttpError, MetadataError +from PowerPlatform.Dataverse.models import ( + CascadeConfiguration, + Label, + LocalizedLabel, LookupAttributeMetadata, - OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, - CascadeConfiguration, + OneToManyRelationshipMetadata, + UpsertItem, ) -from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel from PowerPlatform.Dataverse.common.constants import ( CASCADE_BEHAVIOR_NO_CASCADE, CASCADE_BEHAVIOR_REMOVE_LINK, ) -from PowerPlatform.Dataverse.models.upsert import UpsertItem from azure.identity import InteractiveBrowserCredential diff --git a/examples/basic/installation_example.py b/examples/basic/installation_example.py index 61da149b..255b6ace 100644 --- a/examples/basic/installation_example.py +++ b/examples/basic/installation_example.py @@ -60,10 +60,7 @@ from typing import Optional from datetime import datetime -from PowerPlatform.Dataverse.operations.records import RecordOperations -from PowerPlatform.Dataverse.operations.query import QueryOperations -from PowerPlatform.Dataverse.operations.tables import TableOperations -from PowerPlatform.Dataverse.operations.files import FileOperations +from PowerPlatform.Dataverse.operations import FileOperations, QueryOperations, RecordOperations, TableOperations def validate_imports(): @@ -81,11 +78,11 @@ def validate_imports(): print(f" [OK] Client class: PowerPlatform.Dataverse.client.DataverseClient") # Test submodule imports - from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError + from PowerPlatform.Dataverse.core import HttpError, MetadataError print(f" [OK] Core errors: HttpError, MetadataError") - from PowerPlatform.Dataverse.core.config import DataverseConfig + from PowerPlatform.Dataverse.core import DataverseConfig print(f" [OK] Core config: DataverseConfig") diff --git a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md index d25815d7..a0dbb307 100644 --- a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md +++ b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md @@ -212,7 +212,7 @@ client.records.update("account", [id1, id2, id3], {"industry": "Technology"}) Creates or updates records identified by alternate keys. Single item -> PATCH; multiple items -> `UpsertMultiple` bulk action. > **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. ```python -from PowerPlatform.Dataverse.models.upsert import UpsertItem +from PowerPlatform.Dataverse.models import UpsertItem # Single upsert client.records.upsert("account", [ @@ -403,12 +403,12 @@ client.tables.delete("new_Product") #### Create One-to-Many Relationship ```python -from PowerPlatform.Dataverse.models.relationship import ( - LookupAttributeMetadata, - OneToManyRelationshipMetadata, +from PowerPlatform.Dataverse.models import ( + CascadeConfiguration, Label, LocalizedLabel, - CascadeConfiguration, + LookupAttributeMetadata, + OneToManyRelationshipMetadata, ) from PowerPlatform.Dataverse.common.constants import CASCADE_BEHAVIOR_REMOVE_LINK @@ -435,7 +435,7 @@ print(f"Created lookup field: {result['lookup_schema_name']}") #### Create Many-to-Many Relationship ```python -from PowerPlatform.Dataverse.models.relationship import ManyToManyRelationshipMetadata +from PowerPlatform.Dataverse.models import ManyToManyRelationshipMetadata relationship = ManyToManyRelationshipMetadata( schema_name="new_employee_project", @@ -532,12 +532,12 @@ print(f"Succeeded: {len(result.succeeded)}, Failed: {len(result.failed)}") The SDK provides structured exceptions with detailed error information: ```python -from PowerPlatform.Dataverse.core.errors import ( +from PowerPlatform.Dataverse.core import ( DataverseError, HttpError, - ValidationError, MetadataError, - SQLParseError + SQLParseError, + ValidationError, ) from PowerPlatform.Dataverse.client import DataverseClient diff --git a/src/PowerPlatform/Dataverse/models/filters.py b/src/PowerPlatform/Dataverse/models/filters.py index 2a7929cc..4389e3ee 100644 --- a/src/PowerPlatform/Dataverse/models/filters.py +++ b/src/PowerPlatform/Dataverse/models/filters.py @@ -10,7 +10,7 @@ Example:: - from PowerPlatform.Dataverse.models.filters import col, raw + from PowerPlatform.Dataverse.models import col, raw # Preferred GA idiom — col() proxy expr = col("statecode") == 0 @@ -373,7 +373,7 @@ class ColumnProxy: Example:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col expr = col("statecode") == 0 # equality expr = col("revenue") > 1_000_000 # comparison @@ -512,7 +512,7 @@ def col(name: str) -> ColumnProxy: This is the preferred GA idiom for constructing filter expressions:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col expr = col("statecode") == 0 expr = col("revenue") > 1_000_000 diff --git a/src/PowerPlatform/Dataverse/models/query_builder.py b/src/PowerPlatform/Dataverse/models/query_builder.py index 0ddd2782..c53a4929 100644 --- a/src/PowerPlatform/Dataverse/models/query_builder.py +++ b/src/PowerPlatform/Dataverse/models/query_builder.py @@ -10,7 +10,7 @@ Example:: # Via client (recommended) -- flat iteration over records - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col for record in (client.query.builder("account") .select("name", "revenue") @@ -22,7 +22,7 @@ print(record["name"]) # With composable expression tree - from PowerPlatform.Dataverse.models.filters import col, raw + from PowerPlatform.Dataverse.models import col, raw for record in (client.query.builder("account") .select("name", "revenue") @@ -245,7 +245,7 @@ def where(self, expression: filters.FilterExpression) -> Self: Example:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col query = (QueryBuilder("account") .where((col("statecode") == 0) | (col("statecode") == 1)) @@ -461,7 +461,7 @@ class QueryBuilder(_QueryBuilderBase): Example: Standalone query construction:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col query = (QueryBuilder("account") .select("name") @@ -506,7 +506,7 @@ def execute(self, *, by_page=_BY_PAGE_UNSET) -> Union[QueryResult, Iterator[Quer Example:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col for record in (client.query.builder("account") .select("name") @@ -587,7 +587,7 @@ def execute_pages(self) -> Iterator[QueryResult]: Example:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col for page in (client.query.builder("account") .select("name") @@ -652,7 +652,7 @@ def to_dataframe(self) -> pd.DataFrame: Example:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col df = (client.query.builder("account") .select("name", "telephone1") diff --git a/src/PowerPlatform/Dataverse/operations/batch.py b/src/PowerPlatform/Dataverse/operations/batch.py index e88b6068..e55c874b 100644 --- a/src/PowerPlatform/Dataverse/operations/batch.py +++ b/src/PowerPlatform/Dataverse/operations/batch.py @@ -327,7 +327,7 @@ def upsert( Example:: - from PowerPlatform.Dataverse.models.upsert import UpsertItem + from PowerPlatform.Dataverse.models import UpsertItem batch.records.upsert("account", [ UpsertItem( diff --git a/src/PowerPlatform/Dataverse/operations/query.py b/src/PowerPlatform/Dataverse/operations/query.py index 1f3c8ef2..bf8351c2 100644 --- a/src/PowerPlatform/Dataverse/operations/query.py +++ b/src/PowerPlatform/Dataverse/operations/query.py @@ -33,7 +33,7 @@ class QueryOperations: Example:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col client = DataverseClient(base_url, credential) @@ -72,7 +72,7 @@ def builder(self, table: str) -> QueryBuilder: Example: Build and execute a query fluently:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col for record in (client.query.builder("account") .select("name", "revenue") @@ -86,7 +86,7 @@ def builder(self, table: str) -> QueryBuilder: With composable expression tree:: - from PowerPlatform.Dataverse.models.filters import col + from PowerPlatform.Dataverse.models import col for record in (client.query.builder("account") .where((col("statecode") == 0) | (col("statecode") == 1)) diff --git a/src/PowerPlatform/Dataverse/operations/records.py b/src/PowerPlatform/Dataverse/operations/records.py index 70f05e25..0a9f7b4c 100644 --- a/src/PowerPlatform/Dataverse/operations/records.py +++ b/src/PowerPlatform/Dataverse/operations/records.py @@ -703,7 +703,7 @@ def upsert(self, table: str, items: List[Union[UpsertItem, Dict[str, Any]]]) -> Example: Upsert a single record using ``UpsertItem``:: - from PowerPlatform.Dataverse.models.upsert import UpsertItem + from PowerPlatform.Dataverse.models import UpsertItem client.records.upsert("account", [ UpsertItem( @@ -723,7 +723,7 @@ def upsert(self, table: str, items: List[Union[UpsertItem, Dict[str, Any]]]) -> Upsert multiple records using ``UpsertItem``:: - from PowerPlatform.Dataverse.models.upsert import UpsertItem + from PowerPlatform.Dataverse.models import UpsertItem client.records.upsert("account", [ UpsertItem( diff --git a/src/PowerPlatform/Dataverse/operations/tables.py b/src/PowerPlatform/Dataverse/operations/tables.py index df9f8404..b24cb1a1 100644 --- a/src/PowerPlatform/Dataverse/operations/tables.py +++ b/src/PowerPlatform/Dataverse/operations/tables.py @@ -344,7 +344,7 @@ def create_one_to_many_relationship( Example: Create a one-to-many relationship: Department (1) -> Employee (N):: - from PowerPlatform.Dataverse.models.relationship import ( + from PowerPlatform.Dataverse.models import ( LookupAttributeMetadata, OneToManyRelationshipMetadata, Label, @@ -420,7 +420,7 @@ def create_many_to_many_relationship( Example: Create a many-to-many relationship: Employee <-> Project:: - from PowerPlatform.Dataverse.models.relationship import ( + from PowerPlatform.Dataverse.models import ( ManyToManyRelationshipMetadata, ) From cc8f4e49883c366932092607df2e4f1b9b904e50 Mon Sep 17 00:00:00 2001 From: Abel Milash Date: Fri, 22 May 2026 00:08:43 -0700 Subject: [PATCH 18/18] Remove accidentally committed chunking_verification.py Co-Authored-By: Claude Sonnet 4.6 --- examples/advanced/chunking_verification.py | 413 --------------------- 1 file changed, 413 deletions(-) delete mode 100644 examples/advanced/chunking_verification.py diff --git a/examples/advanced/chunking_verification.py b/examples/advanced/chunking_verification.py deleted file mode 100644 index fd20a378..00000000 --- a/examples/advanced/chunking_verification.py +++ /dev/null @@ -1,413 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -""" -Chunking verification for CreateMultiple / UpdateMultiple / UpsertMultiple. - -Tests auto-chunking at every boundary relative to _MULTIPLE_BATCH_SIZE (B = 1000): - - 0 records (no-op) - - 1 record (well below B) - - B-1 (just under one full chunk) - - B (exactly one chunk) - - B+1 (spills into a second chunk) - - 2*B (exactly two full chunks) - - 2*B+1 (spills into a third chunk) - -For update, both broadcast (one patch for all IDs) and paired (per-record patches) are tested. - -Prerequisites: -- pip install PowerPlatform-Dataverse-Client -- pip install azure-identity -""" - -import argparse -import time -from azure.identity import InteractiveBrowserCredential -from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse.core import MetadataError -from PowerPlatform.Dataverse.models import UpsertItem - -B = 1000 # Must match _MULTIPLE_BATCH_SIZE in _odata.py - -SIZES = [0, 1, B - 1, B, B + 1, 2 * B, 2 * B + 1] - -TABLE = "new_ChunkingVerification" - -# Global pass/fail counters -_pass = 0 -_fail = 0 - - -# Simple logging helper (mirrors walkthrough style) -def log_call(description): - print(f"\n-> {description}") - - -def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): - last = None - total_delay = 0 - attempts = 0 - for d in delays: - if d: - time.sleep(d) - total_delay += d - attempts += 1 - try: - result = op() - if attempts > 1: - retry_count = attempts - 1 - print(f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total.") - return result - except Exception as ex: # noqa: BLE001 - last = ex - continue - if last: - if attempts: - retry_count = max(attempts - 1, 0) - print(f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total.") - raise last - - -def check(condition, msg): - global _pass, _fail - if condition: - _pass += 1 - print(f"[OK] {msg}") - else: - _fail += 1 - print(f"[FAIL] {msg}") - - -def timed(op): - """Run op(), return (result, elapsed_seconds).""" - t0 = time.time() - result = op() - return result, round(time.time() - t0, 2) - - -def count_records(client, pk_attr, filter_expr=None): - """Return total record count by paging with minimal field selection.""" - total = 0 - kwargs = {"select": [pk_attr]} - if filter_expr: - kwargs["filter"] = filter_expr - for page in client.records.get(TABLE, **kwargs): - total += len(page) - return total - - -def delete_all(client, pk_attr): - """Delete all records in the test table via synchronous $batch chunks. - - Uses $batch (not BulkDelete) so the table is guaranteed empty on return. - BulkDelete is asynchronous — it returns before records are removed, which - would corrupt record counts in subsequent test iterations. - """ - log_call(f"delete_all: fetching IDs from {TABLE}") - ids = [] - for page in client.records.get(TABLE, select=[pk_attr]): - ids.extend(r[pk_attr] for r in page) - if not ids: - print(f"[OK] {TABLE} is already empty.") - return - log_call(f"delete_all: deleting {len(ids)} records via $batch (chunks of {B})") - for i in range(0, len(ids), B): - chunk = ids[i : i + B] - batch = client.batch.new() - for record_id in chunk: - batch.records.delete(TABLE, record_id) - backoff(lambda b=batch: b.execute(continue_on_error=True)) - print(f" chunk {i // B + 1}: deleted {len(chunk)} records") - print(f"[OK] Deleted {len(ids)} records from {TABLE}.") - - -def make_records(n, *, marker="create"): - """Build n record payloads with a unique marker and sequential index.""" - return [{"new_Label": f"{marker}-{i}", "new_Index": i} for i in range(n)] - - -def make_upsert_items(n, *, marker="upsert"): - """Build n UpsertItems using new_Code as the alternate key.""" - return [ - UpsertItem( - alternate_key={"new_code": f"{marker}-{i}"}, - record={"new_Label": f"{marker}-label-{i}", "new_Index": i}, - ) - for i in range(n) - ] - - -def expected_chunks(n): - """Return the number of chunks n records will be split into.""" - return max(1, -(-n // B)) if n > 0 else 0 # ceiling division - - -# --------------------------------------------------------------------------- -# Test sections -# --------------------------------------------------------------------------- - - -def test_create_multiple(client, pk_attr): - print("\n" + "=" * 80) - print("CREATE MULTIPLE — boundary sizes") - print("=" * 80) - - for n in SIZES: - print(f"\n-- n={n} ({expected_chunks(n)} chunk(s) expected) --") - delete_all(client, pk_attr) - - if n == 0: - log_call(f"client.records.create('{TABLE}', []) # empty list — no HTTP call expected") - actual = count_records(client, pk_attr) - check(actual == 0, f"n={n:5d}: server count=0 (empty create is a no-op)") - continue - - records = make_records(n) - log_call(f"client.records.create('{TABLE}', [{n} records]) # {expected_chunks(n)} chunk(s)") - ids, elapsed = timed(lambda r=records: backoff(lambda: client.records.create(TABLE, r))) - print(f" create returned {len(ids)} IDs in {elapsed}s") - - log_call(f"count_records (expected {n})") - actual = count_records(client, pk_attr) - - check(len(ids) == n, f"n={n:5d}: IDs returned={len(ids)} (expected {n}) [{elapsed}s]") - check(actual == n, f"n={n:5d}: server count={actual} (expected {n})") - - -def test_update_multiple_broadcast(client, pk_attr): - print("\n" + "=" * 80) - print("UPDATE MULTIPLE — broadcast (same patch for all IDs)") - print("=" * 80) - - for n in SIZES: - print(f"\n-- n={n} ({expected_chunks(n)} chunk(s) expected) --") - if n == 0: - print("[OK] n=0: skipped (no records to update)") - continue - - delete_all(client, pk_attr) - records = make_records(n) - log_call(f"client.records.create('{TABLE}', [{n} records]) # seed") - ids, elapsed = timed(lambda r=records: backoff(lambda: client.records.create(TABLE, r))) - print(f" seeded {len(ids)} records in {elapsed}s") - - log_call( - f"client.records.update('{TABLE}', [{n} IDs], {{'new_Index': 9999}}) # broadcast, {expected_chunks(n)} chunk(s)" - ) - _, elapsed = timed(lambda i=ids: backoff(lambda: client.records.update(TABLE, i, {"new_Index": 9999}))) - print(f" update completed in {elapsed}s") - - log_call("count_records(filter='new_index eq 9999')") - updated = count_records(client, pk_attr, filter_expr="new_index eq 9999") - check(updated == n, f"n={n:5d}: {updated}/{n} records have new_Index=9999 [{elapsed}s]") - - -def test_update_multiple_paired(client, pk_attr): - print("\n" + "=" * 80) - print("UPDATE MULTIPLE — paired (per-record patches)") - print("=" * 80) - - for n in SIZES: - print(f"\n-- n={n} ({expected_chunks(n)} chunk(s) expected) --") - if n == 0: - print("[OK] n=0: skipped (no records to update)") - continue - - delete_all(client, pk_attr) - records = make_records(n) - log_call(f"client.records.create('{TABLE}', [{n} records]) # seed") - ids, elapsed = timed(lambda r=records: backoff(lambda: client.records.create(TABLE, r))) - print(f" seeded {len(ids)} records in {elapsed}s") - - patches = [{"new_Index": n - 1 - i} for i in range(n)] - log_call(f"client.records.update('{TABLE}', [{n} IDs], [{n} patches]) # paired, {expected_chunks(n)} chunk(s)") - _, elapsed = timed(lambda i=ids, p=patches: backoff(lambda: client.records.update(TABLE, i, p))) - print(f" update completed in {elapsed}s") - - log_call("sum new_index across all records (expect n*(n-1)/2)") - total_index = 0 - for page in client.records.get(TABLE, select=["new_index"]): - total_index += sum(r.get("new_index", 0) for r in page) - expected_sum = n * (n - 1) // 2 - check( - total_index == expected_sum, - f"n={n:5d}: index sum={total_index} (expected {expected_sum}) [{elapsed}s]", - ) - - -def test_upsert_multiple(client, pk_attr): - print("\n" + "=" * 80) - print("UPSERT MULTIPLE — insert then update via alternate key") - print("=" * 80) - - for n in SIZES: - print(f"\n-- n={n} ({expected_chunks(n)} chunk(s) expected) --") - if n == 0: - print("[OK] n=0: skipped (no records to upsert)") - continue - - delete_all(client, pk_attr) - items = make_upsert_items(n) - - log_call(f"client.records.upsert('{TABLE}', [{n} items]) # insert pass, {expected_chunks(n)} chunk(s)") - _, elapsed = timed(lambda i=items: backoff(lambda: client.records.upsert(TABLE, i))) - print(f" first upsert completed in {elapsed}s") - after_insert = count_records(client, pk_attr) - check(after_insert == n, f"n={n:5d}: {after_insert}/{n} records after insert pass [{elapsed}s]") - - update_items = [ - UpsertItem( - alternate_key={"new_code": f"upsert-{i}"}, - record={"new_Label": f"upsert-updated-{i}", "new_Index": i + 1000}, - ) - for i in range(n) - ] - - log_call( - f"client.records.upsert('{TABLE}', [{n} items]) # update pass (same keys), {expected_chunks(n)} chunk(s)" - ) - _, elapsed = timed(lambda i=update_items: backoff(lambda: client.records.upsert(TABLE, i))) - print(f" second upsert completed in {elapsed}s") - - after_update = count_records(client, pk_attr) - check( - after_update == n, f"n={n:5d}: {after_update}/{n} records after update pass (no duplicates) [{elapsed}s]" - ) - - log_call("count_records(filter='new_index gt 999') # verify values updated") - updated_count = count_records(client, pk_attr, filter_expr="new_index gt 999") - check(updated_count == n, f"n={n:5d}: {updated_count}/{n} records have updated new_Index (>999) [{elapsed}s]") - - -# --------------------------------------------------------------------------- -# Setup / teardown -# --------------------------------------------------------------------------- - - -def setup_table(client): - """Create table and alternate key; return the primary ID attribute name.""" - print("\n" + "=" * 80) - print("SETUP") - print("=" * 80) - - log_call(f"client.tables.get('{TABLE}')") - table_info = backoff(lambda: client.tables.get(TABLE)) - - if table_info: - print(f"[OK] Table already exists: {TABLE}") - else: - log_call(f"client.tables.create('{TABLE}', {{...}})") - table_info = backoff( - lambda: client.tables.create( - TABLE, - { - "new_Label": "string", - "new_Index": "int", - "new_Code": "string", - }, - ) - ) - print(f"[OK] Created table: {TABLE}") - - pk_attr = table_info.primary_id_attribute - print(f"[OK] Primary ID attribute: {pk_attr}") - - log_call(f"delete_all (clear any leftovers from a previous run)") - delete_all(client, pk_attr) - - # TODO: alternate key + upsert tests are commented out because index creation - # can take several minutes on Dataverse. Uncomment to run upsert verification. - # log_call(f"client.tables.add_alternate_key('{TABLE}', 'new_ChunkCodeKey', ['new_code'])") - # try: - # backoff(lambda: client.tables.add_alternate_key(TABLE, "new_ChunkCodeKey", ["new_code"])) - # print("[OK] Added alternate key on new_Code") - # except Exception as ex: # noqa: BLE001 - # print(f"[OK] Alternate key already exists (skipped): {ex}") - # - # log_call("client.tables.get_alternate_keys # poll until Active (index build can take several minutes)") - # deadline = time.time() + 600 - # while time.time() < deadline: - # keys = backoff(lambda: client.tables.get_alternate_keys(TABLE)) - # print(f" all keys: {[(k.schema_name, k.status) for k in keys]}") - # match = next((k for k in keys if k.schema_name.lower() == "new_chunkcodekey"), None) - # status = match.status if match else "missing" - # print(f" new_ChunkCodeKey status: {status}") - # if status == "Active": - # break - # if status == "Failed": - # raise RuntimeError("Alternate key index creation failed — check Dataverse solution health.") - # time.sleep(15) - # else: - # raise RuntimeError("Timed out waiting for alternate key to become Active (>600s).") - - print("[OK] Setup complete.") - return pk_attr - - -def teardown_table(client): - print("\n" + "=" * 80) - print("TEARDOWN") - print("=" * 80) - - log_call(f"client.tables.delete('{TABLE}')") - try: - backoff(lambda: client.tables.delete(TABLE)) - print(f"[OK] Deleted table: {TABLE}") - except MetadataError as ex: - if "not found" in str(ex).lower(): - print(f"[OK] Table already removed: {TABLE}") - else: - raise - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -def main(): - print("=" * 80) - print("Chunking Verification") - print(f"B = {B} (_MULTIPLE_BATCH_SIZE) Sizes: {SIZES}") - print("=" * 80) - print() - print("NOTE: The first API call opens a browser for authentication.") - print("Table creation and alternate key indexing can take several minutes.") - print("=" * 80) - - base_url = "https://aurorabapenv642a3.crmtest.dynamics.com" - print(f"Using org URL: {base_url}") - - log_call("InteractiveBrowserCredential()") - credential = InteractiveBrowserCredential() - - log_call(f"DataverseClient(base_url='{base_url}', credential=...)") - with DataverseClient(base_url=base_url, credential=credential) as client: - print(f"[OK] Connected to: {base_url}") - - pk_attr = setup_table(client) - try: - test_create_multiple(client, pk_attr) - test_update_multiple_broadcast(client, pk_attr) - test_update_multiple_paired(client, pk_attr) - # test_upsert_multiple(client, pk_attr) # TODO: requires alternate key (see setup_table) - finally: - input("\n[PAUSE] Validate table in Maker Portal, then press Enter to proceed with cleanup...") - delete_all(client, pk_attr) - teardown_table(client) - - print("\n" + "=" * 80) - print("RESULTS") - print("=" * 80) - total = _pass + _fail - print(f" Passed: {_pass}/{total}") - print(f" Failed: {_fail}/{total}") - if _fail == 0: - print(" All checks passed.") - else: - print(" Some checks FAILED — review [FAIL] lines above.") - print("=" * 80) - - -if __name__ == "__main__": - main()