Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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", [
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
101 changes: 101 additions & 0 deletions docs/spec-module-level-exports.md
Original file line number Diff line number Diff line change
@@ -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.)
Comment on lines +56 to +60
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec lists exports that don't match the current implementation: models.filters does not define and_/or_/not_ helpers (composition is via &, |, ~), and relationship classes are named OneToManyRelationshipMetadata/ManyToManyRelationshipMetadata (not OneToManyRelationship/ManyToManyRelationship). To avoid confusion for contributors, update this spec section to reflect the actual public symbols (or add the missing helpers/types if they are intended).

Suggested change
- `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.filters``eq`, `ne`, `gt`, `lt`, `ge`, `le`, `contains`, `startswith`, `endswith`, `filter_in`, `between`
- `models.batch``BatchItemResponse`, `BatchResult`
- `models.record``Record`
- `models.table_info``TableInfo`, `ColumnInfo`, `AlternateKeyInfo`
- `models.relationship``OneToManyRelationshipMetadata`, `ManyToManyRelationshipMetadata`, `RelationshipInfo` (etc.)

Copilot uses AI. Check for mistakes.
- `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.
2 changes: 1 addition & 1 deletion examples/advanced/alternate_keys_upsert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
47 changes: 24 additions & 23 deletions examples/advanced/relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,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})")
Expand Down Expand Up @@ -236,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)
Expand All @@ -265,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
Expand All @@ -292,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
Expand All @@ -308,21 +309,21 @@ 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")

log_call("Retrieving M:N relationship by schema name")

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")

Expand Down
5 changes: 2 additions & 3 deletions examples/advanced/walkthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
13 changes: 7 additions & 6 deletions examples/basic/functional_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
9 changes: 3 additions & 6 deletions examples/basic/installation_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", [
Expand Down Expand Up @@ -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

Expand All @@ -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",
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading