From 073e369bf89d271f1091a9e0cb50673c74635faf Mon Sep 17 00:00:00 2001 From: tpellissier Date: Fri, 30 Jan 2026 12:03:36 -0800 Subject: [PATCH 1/7] Add relationship metadata API for creating table relationships - Add metadata dataclasses (LocalizedLabel, Label, CascadeConfiguration, AssociatedMenuConfiguration, LookupAttributeMetadata, OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata) - Add _RelationshipOperationsMixin with create/get/delete operations - Add create_lookup_field() convenience method to DataverseClient - Add comprehensive unit tests for all new functionality - Add relationships.py example demonstrating the API Co-Authored-By: Claude Opus 4.5 --- README.md | 104 +++-- examples/advanced/file_upload.py | 4 +- examples/advanced/relationships.py | 404 ++++++++++++++++++ examples/advanced/walkthrough.py | 64 +-- examples/basic/functional_testing.py | 134 +++--- examples/basic/installation_example.py | 162 +++---- pyproject.toml | 4 - src/PowerPlatform/Dataverse/client.py | 253 ++++++++++- src/PowerPlatform/Dataverse/data/_odata.py | 3 +- .../Dataverse/data/_relationships.py | 160 +++++++ .../Dataverse/models/__init__.py | 7 +- .../Dataverse/models/metadata.py | 347 +++++++++++++++ tests/unit/data/test_relationships.py | 294 +++++++++++++ tests/unit/models/__init__.py | 4 + tests/unit/models/test_metadata.py | 383 +++++++++++++++++ tests/unit/test_client.py | 216 ++++++++++ 16 files changed, 2316 insertions(+), 227 deletions(-) create mode 100644 examples/advanced/relationships.py create mode 100644 src/PowerPlatform/Dataverse/data/_relationships.py create mode 100644 src/PowerPlatform/Dataverse/models/metadata.py create mode 100644 tests/unit/data/test_relationships.py create mode 100644 tests/unit/models/__init__.py create mode 100644 tests/unit/models/test_metadata.py diff --git a/README.md b/README.md index cd959f1..0ed2459 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac - [Bulk operations](#bulk-operations) - [Query data](#query-data) - [Table management](#table-management) + - [Relationship management](#relationship-management) - [File operations](#file-operations) - [Next steps](#next-steps) - [Troubleshooting](#troubleshooting) @@ -36,6 +37,7 @@ A Python client library for Microsoft Dataverse that provides a unified interfac - **โšก True Bulk Operations**: Automatically uses Dataverse's native `CreateMultiple`, `UpdateMultiple`, and `BulkDelete` Web API operations for maximum performance and transactional integrity - **๐Ÿ“Š SQL Queries**: Execute read-only SQL queries via the Dataverse Web API `?sql=` parameter - **๐Ÿ—๏ธ Table Management**: Create, inspect, and delete custom tables and columns programmatically +- **๐Ÿ”— Relationship Management**: Create one-to-many and many-to-many relationships between tables with full metadata control - **๐Ÿ“Ž File Operations**: Upload files to Dataverse file columns with automatic chunking for large files - **๐Ÿ” Azure Identity**: Built-in authentication using Azure Identity credential providers with comprehensive support - **๐Ÿ›ก๏ธ Error Handling**: Structured exception hierarchy with detailed error context and retry guidance @@ -57,21 +59,7 @@ Install the PowerPlatform Dataverse Client using [pip](https://pypi.org/project/ pip install PowerPlatform-Dataverse-Client ``` -(Optional) Install Claude Skill globally with the Client: - -```bash -pip install PowerPlatform-Dataverse-Client && dataverse-install-claude-skill -``` - -This installs a Claude Skill that enables Claude Code to: -- Apply SDK best practices automatically -- Provide context-aware code suggestions -- Help with error handling and troubleshooting -- Guide you through common patterns - -The skill works with both the Claude Code CLI and VSCode extension. Once installed, Claude will automatically use it when working with Dataverse operations. For more information on Claude Skill see https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview. See skill definition here: `.claude/skills/dataverse-sdk/SKILL.md`. - -For development from source (Claude Skill auto loaded): +For development from source: ```bash git clone https://github.com/microsoft/PowerPlatform-DataverseClient-Python.git @@ -239,16 +227,6 @@ table_info = client.create_table( primary_column_schema_name="new_ProductName" # Optional: custom primary column (default is "{customization prefix value}_Name") ) -# Get table information -info = client.get_table_info("new_Product") -print(f"Logical name: {info['table_logical_name']}") -print(f"Entity set: {info['entity_set_name']}") - -# List all tables -tables = client.list_tables() -for table in tables: - print(table) - # Add columns to existing table (columns must include customization prefix value) client.create_columns("new_Product", {"new_Category": "string"}) @@ -259,9 +237,71 @@ client.delete_columns("new_Product", ["new_Category"]) client.delete_table("new_Product") ``` -> **Important**: All custom column names must include the customization prefix value (e.g., `"new_"`). +> **Important**: All custom column names must include the customization prefix value (e.g., `"new_"`). > This ensures explicit, predictable naming and aligns with Dataverse metadata requirements. +### Relationship management + +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.metadata import ( + LookupAttributeMetadata, + OneToManyRelationshipMetadata, + ManyToManyRelationshipMetadata, + Label, + LocalizedLabel, +) + +# Create a one-to-many relationship: Department (1) -> Employee (N) +# This adds a "Department" lookup field to the Employee table +lookup = LookupAttributeMetadata( + schema_name="new_DepartmentId", + display_name=Label(localized_labels=[LocalizedLabel(label="Department", language_code=1033)]), +) + +relationship = OneToManyRelationshipMetadata( + schema_name="new_Department_Employee", + referenced_entity="new_department", # Parent table (the "one" side) + referencing_entity="new_employee", # Child table (the "many" side) + referenced_attribute="new_departmentid", +) + +result = client.create_one_to_many_relationship(lookup, relationship) +print(f"Created lookup field: {result['lookup_schema_name']}") + +# Create a many-to-many relationship: Employee (N) <-> Project (N) +# Employees work on multiple projects; projects have multiple team members +m2m_relationship = ManyToManyRelationshipMetadata( + schema_name="new_employee_project", + entity1_logical_name="new_employee", + entity2_logical_name="new_project", +) + +result = client.create_many_to_many_relationship(m2m_relationship) +print(f"Created M:N relationship: {result['relationship_schema_name']}") + +# Query relationship metadata +rel = client.get_relationship("new_Department_Employee") +if rel: + print(f"Found: {rel['SchemaName']}") + +# Delete a relationship +client.delete_relationship(result['relationship_id']) +``` + +For simpler scenarios, use the convenience method: + +```python +# Quick way to create a lookup field with sensible defaults +result = client.create_lookup_field( + referencing_table="contact", # Child table gets the lookup field + lookup_field_name="new_AccountId", + referenced_table="account", # Parent table being referenced + display_name="Account", +) +``` + ### File operations ```python @@ -285,7 +325,8 @@ Explore our comprehensive examples in the [`examples/`](https://github.com/micro - **[Functional Testing](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/basic/functional_testing.py)** - Test core functionality in your environment **๐Ÿš€ Advanced Usage:** -- **[Complete Walkthrough](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/walkthrough.py)** - Full feature demonstration with production patterns +- **[Complete Walkthrough](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/walkthrough.py)** - Full feature demonstration with production patterns +- **[Relationship Management](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/relationships.py)** - Create and manage table relationships - **[File Upload](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/advanced/file_upload.py)** - Upload files to Dataverse file columns ๐Ÿ“– See the [examples README](https://github.com/microsoft/PowerPlatform-DataverseClient-Python/blob/main/examples/README.md) for detailed guidance and learning progression. @@ -326,9 +367,9 @@ except ValidationError as e: ### Authentication issues -**Common fixes:** +**Common fixes:** - Verify environment URL format: `https://yourorg.crm.dynamics.com` (no trailing slash) -- Ensure Azure Identity credentials have proper Dataverse permissions +- Ensure Azure Identity credentials have proper Dataverse permissions - Check app registration permissions are granted and admin-consented ### Performance considerations @@ -337,7 +378,7 @@ For optimal performance in production environments: | Best Practice | Description | |---------------|-------------| -| **Bulk Operations** | Pass lists to `create()`, `update()` for automatic bulk processing, for `delete()`, set `use_bulk_delete` when passing lists to use bulk operation | +| **Bulk Operations** | Pass lists to `create()`, `update()`, and `delete()` for automatic bulk processing | | **Select Fields** | Specify `select` parameter to limit returned columns and reduce payload size | | **Page Size Control** | Use `top` and `page_size` parameters to control memory usage | | **Connection Reuse** | Reuse `DataverseClient` instances across operations | @@ -347,8 +388,7 @@ For optimal performance in production environments: ### Limitations - SQL queries are **read-only** and support a limited subset of SQL syntax -- Create Table supports a limited number of column types. Lookup columns are not yet supported. -- Creating relationships between tables is not yet supported. +- Create Table supports a limited number of column types (string, int, decimal, bool, datetime, picklist) - File uploads are limited by Dataverse file size restrictions (default 128MB per file) ## Contributing diff --git a/examples/advanced/file_upload.py b/examples/advanced/file_upload.py index d3499b7..60f9495 100644 --- a/examples/advanced/file_upload.py +++ b/examples/advanced/file_upload.py @@ -167,7 +167,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if attempts > 1: retry_count = attempts - 1 print( - f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." + f" โ†บ Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." ) return result except Exception as ex: # noqa: BLE001 @@ -177,7 +177,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if attempts: retry_count = max(attempts - 1, 0) print( - f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." + f" โš  Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." ) raise last diff --git a/examples/advanced/relationships.py b/examples/advanced/relationships.py new file mode 100644 index 0000000..eb77188 --- /dev/null +++ b/examples/advanced/relationships.py @@ -0,0 +1,404 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Relationship Management Example for Dataverse SDK. + +This example demonstrates: +- Creating one-to-many relationships using the core SDK API +- Creating lookup fields using the convenience method +- Creating many-to-many relationships +- Querying and deleting relationships +- Working with relationship metadata types + +Prerequisites: +- pip install PowerPlatform-Dataverse-Client +- pip install azure-identity +""" + +import sys +import time +from azure.identity import InteractiveBrowserCredential +from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.models.metadata import ( + LookupAttributeMetadata, + OneToManyRelationshipMetadata, + ManyToManyRelationshipMetadata, + Label, + LocalizedLabel, + CascadeConfiguration, + AssociatedMenuConfiguration, +) + + +# Simple logging helper +def log_call(description): + print(f"\n-> {description}") + + +def delete_relationship_if_exists(client, schema_name): + """Delete a relationship by schema name if it exists.""" + rel = client.get_relationship(schema_name) + if rel: + rel_id = rel.get("MetadataId") + if rel_id: + client.delete_relationship(rel_id) + print(f" (Cleaned up existing relationship: {schema_name})") + return True + return False + + +def cleanup_previous_run(client): + """Clean up any resources from a previous run to make the example idempotent.""" + print("\n-> Checking for resources from previous runs...") + + # Known relationship names created by this example + relationships = [ + "new_Department_Employee", + "contact_new_employee_new_ManagerId", + "new_employee_project", + ] + + # Known table names created by this example + tables = ["new_Employee", "new_Department", "new_Project"] + + # Delete relationships first (required before tables can be deleted) + for rel_name in relationships: + try: + delete_relationship_if_exists(client, rel_name) + except Exception as e: + print(f" [WARN] Could not delete relationship {rel_name}: {e}") + + # Delete tables + for table_name in tables: + try: + if client.get_table_info(table_name): + client.delete_table(table_name) + print(f" (Cleaned up existing table: {table_name})") + except Exception as e: + print(f" [WARN] Could not delete table {table_name}: {e}") + + +def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): + """Retry helper with exponential backoff.""" + 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" * 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 main(): + # Initialize relationship IDs to None for cleanup safety + rel_id_1 = None + rel_id_2 = None + rel_id_3 = None + + print("=" * 80) + print("Dataverse SDK - Relationship Management Example") + print("=" * 80) + + # ============================================================================ + # 1. SETUP & AUTHENTICATION + # ============================================================================ + print("\n" + "=" * 80) + print("1. Setup & Authentication") + print("=" * 80) + + base_url = input("Enter Dataverse org URL (e.g. https://yourorg.crm.dynamics.com): ").strip() + if not base_url: + print("No URL entered; exiting.") + sys.exit(1) + + base_url = base_url.rstrip("/") + + log_call("InteractiveBrowserCredential()") + credential = InteractiveBrowserCredential() + + log_call(f"DataverseClient(base_url='{base_url}', credential=...)") + client = DataverseClient(base_url=base_url, credential=credential) + print(f"[OK] Connected to: {base_url}") + + # ============================================================================ + # 2. CLEANUP PREVIOUS RUN (Idempotency) + # ============================================================================ + print("\n" + "=" * 80) + print("2. Cleanup Previous Run (Idempotency)") + print("=" * 80) + + cleanup_previous_run(client) + + # ============================================================================ + # 3. CREATE SAMPLE TABLES + # ============================================================================ + print("\n" + "=" * 80) + print("3. Create Sample Tables") + print("=" * 80) + + # Create a parent table (Department) + log_call("Creating 'new_Department' table") + + dept_table = backoff( + lambda: client.create_table( + "new_Department", + { + "new_DepartmentCode": "string", + "new_Budget": "decimal", + }, + ) + ) + print(f"[OK] Created table: {dept_table['table_schema_name']}") + + # Create a child table (Employee) + log_call("Creating 'new_Employee' table") + + emp_table = backoff( + lambda: client.create_table( + "new_Employee", + { + "new_EmployeeNumber": "string", + "new_Salary": "decimal", + }, + ) + ) + print(f"[OK] Created table: {emp_table['table_schema_name']}") + + # Create a project table for many-to-many example + log_call("Creating 'new_Project' table") + + proj_table = backoff( + lambda: client.create_table( + "new_Project", + { + "new_ProjectCode": "string", + "new_StartDate": "datetime", + }, + ) + ) + print(f"[OK] Created table: {proj_table['table_schema_name']}") + + # ============================================================================ + # 4. CREATE ONE-TO-MANY RELATIONSHIP (Core SDK API) + # ============================================================================ + print("\n" + "=" * 80) + print("4. Create One-to-Many Relationship (Core API)") + print("=" * 80) + + log_call("Creating lookup field on Employee referencing Department") + + # Define the lookup attribute metadata + lookup = LookupAttributeMetadata( + schema_name="new_DepartmentId", + display_name=Label(localized_labels=[LocalizedLabel(label="Department", language_code=1033)]), + required_level="None", + ) + + # Define the relationship metadata + relationship = OneToManyRelationshipMetadata( + schema_name="new_Department_Employee", + referenced_entity=dept_table["table_logical_name"], + referencing_entity=emp_table["table_logical_name"], + referenced_attribute=f"{dept_table['table_logical_name']}id", + cascade_configuration=CascadeConfiguration( + delete="RemoveLink", # When department is deleted, remove the link but keep employees + assign="NoCascade", + merge="NoCascade", + ), + associated_menu_configuration=AssociatedMenuConfiguration( + behavior="UseLabel", + group="Details", + label=Label(localized_labels=[LocalizedLabel(label="Employees", language_code=1033)]), + order=10000, + ), + ) + + # Create the relationship + result = backoff( + lambda: client.create_one_to_many_relationship( + lookup=lookup, + relationship=relationship, + ) + ) + + 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"] + + # ============================================================================ + # 5. CREATE LOOKUP FIELD (Convenience Method) + # ============================================================================ + print("\n" + "=" * 80) + print("5. Create Lookup Field (Convenience Method)") + print("=" * 80) + + log_call("Creating lookup field on Employee referencing Contact as Manager") + + # Use the convenience method for simpler scenarios + # An Employee has a Manager (who is a Contact in the system) + result2 = backoff( + lambda: client.create_lookup_field( + referencing_table=emp_table["table_logical_name"], + lookup_field_name="new_ManagerId", + referenced_table="contact", + display_name="Manager", + description="The employee's direct manager", + required=False, + cascade_delete="RemoveLink", + ) + ) + + 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"] + + # ============================================================================ + # 6. CREATE MANY-TO-MANY RELATIONSHIP + # ============================================================================ + print("\n" + "=" * 80) + print("6. Create Many-to-Many Relationship") + print("=" * 80) + + log_call("Creating M:N relationship between Employee and Project") + + # Define many-to-many relationship + m2m_relationship = ManyToManyRelationshipMetadata( + schema_name="new_employee_project", + entity1_logical_name=emp_table["table_logical_name"], + entity2_logical_name=proj_table["table_logical_name"], + entity1_associated_menu_configuration=AssociatedMenuConfiguration( + behavior="UseLabel", + group="Details", + label=Label(localized_labels=[LocalizedLabel(label="Projects", language_code=1033)]), + ), + entity2_associated_menu_configuration=AssociatedMenuConfiguration( + behavior="UseLabel", + group="Details", + label=Label(localized_labels=[LocalizedLabel(label="Team Members", language_code=1033)]), + ), + ) + + result3 = backoff( + lambda: client.create_many_to_many_relationship( + relationship=m2m_relationship, + ) + ) + + print(f"[OK] Created M:N relationship: {result3['relationship_schema_name']}") + print(f" Relationship ID: {result3['relationship_id']}") + + rel_id_3 = result3["relationship_id"] + + # ============================================================================ + # 7. QUERY RELATIONSHIP METADATA + # ============================================================================ + print("\n" + "=" * 80) + print("7. Query Relationship Metadata") + print("=" * 80) + + log_call("Retrieving 1:N relationship by schema name") + + rel_metadata = client.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')}") + else: + print(" Relationship not found") + + log_call("Retrieving M:N relationship by schema name") + + m2m_metadata = client.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')}") + else: + print(" Relationship not found") + + # ============================================================================ + # 8. CLEANUP + # ============================================================================ + print("\n" + "=" * 80) + print("8. Cleanup") + print("=" * 80) + + cleanup = input("\nDelete created relationships and tables? (y/n): ").strip().lower() + + if cleanup == "y": + # Delete relationships first (required before deleting tables) + log_call("Deleting relationships") + try: + if rel_id_1: + backoff(lambda: client.delete_relationship(rel_id_1)) + print(f" [OK] Deleted relationship: new_Department_Employee") + except Exception as e: + print(f" [WARN] Error deleting relationship 1: {e}") + + try: + if rel_id_2: + backoff(lambda: client.delete_relationship(rel_id_2)) + print(f" [OK] Deleted relationship: contact->employee (Manager)") + except Exception as e: + print(f" [WARN] Error deleting relationship 2: {e}") + + try: + if rel_id_3: + backoff(lambda: client.delete_relationship(rel_id_3)) + print(f" [OK] Deleted relationship: new_employee_project") + except Exception as e: + print(f" [WARN] Error deleting relationship 3: {e}") + + # Delete tables + log_call("Deleting tables") + for table_name in ["new_Employee", "new_Department", "new_Project"]: + try: + backoff(lambda name=table_name: client.delete_table(name)) + print(f" [OK] Deleted table: {table_name}") + except Exception as e: + print(f" [WARN] Error deleting {table_name}: {e}") + + print("\n[OK] Cleanup complete") + else: + print("\nSkipping cleanup. Remember to manually delete:") + print(" - Relationships: new_Department_Employee, contact->employee (Manager), new_employee_project") + print(" - Tables: new_Employee, new_Department, new_Project") + + print("\n" + "=" * 80) + print("Example Complete!") + print("=" * 80) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nExample interrupted by user.") + sys.exit(1) + except Exception as e: + print(f"\n\nError: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/examples/advanced/walkthrough.py b/examples/advanced/walkthrough.py index 6016f3e..7cad0e2 100644 --- a/examples/advanced/walkthrough.py +++ b/examples/advanced/walkthrough.py @@ -29,7 +29,7 @@ # Simple logging helper def log_call(description): - print(f"\n-> {description}") + print(f"\nโ†’ {description}") # Define enum for priority picklist @@ -53,7 +53,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if attempts > 1: retry_count = attempts - 1 print( - f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." + f" โ†บ Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." ) return result except Exception as ex: # noqa: BLE001 @@ -63,7 +63,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if attempts: retry_count = max(attempts - 1, 0) print( - f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." + f" โš  Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." ) raise last @@ -92,7 +92,7 @@ def main(): log_call(f"DataverseClient(base_url='{base_url}', credential=...)") client = DataverseClient(base_url=base_url, credential=credential) - print(f"[OK] Connected to: {base_url}") + print(f"โœ“ Connected to: {base_url}") # ============================================================================ # 2. TABLE CREATION (METADATA) @@ -107,7 +107,7 @@ def main(): table_info = backoff(lambda: client.get_table_info(table_name)) if table_info: - print(f"[OK] Table already exists: {table_info.get('table_schema_name')}") + print(f"โœ“ Table already exists: {table_info.get('table_schema_name')}") print(f" Logical Name: {table_info.get('table_logical_name')}") print(f" Entity Set: {table_info.get('entity_set_name')}") else: @@ -120,7 +120,7 @@ def main(): "new_Priority": Priority, } table_info = backoff(lambda: client.create_table(table_name, columns)) - print(f"[OK] Created table: {table_info.get('table_schema_name')}") + print(f"โœ“ Created table: {table_info.get('table_schema_name')}") print(f" Columns created: {', '.join(table_info.get('columns_created', []))}") # ============================================================================ @@ -140,7 +140,7 @@ def main(): "new_Priority": Priority.MEDIUM, } id1 = backoff(lambda: client.create(table_name, single_record))[0] - print(f"[OK] Created single record: {id1}") + print(f"โœ“ Created single record: {id1}") # Multiple create log_call(f"client.create('{table_name}', [{{...}}, {{...}}, {{...}}])") @@ -168,7 +168,7 @@ def main(): }, ] ids = backoff(lambda: client.create(table_name, multiple_records)) - print(f"[OK] Created {len(ids)} records: {ids}") + print(f"โœ“ Created {len(ids)} records: {ids}") # ============================================================================ # 4. READ OPERATIONS @@ -180,7 +180,7 @@ def main(): # Single read by ID log_call(f"client.get('{table_name}', '{id1}')") record = backoff(lambda: client.get(table_name, id1)) - print("[OK] Retrieved single record:") + print("โœ“ Retrieved single record:") print( json.dumps( { @@ -202,7 +202,7 @@ def main(): records_iterator = backoff(lambda: client.get(table_name, filter="new_quantity gt 5")) for page in records_iterator: all_records.extend(page) - print(f"[OK] Found {len(all_records)} records with new_quantity > 5") + print(f"โœ“ Found {len(all_records)} records with new_quantity > 5") for rec in all_records: print(f" - new_Title='{rec.get('new_title')}', new_Quantity={rec.get('new_quantity')}") @@ -217,12 +217,12 @@ def main(): log_call(f"client.update('{table_name}', '{id1}', {{...}})") backoff(lambda: client.update(table_name, id1, {"new_Quantity": 100})) updated = backoff(lambda: client.get(table_name, id1)) - print(f"[OK] Updated single record new_Quantity: {updated.get('new_quantity')}") + print(f"โœ“ Updated single record new_Quantity: {updated.get('new_quantity')}") # Multiple update (broadcast same change) log_call(f"client.update('{table_name}', [{len(ids)} IDs], {{...}})") backoff(lambda: client.update(table_name, ids, {"new_Completed": True})) - print(f"[OK] Updated {len(ids)} records to new_Completed=True") + print(f"โœ“ Updated {len(ids)} records to new_Completed=True") # ============================================================================ # 6. PAGING DEMO @@ -244,7 +244,7 @@ def main(): for i in range(1, 21) ] paging_ids = backoff(lambda: client.create(table_name, paging_records)) - print(f"[OK] Created {len(paging_ids)} records for paging demo") + print(f"โœ“ Created {len(paging_ids)} records for paging demo") # Query with paging log_call(f"client.get('{table_name}', page_size=5)") @@ -265,11 +265,11 @@ def main(): sql = f"SELECT new_title, new_quantity FROM new_walkthroughdemo WHERE new_completed = 1" try: results = backoff(lambda: client.query_sql(sql)) - print(f"[OK] SQL query returned {len(results)} completed records:") + print(f"โœ“ SQL query returned {len(results)} completed records:") for result in results[:5]: # Show first 5 print(f" - new_Title='{result.get('new_title')}', new_Quantity={result.get('new_quantity')}") except Exception as e: - print(f"[WARN] SQL query failed (known server-side bug): {str(e)}") + print(f"โš  SQL query failed (known server-side bug): {str(e)}") # ============================================================================ # 8. PICKLIST LABEL CONVERSION @@ -288,7 +288,7 @@ def main(): } label_id = backoff(lambda: client.create(table_name, label_record))[0] retrieved = backoff(lambda: client.get(table_name, label_id)) - print(f"[OK] Created record with string label 'High' for new_Priority") + print(f"โœ“ Created record with string label 'High' for new_Priority") print(f" new_Priority stored as integer: {retrieved.get('new_priority')}") print(f" new_Priority@FormattedValue: {retrieved.get('new_priority@OData.Community.Display.V1.FormattedValue')}") @@ -301,12 +301,12 @@ def main(): log_call(f"client.create_columns('{table_name}', {{'new_Notes': 'string'}})") created_cols = backoff(lambda: client.create_columns(table_name, {"new_Notes": "string"})) - print(f"[OK] Added column: {created_cols[0]}") + print(f"โœ“ Added column: {created_cols[0]}") # Delete the column we just added log_call(f"client.delete_columns('{table_name}', ['new_Notes'])") backoff(lambda: client.delete_columns(table_name, ["new_Notes"])) - print(f"[OK] Deleted column: new_Notes") + print(f"โœ“ Deleted column: new_Notes") # ============================================================================ # 10. DELETE OPERATIONS @@ -318,12 +318,12 @@ def main(): # Single delete log_call(f"client.delete('{table_name}', '{id1}')") backoff(lambda: client.delete(table_name, id1)) - print(f"[OK] Deleted single record: {id1}") + print(f"โœ“ Deleted single record: {id1}") # Multiple delete (delete the paging demo records) log_call(f"client.delete('{table_name}', [{len(paging_ids)} IDs])") job_id = backoff(lambda: client.delete(table_name, paging_ids)) - print(f"[OK] Bulk delete job started: {job_id}") + print(f"โœ“ Bulk delete job started: {job_id}") print(f" (Deleting {len(paging_ids)} paging demo records)") # ============================================================================ @@ -336,11 +336,11 @@ def main(): log_call(f"client.delete_table('{table_name}')") try: backoff(lambda: client.delete_table(table_name)) - print(f"[OK] Deleted table: {table_name}") + print(f"โœ“ Deleted table: {table_name}") except Exception as ex: # noqa: BLE001 code = getattr(getattr(ex, "response", None), "status_code", None) if (isinstance(ex, (requests.exceptions.HTTPError, MetadataError)) and code == 404): - print(f"[OK] Table removed: {table_name}") + print(f"โœ“ Table removed: {table_name}") else: raise @@ -351,16 +351,16 @@ def main(): print("Walkthrough Complete!") print("=" * 80) print("\nDemonstrated operations:") - print(" [OK] Table creation with multiple column types") - print(" [OK] Single and multiple record creation") - print(" [OK] Reading records by ID and with filters") - print(" [OK] Single and multiple record updates") - print(" [OK] Paging through large result sets") - print(" [OK] SQL queries") - print(" [OK] Picklist label-to-value conversion") - print(" [OK] Column management") - print(" [OK] Single and bulk delete operations") - print(" [OK] Table cleanup") + print(" โœ“ Table creation with multiple column types") + print(" โœ“ Single and multiple record creation") + print(" โœ“ Reading records by ID and with filters") + print(" โœ“ Single and multiple record updates") + print(" โœ“ Paging through large result sets") + print(" โœ“ SQL queries") + print(" โœ“ Picklist label-to-value conversion") + print(" โœ“ Column management") + print(" โœ“ Single and bulk delete operations") + print(" โœ“ Table cleanup") print("=" * 80) diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index 93f3c9d..9d39eee 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -37,23 +37,23 @@ def get_dataverse_org_url() -> str: """Get Dataverse org URL from user input.""" - print("\n-> Dataverse Environment Setup") + print("\n๐ŸŒ Dataverse Environment Setup") print("=" * 50) if not sys.stdin.isatty(): - print("[ERR] Interactive input required. Run this script in a terminal.") + print("โŒ Interactive input required. Run this script in a terminal.") sys.exit(1) while True: org_url = input("Enter your Dataverse org URL (e.g., https://yourorg.crm.dynamics.com): ").strip() if org_url: return org_url.rstrip("/") - print("[WARN] Please enter a valid URL.") + print("โš ๏ธ Please enter a valid URL.") def setup_authentication() -> DataverseClient: """Set up authentication and create Dataverse client.""" - print("\n-> Authentication Setup") + print("\n๐Ÿ” Authentication Setup") print("=" * 50) org_url = get_dataverse_org_url() @@ -62,14 +62,14 @@ def setup_authentication() -> DataverseClient: client = DataverseClient(org_url, credential) # Test the connection - print("Testing connection...") + print("๐Ÿงช Testing connection...") tables = client.list_tables() - print(f"[OK] Connection successful! Found {len(tables)} tables.") + print(f"โœ… Connection successful! Found {len(tables)} tables.") return client except Exception as e: - print(f"[ERR] Authentication failed: {e}") - print("Please check your credentials and permissions.") + print(f"โŒ Authentication failed: {e}") + print("๐Ÿ’ก Please check your credentials and permissions.") sys.exit(1) @@ -92,7 +92,7 @@ def wait_for_table_metadata( if attempt > 1: print( - f" [OK] Table metadata available after {attempt} attempts." + f" โœ… Table metadata available after {attempt} attempts." ) return info except Exception: @@ -100,7 +100,7 @@ def wait_for_table_metadata( if attempt < retries: print( - f" Waiting for table metadata to publish (attempt {attempt}/{retries})..." + f" โณ Waiting for table metadata to publish (attempt {attempt}/{retries})..." ) time.sleep(delay_seconds) @@ -111,7 +111,7 @@ def wait_for_table_metadata( def ensure_test_table(client: DataverseClient) -> Dict[str, Any]: """Create or verify test table exists.""" - print("\n-> Test Table Setup") + print("\n๐Ÿ“‹ Test Table Setup") print("=" * 50) table_schema_name = "test_TestSDKFunctionality" @@ -120,14 +120,14 @@ def ensure_test_table(client: DataverseClient) -> Dict[str, Any]: # Check if table already exists existing_table = client.get_table_info(table_schema_name) if existing_table: - print(f"[OK] Test table '{table_schema_name}' already exists") + print(f"โœ… Test table '{table_schema_name}' already exists") return existing_table except Exception: - print(f"Table '{table_schema_name}' not found, creating...") + print(f"๐Ÿ“ Table '{table_schema_name}' not found, creating...") try: - print("Creating new test table...") + print("๐Ÿ”จ Creating new test table...") # Create the test table with various field types table_info = client.create_table( table_schema_name, @@ -141,20 +141,20 @@ def ensure_test_table(client: DataverseClient) -> Dict[str, Any]: }, ) - print(f"[OK] Created test table: {table_info.get('table_schema_name')}") + print(f"โœ… Created test table: {table_info.get('table_schema_name')}") print(f" Logical name: {table_info.get('table_logical_name')}") print(f" Entity set: {table_info.get('entity_set_name')}") return wait_for_table_metadata(client, table_schema_name) except MetadataError as e: - print(f"[ERR] Failed to create table: {e}") + print(f"โŒ Failed to create table: {e}") sys.exit(1) def test_create_record(client: DataverseClient, table_info: Dict[str, Any]) -> str: """Test record creation.""" - print("\n-> Record Creation Test") + print("\n๐Ÿ“ Record Creation Test") print("=" * 50) table_schema_name = table_info.get("table_schema_name") @@ -173,18 +173,18 @@ def test_create_record(client: DataverseClient, table_info: Dict[str, Any]) -> s } try: - print("Creating test record...") + print("๐Ÿš€ Creating test record...") created_ids: Optional[List[str]] = None for attempt in range(1, retries + 1): try: created_ids = client.create(table_schema_name, test_data) if attempt > 1: - print(f" [OK] Record creation succeeded after {attempt} attempts.") + print(f" โœ… Record creation succeeded after {attempt} attempts.") break except HttpError as err: if getattr(err, "status_code", None) == 404 and attempt < retries: print( - f" Table not ready for create (attempt {attempt}/{retries}). Retrying in {delay_seconds}s..." + f" โณ Table not ready for create (attempt {attempt}/{retries}). Retrying in {delay_seconds}s..." ) time.sleep(delay_seconds) continue @@ -192,7 +192,7 @@ def test_create_record(client: DataverseClient, table_info: Dict[str, Any]) -> s if isinstance(created_ids, list) and created_ids: record_id = created_ids[0] - print(f"[OK] Record created successfully!") + print(f"โœ… Record created successfully!") print(f" Record ID: {record_id}") print(f" Name: {test_data[f'{attr_prefix}_name']}") return record_id @@ -200,16 +200,16 @@ def test_create_record(client: DataverseClient, table_info: Dict[str, Any]) -> s raise ValueError("Unexpected response from create operation") except HttpError as e: - print(f"[ERR] HTTP error during record creation: {e}") + print(f"โŒ HTTP error during record creation: {e}") sys.exit(1) except Exception as e: - print(f"[ERR] Failed to create record: {e}") + print(f"โŒ Failed to create record: {e}") sys.exit(1) def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record_id: str) -> Dict[str, Any]: """Test record reading.""" - print("\n-> Record Reading Test") + print("\n๐Ÿ“– Record Reading Test") print("=" * 50) table_schema_name = table_info.get("table_schema_name") @@ -219,18 +219,18 @@ def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record delay_seconds = 3 try: - print(f"Reading record: {record_id}") + print(f"๐Ÿ” Reading record: {record_id}") record = None for attempt in range(1, retries + 1): try: record = client.get(table_schema_name, record_id) if attempt > 1: - print(f" [OK] Record read succeeded after {attempt} attempts.") + print(f" โœ… Record read succeeded after {attempt} attempts.") break except HttpError as err: if getattr(err, "status_code", None) == 404 and attempt < retries: print( - f" Record not queryable yet (attempt {attempt}/{retries}). Retrying in {delay_seconds}s..." + f" โณ Record not queryable yet (attempt {attempt}/{retries}). Retrying in {delay_seconds}s..." ) time.sleep(delay_seconds) continue @@ -240,7 +240,7 @@ def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record raise RuntimeError("Record did not become available in time.") if record: - print("[OK] Record retrieved successfully!") + print("โœ… Record retrieved successfully!") print(" Retrieved data:") # Display key fields @@ -259,16 +259,16 @@ def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record raise ValueError("Record not found") except HttpError as e: - print(f"[ERR] HTTP error during record reading: {e}") + print(f"โŒ HTTP error during record reading: {e}") sys.exit(1) except Exception as e: - print(f"[ERR] Failed to read record: {e}") + print(f"โŒ Failed to read record: {e}") sys.exit(1) def test_query_records(client: DataverseClient, table_info: Dict[str, Any]) -> None: """Test querying multiple records.""" - print("\n-> Record Query Test") + print("\n๐Ÿ” Record Query Test") print("=" * 50) table_schema_name = table_info.get("table_schema_name") @@ -277,7 +277,7 @@ def test_query_records(client: DataverseClient, table_info: Dict[str, Any]) -> N delay_seconds = 3 try: - print("Querying records from test table...") + print("๐Ÿ” Querying records from test table...") for attempt in range(1, retries + 1): try: records_iterator = client.get( @@ -297,25 +297,25 @@ def test_query_records(client: DataverseClient, table_info: Dict[str, Any]) -> N amount = record.get(f"{attr_prefix}_amount", "N/A") print(f" Record {record_count}: {name} (Count: {count}, Amount: {amount})") - print(f"[OK] Query completed! Found {record_count} active records.") + print(f"โœ… Query completed! Found {record_count} active records.") break except HttpError as err: if getattr(err, "status_code", None) == 404 and attempt < retries: print( - f" Query retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." + f" โณ Query retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." ) time.sleep(delay_seconds) continue raise except Exception as e: - print(f"[WARN] Query test encountered an issue: {e}") + print(f"โš ๏ธ Query test encountered an issue: {e}") print(" This might be expected if the table is very new.") def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], record_id: str) -> None: """Clean up test data.""" - print("\n-> Cleanup") + print("\n๐Ÿงน Cleanup") print("=" * 50) table_schema_name = table_info.get("table_schema_name") @@ -329,25 +329,25 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor for attempt in range(1, retries + 1): try: client.delete(table_schema_name, record_id) - print("[OK] Test record deleted successfully") + print("โœ… Test record deleted successfully") break except HttpError as err: status = getattr(err, "status_code", None) if status == 404: - print("Record already deleted or not yet available; skipping.") + print("โ„น๏ธ Record already deleted or not yet available; skipping.") break if attempt < retries: print( - f" Record delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." + f" โณ Record delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." ) time.sleep(delay_seconds) continue - print(f"[WARN] Failed to delete test record: {err}") + print(f"โš ๏ธ Failed to delete test record: {err}") except Exception as e: - print(f"[WARN] Failed to delete test record: {e}") + print(f"โš ๏ธ Failed to delete test record: {e}") break else: - print("Test record kept for inspection") + print("โ„น๏ธ Test record kept for inspection") # Ask about table cleanup table_cleanup = input("Do you want to delete the test table? (y/N): ").strip().lower() @@ -356,7 +356,7 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor for attempt in range(1, retries + 1): try: client.delete_table(table_info.get("table_schema_name")) - print("[OK] Test table deleted successfully") + print("โœ… Test table deleted successfully") break except HttpError as err: status = getattr(err, "status_code", None) @@ -364,26 +364,26 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor if _table_still_exists(client, table_info.get("table_schema_name")): if attempt < retries: print( - f" Table delete retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." + f" โณ Table delete retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." ) time.sleep(delay_seconds) continue - print(f"[WARN] Failed to delete test table due to metadata delay: {err}") + print(f"โš ๏ธ Failed to delete test table due to metadata delay: {err}") break - print("[OK] Test table deleted successfully (404 reported).") + print("โœ… Test table deleted successfully (404 reported).") break if attempt < retries: print( - f" Table delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." + f" โณ Table delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." ) time.sleep(delay_seconds) continue - print(f"[WARN] Failed to delete test table: {err}") + print(f"โš ๏ธ Failed to delete test table: {err}") except Exception as e: - print(f"[WARN] Failed to delete test table: {e}") + print(f"โš ๏ธ Failed to delete test table: {e}") break else: - print("Test table kept for future testing") + print("โ„น๏ธ Test table kept for future testing") def _table_still_exists(client: DataverseClient, table_schema_name: Optional[str]) -> bool: @@ -402,16 +402,16 @@ def _table_still_exists(client: DataverseClient, table_schema_name: Optional[str def main(): """Main test function.""" - print("PowerPlatform Dataverse Client SDK - Advanced Functional Testing") + print("๐Ÿš€ PowerPlatform Dataverse Client SDK - Advanced Functional Testing") print("=" * 70) print("This script tests SDK functionality in a real Dataverse environment:") - print(" - Authentication & Connection") - print(" - Table Creation & Metadata Operations") - print(" - Record CRUD Operations") - print(" - Query Functionality") - print(" - Interactive Cleanup") + print(" โ€ข Authentication & Connection") + print(" โ€ข Table Creation & Metadata Operations") + print(" โ€ข Record CRUD Operations") + print(" โ€ข Query Functionality") + print(" โ€ข Interactive Cleanup") print("=" * 70) - print("For installation validation, run examples/basic/installation_example.py first") + print("๐Ÿ’ก For installation validation, run examples/basic/installation_example.py first") print("=" * 70) try: @@ -429,24 +429,24 @@ def main(): test_query_records(client, table_info) # Success summary - print("\nFunctional Test Summary") + print("\n๐ŸŽ‰ Functional Test Summary") print("=" * 50) - print("[OK] Authentication: Success") - print("[OK] Table Operations: Success") - print("[OK] Record Creation: Success") - print("[OK] Record Reading: Success") - print("[OK] Record Querying: Success") - print("\nYour PowerPlatform Dataverse Client SDK is fully functional!") + print("โœ… Authentication: Success") + print("โœ… Table Operations: Success") + print("โœ… Record Creation: Success") + print("โœ… Record Reading: Success") + print("โœ… Record Querying: Success") + print("\n๐Ÿ’ก Your PowerPlatform Dataverse Client SDK is fully functional!") # Cleanup cleanup_test_data(client, table_info, record_id) except KeyboardInterrupt: - print("\n\n[WARN] Test interrupted by user") + print("\n\nโš ๏ธ Test interrupted by user") sys.exit(1) except Exception as e: - print(f"\n[ERR] Unexpected error: {e}") - print("Please check your environment and try again") + print(f"\nโŒ Unexpected error: {e}") + print("๐Ÿ’ก Please check your environment and try again") sys.exit(1) diff --git a/examples/basic/installation_example.py b/examples/basic/installation_example.py index 13ee785..360dcd4 100644 --- a/examples/basic/installation_example.py +++ b/examples/basic/installation_example.py @@ -35,18 +35,18 @@ - `pip install -e .` โ†’ Installs from local source code in "editable" mode **Editable Mode Benefits:** -- Changes to source code are immediately available (no reinstall needed) -- Perfect for development, testing, and contributing -- Examples and tests can access the local codebase -- Supports debugging and live code modifications +- โœ… Changes to source code are immediately available (no reinstall needed) +- โœ… Perfect for development, testing, and contributing +- โœ… Examples and tests can access the local codebase +- โœ… Supports debugging and live code modifications ## What This Script Does -- Validates package installation and imports -- Checks version and package metadata -- Shows code examples and usage patterns -- Offers optional interactive testing -- Provides troubleshooting guidance +- โœ… Validates package installation and imports +- โœ… Checks version and package metadata +- โœ… Shows code examples and usage patterns +- โœ… Offers optional interactive testing +- โœ… Provides troubleshooting guidance Prerequisites for Interactive Testing: - Access to a Microsoft Dataverse environment @@ -63,7 +63,7 @@ def validate_imports(): """Validate that all key imports work correctly.""" - print("Validating Package Imports...") + print("๐Ÿ” Validating Package Imports...") print("-" * 50) try: @@ -71,52 +71,52 @@ def validate_imports(): from PowerPlatform.Dataverse import __version__ from PowerPlatform.Dataverse.client import DataverseClient - print(f" [OK] Namespace: PowerPlatform.Dataverse") - print(f" [OK] Package version: {__version__}") - print(f" [OK] Client class: PowerPlatform.Dataverse.client.DataverseClient") + print(f" โœ… Namespace: PowerPlatform.Dataverse") + print(f" โœ… Package version: {__version__}") + print(f" โœ… Client class: PowerPlatform.Dataverse.client.DataverseClient") # Test submodule imports from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError - print(f" [OK] Core errors: HttpError, MetadataError") + print(f" โœ… Core errors: HttpError, MetadataError") from PowerPlatform.Dataverse.core.config import DataverseConfig - print(f" [OK] Core config: DataverseConfig") + print(f" โœ… Core config: DataverseConfig") from PowerPlatform.Dataverse.data._odata import _ODataClient - print(f" [OK] Data layer: _ODataClient") + print(f" โœ… Data layer: _ODataClient") # Test Azure Identity import from azure.identity import InteractiveBrowserCredential - print(f" [OK] Azure Identity: InteractiveBrowserCredential") + print(f" โœ… Azure Identity: InteractiveBrowserCredential") return True, __version__, DataverseClient except ImportError as e: - print(f" [ERR] Import failed: {e}") - print("\nTroubleshooting:") - print(" For end users (published package):") - print(" - pip install PowerPlatform-Dataverse-Client") - print(" - pip install azure-identity") + print(f" โŒ Import failed: {e}") + print("\n๐Ÿ’ก Troubleshooting:") + print(" ๐Ÿ“ฆ For end users (published package):") + print(" โ€ข pip install PowerPlatform-Dataverse-Client") + print(" โ€ข pip install azure-identity") print(" ") - print(" For developers (local development):") - print(" - Navigate to the project root directory") - print(" - pip install -e .") - print(" - This enables 'editable mode' for live development") + print(" ๐Ÿ› ๏ธ For developers (local development):") + print(" โ€ข Navigate to the project root directory") + print(" โ€ข pip install -e .") + print(" โ€ข This enables 'editable mode' for live development") print(" ") - print(" General fixes:") - print(" - Check virtual environment is activated") - print(" - Verify you're in the correct directory") - print(" - Try: pip list | grep PowerPlatform") + print(" ๐Ÿ”ง General fixes:") + print(" โ€ข Check virtual environment is activated") + print(" โ€ข Verify you're in the correct directory") + print(" โ€ข Try: pip list | grep PowerPlatform") return False, None, None def validate_client_methods(DataverseClient): """Validate that DataverseClient has expected methods.""" - print("\nValidating Client Methods...") + print("\n๐Ÿ—๏ธ Validating Client Methods...") print("-" * 50) expected_methods = [ @@ -134,9 +134,9 @@ def validate_client_methods(DataverseClient): missing_methods = [] for method in expected_methods: if hasattr(DataverseClient, method): - print(f" [OK] Method exists: {method}") + print(f" โœ… Method exists: {method}") else: - print(f" [ERR] Method missing: {method}") + print(f" โŒ Method missing: {method}") missing_methods.append(method) return len(missing_methods) == 0 @@ -144,7 +144,7 @@ def validate_client_methods(DataverseClient): def validate_package_metadata(): """Validate package metadata from pip.""" - print("\nValidating Package Metadata...") + print("\n๐Ÿ“ฆ Validating Package Metadata...") print("-" * 50) try: @@ -156,26 +156,26 @@ def validate_package_metadata(): lines = result.stdout.split("\n") for line in lines: if any(line.startswith(prefix) for prefix in ["Name:", "Version:", "Summary:", "Location:"]): - print(f" [OK] {line}") + print(f" โœ… {line}") return True else: - print(f" [ERR] Package not found in pip list") - print(" Try: pip install PowerPlatform-Dataverse-Client") + print(f" โŒ Package not found in pip list") + print(" ๐Ÿ’ก Try: pip install PowerPlatform-Dataverse-Client") return False except Exception as e: - print(f" [ERR] Metadata validation failed: {e}") + print(f" โŒ Metadata validation failed: {e}") return False def show_usage_examples(): """Display comprehensive usage examples.""" - print("\nUsage Examples") + print("\n๐Ÿ“š Usage Examples") print("=" * 50) print( """ -Basic Setup: +๐Ÿ”ง Basic Setup: ```python from PowerPlatform.Dataverse.client import DataverseClient from azure.identity import InteractiveBrowserCredential @@ -190,7 +190,7 @@ def show_usage_examples(): ) ``` -CRUD Operations: +๐Ÿ“ CRUD Operations: ```python # Create a record account_data = {"name": "Contoso Ltd", "telephone1": "555-0100"} @@ -208,7 +208,7 @@ def show_usage_examples(): client.delete("account", account_ids[0]) ``` -Querying Data: +๐Ÿ” Querying Data: ```python # Query with OData filter accounts = client.get("account", @@ -226,7 +226,7 @@ def show_usage_examples(): print(row['name']) ``` -Table Management: +๐Ÿ—๏ธ Table Management: ```python # Create custom table table_info = client.create_table("CustomEntity", { @@ -250,71 +250,71 @@ def show_usage_examples(): def interactive_test(): """Offer optional interactive testing with real Dataverse environment.""" - print("\nInteractive Testing") + print("\n๐Ÿงช Interactive Testing") print("=" * 50) choice = input("Would you like to test with a real Dataverse environment? (y/N): ").strip().lower() if choice not in ["y", "yes"]: - print(" Skipping interactive test") + print(" โ„น๏ธ Skipping interactive test") return - print("\nDataverse Environment Setup") + print("\n๐ŸŒ Dataverse Environment Setup") print("-" * 50) if not sys.stdin.isatty(): - print(" [ERR] Interactive input required for testing") + print(" โŒ Interactive input required for testing") return org_url = input("Enter your Dataverse org URL (e.g., https://yourorg.crm.dynamics.com): ").strip() if not org_url: - print(" [WARN] No URL provided, skipping test") + print(" โš ๏ธ No URL provided, skipping test") return try: from PowerPlatform.Dataverse.client import DataverseClient from azure.identity import InteractiveBrowserCredential - print(" Setting up authentication...") + print(" ๐Ÿ” Setting up authentication...") credential = InteractiveBrowserCredential() - print(" Creating client...") + print(" ๐Ÿš€ Creating client...") client = DataverseClient(org_url.rstrip("/"), credential) - print(" Testing connection...") + print(" ๐Ÿงช Testing connection...") tables = client.list_tables() - print(f" [OK] Connection successful!") - print(f" Found {len(tables)} tables in environment") - print(f" Connected to: {org_url}") + print(f" โœ… Connection successful!") + print(f" ๐Ÿ“‹ Found {len(tables)} tables in environment") + print(f" ๐ŸŒ Connected to: {org_url}") - print("\n Your SDK is ready for use!") - print(" Check the usage examples above for common patterns") + print("\n ๐Ÿ’ก Your SDK is ready for use!") + print(" ๐Ÿ’ก Check the usage examples above for common patterns") except Exception as e: - print(f" [ERR] Interactive test failed: {e}") - print(" This might be due to authentication, network, or permissions") - print(" The SDK imports are still valid for offline development") + print(f" โŒ Interactive test failed: {e}") + print(" ๐Ÿ’ก This might be due to authentication, network, or permissions") + print(" ๐Ÿ’ก The SDK imports are still valid for offline development") def main(): """Run comprehensive installation validation and demonstration.""" - print("PowerPlatform Dataverse Client SDK - Installation & Validation") + print("๐Ÿš€ PowerPlatform Dataverse Client SDK - Installation & Validation") print("=" * 70) - print(f"Validation Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"๐Ÿ•’ Validation Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print("=" * 70) # Step 1: Validate imports imports_success, version, DataverseClient = validate_imports() if not imports_success: - print("\n[ERR] Import validation failed. Please check installation.") + print("\nโŒ Import validation failed. Please check installation.") sys.exit(1) # Step 2: Validate client methods if DataverseClient: methods_success = validate_client_methods(DataverseClient) if not methods_success: - print("\n[WARN] Some client methods are missing, but basic functionality should work.") + print("\nโš ๏ธ Some client methods are missing, but basic functionality should work.") # Step 3: Validate package metadata metadata_success = validate_package_metadata() @@ -327,7 +327,7 @@ def main(): # Summary print("\n" + "=" * 70) - print("VALIDATION SUMMARY") + print("๐Ÿ“Š VALIDATION SUMMARY") print("=" * 70) results = [ @@ -338,37 +338,37 @@ def main(): all_passed = True for test_name, success in results: - status = "[OK] PASS" if success else "[ERR] FAIL" + status = "โœ… PASS" if success else "โŒ FAIL" print(f"{test_name:<20} {status}") if not success: all_passed = False print("=" * 70) if all_passed: - print("SUCCESS: PowerPlatform-Dataverse-Client is properly installed!") + print("๐ŸŽ‰ SUCCESS: PowerPlatform-Dataverse-Client is properly installed!") if version: - print(f"Package Version: {version}") - print("\nWhat this validates:") - print(" - Package installation is correct") - print(" - All namespace imports work") - print(" - Client classes are accessible") - print(" - Package metadata is valid") - print(" - Ready for development and production use") - - print(f"\nNext Steps:") - print(" - Review the usage examples above") - print(" - Configure your Azure Identity credentials") - print(" - Start building with PowerPlatform.Dataverse!") + print(f"๐Ÿ“ฆ Package Version: {version}") + print("\n๐Ÿ’ก What this validates:") + print(" โœ… Package installation is correct") + print(" โœ… All namespace imports work") + print(" โœ… Client classes are accessible") + print(" โœ… Package metadata is valid") + print(" โœ… Ready for development and production use") + + print(f"\n๐ŸŽฏ Next Steps:") + print(" โ€ข Review the usage examples above") + print(" โ€ข Configure your Azure Identity credentials") + print(" โ€ข Start building with PowerPlatform.Dataverse!") else: - print("[ERR] Some validation checks failed!") - print("Review the errors above and reinstall if needed:") + print("โŒ Some validation checks failed!") + print("๐Ÿ’ก Review the errors above and reinstall if needed:") print(" pip uninstall PowerPlatform-Dataverse-Client") print(" pip install PowerPlatform-Dataverse-Client") sys.exit(1) if __name__ == "__main__": - print("PowerPlatform-Dataverse-Client SDK Installation Example") + print("๐Ÿš€ PowerPlatform-Dataverse-Client SDK Installation Example") print("=" * 60) main() diff --git a/pyproject.toml b/pyproject.toml index 9cbcab4..f4f8737 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,9 +37,6 @@ dependencies = [ "Issues" = "https://github.com/microsoft/PowerPlatform-DataverseClient-Python/issues" "Documentation" = "https://github.com/microsoft/PowerPlatform-DataverseClient-Python#readme" -[project.scripts] -dataverse-install-claude-skill = "PowerPlatform.Dataverse._skill_installer:main" - [project.optional-dependencies] dev = [ "pytest>=7.0.0", @@ -61,7 +58,6 @@ namespaces = false [tool.setuptools.package-data] "*" = ["py.typed"] -"PowerPlatform.Dataverse.claude_skill" = ["SKILL.md"] # Microsoft Python Standards - Linting & Formatting [tool.black] diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 84bd5d4..7d1473b 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -11,6 +11,14 @@ from .core._auth import _AuthManager from .core.config import DataverseConfig from .data._odata import _ODataClient +from .models.metadata import ( + LookupAttributeMetadata, + OneToManyRelationshipMetadata, + ManyToManyRelationshipMetadata, + Label, + LocalizedLabel, + CascadeConfiguration, +) class DataverseClient: @@ -436,7 +444,7 @@ def create_table( :param columns: Dictionary mapping column names (with customization prefix value) to their types. All custom column names must include the customization prefix value (e.g. ``"new_Title"``). Supported types: - - Primitive types: ``"string"`` (alias: ``"text"``), ``"int"`` (alias: ``"integer"``), ``"decimal"`` (alias: ``"money"``), ``"float"`` (alias: ``"double"``), ``"datetime"`` (alias: ``"date"``), ``"bool"`` (alias: ``"boolean"``) + - Primitive types: ``"string"``, ``"int"``, ``"decimal"``, ``"float"``, ``"datetime"``, ``"bool"`` - Enum subclass (IntEnum preferred): Creates a local option set. Optional multilingual labels can be provided via ``__labels__`` class attribute, defined inside the Enum subclass:: @@ -546,7 +554,7 @@ def create_columns( :param table_schema_name: Schema name of the table (e.g. ``"new_MyTestTable"``). :type table_schema_name: :class:`str` :param columns: Mapping of column schema names (with customization prefix value) to supported types. All custom column names must include the customization prefix value** (e.g. ``"new_Notes"``). Primitive types include - ``"string"`` (alias: ``"text"``), ``"int"`` (alias: ``"integer"``), ``"decimal"`` (alias: ``"money"``), ``"float"`` (alias: ``"double"``), ``"datetime"`` (alias: ``"date"``), and ``"bool"`` (alias: ``"boolean"``). Enum subclasses (IntEnum preferred) + ``string``, ``int``, ``decimal``, ``float``, ``datetime``, and ``bool``. Enum subclasses (IntEnum preferred) generate a local option set and can specify localized labels via ``__labels__``. :type columns: :class:`dict` mapping :class:`str` to :class:`typing.Any` :returns: Schema names for the columns that were created. @@ -698,5 +706,246 @@ def flush_cache(self, kind) -> int: with self._scoped_odata() as od: return od._flush_cache(kind) + # Relationship operations + def create_one_to_many_relationship( + self, + lookup: LookupAttributeMetadata, + relationship: OneToManyRelationshipMetadata, + solution_unique_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a one-to-many relationship between tables. + + This operation creates both the relationship and the lookup attribute + on the referencing table. + + :param lookup: Metadata defining the lookup attribute. + :type lookup: ~PowerPlatform.Dataverse.models.metadata.LookupAttributeMetadata + :param relationship: Metadata defining the relationship. + :type relationship: ~PowerPlatform.Dataverse.models.metadata.OneToManyRelationshipMetadata + :param solution_unique_name: Optional solution to add relationship to. + :type solution_unique_name: :class:`str` or None + + :return: Dictionary with relationship_id, lookup_schema_name, and related metadata. + :rtype: :class:`dict` + + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails. + + Example: + Create a one-to-many relationship: Department (1) -> Employee (N):: + + from PowerPlatform.Dataverse.models.metadata import ( + LookupAttributeMetadata, + OneToManyRelationshipMetadata, + Label, + LocalizedLabel, + CascadeConfiguration, + ) + + # Define the lookup attribute (added to Employee table) + lookup = LookupAttributeMetadata( + schema_name="new_DepartmentId", + display_name=Label( + localized_labels=[ + LocalizedLabel(label="Department", language_code=1033) + ] + ), + ) + + # Define the relationship + relationship = OneToManyRelationshipMetadata( + schema_name="new_Department_Employee", + referenced_entity="new_department", # Parent table (the "one" side) + referencing_entity="new_employee", # Child table (the "many" side) + referenced_attribute="new_departmentid", + cascade_configuration=CascadeConfiguration( + delete="RemoveLink", # When department deleted, unlink employees + ), + ) + + result = client.create_one_to_many_relationship(lookup, relationship) + print(f"Created lookup field: {result['lookup_schema_name']}") + """ + with self._scoped_odata() as od: + return od._create_one_to_many_relationship( + lookup, + relationship, + solution_unique_name, + ) + + def create_many_to_many_relationship( + self, + relationship: ManyToManyRelationshipMetadata, + solution_unique_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a many-to-many relationship between tables. + + This operation creates a many-to-many relationship and an intersect table + to manage the relationship. + + :param relationship: Metadata defining the many-to-many relationship. + :type relationship: ~PowerPlatform.Dataverse.models.metadata.ManyToManyRelationshipMetadata + :param solution_unique_name: Optional solution to add relationship to. + :type solution_unique_name: :class:`str` or None + + :return: Dictionary with relationship_id, relationship_schema_name, and entity names. + :rtype: :class:`dict` + + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails. + + Example: + Create a many-to-many relationship: Employee (N) <-> Project (N):: + + from PowerPlatform.Dataverse.models.metadata import ( + ManyToManyRelationshipMetadata, + ) + + # Employees work on multiple projects; projects have multiple team members + relationship = ManyToManyRelationshipMetadata( + schema_name="new_employee_project", + entity1_logical_name="new_employee", + entity2_logical_name="new_project", + ) + + result = client.create_many_to_many_relationship(relationship) + print(f"Created M:N relationship: {result['relationship_schema_name']}") + """ + with self._scoped_odata() as od: + return od._create_many_to_many_relationship( + relationship, + solution_unique_name, + ) + + def delete_relationship(self, relationship_id: str) -> None: + """ + Delete a relationship by its metadata ID. + + :param relationship_id: The GUID of the relationship metadata. + :type relationship_id: :class:`str` + + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails. + + .. warning:: + Deleting a relationship also removes the associated lookup attribute + for one-to-many relationships. This operation is irreversible. + + Example: + Delete a relationship:: + + client.delete_relationship("12345678-1234-1234-1234-123456789abc") + """ + with self._scoped_odata() as od: + od._delete_relationship(relationship_id) + + def get_relationship(self, schema_name: str) -> Optional[Dict[str, Any]]: + """ + Retrieve relationship metadata by schema name. + + :param schema_name: The schema name of the relationship. + :type schema_name: :class:`str` + + :return: Relationship metadata dictionary, or None if not found. + :rtype: :class:`dict` or None + + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails. + + Example: + Get relationship metadata:: + + rel = client.get_relationship("new_Department_Employee") + if rel: + print(f"Found relationship: {rel['SchemaName']}") + """ + with self._scoped_odata() as od: + return od._get_relationship(schema_name) + + def create_lookup_field( + self, + referencing_table: str, + lookup_field_name: str, + referenced_table: str, + display_name: Optional[str] = None, + description: Optional[str] = None, + required: bool = False, + cascade_delete: str = "RemoveLink", + solution_unique_name: Optional[str] = None, + language_code: int = 1033, + ) -> Dict[str, Any]: + """ + Create a simple lookup field relationship. + + This is a convenience method that wraps :meth:`create_one_to_many_relationship` + for the common case of adding a lookup field to an existing table. + + :param referencing_table: Logical name of the table that will have the lookup field (child table). + :type referencing_table: :class:`str` + :param lookup_field_name: Schema name for the lookup field (e.g., ``"new_AccountId"``). + :type lookup_field_name: :class:`str` + :param referenced_table: Logical name of the table being referenced (parent table). + :type referenced_table: :class:`str` + :param display_name: Display name for the lookup field. Defaults to the referenced table name. + :type display_name: :class:`str` or None + :param description: Optional description for the lookup field. + :type description: :class:`str` or None + :param required: Whether the lookup is required. Defaults to ``False``. + :type required: :class:`bool` + :param cascade_delete: Delete behavior (``"RemoveLink"``, ``"Cascade"``, ``"Restrict"``). + Defaults to ``"RemoveLink"``. + :type cascade_delete: :class:`str` + :param solution_unique_name: Optional solution to add the relationship to. + :type solution_unique_name: :class:`str` or None + :param language_code: Language code for labels. Defaults to 1033 (English). + :type language_code: :class:`int` + + :return: Dictionary with ``relationship_id``, ``lookup_schema_name``, and related metadata. + :rtype: :class:`dict` + + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API request fails. + + Example: + Create a simple lookup field:: + + result = client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + display_name="Account", + required=True, + cascade_delete="RemoveLink" + ) + + print(f"Created lookup: {result['lookup_schema_name']}") + """ + # Build the label + localized_labels = [LocalizedLabel(label=display_name or referenced_table, language_code=language_code)] + + # Build the lookup attribute + lookup = LookupAttributeMetadata( + schema_name=lookup_field_name, + display_name=Label(localized_labels=localized_labels), + required_level="ApplicationRequired" if required else "None", + ) + + # Add description if provided + if description: + lookup.description = Label( + localized_labels=[LocalizedLabel(label=description, language_code=language_code)] + ) + + # Generate a relationship name + relationship_name = f"{referenced_table}_{referencing_table}_{lookup_field_name}" + + # Build the relationship metadata + relationship = OneToManyRelationshipMetadata( + schema_name=relationship_name, + referenced_entity=referenced_table, + referencing_entity=referencing_table, + referenced_attribute=f"{referenced_table}id", + cascade_configuration=CascadeConfiguration(delete=cascade_delete), + ) + + return self.create_one_to_many_relationship(lookup, relationship, solution_unique_name) + __all__ = ["DataverseClient"] diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 7c5fc6c..496d91d 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -20,6 +20,7 @@ from ..core._http import _HttpClient from ._upload import _ODataFileUpload +from ._relationships import _RelationshipOperationsMixin from ..core.errors import * from ..core._error_codes import ( _http_subcode, @@ -76,7 +77,7 @@ def build( ) -class _ODataClient(_ODataFileUpload): +class _ODataClient(_ODataFileUpload, _RelationshipOperationsMixin): """Dataverse Web API client: CRUD, SQL-over-API, and table metadata helpers.""" @staticmethod diff --git a/src/PowerPlatform/Dataverse/data/_relationships.py b/src/PowerPlatform/Dataverse/data/_relationships.py new file mode 100644 index 0000000..3a2be86 --- /dev/null +++ b/src/PowerPlatform/Dataverse/data/_relationships.py @@ -0,0 +1,160 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Relationship metadata operations for Dataverse Web API. + +This module provides mixin functionality for relationship CRUD operations. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Optional + + +class _RelationshipOperationsMixin: + """ + Mixin providing relationship metadata operations. + + This mixin is designed to be used with _ODataClient and depends on: + - self.api: The API base URL + - self._headers(): Method to get auth headers + - self._request(): Method to make HTTP requests + """ + + def _create_one_to_many_relationship( + self, + lookup, + relationship, + solution_unique_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a one-to-many relationship with lookup attribute. + + This mirrors the CreateOneToManyRequest from the .NET SDK by posting + to /RelationshipDefinitions with OneToManyRelationshipMetadata. + + :param lookup: Lookup attribute metadata (LookupAttributeMetadata instance). + :type lookup: ~PowerPlatform.Dataverse.models.metadata.LookupAttributeMetadata + :param relationship: Relationship metadata (OneToManyRelationshipMetadata instance). + :type relationship: ~PowerPlatform.Dataverse.models.metadata.OneToManyRelationshipMetadata + :param solution_unique_name: Optional solution to add the relationship to. + :type solution_unique_name: ``str`` | ``None`` + + :return: Dictionary with relationship_id, attribute_id, and schema names. + :rtype: ``dict[str, Any]`` + + :raises HttpError: If the Web API request fails. + """ + url = f"{self.api}/RelationshipDefinitions" + + # Build the payload by combining relationship and lookup metadata + payload = relationship.to_dict() + payload["Lookup"] = lookup.to_dict() + + headers = self._headers().copy() + if solution_unique_name: + headers["MSCRM.SolutionUniqueName"] = solution_unique_name + + r = self._request("post", url, headers=headers, json=payload) + + # Extract IDs from response headers + relationship_id = self._extract_id_from_header(r.headers.get("OData-EntityId")) + + return { + "relationship_id": relationship_id, + "relationship_schema_name": relationship.schema_name, + "lookup_schema_name": lookup.schema_name, + "referenced_entity": relationship.referenced_entity, + "referencing_entity": relationship.referencing_entity, + } + + def _create_many_to_many_relationship( + self, + relationship, + solution_unique_name: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a many-to-many relationship. + + This mirrors the CreateManyToManyRequest from the .NET SDK by posting + to /RelationshipDefinitions with ManyToManyRelationshipMetadata. + + :param relationship: Relationship metadata (ManyToManyRelationshipMetadata instance). + :type relationship: ~PowerPlatform.Dataverse.models.metadata.ManyToManyRelationshipMetadata + :param solution_unique_name: Optional solution to add the relationship to. + :type solution_unique_name: ``str`` | ``None`` + + :return: Dictionary with relationship_id and schema name. + :rtype: ``dict[str, Any]`` + + :raises HttpError: If the Web API request fails. + """ + url = f"{self.api}/RelationshipDefinitions" + + payload = relationship.to_dict() + + headers = self._headers().copy() + if solution_unique_name: + headers["MSCRM.SolutionUniqueName"] = solution_unique_name + + r = self._request("post", url, headers=headers, json=payload) + + # Extract ID from response header + relationship_id = self._extract_id_from_header(r.headers.get("OData-EntityId")) + + return { + "relationship_id": relationship_id, + "relationship_schema_name": relationship.schema_name, + "entity1_logical_name": relationship.entity1_logical_name, + "entity2_logical_name": relationship.entity2_logical_name, + } + + def _delete_relationship(self, relationship_id: str) -> None: + """ + Delete a relationship by its metadata ID. + + :param relationship_id: The GUID of the relationship metadata. + :type relationship_id: ``str`` + + :raises HttpError: If the Web API request fails. + """ + url = f"{self.api}/RelationshipDefinitions({relationship_id})" + headers = self._headers().copy() + headers["If-Match"] = "*" + self._request("delete", url, headers=headers) + + def _get_relationship(self, schema_name: str) -> Optional[Dict[str, Any]]: + """ + Retrieve relationship metadata by schema name. + + :param schema_name: The schema name of the relationship. + :type schema_name: ``str`` + + :return: Relationship metadata dictionary, or None if not found. + :rtype: ``dict[str, Any]`` | ``None`` + + :raises HttpError: If the Web API request fails. + """ + url = f"{self.api}/RelationshipDefinitions" + params = {"$filter": f"SchemaName eq '{self._escape_odata_quotes(schema_name)}'"} + r = self._request("get", url, headers=self._headers(), params=params) + data = r.json() + results = data.get("value", []) + return results[0] if results else None + + def _extract_id_from_header(self, header_value: Optional[str]) -> Optional[str]: + """ + Extract a GUID from an OData-EntityId header value. + + :param header_value: The header value containing a URL with GUID. + :type header_value: ``str`` | ``None`` + + :return: Extracted GUID or None if not found. + :rtype: ``str`` | ``None`` + """ + if not header_value: + return None + match = re.search(r"\(([0-9a-fA-F-]+)\)", header_value) + return match.group(1) if match else None diff --git a/src/PowerPlatform/Dataverse/models/__init__.py b/src/PowerPlatform/Dataverse/models/__init__.py index 396cd4e..6be73bf 100644 --- a/src/PowerPlatform/Dataverse/models/__init__.py +++ b/src/PowerPlatform/Dataverse/models/__init__.py @@ -1,9 +1,4 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -""" -Data models and type definitions for the Dataverse SDK. Currently a placeholder. -""" - -# Will be populated with models as they are created -__all__ = [] +"""Data models for Dataverse metadata types.""" diff --git a/src/PowerPlatform/Dataverse/models/metadata.py b/src/PowerPlatform/Dataverse/models/metadata.py new file mode 100644 index 0000000..6a8b7be --- /dev/null +++ b/src/PowerPlatform/Dataverse/models/metadata.py @@ -0,0 +1,347 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Metadata entity types for Microsoft Dataverse. + +These classes represent the metadata entity types used in the Dataverse Web API +for defining and managing table definitions, attributes, and relationships. + +See: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/reference/metadataentitytypes +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from dataclasses import dataclass, field + + +@dataclass +class LocalizedLabel: + """ + Represents a localized label with a language code. + + :param label: The text of the label. + :type label: str + :param language_code: The language code (LCID), e.g., 1033 for English. + :type language_code: int + :param additional_properties: Optional dict of additional properties to include + in the Web API payload. These are merged last and can override default values. + :type additional_properties: Optional[Dict[str, Any]] + """ + + label: str + language_code: int + additional_properties: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to Web API JSON format.""" + result = { + "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", + "Label": self.label, + "LanguageCode": self.language_code, + } + if self.additional_properties: + result.update(self.additional_properties) + return result + + +@dataclass +class Label: + """ + Represents a label that can have multiple localized versions. + + :param localized_labels: List of LocalizedLabel instances. + :type localized_labels: List[LocalizedLabel] + :param user_localized_label: Optional user-specific localized label. + :type user_localized_label: Optional[LocalizedLabel] + :param additional_properties: Optional dict of additional properties to include + in the Web API payload. These are merged last and can override default values. + :type additional_properties: Optional[Dict[str, Any]] + """ + + localized_labels: List[LocalizedLabel] + user_localized_label: Optional[LocalizedLabel] = None + additional_properties: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to Web API JSON format.""" + result = { + "@odata.type": "Microsoft.Dynamics.CRM.Label", + "LocalizedLabels": [ll.to_dict() for ll in self.localized_labels], + } + # Use explicit user_localized_label, or default to first localized label + if self.user_localized_label: + result["UserLocalizedLabel"] = self.user_localized_label.to_dict() + elif self.localized_labels: + result["UserLocalizedLabel"] = self.localized_labels[0].to_dict() + if self.additional_properties: + result.update(self.additional_properties) + return result + + +@dataclass +class CascadeConfiguration: + """ + Defines cascade behavior for relationship operations. + + :param assign: Cascade behavior for assign operations. + :type assign: str + :param delete: Cascade behavior for delete operations. + :type delete: str + :param merge: Cascade behavior for merge operations. + :type merge: str + :param reparent: Cascade behavior for reparent operations. + :type reparent: str + :param share: Cascade behavior for share operations. + :type share: str + :param unshare: Cascade behavior for unshare operations. + :type unshare: str + :param additional_properties: Optional dict of additional properties to include + in the Web API payload (e.g., "Archive", "RollupView"). These are merged + last and can override default values. + :type additional_properties: Optional[Dict[str, Any]] + + Valid values for each parameter: + - "Cascade": Perform the operation on all related records + - "NoCascade": Do not perform the operation on related records + - "RemoveLink": Remove the relationship link but keep the records + - "Restrict": Prevent the operation if related records exist + """ + + assign: str = "NoCascade" + delete: str = "RemoveLink" + merge: str = "NoCascade" + reparent: str = "NoCascade" + share: str = "NoCascade" + unshare: str = "NoCascade" + additional_properties: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to Web API JSON format.""" + result = { + "Assign": self.assign, + "Delete": self.delete, + "Merge": self.merge, + "Reparent": self.reparent, + "Share": self.share, + "Unshare": self.unshare, + } + if self.additional_properties: + result.update(self.additional_properties) + return result + + +@dataclass +class AssociatedMenuConfiguration: + """ + Configuration for how the relationship appears in the associated menu. + + :param behavior: Display behavior in the menu. + :type behavior: str + :param group: The menu group where the item appears. + :type group: str + :param label: Display label for the menu item. + :type label: Optional[Label] + :param order: Display order within the group. + :type order: int + :param additional_properties: Optional dict of additional properties to include + in the Web API payload (e.g., "Icon", "ViewId", "AvailableOffline"). + These are merged last and can override default values. + :type additional_properties: Optional[Dict[str, Any]] + + Valid behavior values: + - "UseCollectionName": Use the collection name + - "UseLabel": Use the specified label + - "DoNotDisplay": Do not display in the menu + """ + + behavior: str = "UseLabel" + group: str = "Details" + label: Optional[Label] = None + order: int = 10000 + additional_properties: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to Web API JSON format.""" + result = { + "Behavior": self.behavior, + "Group": self.group, + "Order": self.order, + } + if self.label: + result["Label"] = self.label.to_dict() + if self.additional_properties: + result.update(self.additional_properties) + return result + + +@dataclass +class LookupAttributeMetadata: + """ + Metadata for a lookup attribute. + + :param schema_name: Schema name for the attribute (e.g., "new_AccountId"). + :type schema_name: str + :param display_name: Display name for the attribute. + :type display_name: Label + :param description: Optional description of the attribute. + :type description: Optional[Label] + :param required_level: Requirement level for the attribute. + :type required_level: str + :param additional_properties: Optional dict of additional properties to include + in the Web API payload. Useful for setting properties like "Targets" (to + specify which entity types the lookup can reference), "LogicalName", + "IsSecured", "IsValidForAdvancedFind", etc. These are merged last and + can override default values. + :type additional_properties: Optional[Dict[str, Any]] + + Valid required_level values: + - "None": The attribute is optional + - "Recommended": The attribute is recommended + - "ApplicationRequired": The attribute is required + """ + + schema_name: str + display_name: Label + description: Optional[Label] = None + required_level: str = "None" + additional_properties: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to Web API JSON format.""" + result = { + "@odata.type": "Microsoft.Dynamics.CRM.LookupAttributeMetadata", + "SchemaName": self.schema_name, + "AttributeType": "Lookup", + "AttributeTypeName": {"Value": "LookupType"}, + "DisplayName": self.display_name.to_dict(), + "RequiredLevel": { + "Value": self.required_level, + "CanBeChanged": True, + "ManagedPropertyLogicalName": "canmodifyrequirementlevelsettings", + }, + } + if self.description: + result["Description"] = self.description.to_dict() + if self.additional_properties: + result.update(self.additional_properties) + return result + + +@dataclass +class OneToManyRelationshipMetadata: + """ + Metadata for a one-to-many entity relationship. + + :param schema_name: Schema name for the relationship (e.g., "new_Account_Orders"). + :type schema_name: str + :param referenced_entity: Logical name of the referenced (parent) entity. + :type referenced_entity: str + :param referencing_entity: Logical name of the referencing (child) entity. + :type referencing_entity: str + :param referenced_attribute: Attribute on the referenced entity (typically the primary key). + :type referenced_attribute: str + :param cascade_configuration: Cascade behavior configuration. + :type cascade_configuration: CascadeConfiguration + :param associated_menu_configuration: Optional menu display configuration. + :type associated_menu_configuration: Optional[AssociatedMenuConfiguration] + :param referencing_attribute: Optional name for the referencing attribute (usually auto-generated). + :type referencing_attribute: Optional[str] + :param additional_properties: Optional dict of additional properties to include + in the Web API payload. Useful for setting inherited properties like + "IsValidForAdvancedFind", "IsCustomizable", "SecurityTypes", etc. + These are merged last and can override default values. + :type additional_properties: Optional[Dict[str, Any]] + """ + + schema_name: str + referenced_entity: str + referencing_entity: str + referenced_attribute: str + cascade_configuration: CascadeConfiguration = field(default_factory=CascadeConfiguration) + associated_menu_configuration: Optional[AssociatedMenuConfiguration] = None + referencing_attribute: Optional[str] = None + additional_properties: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to Web API JSON format.""" + result = { + "@odata.type": "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", + "SchemaName": self.schema_name, + "ReferencedEntity": self.referenced_entity, + "ReferencingEntity": self.referencing_entity, + "ReferencedAttribute": self.referenced_attribute, + "CascadeConfiguration": self.cascade_configuration.to_dict(), + } + if self.associated_menu_configuration: + result["AssociatedMenuConfiguration"] = self.associated_menu_configuration.to_dict() + if self.referencing_attribute: + result["ReferencingAttribute"] = self.referencing_attribute + if self.additional_properties: + result.update(self.additional_properties) + return result + + +@dataclass +class ManyToManyRelationshipMetadata: + """ + Metadata for a many-to-many entity relationship. + + :param schema_name: Schema name for the relationship. + :type schema_name: str + :param entity1_logical_name: Logical name of the first entity. + :type entity1_logical_name: str + :param entity2_logical_name: Logical name of the second entity. + :type entity2_logical_name: str + :param intersect_entity_name: Name for the intersect table (defaults to schema_name if not provided). + :type intersect_entity_name: Optional[str] + :param entity1_associated_menu_configuration: Menu configuration for entity1. + :type entity1_associated_menu_configuration: Optional[AssociatedMenuConfiguration] + :param entity2_associated_menu_configuration: Menu configuration for entity2. + :type entity2_associated_menu_configuration: Optional[AssociatedMenuConfiguration] + :param additional_properties: Optional dict of additional properties to include + in the Web API payload. Useful for setting inherited properties like + "IsValidForAdvancedFind", "IsCustomizable", "SecurityTypes", or direct + properties like "Entity1NavigationPropertyName". These are merged last + and can override default values. + :type additional_properties: Optional[Dict[str, Any]] + """ + + schema_name: str + entity1_logical_name: str + entity2_logical_name: str + intersect_entity_name: Optional[str] = None + entity1_associated_menu_configuration: Optional[AssociatedMenuConfiguration] = None + entity2_associated_menu_configuration: Optional[AssociatedMenuConfiguration] = None + additional_properties: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to Web API JSON format.""" + # IntersectEntityName is required - use provided value or default to schema_name + intersect_name = self.intersect_entity_name or self.schema_name + result = { + "@odata.type": "Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata", + "SchemaName": self.schema_name, + "Entity1LogicalName": self.entity1_logical_name, + "Entity2LogicalName": self.entity2_logical_name, + "IntersectEntityName": intersect_name, + } + if self.entity1_associated_menu_configuration: + result["Entity1AssociatedMenuConfiguration"] = self.entity1_associated_menu_configuration.to_dict() + if self.entity2_associated_menu_configuration: + result["Entity2AssociatedMenuConfiguration"] = self.entity2_associated_menu_configuration.to_dict() + if self.additional_properties: + result.update(self.additional_properties) + return result + + +__all__ = [ + "LocalizedLabel", + "Label", + "CascadeConfiguration", + "AssociatedMenuConfiguration", + "LookupAttributeMetadata", + "OneToManyRelationshipMetadata", + "ManyToManyRelationshipMetadata", +] diff --git a/tests/unit/data/test_relationships.py b/tests/unit/data/test_relationships.py new file mode 100644 index 0000000..1a0c024 --- /dev/null +++ b/tests/unit/data/test_relationships.py @@ -0,0 +1,294 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for relationship metadata operations.""" + +import unittest +from unittest.mock import MagicMock, Mock + +from PowerPlatform.Dataverse.data._relationships import _RelationshipOperationsMixin +from PowerPlatform.Dataverse.models.metadata import ( + LookupAttributeMetadata, + OneToManyRelationshipMetadata, + ManyToManyRelationshipMetadata, + Label, + LocalizedLabel, +) + + +class TestExtractIdFromHeader(unittest.TestCase): + """Tests for _extract_id_from_header method.""" + + def setUp(self): + """Create a minimal mixin instance for testing.""" + self.mixin = _RelationshipOperationsMixin() + + def test_extract_id_from_standard_header(self): + """Test extracting GUID from standard OData-EntityId header.""" + header = "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(12345678-1234-1234-1234-123456789abc)" + result = self.mixin._extract_id_from_header(header) + self.assertEqual(result, "12345678-1234-1234-1234-123456789abc") + + def test_extract_id_from_header_uppercase_guid(self): + """Test extracting uppercase GUID.""" + header = "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(ABCDEF12-3456-7890-ABCD-EF1234567890)" + result = self.mixin._extract_id_from_header(header) + self.assertEqual(result, "ABCDEF12-3456-7890-ABCD-EF1234567890") + + def test_extract_id_from_none_header(self): + """Test that None header returns None.""" + result = self.mixin._extract_id_from_header(None) + self.assertIsNone(result) + + def test_extract_id_from_empty_header(self): + """Test that empty header returns None.""" + result = self.mixin._extract_id_from_header("") + self.assertIsNone(result) + + def test_extract_id_from_header_without_guid(self): + """Test that header without GUID returns None.""" + header = "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions" + result = self.mixin._extract_id_from_header(header) + self.assertIsNone(result) + + +class MockODataClient(_RelationshipOperationsMixin): + """Mock client that inherits from mixin for integration testing.""" + + def __init__(self, api_base: str): + self.api = api_base + self._mock_request = MagicMock() + self._mock_headers = {"Authorization": "Bearer test-token"} + + def _headers(self): + return self._mock_headers.copy() + + def _request(self, method, url, **kwargs): + return self._mock_request(method, url, **kwargs) + + def _escape_odata_quotes(self, value: str) -> str: + """Escape single quotes for OData filter values.""" + return value.replace("'", "''") + + +class TestCreateOneToManyRelationship(unittest.TestCase): + """Tests for _create_one_to_many_relationship method.""" + + def setUp(self): + """Set up test fixtures.""" + self.client = MockODataClient("https://example.crm.dynamics.com/api/data/v9.2") + + # Create test metadata objects + self.lookup = LookupAttributeMetadata( + schema_name="new_AccountId", + display_name=Label(localized_labels=[LocalizedLabel(label="Account", language_code=1033)]), + ) + self.relationship = OneToManyRelationshipMetadata( + schema_name="new_account_orders", + referenced_entity="account", + referencing_entity="new_order", + referenced_attribute="accountid", + ) + + def test_create_relationship_url(self): + """Test that correct URL is used.""" + mock_response = Mock() + mock_response.headers = { + "OData-EntityId": "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(12345678-1234-1234-1234-123456789abc)" + } + self.client._mock_request.return_value = mock_response + + self.client._create_one_to_many_relationship(self.lookup, self.relationship) + + # Verify URL + call_args = self.client._mock_request.call_args + self.assertEqual(call_args[0][0], "post") + self.assertEqual(call_args[0][1], "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions") + + def test_create_relationship_payload_includes_lookup(self): + """Test that payload includes both relationship and lookup metadata.""" + mock_response = Mock() + mock_response.headers = { + "OData-EntityId": "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(12345678-1234-1234-1234-123456789abc)" + } + self.client._mock_request.return_value = mock_response + + self.client._create_one_to_many_relationship(self.lookup, self.relationship) + + # Verify payload + call_args = self.client._mock_request.call_args + payload = call_args[1]["json"] + self.assertIn("@odata.type", payload) + self.assertEqual(payload["@odata.type"], "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata") + self.assertIn("Lookup", payload) + self.assertEqual(payload["Lookup"]["SchemaName"], "new_AccountId") + + def test_create_relationship_with_solution(self): + """Test that solution header is added when specified.""" + mock_response = Mock() + mock_response.headers = { + "OData-EntityId": "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(12345678-1234-1234-1234-123456789abc)" + } + self.client._mock_request.return_value = mock_response + + self.client._create_one_to_many_relationship(self.lookup, self.relationship, solution_unique_name="MySolution") + + # Verify solution header + call_args = self.client._mock_request.call_args + headers = call_args[1]["headers"] + self.assertEqual(headers["MSCRM.SolutionUniqueName"], "MySolution") + + def test_create_relationship_returns_result(self): + """Test that result dictionary is correctly populated.""" + mock_response = Mock() + mock_response.headers = { + "OData-EntityId": "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(12345678-1234-1234-1234-123456789abc)" + } + self.client._mock_request.return_value = mock_response + + result = self.client._create_one_to_many_relationship(self.lookup, self.relationship) + + self.assertEqual(result["relationship_id"], "12345678-1234-1234-1234-123456789abc") + self.assertEqual(result["relationship_schema_name"], "new_account_orders") + self.assertEqual(result["lookup_schema_name"], "new_AccountId") + self.assertEqual(result["referenced_entity"], "account") + self.assertEqual(result["referencing_entity"], "new_order") + + +class TestCreateManyToManyRelationship(unittest.TestCase): + """Tests for _create_many_to_many_relationship method.""" + + def setUp(self): + """Set up test fixtures.""" + self.client = MockODataClient("https://example.crm.dynamics.com/api/data/v9.2") + + self.relationship = ManyToManyRelationshipMetadata( + schema_name="new_account_contact", + entity1_logical_name="account", + entity2_logical_name="contact", + ) + + def test_create_m2m_relationship_url(self): + """Test that correct URL is used.""" + mock_response = Mock() + mock_response.headers = { + "OData-EntityId": "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(abcd1234-abcd-1234-abcd-1234abcd5678)" + } + self.client._mock_request.return_value = mock_response + + self.client._create_many_to_many_relationship(self.relationship) + + call_args = self.client._mock_request.call_args + self.assertEqual(call_args[0][0], "post") + self.assertEqual(call_args[0][1], "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions") + + def test_create_m2m_relationship_returns_result(self): + """Test that result dictionary is correctly populated.""" + mock_response = Mock() + mock_response.headers = { + "OData-EntityId": "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(abcd1234-abcd-1234-abcd-1234abcd5678)" + } + self.client._mock_request.return_value = mock_response + + result = self.client._create_many_to_many_relationship(self.relationship) + + self.assertEqual(result["relationship_id"], "abcd1234-abcd-1234-abcd-1234abcd5678") + self.assertEqual(result["relationship_schema_name"], "new_account_contact") + self.assertEqual(result["entity1_logical_name"], "account") + self.assertEqual(result["entity2_logical_name"], "contact") + + +class TestDeleteRelationship(unittest.TestCase): + """Tests for _delete_relationship method.""" + + def setUp(self): + """Set up test fixtures.""" + self.client = MockODataClient("https://example.crm.dynamics.com/api/data/v9.2") + + def test_delete_relationship_url(self): + """Test that correct URL is constructed.""" + mock_response = Mock() + self.client._mock_request.return_value = mock_response + + self.client._delete_relationship("12345678-1234-1234-1234-123456789abc") + + call_args = self.client._mock_request.call_args + self.assertEqual(call_args[0][0], "delete") + self.assertEqual( + call_args[0][1], + "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions(12345678-1234-1234-1234-123456789abc)", + ) + + def test_delete_relationship_has_if_match_header(self): + """Test that If-Match header is set.""" + mock_response = Mock() + self.client._mock_request.return_value = mock_response + + self.client._delete_relationship("12345678-1234-1234-1234-123456789abc") + + call_args = self.client._mock_request.call_args + headers = call_args[1]["headers"] + self.assertEqual(headers["If-Match"], "*") + + +class TestGetRelationship(unittest.TestCase): + """Tests for _get_relationship method.""" + + def setUp(self): + """Set up test fixtures.""" + self.client = MockODataClient("https://example.crm.dynamics.com/api/data/v9.2") + + def test_get_relationship_url_and_filter(self): + """Test that correct URL and filter are used.""" + mock_response = Mock() + mock_response.json.return_value = {"value": []} + self.client._mock_request.return_value = mock_response + + self.client._get_relationship("new_account_orders") + + call_args = self.client._mock_request.call_args + self.assertEqual(call_args[0][0], "get") + self.assertEqual(call_args[0][1], "https://example.crm.dynamics.com/api/data/v9.2/RelationshipDefinitions") + self.assertEqual(call_args[1]["params"]["$filter"], "SchemaName eq 'new_account_orders'") + + def test_get_relationship_escapes_quotes(self): + """Test that single quotes in schema name are escaped.""" + mock_response = Mock() + mock_response.json.return_value = {"value": []} + self.client._mock_request.return_value = mock_response + + # Schema names shouldn't have quotes, but test the escaping anyway + self.client._get_relationship("schema'name") + + call_args = self.client._mock_request.call_args + self.assertEqual(call_args[1]["params"]["$filter"], "SchemaName eq 'schema''name'") + + def test_get_relationship_returns_first_result(self): + """Test that first result is returned when found.""" + mock_response = Mock() + mock_response.json.return_value = { + "value": [ + {"SchemaName": "new_account_orders", "MetadataId": "12345"}, + {"SchemaName": "other", "MetadataId": "67890"}, + ] + } + self.client._mock_request.return_value = mock_response + + result = self.client._get_relationship("new_account_orders") + + self.assertEqual(result["SchemaName"], "new_account_orders") + self.assertEqual(result["MetadataId"], "12345") + + def test_get_relationship_returns_none_when_not_found(self): + """Test that None is returned when not found.""" + mock_response = Mock() + mock_response.json.return_value = {"value": []} + self.client._mock_request.return_value = mock_response + + result = self.client._get_relationship("nonexistent") + + self.assertIsNone(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/models/__init__.py b/tests/unit/models/__init__.py new file mode 100644 index 0000000..4d4828e --- /dev/null +++ b/tests/unit/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Unit tests for metadata models.""" diff --git a/tests/unit/models/test_metadata.py b/tests/unit/models/test_metadata.py new file mode 100644 index 0000000..1149b35 --- /dev/null +++ b/tests/unit/models/test_metadata.py @@ -0,0 +1,383 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for metadata entity types.""" + +from PowerPlatform.Dataverse.models.metadata import ( + LocalizedLabel, + Label, + CascadeConfiguration, + AssociatedMenuConfiguration, + LookupAttributeMetadata, + OneToManyRelationshipMetadata, + ManyToManyRelationshipMetadata, +) + + +class TestLocalizedLabel: + """Tests for LocalizedLabel.""" + + def test_to_dict_basic(self): + """Test basic serialization.""" + label = LocalizedLabel(label="Test", language_code=1033) + result = label.to_dict() + + assert result["@odata.type"] == "Microsoft.Dynamics.CRM.LocalizedLabel" + assert result["Label"] == "Test" + assert result["LanguageCode"] == 1033 + + def test_to_dict_with_additional_properties(self): + """Test that additional_properties are merged.""" + label = LocalizedLabel( + label="Test", + language_code=1033, + additional_properties={"IsManaged": True, "MetadataId": "abc-123"}, + ) + result = label.to_dict() + + assert result["Label"] == "Test" + assert result["IsManaged"] is True + assert result["MetadataId"] == "abc-123" + + def test_additional_properties_can_override(self): + """Test that additional_properties can override default values.""" + label = LocalizedLabel( + label="Original", + language_code=1033, + additional_properties={"Label": "Overridden"}, + ) + result = label.to_dict() + + assert result["Label"] == "Overridden" + + +class TestLabel: + """Tests for Label.""" + + def test_to_dict_basic(self): + """Test basic serialization with auto UserLocalizedLabel.""" + label = Label(localized_labels=[LocalizedLabel(label="Test", language_code=1033)]) + result = label.to_dict() + + assert result["@odata.type"] == "Microsoft.Dynamics.CRM.Label" + assert len(result["LocalizedLabels"]) == 1 + assert result["LocalizedLabels"][0]["Label"] == "Test" + # UserLocalizedLabel should default to first localized label + assert result["UserLocalizedLabel"]["Label"] == "Test" + + def test_to_dict_with_explicit_user_label(self): + """Test that explicit user_localized_label is used.""" + label = Label( + localized_labels=[ + LocalizedLabel(label="English", language_code=1033), + LocalizedLabel(label="French", language_code=1036), + ], + user_localized_label=LocalizedLabel(label="French", language_code=1036), + ) + result = label.to_dict() + + assert result["UserLocalizedLabel"]["Label"] == "French" + assert result["UserLocalizedLabel"]["LanguageCode"] == 1036 + + def test_to_dict_with_additional_properties(self): + """Test that additional_properties are merged.""" + label = Label( + localized_labels=[LocalizedLabel(label="Test", language_code=1033)], + additional_properties={"CustomProperty": "value"}, + ) + result = label.to_dict() + + assert result["CustomProperty"] == "value" + + +class TestCascadeConfiguration: + """Tests for CascadeConfiguration.""" + + def test_to_dict_defaults(self): + """Test default values.""" + cascade = CascadeConfiguration() + result = cascade.to_dict() + + assert result["Assign"] == "NoCascade" + assert result["Delete"] == "RemoveLink" + assert result["Merge"] == "NoCascade" + assert result["Reparent"] == "NoCascade" + assert result["Share"] == "NoCascade" + assert result["Unshare"] == "NoCascade" + + def test_to_dict_custom_values(self): + """Test custom cascade values.""" + cascade = CascadeConfiguration( + assign="Cascade", + delete="Restrict", + ) + result = cascade.to_dict() + + assert result["Assign"] == "Cascade" + assert result["Delete"] == "Restrict" + + def test_to_dict_with_additional_properties(self): + """Test additional properties like Archive and RollupView.""" + cascade = CascadeConfiguration( + additional_properties={ + "Archive": "NoCascade", + "RollupView": "NoCascade", + } + ) + result = cascade.to_dict() + + assert result["Archive"] == "NoCascade" + assert result["RollupView"] == "NoCascade" + + +class TestAssociatedMenuConfiguration: + """Tests for AssociatedMenuConfiguration.""" + + def test_to_dict_defaults(self): + """Test default values.""" + menu = AssociatedMenuConfiguration() + result = menu.to_dict() + + assert result["Behavior"] == "UseLabel" + assert result["Group"] == "Details" + assert result["Order"] == 10000 + assert "Label" not in result + + def test_to_dict_with_label(self): + """Test with a label.""" + menu = AssociatedMenuConfiguration( + label=Label(localized_labels=[LocalizedLabel(label="Related Items", language_code=1033)]) + ) + result = menu.to_dict() + + assert result["Label"]["LocalizedLabels"][0]["Label"] == "Related Items" + + def test_to_dict_with_additional_properties(self): + """Test additional properties like Icon and ViewId.""" + menu = AssociatedMenuConfiguration( + additional_properties={ + "Icon": "custom_icon", + "ViewId": "00000000-0000-0000-0000-000000000000", + "AvailableOffline": True, + } + ) + result = menu.to_dict() + + assert result["Icon"] == "custom_icon" + assert result["ViewId"] == "00000000-0000-0000-0000-000000000000" + assert result["AvailableOffline"] is True + + +class TestLookupAttributeMetadata: + """Tests for LookupAttributeMetadata.""" + + def test_to_dict_basic(self): + """Test basic serialization.""" + lookup = LookupAttributeMetadata( + schema_name="new_AccountId", + display_name=Label(localized_labels=[LocalizedLabel(label="Account", language_code=1033)]), + ) + result = lookup.to_dict() + + assert result["@odata.type"] == "Microsoft.Dynamics.CRM.LookupAttributeMetadata" + assert result["SchemaName"] == "new_AccountId" + assert result["AttributeType"] == "Lookup" + assert result["AttributeTypeName"]["Value"] == "LookupType" + assert result["RequiredLevel"]["Value"] == "None" + + def test_to_dict_required(self): + """Test required level.""" + lookup = LookupAttributeMetadata( + schema_name="new_AccountId", + display_name=Label(localized_labels=[LocalizedLabel(label="Account", language_code=1033)]), + required_level="ApplicationRequired", + ) + result = lookup.to_dict() + + assert result["RequiredLevel"]["Value"] == "ApplicationRequired" + + def test_to_dict_with_description(self): + """Test with description.""" + lookup = LookupAttributeMetadata( + schema_name="new_AccountId", + display_name=Label(localized_labels=[LocalizedLabel(label="Account", language_code=1033)]), + description=Label(localized_labels=[LocalizedLabel(label="The related account", language_code=1033)]), + ) + result = lookup.to_dict() + + assert "Description" in result + assert result["Description"]["LocalizedLabels"][0]["Label"] == "The related account" + + def test_to_dict_with_additional_properties(self): + """Test additional properties like Targets and IsSecured.""" + lookup = LookupAttributeMetadata( + schema_name="new_ParentId", + display_name=Label(localized_labels=[LocalizedLabel(label="Parent", language_code=1033)]), + additional_properties={ + "Targets": ["account", "contact"], + "IsSecured": True, + "IsValidForAdvancedFind": True, + }, + ) + result = lookup.to_dict() + + assert result["Targets"] == ["account", "contact"] + assert result["IsSecured"] is True + assert result["IsValidForAdvancedFind"] is True + + +class TestOneToManyRelationshipMetadata: + """Tests for OneToManyRelationshipMetadata.""" + + def test_to_dict_basic(self): + """Test basic serialization.""" + rel = OneToManyRelationshipMetadata( + schema_name="new_account_orders", + referenced_entity="account", + referencing_entity="new_order", + referenced_attribute="accountid", + ) + result = rel.to_dict() + + assert result["@odata.type"] == "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata" + assert result["SchemaName"] == "new_account_orders" + assert result["ReferencedEntity"] == "account" + assert result["ReferencingEntity"] == "new_order" + assert result["ReferencedAttribute"] == "accountid" + assert "CascadeConfiguration" in result + + def test_to_dict_with_custom_cascade(self): + """Test with custom cascade configuration.""" + rel = OneToManyRelationshipMetadata( + schema_name="new_account_orders", + referenced_entity="account", + referencing_entity="new_order", + referenced_attribute="accountid", + cascade_configuration=CascadeConfiguration( + delete="Cascade", + assign="Cascade", + ), + ) + result = rel.to_dict() + + assert result["CascadeConfiguration"]["Delete"] == "Cascade" + assert result["CascadeConfiguration"]["Assign"] == "Cascade" + + def test_to_dict_with_menu_configuration(self): + """Test with associated menu configuration.""" + rel = OneToManyRelationshipMetadata( + schema_name="new_account_orders", + referenced_entity="account", + referencing_entity="new_order", + referenced_attribute="accountid", + associated_menu_configuration=AssociatedMenuConfiguration( + behavior="UseLabel", + label=Label(localized_labels=[LocalizedLabel(label="Orders", language_code=1033)]), + ), + ) + result = rel.to_dict() + + assert "AssociatedMenuConfiguration" in result + assert result["AssociatedMenuConfiguration"]["Label"]["LocalizedLabels"][0]["Label"] == "Orders" + + def test_to_dict_with_referencing_attribute(self): + """Test with explicit referencing attribute.""" + rel = OneToManyRelationshipMetadata( + schema_name="new_account_orders", + referenced_entity="account", + referencing_entity="new_order", + referenced_attribute="accountid", + referencing_attribute="new_accountid", + ) + result = rel.to_dict() + + assert result["ReferencingAttribute"] == "new_accountid" + + def test_to_dict_with_additional_properties(self): + """Test additional properties like IsCustomizable.""" + rel = OneToManyRelationshipMetadata( + schema_name="new_account_orders", + referenced_entity="account", + referencing_entity="new_order", + referenced_attribute="accountid", + additional_properties={ + "IsCustomizable": {"Value": True, "CanBeChanged": True}, + "IsValidForAdvancedFind": True, + "SecurityTypes": "None", + }, + ) + result = rel.to_dict() + + assert result["IsCustomizable"]["Value"] is True + assert result["IsValidForAdvancedFind"] is True + assert result["SecurityTypes"] == "None" + + +class TestManyToManyRelationshipMetadata: + """Tests for ManyToManyRelationshipMetadata.""" + + def test_to_dict_basic(self): + """Test basic serialization with auto intersect name.""" + rel = ManyToManyRelationshipMetadata( + schema_name="new_account_contact", + entity1_logical_name="account", + entity2_logical_name="contact", + ) + result = rel.to_dict() + + assert result["@odata.type"] == "Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata" + assert result["SchemaName"] == "new_account_contact" + assert result["Entity1LogicalName"] == "account" + assert result["Entity2LogicalName"] == "contact" + # IntersectEntityName should default to schema_name + assert result["IntersectEntityName"] == "new_account_contact" + + def test_to_dict_with_explicit_intersect_name(self): + """Test with explicit intersect entity name.""" + rel = ManyToManyRelationshipMetadata( + schema_name="new_account_contact", + entity1_logical_name="account", + entity2_logical_name="contact", + intersect_entity_name="new_account_contact_assoc", + ) + result = rel.to_dict() + + assert result["IntersectEntityName"] == "new_account_contact_assoc" + + def test_to_dict_with_menu_configurations(self): + """Test with associated menu configurations for both entities.""" + rel = ManyToManyRelationshipMetadata( + schema_name="new_account_contact", + entity1_logical_name="account", + entity2_logical_name="contact", + entity1_associated_menu_configuration=AssociatedMenuConfiguration( + label=Label(localized_labels=[LocalizedLabel(label="Contacts", language_code=1033)]) + ), + entity2_associated_menu_configuration=AssociatedMenuConfiguration( + label=Label(localized_labels=[LocalizedLabel(label="Accounts", language_code=1033)]) + ), + ) + result = rel.to_dict() + + assert "Entity1AssociatedMenuConfiguration" in result + assert "Entity2AssociatedMenuConfiguration" in result + assert result["Entity1AssociatedMenuConfiguration"]["Label"]["LocalizedLabels"][0]["Label"] == "Contacts" + assert result["Entity2AssociatedMenuConfiguration"]["Label"]["LocalizedLabels"][0]["Label"] == "Accounts" + + def test_to_dict_with_additional_properties(self): + """Test additional properties like navigation property names.""" + rel = ManyToManyRelationshipMetadata( + schema_name="new_account_contact", + entity1_logical_name="account", + entity2_logical_name="contact", + additional_properties={ + "Entity1NavigationPropertyName": "new_contacts", + "Entity2NavigationPropertyName": "new_accounts", + "IsCustomizable": {"Value": True, "CanBeChanged": True}, + }, + ) + result = rel.to_dict() + + assert result["Entity1NavigationPropertyName"] == "new_contacts" + assert result["Entity2NavigationPropertyName"] == "new_accounts" + assert result["IsCustomizable"]["Value"] is True diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e765ba0..1b74b42 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -127,3 +127,219 @@ def test_get_multiple(self): page_size=None, ) self.assertEqual(results, [expected_batch]) + + +class TestCreateLookupField(unittest.TestCase): + """Tests for DataverseClient.create_lookup_field convenience method.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_credential = MagicMock(spec=TokenCredential) + self.base_url = "https://example.crm.dynamics.com" + self.client = DataverseClient(self.base_url, self.mock_credential) + + # Mock create_one_to_many_relationship since create_lookup_field calls it + self.client.create_one_to_many_relationship = MagicMock( + return_value={ + "relationship_id": "12345678-1234-1234-1234-123456789abc", + "relationship_schema_name": "account_new_order_new_AccountId", + "lookup_schema_name": "new_AccountId", + "referenced_entity": "account", + "referencing_entity": "new_order", + } + ) + + def test_basic_lookup_field_creation(self): + """Test basic lookup field creation with minimal parameters.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + ) + + # Verify create_one_to_many_relationship was called + self.client.create_one_to_many_relationship.assert_called_once() + + # Get the call arguments + call_args = self.client.create_one_to_many_relationship.call_args + lookup = call_args[0][0] + relationship = call_args[0][1] + solution = call_args[0][2] + + # Verify lookup metadata + self.assertEqual(lookup.schema_name, "new_AccountId") + self.assertEqual(lookup.required_level, "None") + + # Verify relationship metadata + self.assertEqual(relationship.referenced_entity, "account") + self.assertEqual(relationship.referencing_entity, "new_order") + self.assertEqual(relationship.referenced_attribute, "accountid") + + # Verify no solution + self.assertIsNone(solution) + + def test_lookup_with_display_name(self): + """Test that display_name is correctly set.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + display_name="Parent Account", + ) + + call_args = self.client.create_one_to_many_relationship.call_args + lookup = call_args[0][0] + + # Verify display name is in the label + label_dict = lookup.display_name.to_dict() + self.assertEqual(label_dict["LocalizedLabels"][0]["Label"], "Parent Account") + + def test_lookup_with_default_display_name(self): + """Test that display_name defaults to referenced table name.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + ) + + call_args = self.client.create_one_to_many_relationship.call_args + lookup = call_args[0][0] + + # Verify display name defaults to referenced table + label_dict = lookup.display_name.to_dict() + self.assertEqual(label_dict["LocalizedLabels"][0]["Label"], "account") + + def test_lookup_with_description(self): + """Test that description is correctly set.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + description="The customer account for this order", + ) + + call_args = self.client.create_one_to_many_relationship.call_args + lookup = call_args[0][0] + + # Verify description is set + self.assertIsNotNone(lookup.description) + desc_dict = lookup.description.to_dict() + self.assertEqual(desc_dict["LocalizedLabels"][0]["Label"], "The customer account for this order") + + def test_lookup_required_true(self): + """Test that required=True sets ApplicationRequired level.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + required=True, + ) + + call_args = self.client.create_one_to_many_relationship.call_args + lookup = call_args[0][0] + + self.assertEqual(lookup.required_level, "ApplicationRequired") + + def test_lookup_required_false(self): + """Test that required=False sets None level.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + required=False, + ) + + call_args = self.client.create_one_to_many_relationship.call_args + lookup = call_args[0][0] + + self.assertEqual(lookup.required_level, "None") + + def test_cascade_delete_configuration(self): + """Test that cascade_delete is correctly passed to relationship.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + cascade_delete="Cascade", + ) + + call_args = self.client.create_one_to_many_relationship.call_args + relationship = call_args[0][1] + + cascade_dict = relationship.cascade_configuration.to_dict() + self.assertEqual(cascade_dict["Delete"], "Cascade") + + def test_solution_unique_name_passed(self): + """Test that solution_unique_name is passed through.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + solution_unique_name="MySolution", + ) + + call_args = self.client.create_one_to_many_relationship.call_args + solution = call_args[0][2] + + self.assertEqual(solution, "MySolution") + + def test_custom_language_code(self): + """Test that custom language_code is used for labels.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + display_name="Compte", + language_code=1036, # French + ) + + call_args = self.client.create_one_to_many_relationship.call_args + lookup = call_args[0][0] + + label_dict = lookup.display_name.to_dict() + self.assertEqual(label_dict["LocalizedLabels"][0]["LanguageCode"], 1036) + self.assertEqual(label_dict["LocalizedLabels"][0]["Label"], "Compte") + + def test_generated_relationship_name(self): + """Test that relationship name is auto-generated correctly.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + ) + + call_args = self.client.create_one_to_many_relationship.call_args + relationship = call_args[0][1] + + # Should be: {referenced}_{referencing}_{lookup_field} + self.assertEqual(relationship.schema_name, "account_new_order_new_AccountId") + + def test_referenced_attribute_auto_generated(self): + """Test that referenced_attribute defaults to {table}id.""" + self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + ) + + call_args = self.client.create_one_to_many_relationship.call_args + relationship = call_args[0][1] + + self.assertEqual(relationship.referenced_attribute, "accountid") + + def test_returns_result(self): + """Test that the method returns the result from create_one_to_many_relationship.""" + expected_result = { + "relationship_id": "test-guid", + "relationship_schema_name": "test_schema", + "lookup_schema_name": "test_lookup", + } + self.client.create_one_to_many_relationship.return_value = expected_result + + result = self.client.create_lookup_field( + referencing_table="new_order", + lookup_field_name="new_AccountId", + referenced_table="account", + ) + + self.assertEqual(result, expected_result) From c9c97ad54c1bfdf0470865bda0035d2b3b21e602 Mon Sep 17 00:00:00 2001 From: tpellissier Date: Fri, 30 Jan 2026 12:03:54 -0800 Subject: [PATCH 2/7] Address PR review feedback - Move OData type constants to common/constants.py - Add input/output examples to metadata to_dict() docstrings - Remove .NET SDK references from _relationships.py docstrings - Add __all__ to models/__init__.py Co-Authored-By: Claude Opus 4.5 --- .../Dataverse/common/__init__.py | 10 ++ .../Dataverse/common/constants.py | 16 ++ .../Dataverse/data/_relationships.py | 6 +- .../Dataverse/models/__init__.py | 2 + .../Dataverse/models/metadata.py | 137 ++++++++++++++++-- 5 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 src/PowerPlatform/Dataverse/common/__init__.py create mode 100644 src/PowerPlatform/Dataverse/common/constants.py diff --git a/src/PowerPlatform/Dataverse/common/__init__.py b/src/PowerPlatform/Dataverse/common/__init__.py new file mode 100644 index 0000000..049d80d --- /dev/null +++ b/src/PowerPlatform/Dataverse/common/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Common utilities and constants for the Dataverse SDK. + +This module contains shared constants and utilities used across the SDK. +""" + +__all__ = [] diff --git a/src/PowerPlatform/Dataverse/common/constants.py b/src/PowerPlatform/Dataverse/common/constants.py new file mode 100644 index 0000000..bc56641 --- /dev/null +++ b/src/PowerPlatform/Dataverse/common/constants.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Constants for Dataverse Web API metadata types. + +These constants define the OData type identifiers used in Web API payloads +for metadata operations. +""" + +# OData type identifiers for metadata entities +ODATA_TYPE_LOCALIZED_LABEL = "Microsoft.Dynamics.CRM.LocalizedLabel" +ODATA_TYPE_LABEL = "Microsoft.Dynamics.CRM.Label" +ODATA_TYPE_LOOKUP_ATTRIBUTE = "Microsoft.Dynamics.CRM.LookupAttributeMetadata" +ODATA_TYPE_ONE_TO_MANY_RELATIONSHIP = "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata" +ODATA_TYPE_MANY_TO_MANY_RELATIONSHIP = "Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata" diff --git a/src/PowerPlatform/Dataverse/data/_relationships.py b/src/PowerPlatform/Dataverse/data/_relationships.py index 3a2be86..f2fc0f7 100644 --- a/src/PowerPlatform/Dataverse/data/_relationships.py +++ b/src/PowerPlatform/Dataverse/data/_relationships.py @@ -32,8 +32,7 @@ def _create_one_to_many_relationship( """ Create a one-to-many relationship with lookup attribute. - This mirrors the CreateOneToManyRequest from the .NET SDK by posting - to /RelationshipDefinitions with OneToManyRelationshipMetadata. + Posts to /RelationshipDefinitions with OneToManyRelationshipMetadata. :param lookup: Lookup attribute metadata (LookupAttributeMetadata instance). :type lookup: ~PowerPlatform.Dataverse.models.metadata.LookupAttributeMetadata @@ -78,8 +77,7 @@ def _create_many_to_many_relationship( """ Create a many-to-many relationship. - This mirrors the CreateManyToManyRequest from the .NET SDK by posting - to /RelationshipDefinitions with ManyToManyRelationshipMetadata. + Posts to /RelationshipDefinitions with ManyToManyRelationshipMetadata. :param relationship: Relationship metadata (ManyToManyRelationshipMetadata instance). :type relationship: ~PowerPlatform.Dataverse.models.metadata.ManyToManyRelationshipMetadata diff --git a/src/PowerPlatform/Dataverse/models/__init__.py b/src/PowerPlatform/Dataverse/models/__init__.py index 6be73bf..d37d084 100644 --- a/src/PowerPlatform/Dataverse/models/__init__.py +++ b/src/PowerPlatform/Dataverse/models/__init__.py @@ -2,3 +2,5 @@ # Licensed under the MIT license. """Data models for Dataverse metadata types.""" + +__all__ = [] diff --git a/src/PowerPlatform/Dataverse/models/metadata.py b/src/PowerPlatform/Dataverse/models/metadata.py index 6a8b7be..8b30ff7 100644 --- a/src/PowerPlatform/Dataverse/models/metadata.py +++ b/src/PowerPlatform/Dataverse/models/metadata.py @@ -15,6 +15,14 @@ from typing import Any, Dict, List, Optional from dataclasses import dataclass, field +from ..common.constants import ( + ODATA_TYPE_LOCALIZED_LABEL, + ODATA_TYPE_LABEL, + ODATA_TYPE_LOOKUP_ATTRIBUTE, + ODATA_TYPE_ONE_TO_MANY_RELATIONSHIP, + ODATA_TYPE_MANY_TO_MANY_RELATIONSHIP, +) + @dataclass class LocalizedLabel: @@ -35,9 +43,21 @@ class LocalizedLabel: additional_properties: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: - """Convert to Web API JSON format.""" + """ + Convert to Web API JSON format. + + Example:: + + >>> label = LocalizedLabel(label="Account", language_code=1033) + >>> label.to_dict() + { + '@odata.type': 'Microsoft.Dynamics.CRM.LocalizedLabel', + 'Label': 'Account', + 'LanguageCode': 1033 + } + """ result = { - "@odata.type": "Microsoft.Dynamics.CRM.LocalizedLabel", + "@odata.type": ODATA_TYPE_LOCALIZED_LABEL, "Label": self.label, "LanguageCode": self.language_code, } @@ -65,9 +85,23 @@ class Label: additional_properties: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: - """Convert to Web API JSON format.""" + """ + Convert to Web API JSON format. + + Example:: + + >>> label = Label(localized_labels=[LocalizedLabel("Account", 1033)]) + >>> label.to_dict() + { + '@odata.type': 'Microsoft.Dynamics.CRM.Label', + 'LocalizedLabels': [ + {'@odata.type': '...', 'Label': 'Account', 'LanguageCode': 1033} + ], + 'UserLocalizedLabel': {'@odata.type': '...', 'Label': 'Account', ...} + } + """ result = { - "@odata.type": "Microsoft.Dynamics.CRM.Label", + "@odata.type": ODATA_TYPE_LABEL, "LocalizedLabels": [ll.to_dict() for ll in self.localized_labels], } # Use explicit user_localized_label, or default to first localized label @@ -118,7 +152,22 @@ class CascadeConfiguration: additional_properties: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: - """Convert to Web API JSON format.""" + """ + Convert to Web API JSON format. + + Example:: + + >>> config = CascadeConfiguration(delete="Cascade", assign="NoCascade") + >>> config.to_dict() + { + 'Assign': 'NoCascade', + 'Delete': 'Cascade', + 'Merge': 'NoCascade', + 'Reparent': 'NoCascade', + 'Share': 'NoCascade', + 'Unshare': 'NoCascade' + } + """ result = { "Assign": self.assign, "Delete": self.delete, @@ -163,7 +212,15 @@ class AssociatedMenuConfiguration: additional_properties: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: - """Convert to Web API JSON format.""" + """ + Convert to Web API JSON format. + + Example:: + + >>> menu = AssociatedMenuConfiguration(behavior="UseLabel", group="Details") + >>> menu.to_dict() + {'Behavior': 'UseLabel', 'Group': 'Details', 'Order': 10000} + """ result = { "Behavior": self.behavior, "Group": self.group, @@ -209,9 +266,27 @@ class LookupAttributeMetadata: additional_properties: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: - """Convert to Web API JSON format.""" + """ + Convert to Web API JSON format. + + Example:: + + >>> lookup = LookupAttributeMetadata( + ... schema_name="new_AccountId", + ... display_name=Label([LocalizedLabel("Account", 1033)]) + ... ) + >>> lookup.to_dict() + { + '@odata.type': 'Microsoft.Dynamics.CRM.LookupAttributeMetadata', + 'SchemaName': 'new_AccountId', + 'AttributeType': 'Lookup', + 'AttributeTypeName': {'Value': 'LookupType'}, + 'DisplayName': {...}, + 'RequiredLevel': {'Value': 'None', 'CanBeChanged': True, ...} + } + """ result = { - "@odata.type": "Microsoft.Dynamics.CRM.LookupAttributeMetadata", + "@odata.type": ODATA_TYPE_LOOKUP_ATTRIBUTE, "SchemaName": self.schema_name, "AttributeType": "Lookup", "AttributeTypeName": {"Value": "LookupType"}, @@ -265,9 +340,29 @@ class OneToManyRelationshipMetadata: additional_properties: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: - """Convert to Web API JSON format.""" + """ + Convert to Web API JSON format. + + Example:: + + >>> rel = OneToManyRelationshipMetadata( + ... schema_name="new_account_orders", + ... referenced_entity="account", + ... referencing_entity="new_order", + ... referenced_attribute="accountid" + ... ) + >>> rel.to_dict() + { + '@odata.type': 'Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata', + 'SchemaName': 'new_account_orders', + 'ReferencedEntity': 'account', + 'ReferencingEntity': 'new_order', + 'ReferencedAttribute': 'accountid', + 'CascadeConfiguration': {...} + } + """ result = { - "@odata.type": "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata", + "@odata.type": ODATA_TYPE_ONE_TO_MANY_RELATIONSHIP, "SchemaName": self.schema_name, "ReferencedEntity": self.referenced_entity, "ReferencingEntity": self.referencing_entity, @@ -317,11 +412,29 @@ class ManyToManyRelationshipMetadata: additional_properties: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: - """Convert to Web API JSON format.""" + """ + Convert to Web API JSON format. + + Example:: + + >>> rel = ManyToManyRelationshipMetadata( + ... schema_name="new_account_contact", + ... entity1_logical_name="account", + ... entity2_logical_name="contact" + ... ) + >>> rel.to_dict() + { + '@odata.type': 'Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata', + 'SchemaName': 'new_account_contact', + 'Entity1LogicalName': 'account', + 'Entity2LogicalName': 'contact', + 'IntersectEntityName': 'new_account_contact' + } + """ # IntersectEntityName is required - use provided value or default to schema_name intersect_name = self.intersect_entity_name or self.schema_name result = { - "@odata.type": "Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata", + "@odata.type": ODATA_TYPE_MANY_TO_MANY_RELATIONSHIP, "SchemaName": self.schema_name, "Entity1LogicalName": self.entity1_logical_name, "Entity2LogicalName": self.entity2_logical_name, From d65ebbd62565c695cb20f1603907137d0667d815 Mon Sep 17 00:00:00 2001 From: tpellissier Date: Fri, 30 Jan 2026 12:17:02 -0800 Subject: [PATCH 3/7] Revert unrelated changes to minimize PR diff - Revert emoji formatting changes in examples (file_upload, walkthrough, functional_testing, installation_example) - Revert pyproject.toml changes (keep claude skill installer) - Update README with only relationship-related changes Co-Authored-By: Claude Opus 4.5 --- README.md | 32 ++++- examples/advanced/file_upload.py | 4 +- examples/advanced/walkthrough.py | 64 +++++----- examples/basic/functional_testing.py | 134 ++++++++++---------- examples/basic/installation_example.py | 162 ++++++++++++------------- pyproject.toml | 4 + 6 files changed, 214 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index 0ed2459..3685187 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,21 @@ Install the PowerPlatform Dataverse Client using [pip](https://pypi.org/project/ pip install PowerPlatform-Dataverse-Client ``` -For development from source: +(Optional) Install Claude Skill globally with the Client: + +```bash +pip install PowerPlatform-Dataverse-Client && dataverse-install-claude-skill +``` + +This installs a Claude Skill that enables Claude Code to: +- Apply SDK best practices automatically +- Provide context-aware code suggestions +- Help with error handling and troubleshooting +- Guide you through common patterns + +The skill works with both the Claude Code CLI and VSCode extension. Once installed, Claude will automatically use it when working with Dataverse operations. For more information on Claude Skill see https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview. See skill definition here: `.claude/skills/dataverse-sdk/SKILL.md`. + +For development from source (Claude Skill auto loaded): ```bash git clone https://github.com/microsoft/PowerPlatform-DataverseClient-Python.git @@ -227,6 +241,16 @@ table_info = client.create_table( primary_column_schema_name="new_ProductName" # Optional: custom primary column (default is "{customization prefix value}_Name") ) +# Get table information +info = client.get_table_info("new_Product") +print(f"Logical name: {info['table_logical_name']}") +print(f"Entity set: {info['entity_set_name']}") + +# List all tables +tables = client.list_tables() +for table in tables: + print(table) + # Add columns to existing table (columns must include customization prefix value) client.create_columns("new_Product", {"new_Category": "string"}) @@ -367,9 +391,9 @@ except ValidationError as e: ### Authentication issues -**Common fixes:** +**Common fixes:** - Verify environment URL format: `https://yourorg.crm.dynamics.com` (no trailing slash) -- Ensure Azure Identity credentials have proper Dataverse permissions +- Ensure Azure Identity credentials have proper Dataverse permissions - Check app registration permissions are granted and admin-consented ### Performance considerations @@ -378,7 +402,7 @@ For optimal performance in production environments: | Best Practice | Description | |---------------|-------------| -| **Bulk Operations** | Pass lists to `create()`, `update()`, and `delete()` for automatic bulk processing | +| **Bulk Operations** | Pass lists to `create()`, `update()` for automatic bulk processing, for `delete()`, set `use_bulk_delete` when passing lists to use bulk operation | | **Select Fields** | Specify `select` parameter to limit returned columns and reduce payload size | | **Page Size Control** | Use `top` and `page_size` parameters to control memory usage | | **Connection Reuse** | Reuse `DataverseClient` instances across operations | diff --git a/examples/advanced/file_upload.py b/examples/advanced/file_upload.py index 60f9495..d3499b7 100644 --- a/examples/advanced/file_upload.py +++ b/examples/advanced/file_upload.py @@ -167,7 +167,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if attempts > 1: retry_count = attempts - 1 print( - f" โ†บ Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." + f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." ) return result except Exception as ex: # noqa: BLE001 @@ -177,7 +177,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if attempts: retry_count = max(attempts - 1, 0) print( - f" โš  Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." + f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." ) raise last diff --git a/examples/advanced/walkthrough.py b/examples/advanced/walkthrough.py index 7cad0e2..6016f3e 100644 --- a/examples/advanced/walkthrough.py +++ b/examples/advanced/walkthrough.py @@ -29,7 +29,7 @@ # Simple logging helper def log_call(description): - print(f"\nโ†’ {description}") + print(f"\n-> {description}") # Define enum for priority picklist @@ -53,7 +53,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if attempts > 1: retry_count = attempts - 1 print( - f" โ†บ Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." + f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." ) return result except Exception as ex: # noqa: BLE001 @@ -63,7 +63,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if attempts: retry_count = max(attempts - 1, 0) print( - f" โš  Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." + f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." ) raise last @@ -92,7 +92,7 @@ def main(): log_call(f"DataverseClient(base_url='{base_url}', credential=...)") client = DataverseClient(base_url=base_url, credential=credential) - print(f"โœ“ Connected to: {base_url}") + print(f"[OK] Connected to: {base_url}") # ============================================================================ # 2. TABLE CREATION (METADATA) @@ -107,7 +107,7 @@ def main(): table_info = backoff(lambda: client.get_table_info(table_name)) if table_info: - print(f"โœ“ Table already exists: {table_info.get('table_schema_name')}") + print(f"[OK] Table already exists: {table_info.get('table_schema_name')}") print(f" Logical Name: {table_info.get('table_logical_name')}") print(f" Entity Set: {table_info.get('entity_set_name')}") else: @@ -120,7 +120,7 @@ def main(): "new_Priority": Priority, } table_info = backoff(lambda: client.create_table(table_name, columns)) - print(f"โœ“ Created table: {table_info.get('table_schema_name')}") + print(f"[OK] Created table: {table_info.get('table_schema_name')}") print(f" Columns created: {', '.join(table_info.get('columns_created', []))}") # ============================================================================ @@ -140,7 +140,7 @@ def main(): "new_Priority": Priority.MEDIUM, } id1 = backoff(lambda: client.create(table_name, single_record))[0] - print(f"โœ“ Created single record: {id1}") + print(f"[OK] Created single record: {id1}") # Multiple create log_call(f"client.create('{table_name}', [{{...}}, {{...}}, {{...}}])") @@ -168,7 +168,7 @@ def main(): }, ] ids = backoff(lambda: client.create(table_name, multiple_records)) - print(f"โœ“ Created {len(ids)} records: {ids}") + print(f"[OK] Created {len(ids)} records: {ids}") # ============================================================================ # 4. READ OPERATIONS @@ -180,7 +180,7 @@ def main(): # Single read by ID log_call(f"client.get('{table_name}', '{id1}')") record = backoff(lambda: client.get(table_name, id1)) - print("โœ“ Retrieved single record:") + print("[OK] Retrieved single record:") print( json.dumps( { @@ -202,7 +202,7 @@ def main(): records_iterator = backoff(lambda: client.get(table_name, filter="new_quantity gt 5")) for page in records_iterator: all_records.extend(page) - print(f"โœ“ Found {len(all_records)} records with new_quantity > 5") + print(f"[OK] Found {len(all_records)} records with new_quantity > 5") for rec in all_records: print(f" - new_Title='{rec.get('new_title')}', new_Quantity={rec.get('new_quantity')}") @@ -217,12 +217,12 @@ def main(): log_call(f"client.update('{table_name}', '{id1}', {{...}})") backoff(lambda: client.update(table_name, id1, {"new_Quantity": 100})) updated = backoff(lambda: client.get(table_name, id1)) - print(f"โœ“ Updated single record new_Quantity: {updated.get('new_quantity')}") + print(f"[OK] Updated single record new_Quantity: {updated.get('new_quantity')}") # Multiple update (broadcast same change) log_call(f"client.update('{table_name}', [{len(ids)} IDs], {{...}})") backoff(lambda: client.update(table_name, ids, {"new_Completed": True})) - print(f"โœ“ Updated {len(ids)} records to new_Completed=True") + print(f"[OK] Updated {len(ids)} records to new_Completed=True") # ============================================================================ # 6. PAGING DEMO @@ -244,7 +244,7 @@ def main(): for i in range(1, 21) ] paging_ids = backoff(lambda: client.create(table_name, paging_records)) - print(f"โœ“ Created {len(paging_ids)} records for paging demo") + print(f"[OK] Created {len(paging_ids)} records for paging demo") # Query with paging log_call(f"client.get('{table_name}', page_size=5)") @@ -265,11 +265,11 @@ def main(): sql = f"SELECT new_title, new_quantity FROM new_walkthroughdemo WHERE new_completed = 1" try: results = backoff(lambda: client.query_sql(sql)) - print(f"โœ“ SQL query returned {len(results)} completed records:") + print(f"[OK] SQL query returned {len(results)} completed records:") for result in results[:5]: # Show first 5 print(f" - new_Title='{result.get('new_title')}', new_Quantity={result.get('new_quantity')}") except Exception as e: - print(f"โš  SQL query failed (known server-side bug): {str(e)}") + print(f"[WARN] SQL query failed (known server-side bug): {str(e)}") # ============================================================================ # 8. PICKLIST LABEL CONVERSION @@ -288,7 +288,7 @@ def main(): } label_id = backoff(lambda: client.create(table_name, label_record))[0] retrieved = backoff(lambda: client.get(table_name, label_id)) - print(f"โœ“ Created record with string label 'High' for new_Priority") + print(f"[OK] Created record with string label 'High' for new_Priority") print(f" new_Priority stored as integer: {retrieved.get('new_priority')}") print(f" new_Priority@FormattedValue: {retrieved.get('new_priority@OData.Community.Display.V1.FormattedValue')}") @@ -301,12 +301,12 @@ def main(): log_call(f"client.create_columns('{table_name}', {{'new_Notes': 'string'}})") created_cols = backoff(lambda: client.create_columns(table_name, {"new_Notes": "string"})) - print(f"โœ“ Added column: {created_cols[0]}") + print(f"[OK] Added column: {created_cols[0]}") # Delete the column we just added log_call(f"client.delete_columns('{table_name}', ['new_Notes'])") backoff(lambda: client.delete_columns(table_name, ["new_Notes"])) - print(f"โœ“ Deleted column: new_Notes") + print(f"[OK] Deleted column: new_Notes") # ============================================================================ # 10. DELETE OPERATIONS @@ -318,12 +318,12 @@ def main(): # Single delete log_call(f"client.delete('{table_name}', '{id1}')") backoff(lambda: client.delete(table_name, id1)) - print(f"โœ“ Deleted single record: {id1}") + print(f"[OK] Deleted single record: {id1}") # Multiple delete (delete the paging demo records) log_call(f"client.delete('{table_name}', [{len(paging_ids)} IDs])") job_id = backoff(lambda: client.delete(table_name, paging_ids)) - print(f"โœ“ Bulk delete job started: {job_id}") + print(f"[OK] Bulk delete job started: {job_id}") print(f" (Deleting {len(paging_ids)} paging demo records)") # ============================================================================ @@ -336,11 +336,11 @@ def main(): log_call(f"client.delete_table('{table_name}')") try: backoff(lambda: client.delete_table(table_name)) - print(f"โœ“ Deleted table: {table_name}") + print(f"[OK] Deleted table: {table_name}") except Exception as ex: # noqa: BLE001 code = getattr(getattr(ex, "response", None), "status_code", None) if (isinstance(ex, (requests.exceptions.HTTPError, MetadataError)) and code == 404): - print(f"โœ“ Table removed: {table_name}") + print(f"[OK] Table removed: {table_name}") else: raise @@ -351,16 +351,16 @@ def main(): print("Walkthrough Complete!") print("=" * 80) print("\nDemonstrated operations:") - print(" โœ“ Table creation with multiple column types") - print(" โœ“ Single and multiple record creation") - print(" โœ“ Reading records by ID and with filters") - print(" โœ“ Single and multiple record updates") - print(" โœ“ Paging through large result sets") - print(" โœ“ SQL queries") - print(" โœ“ Picklist label-to-value conversion") - print(" โœ“ Column management") - print(" โœ“ Single and bulk delete operations") - print(" โœ“ Table cleanup") + print(" [OK] Table creation with multiple column types") + print(" [OK] Single and multiple record creation") + print(" [OK] Reading records by ID and with filters") + print(" [OK] Single and multiple record updates") + print(" [OK] Paging through large result sets") + print(" [OK] SQL queries") + print(" [OK] Picklist label-to-value conversion") + print(" [OK] Column management") + print(" [OK] Single and bulk delete operations") + print(" [OK] Table cleanup") print("=" * 80) diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index 9d39eee..93f3c9d 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -37,23 +37,23 @@ def get_dataverse_org_url() -> str: """Get Dataverse org URL from user input.""" - print("\n๐ŸŒ Dataverse Environment Setup") + print("\n-> Dataverse Environment Setup") print("=" * 50) if not sys.stdin.isatty(): - print("โŒ Interactive input required. Run this script in a terminal.") + print("[ERR] Interactive input required. Run this script in a terminal.") sys.exit(1) while True: org_url = input("Enter your Dataverse org URL (e.g., https://yourorg.crm.dynamics.com): ").strip() if org_url: return org_url.rstrip("/") - print("โš ๏ธ Please enter a valid URL.") + print("[WARN] Please enter a valid URL.") def setup_authentication() -> DataverseClient: """Set up authentication and create Dataverse client.""" - print("\n๐Ÿ” Authentication Setup") + print("\n-> Authentication Setup") print("=" * 50) org_url = get_dataverse_org_url() @@ -62,14 +62,14 @@ def setup_authentication() -> DataverseClient: client = DataverseClient(org_url, credential) # Test the connection - print("๐Ÿงช Testing connection...") + print("Testing connection...") tables = client.list_tables() - print(f"โœ… Connection successful! Found {len(tables)} tables.") + print(f"[OK] Connection successful! Found {len(tables)} tables.") return client except Exception as e: - print(f"โŒ Authentication failed: {e}") - print("๐Ÿ’ก Please check your credentials and permissions.") + print(f"[ERR] Authentication failed: {e}") + print("Please check your credentials and permissions.") sys.exit(1) @@ -92,7 +92,7 @@ def wait_for_table_metadata( if attempt > 1: print( - f" โœ… Table metadata available after {attempt} attempts." + f" [OK] Table metadata available after {attempt} attempts." ) return info except Exception: @@ -100,7 +100,7 @@ def wait_for_table_metadata( if attempt < retries: print( - f" โณ Waiting for table metadata to publish (attempt {attempt}/{retries})..." + f" Waiting for table metadata to publish (attempt {attempt}/{retries})..." ) time.sleep(delay_seconds) @@ -111,7 +111,7 @@ def wait_for_table_metadata( def ensure_test_table(client: DataverseClient) -> Dict[str, Any]: """Create or verify test table exists.""" - print("\n๐Ÿ“‹ Test Table Setup") + print("\n-> Test Table Setup") print("=" * 50) table_schema_name = "test_TestSDKFunctionality" @@ -120,14 +120,14 @@ def ensure_test_table(client: DataverseClient) -> Dict[str, Any]: # Check if table already exists existing_table = client.get_table_info(table_schema_name) if existing_table: - print(f"โœ… Test table '{table_schema_name}' already exists") + print(f"[OK] Test table '{table_schema_name}' already exists") return existing_table except Exception: - print(f"๐Ÿ“ Table '{table_schema_name}' not found, creating...") + print(f"Table '{table_schema_name}' not found, creating...") try: - print("๐Ÿ”จ Creating new test table...") + print("Creating new test table...") # Create the test table with various field types table_info = client.create_table( table_schema_name, @@ -141,20 +141,20 @@ def ensure_test_table(client: DataverseClient) -> Dict[str, Any]: }, ) - print(f"โœ… Created test table: {table_info.get('table_schema_name')}") + print(f"[OK] Created test table: {table_info.get('table_schema_name')}") print(f" Logical name: {table_info.get('table_logical_name')}") print(f" Entity set: {table_info.get('entity_set_name')}") return wait_for_table_metadata(client, table_schema_name) except MetadataError as e: - print(f"โŒ Failed to create table: {e}") + print(f"[ERR] Failed to create table: {e}") sys.exit(1) def test_create_record(client: DataverseClient, table_info: Dict[str, Any]) -> str: """Test record creation.""" - print("\n๐Ÿ“ Record Creation Test") + print("\n-> Record Creation Test") print("=" * 50) table_schema_name = table_info.get("table_schema_name") @@ -173,18 +173,18 @@ def test_create_record(client: DataverseClient, table_info: Dict[str, Any]) -> s } try: - print("๐Ÿš€ Creating test record...") + print("Creating test record...") created_ids: Optional[List[str]] = None for attempt in range(1, retries + 1): try: created_ids = client.create(table_schema_name, test_data) if attempt > 1: - print(f" โœ… Record creation succeeded after {attempt} attempts.") + print(f" [OK] Record creation succeeded after {attempt} attempts.") break except HttpError as err: if getattr(err, "status_code", None) == 404 and attempt < retries: print( - f" โณ Table not ready for create (attempt {attempt}/{retries}). Retrying in {delay_seconds}s..." + f" Table not ready for create (attempt {attempt}/{retries}). Retrying in {delay_seconds}s..." ) time.sleep(delay_seconds) continue @@ -192,7 +192,7 @@ def test_create_record(client: DataverseClient, table_info: Dict[str, Any]) -> s if isinstance(created_ids, list) and created_ids: record_id = created_ids[0] - print(f"โœ… Record created successfully!") + print(f"[OK] Record created successfully!") print(f" Record ID: {record_id}") print(f" Name: {test_data[f'{attr_prefix}_name']}") return record_id @@ -200,16 +200,16 @@ def test_create_record(client: DataverseClient, table_info: Dict[str, Any]) -> s raise ValueError("Unexpected response from create operation") except HttpError as e: - print(f"โŒ HTTP error during record creation: {e}") + print(f"[ERR] HTTP error during record creation: {e}") sys.exit(1) except Exception as e: - print(f"โŒ Failed to create record: {e}") + print(f"[ERR] Failed to create record: {e}") sys.exit(1) def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record_id: str) -> Dict[str, Any]: """Test record reading.""" - print("\n๐Ÿ“– Record Reading Test") + print("\n-> Record Reading Test") print("=" * 50) table_schema_name = table_info.get("table_schema_name") @@ -219,18 +219,18 @@ def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record delay_seconds = 3 try: - print(f"๐Ÿ” Reading record: {record_id}") + print(f"Reading record: {record_id}") record = None for attempt in range(1, retries + 1): try: record = client.get(table_schema_name, record_id) if attempt > 1: - print(f" โœ… Record read succeeded after {attempt} attempts.") + print(f" [OK] Record read succeeded after {attempt} attempts.") break except HttpError as err: if getattr(err, "status_code", None) == 404 and attempt < retries: print( - f" โณ Record not queryable yet (attempt {attempt}/{retries}). Retrying in {delay_seconds}s..." + f" Record not queryable yet (attempt {attempt}/{retries}). Retrying in {delay_seconds}s..." ) time.sleep(delay_seconds) continue @@ -240,7 +240,7 @@ def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record raise RuntimeError("Record did not become available in time.") if record: - print("โœ… Record retrieved successfully!") + print("[OK] Record retrieved successfully!") print(" Retrieved data:") # Display key fields @@ -259,16 +259,16 @@ def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record raise ValueError("Record not found") except HttpError as e: - print(f"โŒ HTTP error during record reading: {e}") + print(f"[ERR] HTTP error during record reading: {e}") sys.exit(1) except Exception as e: - print(f"โŒ Failed to read record: {e}") + print(f"[ERR] Failed to read record: {e}") sys.exit(1) def test_query_records(client: DataverseClient, table_info: Dict[str, Any]) -> None: """Test querying multiple records.""" - print("\n๐Ÿ” Record Query Test") + print("\n-> Record Query Test") print("=" * 50) table_schema_name = table_info.get("table_schema_name") @@ -277,7 +277,7 @@ def test_query_records(client: DataverseClient, table_info: Dict[str, Any]) -> N delay_seconds = 3 try: - print("๐Ÿ” Querying records from test table...") + print("Querying records from test table...") for attempt in range(1, retries + 1): try: records_iterator = client.get( @@ -297,25 +297,25 @@ def test_query_records(client: DataverseClient, table_info: Dict[str, Any]) -> N amount = record.get(f"{attr_prefix}_amount", "N/A") print(f" Record {record_count}: {name} (Count: {count}, Amount: {amount})") - print(f"โœ… Query completed! Found {record_count} active records.") + print(f"[OK] Query completed! Found {record_count} active records.") break except HttpError as err: if getattr(err, "status_code", None) == 404 and attempt < retries: print( - f" โณ Query retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." + f" Query retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." ) time.sleep(delay_seconds) continue raise except Exception as e: - print(f"โš ๏ธ Query test encountered an issue: {e}") + print(f"[WARN] Query test encountered an issue: {e}") print(" This might be expected if the table is very new.") def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], record_id: str) -> None: """Clean up test data.""" - print("\n๐Ÿงน Cleanup") + print("\n-> Cleanup") print("=" * 50) table_schema_name = table_info.get("table_schema_name") @@ -329,25 +329,25 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor for attempt in range(1, retries + 1): try: client.delete(table_schema_name, record_id) - print("โœ… Test record deleted successfully") + print("[OK] Test record deleted successfully") break except HttpError as err: status = getattr(err, "status_code", None) if status == 404: - print("โ„น๏ธ Record already deleted or not yet available; skipping.") + print("Record already deleted or not yet available; skipping.") break if attempt < retries: print( - f" โณ Record delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." + f" Record delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." ) time.sleep(delay_seconds) continue - print(f"โš ๏ธ Failed to delete test record: {err}") + print(f"[WARN] Failed to delete test record: {err}") except Exception as e: - print(f"โš ๏ธ Failed to delete test record: {e}") + print(f"[WARN] Failed to delete test record: {e}") break else: - print("โ„น๏ธ Test record kept for inspection") + print("Test record kept for inspection") # Ask about table cleanup table_cleanup = input("Do you want to delete the test table? (y/N): ").strip().lower() @@ -356,7 +356,7 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor for attempt in range(1, retries + 1): try: client.delete_table(table_info.get("table_schema_name")) - print("โœ… Test table deleted successfully") + print("[OK] Test table deleted successfully") break except HttpError as err: status = getattr(err, "status_code", None) @@ -364,26 +364,26 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor if _table_still_exists(client, table_info.get("table_schema_name")): if attempt < retries: print( - f" โณ Table delete retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." + f" Table delete retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." ) time.sleep(delay_seconds) continue - print(f"โš ๏ธ Failed to delete test table due to metadata delay: {err}") + print(f"[WARN] Failed to delete test table due to metadata delay: {err}") break - print("โœ… Test table deleted successfully (404 reported).") + print("[OK] Test table deleted successfully (404 reported).") break if attempt < retries: print( - f" โณ Table delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." + f" Table delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." ) time.sleep(delay_seconds) continue - print(f"โš ๏ธ Failed to delete test table: {err}") + print(f"[WARN] Failed to delete test table: {err}") except Exception as e: - print(f"โš ๏ธ Failed to delete test table: {e}") + print(f"[WARN] Failed to delete test table: {e}") break else: - print("โ„น๏ธ Test table kept for future testing") + print("Test table kept for future testing") def _table_still_exists(client: DataverseClient, table_schema_name: Optional[str]) -> bool: @@ -402,16 +402,16 @@ def _table_still_exists(client: DataverseClient, table_schema_name: Optional[str def main(): """Main test function.""" - print("๐Ÿš€ PowerPlatform Dataverse Client SDK - Advanced Functional Testing") + print("PowerPlatform Dataverse Client SDK - Advanced Functional Testing") print("=" * 70) print("This script tests SDK functionality in a real Dataverse environment:") - print(" โ€ข Authentication & Connection") - print(" โ€ข Table Creation & Metadata Operations") - print(" โ€ข Record CRUD Operations") - print(" โ€ข Query Functionality") - print(" โ€ข Interactive Cleanup") + print(" - Authentication & Connection") + print(" - Table Creation & Metadata Operations") + print(" - Record CRUD Operations") + print(" - Query Functionality") + print(" - Interactive Cleanup") print("=" * 70) - print("๐Ÿ’ก For installation validation, run examples/basic/installation_example.py first") + print("For installation validation, run examples/basic/installation_example.py first") print("=" * 70) try: @@ -429,24 +429,24 @@ def main(): test_query_records(client, table_info) # Success summary - print("\n๐ŸŽ‰ Functional Test Summary") + print("\nFunctional Test Summary") print("=" * 50) - print("โœ… Authentication: Success") - print("โœ… Table Operations: Success") - print("โœ… Record Creation: Success") - print("โœ… Record Reading: Success") - print("โœ… Record Querying: Success") - print("\n๐Ÿ’ก Your PowerPlatform Dataverse Client SDK is fully functional!") + print("[OK] Authentication: Success") + print("[OK] Table Operations: Success") + print("[OK] Record Creation: Success") + print("[OK] Record Reading: Success") + print("[OK] Record Querying: Success") + print("\nYour PowerPlatform Dataverse Client SDK is fully functional!") # Cleanup cleanup_test_data(client, table_info, record_id) except KeyboardInterrupt: - print("\n\nโš ๏ธ Test interrupted by user") + print("\n\n[WARN] Test interrupted by user") sys.exit(1) except Exception as e: - print(f"\nโŒ Unexpected error: {e}") - print("๐Ÿ’ก Please check your environment and try again") + print(f"\n[ERR] Unexpected error: {e}") + print("Please check your environment and try again") sys.exit(1) diff --git a/examples/basic/installation_example.py b/examples/basic/installation_example.py index 360dcd4..13ee785 100644 --- a/examples/basic/installation_example.py +++ b/examples/basic/installation_example.py @@ -35,18 +35,18 @@ - `pip install -e .` โ†’ Installs from local source code in "editable" mode **Editable Mode Benefits:** -- โœ… Changes to source code are immediately available (no reinstall needed) -- โœ… Perfect for development, testing, and contributing -- โœ… Examples and tests can access the local codebase -- โœ… Supports debugging and live code modifications +- Changes to source code are immediately available (no reinstall needed) +- Perfect for development, testing, and contributing +- Examples and tests can access the local codebase +- Supports debugging and live code modifications ## What This Script Does -- โœ… Validates package installation and imports -- โœ… Checks version and package metadata -- โœ… Shows code examples and usage patterns -- โœ… Offers optional interactive testing -- โœ… Provides troubleshooting guidance +- Validates package installation and imports +- Checks version and package metadata +- Shows code examples and usage patterns +- Offers optional interactive testing +- Provides troubleshooting guidance Prerequisites for Interactive Testing: - Access to a Microsoft Dataverse environment @@ -63,7 +63,7 @@ def validate_imports(): """Validate that all key imports work correctly.""" - print("๐Ÿ” Validating Package Imports...") + print("Validating Package Imports...") print("-" * 50) try: @@ -71,52 +71,52 @@ def validate_imports(): from PowerPlatform.Dataverse import __version__ from PowerPlatform.Dataverse.client import DataverseClient - print(f" โœ… Namespace: PowerPlatform.Dataverse") - print(f" โœ… Package version: {__version__}") - print(f" โœ… Client class: PowerPlatform.Dataverse.client.DataverseClient") + print(f" [OK] Namespace: PowerPlatform.Dataverse") + print(f" [OK] Package version: {__version__}") + print(f" [OK] Client class: PowerPlatform.Dataverse.client.DataverseClient") # Test submodule imports from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError - print(f" โœ… Core errors: HttpError, MetadataError") + print(f" [OK] Core errors: HttpError, MetadataError") from PowerPlatform.Dataverse.core.config import DataverseConfig - print(f" โœ… Core config: DataverseConfig") + print(f" [OK] Core config: DataverseConfig") from PowerPlatform.Dataverse.data._odata import _ODataClient - print(f" โœ… Data layer: _ODataClient") + print(f" [OK] Data layer: _ODataClient") # Test Azure Identity import from azure.identity import InteractiveBrowserCredential - print(f" โœ… Azure Identity: InteractiveBrowserCredential") + print(f" [OK] Azure Identity: InteractiveBrowserCredential") return True, __version__, DataverseClient except ImportError as e: - print(f" โŒ Import failed: {e}") - print("\n๐Ÿ’ก Troubleshooting:") - print(" ๐Ÿ“ฆ For end users (published package):") - print(" โ€ข pip install PowerPlatform-Dataverse-Client") - print(" โ€ข pip install azure-identity") + print(f" [ERR] Import failed: {e}") + print("\nTroubleshooting:") + print(" For end users (published package):") + print(" - pip install PowerPlatform-Dataverse-Client") + print(" - pip install azure-identity") print(" ") - print(" ๐Ÿ› ๏ธ For developers (local development):") - print(" โ€ข Navigate to the project root directory") - print(" โ€ข pip install -e .") - print(" โ€ข This enables 'editable mode' for live development") + print(" For developers (local development):") + print(" - Navigate to the project root directory") + print(" - pip install -e .") + print(" - This enables 'editable mode' for live development") print(" ") - print(" ๐Ÿ”ง General fixes:") - print(" โ€ข Check virtual environment is activated") - print(" โ€ข Verify you're in the correct directory") - print(" โ€ข Try: pip list | grep PowerPlatform") + print(" General fixes:") + print(" - Check virtual environment is activated") + print(" - Verify you're in the correct directory") + print(" - Try: pip list | grep PowerPlatform") return False, None, None def validate_client_methods(DataverseClient): """Validate that DataverseClient has expected methods.""" - print("\n๐Ÿ—๏ธ Validating Client Methods...") + print("\nValidating Client Methods...") print("-" * 50) expected_methods = [ @@ -134,9 +134,9 @@ def validate_client_methods(DataverseClient): missing_methods = [] for method in expected_methods: if hasattr(DataverseClient, method): - print(f" โœ… Method exists: {method}") + print(f" [OK] Method exists: {method}") else: - print(f" โŒ Method missing: {method}") + print(f" [ERR] Method missing: {method}") missing_methods.append(method) return len(missing_methods) == 0 @@ -144,7 +144,7 @@ def validate_client_methods(DataverseClient): def validate_package_metadata(): """Validate package metadata from pip.""" - print("\n๐Ÿ“ฆ Validating Package Metadata...") + print("\nValidating Package Metadata...") print("-" * 50) try: @@ -156,26 +156,26 @@ def validate_package_metadata(): lines = result.stdout.split("\n") for line in lines: if any(line.startswith(prefix) for prefix in ["Name:", "Version:", "Summary:", "Location:"]): - print(f" โœ… {line}") + print(f" [OK] {line}") return True else: - print(f" โŒ Package not found in pip list") - print(" ๐Ÿ’ก Try: pip install PowerPlatform-Dataverse-Client") + print(f" [ERR] Package not found in pip list") + print(" Try: pip install PowerPlatform-Dataverse-Client") return False except Exception as e: - print(f" โŒ Metadata validation failed: {e}") + print(f" [ERR] Metadata validation failed: {e}") return False def show_usage_examples(): """Display comprehensive usage examples.""" - print("\n๐Ÿ“š Usage Examples") + print("\nUsage Examples") print("=" * 50) print( """ -๐Ÿ”ง Basic Setup: +Basic Setup: ```python from PowerPlatform.Dataverse.client import DataverseClient from azure.identity import InteractiveBrowserCredential @@ -190,7 +190,7 @@ def show_usage_examples(): ) ``` -๐Ÿ“ CRUD Operations: +CRUD Operations: ```python # Create a record account_data = {"name": "Contoso Ltd", "telephone1": "555-0100"} @@ -208,7 +208,7 @@ def show_usage_examples(): client.delete("account", account_ids[0]) ``` -๐Ÿ” Querying Data: +Querying Data: ```python # Query with OData filter accounts = client.get("account", @@ -226,7 +226,7 @@ def show_usage_examples(): print(row['name']) ``` -๐Ÿ—๏ธ Table Management: +Table Management: ```python # Create custom table table_info = client.create_table("CustomEntity", { @@ -250,71 +250,71 @@ def show_usage_examples(): def interactive_test(): """Offer optional interactive testing with real Dataverse environment.""" - print("\n๐Ÿงช Interactive Testing") + print("\nInteractive Testing") print("=" * 50) choice = input("Would you like to test with a real Dataverse environment? (y/N): ").strip().lower() if choice not in ["y", "yes"]: - print(" โ„น๏ธ Skipping interactive test") + print(" Skipping interactive test") return - print("\n๐ŸŒ Dataverse Environment Setup") + print("\nDataverse Environment Setup") print("-" * 50) if not sys.stdin.isatty(): - print(" โŒ Interactive input required for testing") + print(" [ERR] Interactive input required for testing") return org_url = input("Enter your Dataverse org URL (e.g., https://yourorg.crm.dynamics.com): ").strip() if not org_url: - print(" โš ๏ธ No URL provided, skipping test") + print(" [WARN] No URL provided, skipping test") return try: from PowerPlatform.Dataverse.client import DataverseClient from azure.identity import InteractiveBrowserCredential - print(" ๐Ÿ” Setting up authentication...") + print(" Setting up authentication...") credential = InteractiveBrowserCredential() - print(" ๐Ÿš€ Creating client...") + print(" Creating client...") client = DataverseClient(org_url.rstrip("/"), credential) - print(" ๐Ÿงช Testing connection...") + print(" Testing connection...") tables = client.list_tables() - print(f" โœ… Connection successful!") - print(f" ๐Ÿ“‹ Found {len(tables)} tables in environment") - print(f" ๐ŸŒ Connected to: {org_url}") + print(f" [OK] Connection successful!") + print(f" Found {len(tables)} tables in environment") + print(f" Connected to: {org_url}") - print("\n ๐Ÿ’ก Your SDK is ready for use!") - print(" ๐Ÿ’ก Check the usage examples above for common patterns") + print("\n Your SDK is ready for use!") + print(" Check the usage examples above for common patterns") except Exception as e: - print(f" โŒ Interactive test failed: {e}") - print(" ๐Ÿ’ก This might be due to authentication, network, or permissions") - print(" ๐Ÿ’ก The SDK imports are still valid for offline development") + print(f" [ERR] Interactive test failed: {e}") + print(" This might be due to authentication, network, or permissions") + print(" The SDK imports are still valid for offline development") def main(): """Run comprehensive installation validation and demonstration.""" - print("๐Ÿš€ PowerPlatform Dataverse Client SDK - Installation & Validation") + print("PowerPlatform Dataverse Client SDK - Installation & Validation") print("=" * 70) - print(f"๐Ÿ•’ Validation Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"Validation Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print("=" * 70) # Step 1: Validate imports imports_success, version, DataverseClient = validate_imports() if not imports_success: - print("\nโŒ Import validation failed. Please check installation.") + print("\n[ERR] Import validation failed. Please check installation.") sys.exit(1) # Step 2: Validate client methods if DataverseClient: methods_success = validate_client_methods(DataverseClient) if not methods_success: - print("\nโš ๏ธ Some client methods are missing, but basic functionality should work.") + print("\n[WARN] Some client methods are missing, but basic functionality should work.") # Step 3: Validate package metadata metadata_success = validate_package_metadata() @@ -327,7 +327,7 @@ def main(): # Summary print("\n" + "=" * 70) - print("๐Ÿ“Š VALIDATION SUMMARY") + print("VALIDATION SUMMARY") print("=" * 70) results = [ @@ -338,37 +338,37 @@ def main(): all_passed = True for test_name, success in results: - status = "โœ… PASS" if success else "โŒ FAIL" + status = "[OK] PASS" if success else "[ERR] FAIL" print(f"{test_name:<20} {status}") if not success: all_passed = False print("=" * 70) if all_passed: - print("๐ŸŽ‰ SUCCESS: PowerPlatform-Dataverse-Client is properly installed!") + print("SUCCESS: PowerPlatform-Dataverse-Client is properly installed!") if version: - print(f"๐Ÿ“ฆ Package Version: {version}") - print("\n๐Ÿ’ก What this validates:") - print(" โœ… Package installation is correct") - print(" โœ… All namespace imports work") - print(" โœ… Client classes are accessible") - print(" โœ… Package metadata is valid") - print(" โœ… Ready for development and production use") - - print(f"\n๐ŸŽฏ Next Steps:") - print(" โ€ข Review the usage examples above") - print(" โ€ข Configure your Azure Identity credentials") - print(" โ€ข Start building with PowerPlatform.Dataverse!") + print(f"Package Version: {version}") + print("\nWhat this validates:") + print(" - Package installation is correct") + print(" - All namespace imports work") + print(" - Client classes are accessible") + print(" - Package metadata is valid") + print(" - Ready for development and production use") + + print(f"\nNext Steps:") + print(" - Review the usage examples above") + print(" - Configure your Azure Identity credentials") + print(" - Start building with PowerPlatform.Dataverse!") else: - print("โŒ Some validation checks failed!") - print("๐Ÿ’ก Review the errors above and reinstall if needed:") + print("[ERR] Some validation checks failed!") + print("Review the errors above and reinstall if needed:") print(" pip uninstall PowerPlatform-Dataverse-Client") print(" pip install PowerPlatform-Dataverse-Client") sys.exit(1) if __name__ == "__main__": - print("๐Ÿš€ PowerPlatform-Dataverse-Client SDK Installation Example") + print("PowerPlatform-Dataverse-Client SDK Installation Example") print("=" * 60) main() diff --git a/pyproject.toml b/pyproject.toml index f4f8737..9cbcab4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ dependencies = [ "Issues" = "https://github.com/microsoft/PowerPlatform-DataverseClient-Python/issues" "Documentation" = "https://github.com/microsoft/PowerPlatform-DataverseClient-Python#readme" +[project.scripts] +dataverse-install-claude-skill = "PowerPlatform.Dataverse._skill_installer:main" + [project.optional-dependencies] dev = [ "pytest>=7.0.0", @@ -58,6 +61,7 @@ namespaces = false [tool.setuptools.package-data] "*" = ["py.typed"] +"PowerPlatform.Dataverse.claude_skill" = ["SKILL.md"] # Microsoft Python Standards - Linting & Formatting [tool.black] From 22a33d9df5d1cf0e258c5efe60a1ca011a237f8c Mon Sep 17 00:00:00 2001 From: tpellissier Date: Fri, 30 Jan 2026 12:24:33 -0800 Subject: [PATCH 4/7] Rename _ODataFileUpload to _FileUploadMixin for consistency Aligns with _RelationshipOperationsMixin naming convention. Co-Authored-By: Claude Opus 4.5 --- src/PowerPlatform/Dataverse/data/_odata.py | 4 ++-- src/PowerPlatform/Dataverse/data/_upload.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 496d91d..024285c 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -19,7 +19,7 @@ from contextvars import ContextVar from ..core._http import _HttpClient -from ._upload import _ODataFileUpload +from ._upload import _FileUploadMixin from ._relationships import _RelationshipOperationsMixin from ..core.errors import * from ..core._error_codes import ( @@ -77,7 +77,7 @@ def build( ) -class _ODataClient(_ODataFileUpload, _RelationshipOperationsMixin): +class _ODataClient(_FileUploadMixin, _RelationshipOperationsMixin): """Dataverse Web API client: CRUD, SQL-over-API, and table metadata helpers.""" @staticmethod diff --git a/src/PowerPlatform/Dataverse/data/_upload.py b/src/PowerPlatform/Dataverse/data/_upload.py index d82efb5..80c951f 100644 --- a/src/PowerPlatform/Dataverse/data/_upload.py +++ b/src/PowerPlatform/Dataverse/data/_upload.py @@ -8,7 +8,7 @@ from typing import Optional -class _ODataFileUpload: +class _FileUploadMixin: """File upload capabilities (small + chunk) with auto selection.""" def _upload_file( From 22d3f6e321e7d137b6f2fcdd5a73fbfe3699b65d Mon Sep 17 00:00:00 2001 From: tpellissier Date: Fri, 30 Jan 2026 12:44:02 -0800 Subject: [PATCH 5/7] Add cascade behavior constants to constants.py Move cascade behavior string values ("Cascade", "NoCascade", "RemoveLink", "Restrict") to constants.py per PR review feedback. Update CascadeConfiguration to use these constants for its default values. Co-Authored-By: Claude Opus 4.5 --- src/PowerPlatform/Dataverse/common/constants.py | 6 ++++++ src/PowerPlatform/Dataverse/models/metadata.py | 16 ++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/PowerPlatform/Dataverse/common/constants.py b/src/PowerPlatform/Dataverse/common/constants.py index bc56641..e725f86 100644 --- a/src/PowerPlatform/Dataverse/common/constants.py +++ b/src/PowerPlatform/Dataverse/common/constants.py @@ -14,3 +14,9 @@ ODATA_TYPE_LOOKUP_ATTRIBUTE = "Microsoft.Dynamics.CRM.LookupAttributeMetadata" ODATA_TYPE_ONE_TO_MANY_RELATIONSHIP = "Microsoft.Dynamics.CRM.OneToManyRelationshipMetadata" ODATA_TYPE_MANY_TO_MANY_RELATIONSHIP = "Microsoft.Dynamics.CRM.ManyToManyRelationshipMetadata" + +# Cascade behavior values for relationship operations +CASCADE_BEHAVIOR_CASCADE = "Cascade" +CASCADE_BEHAVIOR_NO_CASCADE = "NoCascade" +CASCADE_BEHAVIOR_REMOVE_LINK = "RemoveLink" +CASCADE_BEHAVIOR_RESTRICT = "Restrict" diff --git a/src/PowerPlatform/Dataverse/models/metadata.py b/src/PowerPlatform/Dataverse/models/metadata.py index 8b30ff7..81649bc 100644 --- a/src/PowerPlatform/Dataverse/models/metadata.py +++ b/src/PowerPlatform/Dataverse/models/metadata.py @@ -21,6 +21,10 @@ ODATA_TYPE_LOOKUP_ATTRIBUTE, ODATA_TYPE_ONE_TO_MANY_RELATIONSHIP, ODATA_TYPE_MANY_TO_MANY_RELATIONSHIP, + CASCADE_BEHAVIOR_CASCADE, + CASCADE_BEHAVIOR_NO_CASCADE, + CASCADE_BEHAVIOR_REMOVE_LINK, + CASCADE_BEHAVIOR_RESTRICT, ) @@ -143,12 +147,12 @@ class CascadeConfiguration: - "Restrict": Prevent the operation if related records exist """ - assign: str = "NoCascade" - delete: str = "RemoveLink" - merge: str = "NoCascade" - reparent: str = "NoCascade" - share: str = "NoCascade" - unshare: str = "NoCascade" + assign: str = CASCADE_BEHAVIOR_NO_CASCADE + delete: str = CASCADE_BEHAVIOR_REMOVE_LINK + merge: str = CASCADE_BEHAVIOR_NO_CASCADE + reparent: str = CASCADE_BEHAVIOR_NO_CASCADE + share: str = CASCADE_BEHAVIOR_NO_CASCADE + unshare: str = CASCADE_BEHAVIOR_NO_CASCADE additional_properties: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: From 7643640cafb56dafd2dc1aec0d585c34a3ecd4ad Mon Sep 17 00:00:00 2001 From: tpellissier Date: Fri, 30 Jan 2026 12:59:59 -0800 Subject: [PATCH 6/7] Remove AssociatedMenuConfiguration for minimal API surface Remove the AssociatedMenuConfiguration class and related parameters from relationship metadata types. This is a niche UI customization that most users don't need, and can still be achieved via additional_properties. Changes: - Remove AssociatedMenuConfiguration class from metadata.py - Remove associated_menu_configuration from OneToManyRelationshipMetadata - Remove entity1/2_associated_menu_configuration from ManyToManyRelationshipMetadata - Update example and tests accordingly Co-Authored-By: Claude Opus 4.5 --- examples/advanced/relationships.py | 17 ----- .../Dataverse/models/metadata.py | 68 ----------------- tests/unit/models/test_metadata.py | 76 ------------------- 3 files changed, 161 deletions(-) diff --git a/examples/advanced/relationships.py b/examples/advanced/relationships.py index eb77188..087c465 100644 --- a/examples/advanced/relationships.py +++ b/examples/advanced/relationships.py @@ -27,7 +27,6 @@ Label, LocalizedLabel, CascadeConfiguration, - AssociatedMenuConfiguration, ) @@ -221,12 +220,6 @@ def main(): assign="NoCascade", merge="NoCascade", ), - associated_menu_configuration=AssociatedMenuConfiguration( - behavior="UseLabel", - group="Details", - label=Label(localized_labels=[LocalizedLabel(label="Employees", language_code=1033)]), - order=10000, - ), ) # Create the relationship @@ -285,16 +278,6 @@ def main(): schema_name="new_employee_project", entity1_logical_name=emp_table["table_logical_name"], entity2_logical_name=proj_table["table_logical_name"], - entity1_associated_menu_configuration=AssociatedMenuConfiguration( - behavior="UseLabel", - group="Details", - label=Label(localized_labels=[LocalizedLabel(label="Projects", language_code=1033)]), - ), - entity2_associated_menu_configuration=AssociatedMenuConfiguration( - behavior="UseLabel", - group="Details", - label=Label(localized_labels=[LocalizedLabel(label="Team Members", language_code=1033)]), - ), ) result3 = backoff( diff --git a/src/PowerPlatform/Dataverse/models/metadata.py b/src/PowerPlatform/Dataverse/models/metadata.py index 81649bc..7696c6f 100644 --- a/src/PowerPlatform/Dataverse/models/metadata.py +++ b/src/PowerPlatform/Dataverse/models/metadata.py @@ -185,58 +185,6 @@ def to_dict(self) -> Dict[str, Any]: return result -@dataclass -class AssociatedMenuConfiguration: - """ - Configuration for how the relationship appears in the associated menu. - - :param behavior: Display behavior in the menu. - :type behavior: str - :param group: The menu group where the item appears. - :type group: str - :param label: Display label for the menu item. - :type label: Optional[Label] - :param order: Display order within the group. - :type order: int - :param additional_properties: Optional dict of additional properties to include - in the Web API payload (e.g., "Icon", "ViewId", "AvailableOffline"). - These are merged last and can override default values. - :type additional_properties: Optional[Dict[str, Any]] - - Valid behavior values: - - "UseCollectionName": Use the collection name - - "UseLabel": Use the specified label - - "DoNotDisplay": Do not display in the menu - """ - - behavior: str = "UseLabel" - group: str = "Details" - label: Optional[Label] = None - order: int = 10000 - additional_properties: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """ - Convert to Web API JSON format. - - Example:: - - >>> menu = AssociatedMenuConfiguration(behavior="UseLabel", group="Details") - >>> menu.to_dict() - {'Behavior': 'UseLabel', 'Group': 'Details', 'Order': 10000} - """ - result = { - "Behavior": self.behavior, - "Group": self.group, - "Order": self.order, - } - if self.label: - result["Label"] = self.label.to_dict() - if self.additional_properties: - result.update(self.additional_properties) - return result - - @dataclass class LookupAttributeMetadata: """ @@ -323,8 +271,6 @@ class OneToManyRelationshipMetadata: :type referenced_attribute: str :param cascade_configuration: Cascade behavior configuration. :type cascade_configuration: CascadeConfiguration - :param associated_menu_configuration: Optional menu display configuration. - :type associated_menu_configuration: Optional[AssociatedMenuConfiguration] :param referencing_attribute: Optional name for the referencing attribute (usually auto-generated). :type referencing_attribute: Optional[str] :param additional_properties: Optional dict of additional properties to include @@ -339,7 +285,6 @@ class OneToManyRelationshipMetadata: referencing_entity: str referenced_attribute: str cascade_configuration: CascadeConfiguration = field(default_factory=CascadeConfiguration) - associated_menu_configuration: Optional[AssociatedMenuConfiguration] = None referencing_attribute: Optional[str] = None additional_properties: Optional[Dict[str, Any]] = None @@ -373,8 +318,6 @@ def to_dict(self) -> Dict[str, Any]: "ReferencedAttribute": self.referenced_attribute, "CascadeConfiguration": self.cascade_configuration.to_dict(), } - if self.associated_menu_configuration: - result["AssociatedMenuConfiguration"] = self.associated_menu_configuration.to_dict() if self.referencing_attribute: result["ReferencingAttribute"] = self.referencing_attribute if self.additional_properties: @@ -395,10 +338,6 @@ class ManyToManyRelationshipMetadata: :type entity2_logical_name: str :param intersect_entity_name: Name for the intersect table (defaults to schema_name if not provided). :type intersect_entity_name: Optional[str] - :param entity1_associated_menu_configuration: Menu configuration for entity1. - :type entity1_associated_menu_configuration: Optional[AssociatedMenuConfiguration] - :param entity2_associated_menu_configuration: Menu configuration for entity2. - :type entity2_associated_menu_configuration: Optional[AssociatedMenuConfiguration] :param additional_properties: Optional dict of additional properties to include in the Web API payload. Useful for setting inherited properties like "IsValidForAdvancedFind", "IsCustomizable", "SecurityTypes", or direct @@ -411,8 +350,6 @@ class ManyToManyRelationshipMetadata: entity1_logical_name: str entity2_logical_name: str intersect_entity_name: Optional[str] = None - entity1_associated_menu_configuration: Optional[AssociatedMenuConfiguration] = None - entity2_associated_menu_configuration: Optional[AssociatedMenuConfiguration] = None additional_properties: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: @@ -444,10 +381,6 @@ def to_dict(self) -> Dict[str, Any]: "Entity2LogicalName": self.entity2_logical_name, "IntersectEntityName": intersect_name, } - if self.entity1_associated_menu_configuration: - result["Entity1AssociatedMenuConfiguration"] = self.entity1_associated_menu_configuration.to_dict() - if self.entity2_associated_menu_configuration: - result["Entity2AssociatedMenuConfiguration"] = self.entity2_associated_menu_configuration.to_dict() if self.additional_properties: result.update(self.additional_properties) return result @@ -457,7 +390,6 @@ def to_dict(self) -> Dict[str, Any]: "LocalizedLabel", "Label", "CascadeConfiguration", - "AssociatedMenuConfiguration", "LookupAttributeMetadata", "OneToManyRelationshipMetadata", "ManyToManyRelationshipMetadata", diff --git a/tests/unit/models/test_metadata.py b/tests/unit/models/test_metadata.py index 1149b35..691b02e 100644 --- a/tests/unit/models/test_metadata.py +++ b/tests/unit/models/test_metadata.py @@ -7,7 +7,6 @@ LocalizedLabel, Label, CascadeConfiguration, - AssociatedMenuConfiguration, LookupAttributeMetadata, OneToManyRelationshipMetadata, ManyToManyRelationshipMetadata, @@ -130,44 +129,6 @@ def test_to_dict_with_additional_properties(self): assert result["RollupView"] == "NoCascade" -class TestAssociatedMenuConfiguration: - """Tests for AssociatedMenuConfiguration.""" - - def test_to_dict_defaults(self): - """Test default values.""" - menu = AssociatedMenuConfiguration() - result = menu.to_dict() - - assert result["Behavior"] == "UseLabel" - assert result["Group"] == "Details" - assert result["Order"] == 10000 - assert "Label" not in result - - def test_to_dict_with_label(self): - """Test with a label.""" - menu = AssociatedMenuConfiguration( - label=Label(localized_labels=[LocalizedLabel(label="Related Items", language_code=1033)]) - ) - result = menu.to_dict() - - assert result["Label"]["LocalizedLabels"][0]["Label"] == "Related Items" - - def test_to_dict_with_additional_properties(self): - """Test additional properties like Icon and ViewId.""" - menu = AssociatedMenuConfiguration( - additional_properties={ - "Icon": "custom_icon", - "ViewId": "00000000-0000-0000-0000-000000000000", - "AvailableOffline": True, - } - ) - result = menu.to_dict() - - assert result["Icon"] == "custom_icon" - assert result["ViewId"] == "00000000-0000-0000-0000-000000000000" - assert result["AvailableOffline"] is True - - class TestLookupAttributeMetadata: """Tests for LookupAttributeMetadata.""" @@ -263,23 +224,6 @@ def test_to_dict_with_custom_cascade(self): assert result["CascadeConfiguration"]["Delete"] == "Cascade" assert result["CascadeConfiguration"]["Assign"] == "Cascade" - def test_to_dict_with_menu_configuration(self): - """Test with associated menu configuration.""" - rel = OneToManyRelationshipMetadata( - schema_name="new_account_orders", - referenced_entity="account", - referencing_entity="new_order", - referenced_attribute="accountid", - associated_menu_configuration=AssociatedMenuConfiguration( - behavior="UseLabel", - label=Label(localized_labels=[LocalizedLabel(label="Orders", language_code=1033)]), - ), - ) - result = rel.to_dict() - - assert "AssociatedMenuConfiguration" in result - assert result["AssociatedMenuConfiguration"]["Label"]["LocalizedLabels"][0]["Label"] == "Orders" - def test_to_dict_with_referencing_attribute(self): """Test with explicit referencing attribute.""" rel = OneToManyRelationshipMetadata( @@ -344,26 +288,6 @@ def test_to_dict_with_explicit_intersect_name(self): assert result["IntersectEntityName"] == "new_account_contact_assoc" - def test_to_dict_with_menu_configurations(self): - """Test with associated menu configurations for both entities.""" - rel = ManyToManyRelationshipMetadata( - schema_name="new_account_contact", - entity1_logical_name="account", - entity2_logical_name="contact", - entity1_associated_menu_configuration=AssociatedMenuConfiguration( - label=Label(localized_labels=[LocalizedLabel(label="Contacts", language_code=1033)]) - ), - entity2_associated_menu_configuration=AssociatedMenuConfiguration( - label=Label(localized_labels=[LocalizedLabel(label="Accounts", language_code=1033)]) - ), - ) - result = rel.to_dict() - - assert "Entity1AssociatedMenuConfiguration" in result - assert "Entity2AssociatedMenuConfiguration" in result - assert result["Entity1AssociatedMenuConfiguration"]["Label"]["LocalizedLabels"][0]["Label"] == "Contacts" - assert result["Entity2AssociatedMenuConfiguration"]["Label"]["LocalizedLabels"][0]["Label"] == "Accounts" - def test_to_dict_with_additional_properties(self): """Test additional properties like navigation property names.""" rel = ManyToManyRelationshipMetadata( From 74189f83172e04e018a4954cc0bb03d8db225094 Mon Sep 17 00:00:00 2001 From: tpellissier Date: Fri, 30 Jan 2026 13:46:50 -0800 Subject: [PATCH 7/7] Align relationship API with SDK redesign patterns - Rename solution_unique_name parameter to solution (shorter, consistent) - Add keyword-only separator (*) for optional parameters - Update tests to use new parameter style Co-Authored-By: Claude Opus 4.5 --- src/PowerPlatform/Dataverse/client.py | 27 ++++++++++--------- .../Dataverse/data/_relationships.py | 20 +++++++------- tests/unit/data/test_relationships.py | 2 +- tests/unit/test_client.py | 12 ++++----- 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 7d1473b..448ee1c 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -711,7 +711,8 @@ def create_one_to_many_relationship( self, lookup: LookupAttributeMetadata, relationship: OneToManyRelationshipMetadata, - solution_unique_name: Optional[str] = None, + *, + solution: Optional[str] = None, ) -> Dict[str, Any]: """ Create a one-to-many relationship between tables. @@ -723,8 +724,8 @@ def create_one_to_many_relationship( :type lookup: ~PowerPlatform.Dataverse.models.metadata.LookupAttributeMetadata :param relationship: Metadata defining the relationship. :type relationship: ~PowerPlatform.Dataverse.models.metadata.OneToManyRelationshipMetadata - :param solution_unique_name: Optional solution to add relationship to. - :type solution_unique_name: :class:`str` or None + :param solution: Optional solution unique name to add relationship to. + :type solution: :class:`str` or None :return: Dictionary with relationship_id, lookup_schema_name, and related metadata. :rtype: :class:`dict` @@ -770,13 +771,14 @@ def create_one_to_many_relationship( return od._create_one_to_many_relationship( lookup, relationship, - solution_unique_name, + solution, ) def create_many_to_many_relationship( self, relationship: ManyToManyRelationshipMetadata, - solution_unique_name: Optional[str] = None, + *, + solution: Optional[str] = None, ) -> Dict[str, Any]: """ Create a many-to-many relationship between tables. @@ -786,8 +788,8 @@ def create_many_to_many_relationship( :param relationship: Metadata defining the many-to-many relationship. :type relationship: ~PowerPlatform.Dataverse.models.metadata.ManyToManyRelationshipMetadata - :param solution_unique_name: Optional solution to add relationship to. - :type solution_unique_name: :class:`str` or None + :param solution: Optional solution unique name to add relationship to. + :type solution: :class:`str` or None :return: Dictionary with relationship_id, relationship_schema_name, and entity names. :rtype: :class:`dict` @@ -814,7 +816,7 @@ def create_many_to_many_relationship( with self._scoped_odata() as od: return od._create_many_to_many_relationship( relationship, - solution_unique_name, + solution, ) def delete_relationship(self, relationship_id: str) -> None: @@ -865,11 +867,12 @@ def create_lookup_field( referencing_table: str, lookup_field_name: str, referenced_table: str, + *, display_name: Optional[str] = None, description: Optional[str] = None, required: bool = False, cascade_delete: str = "RemoveLink", - solution_unique_name: Optional[str] = None, + solution: Optional[str] = None, language_code: int = 1033, ) -> Dict[str, Any]: """ @@ -893,8 +896,8 @@ def create_lookup_field( :param cascade_delete: Delete behavior (``"RemoveLink"``, ``"Cascade"``, ``"Restrict"``). Defaults to ``"RemoveLink"``. :type cascade_delete: :class:`str` - :param solution_unique_name: Optional solution to add the relationship to. - :type solution_unique_name: :class:`str` or None + :param solution: Optional solution unique name to add the relationship to. + :type solution: :class:`str` or None :param language_code: Language code for labels. Defaults to 1033 (English). :type language_code: :class:`int` @@ -945,7 +948,7 @@ def create_lookup_field( cascade_configuration=CascadeConfiguration(delete=cascade_delete), ) - return self.create_one_to_many_relationship(lookup, relationship, solution_unique_name) + return self.create_one_to_many_relationship(lookup, relationship, solution=solution) __all__ = ["DataverseClient"] diff --git a/src/PowerPlatform/Dataverse/data/_relationships.py b/src/PowerPlatform/Dataverse/data/_relationships.py index f2fc0f7..57e31d5 100644 --- a/src/PowerPlatform/Dataverse/data/_relationships.py +++ b/src/PowerPlatform/Dataverse/data/_relationships.py @@ -27,7 +27,7 @@ def _create_one_to_many_relationship( self, lookup, relationship, - solution_unique_name: Optional[str] = None, + solution: Optional[str] = None, ) -> Dict[str, Any]: """ Create a one-to-many relationship with lookup attribute. @@ -38,8 +38,8 @@ def _create_one_to_many_relationship( :type lookup: ~PowerPlatform.Dataverse.models.metadata.LookupAttributeMetadata :param relationship: Relationship metadata (OneToManyRelationshipMetadata instance). :type relationship: ~PowerPlatform.Dataverse.models.metadata.OneToManyRelationshipMetadata - :param solution_unique_name: Optional solution to add the relationship to. - :type solution_unique_name: ``str`` | ``None`` + :param solution: Optional solution unique name to add the relationship to. + :type solution: ``str`` | ``None`` :return: Dictionary with relationship_id, attribute_id, and schema names. :rtype: ``dict[str, Any]`` @@ -53,8 +53,8 @@ def _create_one_to_many_relationship( payload["Lookup"] = lookup.to_dict() headers = self._headers().copy() - if solution_unique_name: - headers["MSCRM.SolutionUniqueName"] = solution_unique_name + if solution: + headers["MSCRM.SolutionUniqueName"] = solution r = self._request("post", url, headers=headers, json=payload) @@ -72,7 +72,7 @@ def _create_one_to_many_relationship( def _create_many_to_many_relationship( self, relationship, - solution_unique_name: Optional[str] = None, + solution: Optional[str] = None, ) -> Dict[str, Any]: """ Create a many-to-many relationship. @@ -81,8 +81,8 @@ def _create_many_to_many_relationship( :param relationship: Relationship metadata (ManyToManyRelationshipMetadata instance). :type relationship: ~PowerPlatform.Dataverse.models.metadata.ManyToManyRelationshipMetadata - :param solution_unique_name: Optional solution to add the relationship to. - :type solution_unique_name: ``str`` | ``None`` + :param solution: Optional solution unique name to add the relationship to. + :type solution: ``str`` | ``None`` :return: Dictionary with relationship_id and schema name. :rtype: ``dict[str, Any]`` @@ -94,8 +94,8 @@ def _create_many_to_many_relationship( payload = relationship.to_dict() headers = self._headers().copy() - if solution_unique_name: - headers["MSCRM.SolutionUniqueName"] = solution_unique_name + if solution: + headers["MSCRM.SolutionUniqueName"] = solution r = self._request("post", url, headers=headers, json=payload) diff --git a/tests/unit/data/test_relationships.py b/tests/unit/data/test_relationships.py index 1a0c024..581c0a4 100644 --- a/tests/unit/data/test_relationships.py +++ b/tests/unit/data/test_relationships.py @@ -131,7 +131,7 @@ def test_create_relationship_with_solution(self): } self.client._mock_request.return_value = mock_response - self.client._create_one_to_many_relationship(self.lookup, self.relationship, solution_unique_name="MySolution") + self.client._create_one_to_many_relationship(self.lookup, self.relationship, solution="MySolution") # Verify solution header call_args = self.client._mock_request.call_args diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 1b74b42..216df50 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -164,7 +164,7 @@ def test_basic_lookup_field_creation(self): call_args = self.client.create_one_to_many_relationship.call_args lookup = call_args[0][0] relationship = call_args[0][1] - solution = call_args[0][2] + solution = call_args.kwargs.get("solution") # Verify lookup metadata self.assertEqual(lookup.schema_name, "new_AccountId") @@ -175,7 +175,7 @@ def test_basic_lookup_field_creation(self): self.assertEqual(relationship.referencing_entity, "new_order") self.assertEqual(relationship.referenced_attribute, "accountid") - # Verify no solution + # Verify no solution (keyword-only, defaults to None) self.assertIsNone(solution) def test_lookup_with_display_name(self): @@ -269,17 +269,17 @@ def test_cascade_delete_configuration(self): cascade_dict = relationship.cascade_configuration.to_dict() self.assertEqual(cascade_dict["Delete"], "Cascade") - def test_solution_unique_name_passed(self): - """Test that solution_unique_name is passed through.""" + def test_solution_passed(self): + """Test that solution is passed through.""" self.client.create_lookup_field( referencing_table="new_order", lookup_field_name="new_AccountId", referenced_table="account", - solution_unique_name="MySolution", + solution="MySolution", ) call_args = self.client.create_one_to_many_relationship.call_args - solution = call_args[0][2] + solution = call_args.kwargs.get("solution") self.assertEqual(solution, "MySolution")