From 8ec6e2e1b2276504c9686184db692b0216d593f2 Mon Sep 17 00:00:00 2001 From: Max Wang Date: Fri, 14 Nov 2025 11:48:23 -0800 Subject: [PATCH 1/8] stash --- README.md | 2 +- examples/advanced/pandas_integration.py | 2 +- examples/basic/functional_testing.py | 2 +- examples/basic/installation_example.py | 8 +++---- .../Dataverse/{core => _core}/__init__.py | 0 .../Dataverse/{core => _core}/auth.py | 14 +++++------ .../Dataverse/{core => _core}/config.py | 10 ++++---- .../Dataverse/{core => _core}/error_codes.py | 0 .../Dataverse/{core => _core}/errors.py | 10 ++++---- .../Dataverse/{core => _core}/http.py | 2 +- .../Dataverse/{data => _data}/__init__.py | 0 .../Dataverse/{data => _data}/odata.py | 12 +++++----- .../Dataverse/{data => _data}/upload.py | 0 .../{extensions => _extensions}/__init__.py | 0 .../Dataverse/{models => _models}/__init__.py | 0 .../Dataverse/{utils => _utils}/__init__.py | 0 .../{utils => _utils}/pandas_adapter.py | 6 ++--- src/PowerPlatform/Dataverse/client.py | 24 +++++++++---------- tests/conftest.py | 2 +- tests/unit/core/test_http_errors.py | 6 ++--- .../unit/data/test_enum_optionset_payload.py | 2 +- tests/unit/data/test_logical_crud.py | 4 ++-- tests/unit/data/test_sql_parse.py | 2 +- 23 files changed, 54 insertions(+), 54 deletions(-) rename src/PowerPlatform/Dataverse/{core => _core}/__init__.py (100%) rename src/PowerPlatform/Dataverse/{core => _core}/auth.py (85%) rename src/PowerPlatform/Dataverse/{core => _core}/config.py (86%) rename src/PowerPlatform/Dataverse/{core => _core}/error_codes.py (100%) rename src/PowerPlatform/Dataverse/{core => _core}/errors.py (95%) rename src/PowerPlatform/Dataverse/{core => _core}/http.py (97%) rename src/PowerPlatform/Dataverse/{data => _data}/__init__.py (100%) rename src/PowerPlatform/Dataverse/{data => _data}/odata.py (99%) rename src/PowerPlatform/Dataverse/{data => _data}/upload.py (100%) rename src/PowerPlatform/Dataverse/{extensions => _extensions}/__init__.py (100%) rename src/PowerPlatform/Dataverse/{models => _models}/__init__.py (100%) rename src/PowerPlatform/Dataverse/{utils => _utils}/__init__.py (100%) rename src/PowerPlatform/Dataverse/{utils => _utils}/pandas_adapter.py (97%) diff --git a/README.md b/README.md index 1d34165..309f63e 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ The client raises structured exceptions for different error scenarios: ```python from PowerPlatform.Dataverse import DataverseClient -from PowerPlatform.Dataverse.core.errors import HttpError, ValidationError +from PowerPlatform.Dataverse._core.errors import HttpError, ValidationError try: client.get("account", "invalid-id") diff --git a/examples/advanced/pandas_integration.py b/examples/advanced/pandas_integration.py index fc62e06..5be4d0a 100644 --- a/examples/advanced/pandas_integration.py +++ b/examples/advanced/pandas_integration.py @@ -23,7 +23,7 @@ # sys.path.append(str(Path(__file__).resolve().parents[2] / "src")) from PowerPlatform.Dataverse import DataverseClient -from PowerPlatform.Dataverse.utils.pandas_adapter import PandasODataClient +from PowerPlatform.Dataverse._utils.pandas_adapter import PandasODataClient from azure.identity import InteractiveBrowserCredential import traceback import requests diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index ea4b727..663ae44 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -31,7 +31,7 @@ # Import SDK components (assumes installation is already validated) from PowerPlatform.Dataverse import DataverseClient -from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError +from PowerPlatform.Dataverse._core.errors import HttpError, MetadataError from azure.identity import InteractiveBrowserCredential diff --git a/examples/basic/installation_example.py b/examples/basic/installation_example.py index 1974a64..39b5376 100644 --- a/examples/basic/installation_example.py +++ b/examples/basic/installation_example.py @@ -73,16 +73,16 @@ def validate_imports(): print(f" ✅ DataverseClient class: {DataverseClient}") # Test submodule imports - from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError + from PowerPlatform.Dataverse._core.errors import HttpError, MetadataError print(f" ✅ Core errors: HttpError, MetadataError") - from PowerPlatform.Dataverse.core.config import DataverseConfig + from PowerPlatform.Dataverse._core.config import DataverseConfig print(f" ✅ Core config: DataverseConfig") - from PowerPlatform.Dataverse.utils.pandas_adapter import PandasODataClient + from PowerPlatform.Dataverse._utils.pandas_adapter import PandasODataClient print(f" ✅ Utils: PandasODataClient") - from PowerPlatform.Dataverse.data.odata import ODataClient + from PowerPlatform.Dataverse._data.odata import ODataClient print(f" ✅ Data layer: ODataClient") # Test Azure Identity import diff --git a/src/PowerPlatform/Dataverse/core/__init__.py b/src/PowerPlatform/Dataverse/_core/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/core/__init__.py rename to src/PowerPlatform/Dataverse/_core/__init__.py diff --git a/src/PowerPlatform/Dataverse/core/auth.py b/src/PowerPlatform/Dataverse/_core/auth.py similarity index 85% rename from src/PowerPlatform/Dataverse/core/auth.py rename to src/PowerPlatform/Dataverse/_core/auth.py index f5b6973..fcb5cfe 100644 --- a/src/PowerPlatform/Dataverse/core/auth.py +++ b/src/PowerPlatform/Dataverse/_core/auth.py @@ -1,16 +1,16 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from __future__ import annotations - """ Authentication helpers for Dataverse. -This module provides :class:`~PowerPlatform.Dataverse.core.auth.AuthManager`, a thin wrapper over any Azure Identity -``TokenCredential`` for acquiring OAuth2 access tokens, and :class:`~PowerPlatform.Dataverse.core.auth.TokenPair` for +This module provides :class:`~PowerPlatform.Dataverse._core.auth.AuthManager`, a thin wrapper over any Azure Identity +``TokenCredential`` for acquiring OAuth2 access tokens, and :class:`~PowerPlatform.Dataverse._core.auth.TokenPair` for storing the acquired token alongside its scope. """ +from __future__ import annotations + from dataclasses import dataclass from azure.core.credentials import TokenCredential @@ -22,9 +22,9 @@ class TokenPair: Container for an OAuth2 access token and its associated resource scope. :param resource: The OAuth2 scope/resource for which the token was acquired. - :type resource: ``str`` + :type resource: :class:`str` :param access_token: The access token string. - :type access_token: ``str`` + :type access_token: :class:`str` """ resource: str access_token: str @@ -53,7 +53,7 @@ def acquire_token(self, scope: str) -> TokenPair: :param scope: OAuth2 scope string, typically ``"https://.crm.dynamics.com/.default"``. :type scope: ``str`` :return: Token pair containing the scope and access token. - :rtype: ~PowerPlatform.Dataverse.core.auth.TokenPair + :rtype: ~PowerPlatform.Dataverse._core.auth.TokenPair :raises ~azure.core.exceptions.ClientAuthenticationError: If token acquisition fails. """ token = self.credential.get_token(scope) diff --git a/src/PowerPlatform/Dataverse/core/config.py b/src/PowerPlatform/Dataverse/_core/config.py similarity index 86% rename from src/PowerPlatform/Dataverse/core/config.py rename to src/PowerPlatform/Dataverse/_core/config.py index acef530..355d68e 100644 --- a/src/PowerPlatform/Dataverse/core/config.py +++ b/src/PowerPlatform/Dataverse/_core/config.py @@ -1,16 +1,16 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from __future__ import annotations - """ Dataverse client configuration. -Provides :class:`~PowerPlatform.Dataverse.core.config.DataverseConfig`, a lightweight +Provides :class:`~PowerPlatform.Dataverse._core.config.DataverseConfig`, a lightweight immutable container for locale and (reserved) HTTP tuning options plus the -convenience constructor :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`. +convenience constructor :meth:`~PowerPlatform.Dataverse._core.config.DataverseConfig.from_env`. """ +from __future__ import annotations + from dataclasses import dataclass from typing import Optional @@ -42,7 +42,7 @@ def from_env(cls) -> "DataverseConfig": Create a configuration instance with default settings. :return: Configuration instance with default values. - :rtype: ~PowerPlatform.Dataverse.core.config.DataverseConfig + :rtype: ~PowerPlatform.Dataverse._core.config.DataverseConfig """ # Environment-free defaults return cls( diff --git a/src/PowerPlatform/Dataverse/core/error_codes.py b/src/PowerPlatform/Dataverse/_core/error_codes.py similarity index 100% rename from src/PowerPlatform/Dataverse/core/error_codes.py rename to src/PowerPlatform/Dataverse/_core/error_codes.py diff --git a/src/PowerPlatform/Dataverse/core/errors.py b/src/PowerPlatform/Dataverse/_core/errors.py similarity index 95% rename from src/PowerPlatform/Dataverse/core/errors.py rename to src/PowerPlatform/Dataverse/_core/errors.py index ef810a8..08b1321 100644 --- a/src/PowerPlatform/Dataverse/core/errors.py +++ b/src/PowerPlatform/Dataverse/_core/errors.py @@ -4,11 +4,11 @@ """ Structured Dataverse exception hierarchy. -This module provides :class:`~PowerPlatform.Dataverse.core.errors.DataverseError` and -specialized :class:`~PowerPlatform.Dataverse.core.errors.ValidationError`, -:class:`~PowerPlatform.Dataverse.core.errors.MetadataError`, -:class:`~PowerPlatform.Dataverse.core.errors.SQLParseError`, and -:class:`~PowerPlatform.Dataverse.core.errors.HttpError` for validation, metadata, +This module provides :class:`~PowerPlatform.Dataverse._core.errors.DataverseError` and +specialized :class:`~PowerPlatform.Dataverse._core.errors.ValidationError`, +:class:`~PowerPlatform.Dataverse._core.errors.MetadataError`, +:class:`~PowerPlatform.Dataverse._core.errors.SQLParseError`, and +:class:`~PowerPlatform.Dataverse._core.errors.HttpError` for validation, metadata, SQL parsing, and Web API HTTP failures. """ diff --git a/src/PowerPlatform/Dataverse/core/http.py b/src/PowerPlatform/Dataverse/_core/http.py similarity index 97% rename from src/PowerPlatform/Dataverse/core/http.py rename to src/PowerPlatform/Dataverse/_core/http.py index f19c068..f3be62e 100644 --- a/src/PowerPlatform/Dataverse/core/http.py +++ b/src/PowerPlatform/Dataverse/_core/http.py @@ -4,7 +4,7 @@ """ HTTP client with automatic retry logic and timeout handling. -This module provides :class:`~PowerPlatform.Dataverse.core.http.HttpClient`, a wrapper +This module provides :class:`~PowerPlatform.Dataverse._core.http.HttpClient`, a wrapper around the requests library that adds configurable retry behavior for transient network errors and intelligent timeout management based on HTTP method types. """ diff --git a/src/PowerPlatform/Dataverse/data/__init__.py b/src/PowerPlatform/Dataverse/_data/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/data/__init__.py rename to src/PowerPlatform/Dataverse/_data/__init__.py diff --git a/src/PowerPlatform/Dataverse/data/odata.py b/src/PowerPlatform/Dataverse/_data/odata.py similarity index 99% rename from src/PowerPlatform/Dataverse/data/odata.py rename to src/PowerPlatform/Dataverse/_data/odata.py index 385497e..8591517 100644 --- a/src/PowerPlatform/Dataverse/data/odata.py +++ b/src/PowerPlatform/Dataverse/_data/odata.py @@ -14,10 +14,10 @@ from datetime import datetime, timezone import importlib.resources as ir -from ..core.http import HttpClient +from .._core.http import HttpClient from .upload import ODataFileUpload -from ..core.errors import * -from ..core.error_codes import ( +from .._core.errors import * +from .._core.error_codes import ( http_subcode, is_transient_status, VALIDATION_SQL_NOT_STRING, @@ -82,11 +82,11 @@ def __init__( Sets up authentication, base URL, configuration, and internal caches. :param auth: Authentication manager providing ``acquire_token(scope)`` that returns an object with ``access_token``. - :type auth: ~PowerPlatform.Dataverse.core.auth.AuthManager + :type auth: ~PowerPlatform.Dataverse._core.auth.AuthManager :param base_url: Organization base URL (e.g. ``"https://.crm.dynamics.com"``). :type base_url: ``str`` :param config: Optional Dataverse configuration (HTTP retry, backoff, timeout, language code). If omitted ``DataverseConfig.from_env()`` is used. - :type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig | ``None`` + :type config: ~PowerPlatform.Dataverse._core.config.DataverseConfig | ``None`` :raises ValueError: If ``base_url`` is empty after stripping. """ self.auth = auth @@ -94,7 +94,7 @@ def __init__( if not self.base_url: raise ValueError("base_url is required.") self.api = f"{self.base_url}/api/data/v9.2" - self.config = config or __import__("PowerPlatform.Dataverse.core.config", fromlist=["DataverseConfig"]).DataverseConfig.from_env() + self.config = config or __import__("PowerPlatform.Dataverse._core.config", fromlist=["DataverseConfig"]).DataverseConfig.from_env() self._http = HttpClient( retries=self.config.http_retries, backoff=self.config.http_backoff, diff --git a/src/PowerPlatform/Dataverse/data/upload.py b/src/PowerPlatform/Dataverse/_data/upload.py similarity index 100% rename from src/PowerPlatform/Dataverse/data/upload.py rename to src/PowerPlatform/Dataverse/_data/upload.py diff --git a/src/PowerPlatform/Dataverse/extensions/__init__.py b/src/PowerPlatform/Dataverse/_extensions/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/extensions/__init__.py rename to src/PowerPlatform/Dataverse/_extensions/__init__.py diff --git a/src/PowerPlatform/Dataverse/models/__init__.py b/src/PowerPlatform/Dataverse/_models/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/models/__init__.py rename to src/PowerPlatform/Dataverse/_models/__init__.py diff --git a/src/PowerPlatform/Dataverse/utils/__init__.py b/src/PowerPlatform/Dataverse/_utils/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/utils/__init__.py rename to src/PowerPlatform/Dataverse/_utils/__init__.py diff --git a/src/PowerPlatform/Dataverse/utils/pandas_adapter.py b/src/PowerPlatform/Dataverse/_utils/pandas_adapter.py similarity index 97% rename from src/PowerPlatform/Dataverse/utils/pandas_adapter.py rename to src/PowerPlatform/Dataverse/_utils/pandas_adapter.py index 4458457..b1a05d9 100644 --- a/src/PowerPlatform/Dataverse/utils/pandas_adapter.py +++ b/src/PowerPlatform/Dataverse/_utils/pandas_adapter.py @@ -4,7 +4,7 @@ """ Pandas-friendly wrappers for Dataverse OData operations. -This module provides :class:`PowerPlatform.Dataverse.utils.pandas_adapter.PandasODataClient`, +This module provides :class:`PowerPlatform.Dataverse._utils.pandas_adapter.PandasODataClient`, a high-level wrapper that enables DataFrame-based CRUD and query operations. """ @@ -17,7 +17,7 @@ import pandas as pd -from ..data.odata import ODataClient +from .._data.odata import ODataClient @dataclass @@ -39,7 +39,7 @@ class PandasODataClient: High-level pandas-friendly wrapper for Dataverse OData operations. :param odata_client: Initialized low-level OData client with authentication configured. - :type odata_client: ~PowerPlatform.Dataverse.data.odata.ODataClient + :type odata_client: ~PowerPlatform.Dataverse._data.odata.ODataClient """ def __init__(self, odata_client: ODataClient) -> None: diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index ef06d24..146b1e1 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -7,9 +7,9 @@ from azure.core.credentials import TokenCredential -from .core.auth import AuthManager -from .core.config import DataverseConfig -from .data.odata import ODataClient +from ._core.auth import AuthManager +from ._core.config import DataverseConfig +from ._data.odata import ODataClient class DataverseClient: @@ -18,7 +18,7 @@ class DataverseClient: This client provides a simple, stable interface for interacting with Dataverse environments through the Web API. It handles authentication via Azure Identity and delegates HTTP operations - to an internal :class:`~PowerPlatform.Dataverse.data.odata.ODataClient`. + to an internal :class:`~PowerPlatform.Dataverse._data.odata.ODataClient`. Key capabilities: - OData CRUD operations: create, read, update, delete records @@ -32,8 +32,8 @@ class DataverseClient: :param credential: Azure Identity credential for authentication. :type credential: ~azure.core.credentials.TokenCredential :param config: Optional configuration for language, timeouts, and retries. - If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`. - :type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig or None + If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse._core.config.DataverseConfig.from_env`. + :type config: ~PowerPlatform.Dataverse._core.config.DataverseConfig or None :raises ValueError: If ``base_url`` is missing or empty after trimming. @@ -89,7 +89,7 @@ def _get_odata(self) -> ODataClient: deferring construction until the first API call. :return: The lazily-initialized low-level client used to perform HTTP requests. - :rtype: ~PowerPlatform.Dataverse.data.odata.ODataClient + :rtype: ~PowerPlatform.Dataverse._data.odata.ODataClient """ if self._odata is None: self._odata = ODataClient( @@ -360,8 +360,8 @@ def query_sql(self, sql: str): :return: List of result row dictionaries. Returns an empty list if no rows match. :rtype: ``list[dict]`` - :raises ~PowerPlatform.Dataverse.core.errors.SQLParseError: If the SQL query uses unsupported syntax. - :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API returns an error. + :raises ~PowerPlatform.Dataverse._core.errors.SQLParseError: If the SQL query uses unsupported syntax. + :raises ~PowerPlatform.Dataverse._core.errors.HttpError: If the Web API returns an error. .. note:: The SQL support is limited to read-only queries. Complex joins, subqueries, and certain SQL functions may not be supported. Consult the Dataverse documentation for the current feature set. @@ -441,7 +441,7 @@ class ItemStatus(IntEnum): ``entity_set_name``, ``table_logical_name``, ``metadata_id``, and ``columns_created``. :rtype: ``dict`` - :raises ~PowerPlatform.Dataverse.core.errors.MetadataError: If table creation fails or the schema is invalid. + :raises ~PowerPlatform.Dataverse._core.errors.MetadataError: If table creation fails or the schema is invalid. Example: Create a table with simple columns:: @@ -486,7 +486,7 @@ def delete_table(self, table_schema_name: str) -> None: :param table_schema_name: Schema name of the table (e.g. ``"new_MyTestTable"`` or ``"account"``). :type table_schema_name: ``str`` - :raises ~PowerPlatform.Dataverse.core.errors.MetadataError: If the table does not exist or deletion fails. + :raises ~PowerPlatform.Dataverse._core.errors.MetadataError: If the table does not exist or deletion fails. .. warning:: This operation is irreversible and will delete all records in the table along @@ -610,7 +610,7 @@ def upload_file( ``If-Match: *``. Used for small and chunk modes only. :type if_none_match: ``bool`` - :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the upload fails or the file column is not empty + :raises ~PowerPlatform.Dataverse._core.errors.HttpError: If the upload fails or the file column is not empty when ``if_none_match=True``. :raises FileNotFoundError: If the specified file path does not exist. diff --git a/tests/conftest.py b/tests/conftest.py index dcb8000..b089f98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import pytest from unittest.mock import Mock -from PowerPlatform.Dataverse.core.config import DataverseConfig +from PowerPlatform.Dataverse._core.config import DataverseConfig @pytest.fixture diff --git a/tests/unit/core/test_http_errors.py b/tests/unit/core/test_http_errors.py index f3fd29f..e7dc423 100644 --- a/tests/unit/core/test_http_errors.py +++ b/tests/unit/core/test_http_errors.py @@ -2,9 +2,9 @@ # Licensed under the MIT license. import pytest -from PowerPlatform.Dataverse.core.errors import HttpError -from PowerPlatform.Dataverse.core.error_codes import HTTP_404, HTTP_429, HTTP_500 -from PowerPlatform.Dataverse.data.odata import ODataClient +from PowerPlatform.Dataverse._core.errors import HttpError +from PowerPlatform.Dataverse._core.error_codes import HTTP_404, HTTP_429, HTTP_500 +from PowerPlatform.Dataverse._data.odata import ODataClient class DummyAuth: def acquire_token(self, scope): diff --git a/tests/unit/data/test_enum_optionset_payload.py b/tests/unit/data/test_enum_optionset_payload.py index 09d0212..03af7c9 100644 --- a/tests/unit/data/test_enum_optionset_payload.py +++ b/tests/unit/data/test_enum_optionset_payload.py @@ -4,7 +4,7 @@ import pytest from enum import Enum, IntEnum -from PowerPlatform.Dataverse.data.odata import ODataClient +from PowerPlatform.Dataverse._data.odata import ODataClient class DummyAuth: def acquire_token(self, scope): # pragma: no cover - simple stub diff --git a/tests/unit/data/test_logical_crud.py b/tests/unit/data/test_logical_crud.py index 1bc0b72..09cc441 100644 --- a/tests/unit/data/test_logical_crud.py +++ b/tests/unit/data/test_logical_crud.py @@ -3,8 +3,8 @@ import types import pytest -from PowerPlatform.Dataverse.data.odata import ODataClient -from PowerPlatform.Dataverse.core.errors import MetadataError +from PowerPlatform.Dataverse._data.odata import ODataClient +from PowerPlatform.Dataverse._core.errors import MetadataError class DummyAuth: def acquire_token(self, scope): diff --git a/tests/unit/data/test_sql_parse.py b/tests/unit/data/test_sql_parse.py index 1b3fb7d..5116be3 100644 --- a/tests/unit/data/test_sql_parse.py +++ b/tests/unit/data/test_sql_parse.py @@ -2,7 +2,7 @@ # Licensed under the MIT license. import pytest -from PowerPlatform.Dataverse.data.odata import ODataClient +from PowerPlatform.Dataverse._data.odata import ODataClient class DummyAuth: def acquire_token(self, scope): From f0e8987e9a786d9a0a800cddb2b6fba1c6a82d11 Mon Sep 17 00:00:00 2001 From: Max Wang Date: Mon, 17 Nov 2025 11:38:29 -0800 Subject: [PATCH 2/8] sphinx doc string --- README.md | 6 +- examples/advanced/file_upload.py | 2 +- examples/basic/functional_testing.py | 2 +- examples/basic/installation_example.py | 13 +-- src/PowerPlatform/Dataverse/__init__.py | 3 +- src/PowerPlatform/Dataverse/_core/auth.py | 2 +- src/PowerPlatform/Dataverse/_core/config.py | 8 +- .../Dataverse/_core/error_codes.py | 8 +- src/PowerPlatform/Dataverse/_core/errors.py | 56 +++++------ src/PowerPlatform/Dataverse/_core/http.py | 12 +-- src/PowerPlatform/Dataverse/_data/upload.py | 26 ++--- src/PowerPlatform/Dataverse/client.py | 94 +++++++++---------- 12 files changed, 116 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index bd007a1..c9d10de 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ from azure.identity import ( ClientCertificateCredential, AzureCliCredential ) -from PowerPlatform.Dataverse import DataverseClient +from PowerPlatform.Dataverse.client import DataverseClient # Development options credential = InteractiveBrowserCredential() # Browser authentication @@ -111,7 +111,7 @@ The SDK provides a simple, pythonic interface for Dataverse operations: ```python from azure.identity import InteractiveBrowserCredential -from PowerPlatform.Dataverse import DataverseClient +from PowerPlatform.Dataverse.client import DataverseClient # Connect to Dataverse credential = InteractiveBrowserCredential() @@ -285,7 +285,7 @@ For comprehensive information on Microsoft Dataverse and related technologies: The client raises structured exceptions for different error scenarios: ```python -from PowerPlatform.Dataverse import DataverseClient +from PowerPlatform.Dataverse.client import DataverseClient from PowerPlatform.Dataverse._core.errors import HttpError, ValidationError try: diff --git a/examples/advanced/file_upload.py b/examples/advanced/file_upload.py index 5a90c7e..9bfae49 100644 --- a/examples/advanced/file_upload.py +++ b/examples/advanced/file_upload.py @@ -24,7 +24,7 @@ # Uncomment for local development from source # sys.path.append(str(Path(__file__).resolve().parents[2] / "src")) -from PowerPlatform.Dataverse import DataverseClient +from PowerPlatform.Dataverse.client import DataverseClient from azure.identity import InteractiveBrowserCredential # type: ignore import requests diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index 102bd1e..a7d8550 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -30,7 +30,7 @@ from datetime import datetime # Import SDK components (assumes installation is already validated) -from PowerPlatform.Dataverse import DataverseClient +from PowerPlatform.Dataverse.client import DataverseClient from PowerPlatform.Dataverse._core.errors import HttpError, MetadataError from azure.identity import InteractiveBrowserCredential diff --git a/examples/basic/installation_example.py b/examples/basic/installation_example.py index cd72158..f02ea42 100644 --- a/examples/basic/installation_example.py +++ b/examples/basic/installation_example.py @@ -67,12 +67,13 @@ def validate_imports(): print("-" * 50) try: - # Test main namespace import - from PowerPlatform.Dataverse import DataverseClient, __version__ + # Test main namespace and client import + from PowerPlatform.Dataverse import __version__ + from PowerPlatform.Dataverse.client import DataverseClient - print(f" ✅ Main namespace: PowerPlatform.Dataverse") + print(f" ✅ Namespace: PowerPlatform.Dataverse") print(f" ✅ Package version: {__version__}") - print(f" ✅ DataverseClient class: {DataverseClient}") + print(f" ✅ Client class: PowerPlatform.Dataverse.client.DataverseClient") # Test submodule imports from PowerPlatform.Dataverse._core.errors import HttpError, MetadataError @@ -176,7 +177,7 @@ def show_usage_examples(): """ 🔧 Basic Setup: ```python -from PowerPlatform.Dataverse import DataverseClient +from PowerPlatform.Dataverse.client import DataverseClient from azure.identity import InteractiveBrowserCredential # Set up authentication @@ -271,7 +272,7 @@ def interactive_test(): return try: - from PowerPlatform.Dataverse import DataverseClient + from PowerPlatform.Dataverse.client import DataverseClient from azure.identity import InteractiveBrowserCredential print(" 🔐 Setting up authentication...") diff --git a/src/PowerPlatform/Dataverse/__init__.py b/src/PowerPlatform/Dataverse/__init__.py index d5684cc..92f5253 100644 --- a/src/PowerPlatform/Dataverse/__init__.py +++ b/src/PowerPlatform/Dataverse/__init__.py @@ -2,6 +2,5 @@ # Licensed under the MIT license. from .__version__ import __version__ -from .client import DataverseClient -__all__ = ["DataverseClient", "__version__"] +__all__ = ["__version__"] diff --git a/src/PowerPlatform/Dataverse/_core/auth.py b/src/PowerPlatform/Dataverse/_core/auth.py index 93e7c26..9a3e416 100644 --- a/src/PowerPlatform/Dataverse/_core/auth.py +++ b/src/PowerPlatform/Dataverse/_core/auth.py @@ -50,7 +50,7 @@ def acquire_token(self, scope: str) -> TokenPair: Acquire an access token for the specified OAuth2 scope. :param scope: OAuth2 scope string, typically ``"https://.crm.dynamics.com/.default"``. - :type scope: ``str`` + :type scope: :class:`str` :return: Token pair containing the scope and access token. :rtype: ~PowerPlatform.Dataverse._core.auth.TokenPair :raises ~azure.core.exceptions.ClientAuthenticationError: If token acquisition fails. diff --git a/src/PowerPlatform/Dataverse/_core/config.py b/src/PowerPlatform/Dataverse/_core/config.py index 76e6441..77963f3 100644 --- a/src/PowerPlatform/Dataverse/_core/config.py +++ b/src/PowerPlatform/Dataverse/_core/config.py @@ -21,13 +21,13 @@ class DataverseConfig: Configuration settings for Dataverse client operations. :param language_code: LCID (Locale ID) for localized labels and messages. Default is 1033 (English - United States). - :type language_code: ``int`` + :type language_code: :class:`int` :param http_retries: Optional maximum number of retry attempts for transient HTTP errors. Reserved for future use. - :type http_retries: ``int`` | ``None`` + :type http_retries: :class:`int` or None :param http_backoff: Optional backoff multiplier (in seconds) between retry attempts. Reserved for future use. - :type http_backoff: ``float`` | ``None`` + :type http_backoff: :class:`float` or None :param http_timeout: Optional request timeout in seconds. Reserved for future use. - :type http_timeout: ``float`` | ``None`` + :type http_timeout: :class:`float` or None """ language_code: int = 1033 diff --git a/src/PowerPlatform/Dataverse/_core/error_codes.py b/src/PowerPlatform/Dataverse/_core/error_codes.py index 23a99a7..a60d1a2 100644 --- a/src/PowerPlatform/Dataverse/_core/error_codes.py +++ b/src/PowerPlatform/Dataverse/_core/error_codes.py @@ -82,9 +82,9 @@ def http_subcode(status: int) -> str: Convert HTTP status code to error subcode string. :param status: HTTP status code (e.g., 400, 404, 500). - :type status: ``int`` + :type status: :class:`int` :return: Error subcode string (e.g., "http_400", "http_404"). - :rtype: ``str`` + :rtype: :class:`str` """ return HTTP_STATUS_TO_SUBCODE.get(status, f"http_{status}") @@ -97,8 +97,8 @@ def is_transient_status(status: int) -> bool: 503 (Service Unavailable), and 504 (Gateway Timeout). :param status: HTTP status code to check. - :type status: ``int`` + :type status: :class:`int` :return: True if the status code is considered transient. - :rtype: ``bool`` + :rtype: :class:`bool` """ return status in TRANSIENT_STATUS diff --git a/src/PowerPlatform/Dataverse/_core/errors.py b/src/PowerPlatform/Dataverse/_core/errors.py index 880dab5..7ac1236 100644 --- a/src/PowerPlatform/Dataverse/_core/errors.py +++ b/src/PowerPlatform/Dataverse/_core/errors.py @@ -22,19 +22,19 @@ class DataverseError(Exception): Base structured exception for the Dataverse SDK. :param message: Human-readable error message. - :type message: ``str`` + :type message: :class:`str` :param code: Error category code (e.g. ``"validation_error"``, ``"http_error"``). - :type code: ``str`` + :type code: :class:`str` :param subcode: Optional subcategory or specific error identifier. - :type subcode: ``str`` | ``None`` + :type subcode: :class:`str` | None :param status_code: Optional HTTP status code if the error originated from an HTTP response. - :type status_code: ``int`` | ``None`` + :type status_code: :class:`int` | None :param details: Optional dictionary containing additional diagnostic information. - :type details: ``dict`` | ``None`` + :type details: :class:`dict` | None :param source: Error source, either ``"client"`` or ``"server"``. - :type source: ``str`` + :type source: :class:`str` :param is_transient: Whether the error is potentially transient and may succeed on retry. - :type is_transient: ``bool`` + :type is_transient: :class:`bool` """ def __init__( @@ -62,7 +62,7 @@ def to_dict(self) -> Dict[str, Any]: Convert the error to a dictionary representation. :return: Dictionary containing all error properties. - :rtype: ``dict`` + :rtype: :class:`dict` """ return { "message": self.message, @@ -84,11 +84,11 @@ class ValidationError(DataverseError): Exception raised for client-side validation failures. :param message: Human-readable validation error message. - :type message: ``str`` + :type message: :class:`str` :param subcode: Optional specific validation error identifier. - :type subcode: ``str`` | ``None`` + :type subcode: :class:`str` | None :param details: Optional dictionary with additional validation context. - :type details: ``dict`` | ``None`` + :type details: :class:`dict` | None """ def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None): @@ -100,11 +100,11 @@ class MetadataError(DataverseError): Exception raised for metadata operation failures. :param message: Human-readable metadata error message. - :type message: ``str`` + :type message: :class:`str` :param subcode: Optional specific metadata error identifier. - :type subcode: ``str`` | ``None`` + :type subcode: :class:`str` | None :param details: Optional dictionary with additional metadata context. - :type details: ``dict`` | ``None`` + :type details: :class:`dict` | None """ def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None): @@ -116,11 +116,11 @@ class SQLParseError(DataverseError): Exception raised for SQL query parsing failures. :param message: Human-readable SQL parsing error message. - :type message: ``str`` + :type message: :class:`str` :param subcode: Optional specific SQL parsing error identifier. - :type subcode: ``str`` | ``None`` + :type subcode: :class:`str` | None :param details: Optional dictionary with SQL query context and parse information. - :type details: ``dict`` | ``None`` + :type details: :class:`dict` | None """ def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None): @@ -132,27 +132,27 @@ class HttpError(DataverseError): Exception raised for HTTP request failures from the Dataverse Web API. :param message: Human-readable HTTP error message, typically from the API error response. - :type message: ``str`` + :type message: :class:`str` :param status_code: HTTP status code (e.g. 400, 404, 500). - :type status_code: ``int`` + :type status_code: :class:`int` :param is_transient: Whether the error is transient (429, 503, 504) and may succeed on retry. - :type is_transient: ``bool`` + :type is_transient: :class:`bool` :param subcode: Optional HTTP status category (e.g. ``"4xx"``, ``"5xx"``). - :type subcode: ``str`` | ``None`` + :type subcode: :class:`str` | None :param service_error_code: Optional Dataverse-specific error code from the API response. - :type service_error_code: ``str`` | ``None`` + :type service_error_code: :class:`str` | None :param correlation_id: Optional correlation ID for tracking requests across services. - :type correlation_id: ``str`` | ``None`` + :type correlation_id: :class:`str` | None :param request_id: Optional request ID from the API response headers. - :type request_id: ``str`` | ``None`` + :type request_id: :class:`str` | None :param traceparent: Optional W3C trace context for distributed tracing. - :type traceparent: ``str`` | ``None`` + :type traceparent: :class:`str` | None :param body_excerpt: Optional excerpt of the response body for diagnostics. - :type body_excerpt: ``str`` | ``None`` + :type body_excerpt: :class:`str` | None :param retry_after: Optional number of seconds to wait before retrying (from Retry-After header). - :type retry_after: ``int`` | ``None`` + :type retry_after: :class:`int` | None :param details: Optional additional diagnostic details. - :type details: ``dict`` | ``None`` + :type details: :class:`dict` | None """ def __init__( diff --git a/src/PowerPlatform/Dataverse/_core/http.py b/src/PowerPlatform/Dataverse/_core/http.py index 781c55e..f4968ea 100644 --- a/src/PowerPlatform/Dataverse/_core/http.py +++ b/src/PowerPlatform/Dataverse/_core/http.py @@ -25,11 +25,11 @@ class HttpClient: management for different HTTP methods. :param retries: Maximum number of retry attempts for transient errors. Default is 5. - :type retries: ``int`` | ``None`` + :type retries: :class:`int` | None :param backoff: Base delay in seconds between retry attempts. Default is 0.5. - :type backoff: ``float`` | ``None`` + :type backoff: :class:`float` | None :param timeout: Default request timeout in seconds. If None, uses per-method defaults. - :type timeout: ``float`` | ``None`` + :type timeout: :class:`float` | None """ def __init__( @@ -50,12 +50,12 @@ def request(self, method: str, url: str, **kwargs: Any) -> requests.Response: and retries on network errors with exponential backoff. :param method: HTTP method (GET, POST, PUT, DELETE, etc.). - :type method: ``str`` + :type method: :class:`str` :param url: Target URL for the request. - :type url: ``str`` + :type url: :class:`str` :param kwargs: Additional arguments passed to ``requests.request()``, including headers, data, etc. :return: HTTP response object. - :rtype: ``requests.Response`` + :rtype: :class:`requests.Response` :raises requests.exceptions.RequestException: If all retry attempts fail. """ # If no timeout is provided, use the user-specified default timeout if set; diff --git a/src/PowerPlatform/Dataverse/_data/upload.py b/src/PowerPlatform/Dataverse/_data/upload.py index 9458164..c90f062 100644 --- a/src/PowerPlatform/Dataverse/_data/upload.py +++ b/src/PowerPlatform/Dataverse/_data/upload.py @@ -25,19 +25,19 @@ def upload_file( Parameters ---------- - entity_set : ``str`` + entity_set : :class:`str` Target entity set (plural logical name), e.g. "accounts". - record_id : ``str`` + record_id : :class:`str` GUID of the target record. - file_name_attribute : ``str`` + file_name_attribute : :class:`str` Logical name of the file column attribute - path : ``str`` + path : :class:`str` Local filesystem path to the file. - mode : ``str`` | ``None`` + mode : :class:`str` | None Upload strategy: "auto" (default), "small", or "chunk". - mime_type : ``str`` | ``None`` + mime_type : :class:`str` | None Explicit MIME type. If omitted falls back to application/octet-stream. - if_none_match : ``bool`` + if_none_match : :class:`bool` When True (default) only succeeds if column empty. When False overwrites (If-Match: *). """ import os @@ -111,20 +111,20 @@ def _upload_file_chunk( Parameters ---------- - entity_set : ``str`` + entity_set : :class:`str` Target entity set (plural logical name), e.g. "accounts". - record_id : ``str`` + record_id : :class:`str` GUID of the target record. - file_name_attribute : ``str`` + file_name_attribute : :class:`str` Logical name of the file column attribute. - path : ``str`` + path : :class:`str` Local filesystem path to the file. - if_none_match : ``bool`` + if_none_match : :class:`bool` When True sends ``If-None-Match: null`` to only succeed if the column is currently empty. Set False to always overwrite (uses ``If-Match: *``). Returns ------- - ``None`` + None Returns nothing on success. Any failure raises an exception. """ import os, math diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 770d021..4d00fae 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -28,7 +28,7 @@ class DataverseClient: :param base_url: Your Dataverse environment URL, for example ``"https://org.crm.dynamics.com"``. Trailing slash is automatically removed. - :type base_url: ``str`` + :type base_url: :class:`str` :param credential: Azure Identity credential for authentication. :type credential: ~azure.core.credentials.TokenCredential :param config: Optional configuration for language, timeouts, and retries. @@ -44,7 +44,7 @@ class DataverseClient: Create a client and perform basic operations:: from azure.identity import InteractiveBrowserCredential - from PowerPlatform.Dataverse import DataverseClient + from PowerPlatform.Dataverse.client import DataverseClient credential = InteractiveBrowserCredential() client = DataverseClient( @@ -105,13 +105,13 @@ def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dic Create one or more records by table name. :param table_schema_name: Schema name of the table (e.g. ``"account"``, ``"contact"``, or ``"new_MyTestTable"``). - :type table_schema_name: ``str`` + :type table_schema_name: :class:`str` :param records: A single record dictionary or a list of record dictionaries. Each dictionary should contain column schema names as keys. - :type records: ``dict`` or ``list[dict]`` + :type records: :class:`dict` or :class:`list` of :class:`dict` :return: List of created record GUIDs. Returns a single-element list for a single input. - :rtype: ``list[str]`` + :rtype: :class:`list` of :class:`str` :raises TypeError: If ``records`` is not a dict or list[dict], or if the internal client returns an unexpected type. @@ -160,14 +160,14 @@ def update( 3. Paired updates: ``update("account", [id1, id2], [changes1, changes2])`` - one-to-one mapping :param table_schema_name: Schema name of the table (e.g. ``"account"`` or ``"new_MyTestTable"``). - :type table_schema_name: ``str`` + :type table_schema_name: :class:`str` :param ids: Single GUID string or list of GUID strings to update. - :type ids: ``str`` or ``list[str]`` + :type ids: :class:`str` or :class:`list` of :class:`str` :param changes: Dictionary of changes for single/broadcast mode, or list of dictionaries for paired mode. When ``ids`` is a list and ``changes`` is a single dict, the same changes are broadcast to all records. When both are lists, they must have equal length for one-to-one mapping. - :type changes: ``dict`` or ``list[dict]`` + :type changes: :class:`dict` or :class:`list` of :class:`dict` :raises TypeError: If ``ids`` is not str or list[str], or if ``changes`` type doesn't match usage pattern. @@ -213,18 +213,18 @@ def delete( Delete one or more records by GUID. :param table_schema_name: Schema name of the table (e.g. ``"account"`` or ``"new_MyTestTable"``). - :type table_schema_name: ``str`` + :type table_schema_name: :class:`str` :param ids: Single GUID string or list of GUID strings to delete. - :type ids: ``str`` or ``list[str]`` + :type ids: :class:`str` or :class:`list` of :class:`str` :param use_bulk_delete: When ``True`` (default) and ``ids`` is a list, execute the BulkDelete action and return its async job identifier. When ``False`` each record is deleted sequentially. - :type use_bulk_delete: ``bool`` + :type use_bulk_delete: :class:`bool` :raises TypeError: If ``ids`` is not str or list[str]. :raises HttpError: If the underlying Web API delete request fails. :return: BulkDelete job ID when deleting multiple records via BulkDelete; otherwise ``None``. - :rtype: ``str`` or ``None`` + :rtype: :class:`str` or None Example: Delete a single record:: @@ -269,25 +269,25 @@ def get( When ``record_id`` is None, returns a generator yielding batches of records. :param table_schema_name: Schema name of the table (e.g. ``"account"`` or ``"new_MyTestTable"``). - :type table_schema_name: ``str`` + :type table_schema_name: :class:`str` :param record_id: Optional GUID to fetch a specific record. If None, queries multiple records. - :type record_id: ``str`` or ``None`` + :type record_id: :class:`str` or None :param select: Optional list of attribute logical names to retrieve. Column names are case-insensitive and automatically lowercased (e.g. ``["new_Title", "new_Amount"]`` becomes ``"new_title,new_amount"``). - :type select: ``list[str]`` or ``None`` + :type select: :class:`list` of :class:`str` or None :param filter: Optional OData filter string, e.g. ``"name eq 'Contoso'"`` or ``"new_quantity gt 5"``. Column names in filter expressions must use exact lowercase logical names (e.g. ``"new_quantity"``, not ``"new_Quantity"``). The filter string is passed directly to the Dataverse Web API without transformation. - :type filter: ``str`` or ``None`` + :type filter: :class:`str` or None :param orderby: Optional list of attributes to sort by, e.g. ``["name asc", "createdon desc"]``. Column names are automatically lowercased. - :type orderby: ``list[str]`` or ``None`` + :type orderby: :class:`list` of :class:`str` or None :param top: Optional maximum number of records to return. - :type top: ``int`` or ``None`` + :type top: :class:`int` or None :param expand: Optional list of navigation properties to expand, e.g. ``["primarycontactid"]``. Navigation property names are case-sensitive and must match the server-defined names exactly. These are NOT automatically transformed. Consult entity metadata for correct casing. - :type expand: ``list[str]`` or ``None`` + :type expand: :class:`list` of :class:`str` or None :param page_size: Optional number of records per page for pagination. - :type page_size: ``int`` or ``None`` + :type page_size: :class:`int` or None :return: Single record dict if ``record_id`` is provided, otherwise a generator yielding lists of record dictionaries (one list per page). - :rtype: ``dict`` or ``Iterable[list[dict]]`` + :rtype: :class:`dict` or :class:`collections.abc.Iterable` of :class:`list` of :class:`dict` :raises TypeError: If ``record_id`` is provided but not a string. @@ -357,10 +357,10 @@ def query_sql(self, sql: str): table alias after FROM. :param sql: Supported SQL SELECT statement. - :type sql: ``str`` + :type sql: :class:`str` :return: List of result row dictionaries. Returns an empty list if no rows match. - :rtype: ``list[dict]`` + :rtype: :class:`list` of :class:`dict` :raises ~PowerPlatform.Dataverse._core.errors.SQLParseError: If the SQL query uses unsupported syntax. :raises ~PowerPlatform.Dataverse._core.errors.HttpError: If the Web API returns an error. @@ -389,12 +389,12 @@ def get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]: Get basic metadata for a table if it exists. :param table_schema_name: Schema name of the table (e.g. ``"new_MyTestTable"`` or ``"account"``). - :type table_schema_name: ``str`` + :type table_schema_name: :class:`str` :return: Dictionary containing table metadata with keys ``table_schema_name``, ``table_logical_name``, ``entity_set_name``, and ``metadata_id``. Returns None if the table is not found. - :rtype: ``dict`` or ``None`` + :rtype: :class:`dict` or None Example: Retrieve table metadata:: @@ -417,7 +417,7 @@ def create_table( Create a simple custom table with specified columns. :param table_schema_name: Schema name of the table with customization prefix value (e.g. ``"new_MyTestTable"``). - :type table_schema_name: ``str`` + :type table_schema_name: :class:`str` :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: @@ -433,15 +433,15 @@ class ItemStatus(IntEnum): 1036: {"Active": "Actif", "Inactive": "Inactif"} } - :type columns: dict[str, Any] + :type columns: :class:`dict` mapping :class:`str` to :class:`typing.Any` :param solution_unique_name: Optional solution unique name that should own the new table. When omitted the table is created in the default solution. - :type solution_unique_name: ``str`` or ``None`` + :type solution_unique_name: :class:`str` or None :param primary_column_schema_name: Optional primary name column schema name with customization prefix value (e.g. ``"new_MyTestTable"``). If not provided, defaults to ``"{customization prefix value}_Name"``. - :type primary_column_schema_name: ``str`` or ``None`` + :type primary_column_schema_name: :class:`str` or None :return: Dictionary containing table metadata including ``table_schema_name``, ``entity_set_name``, ``table_logical_name``, ``metadata_id``, and ``columns_created``. - :rtype: ``dict`` + :rtype: :class:`dict` :raises ~PowerPlatform.Dataverse._core.errors.MetadataError: If table creation fails or the schema is invalid. @@ -486,7 +486,7 @@ def delete_table(self, table_schema_name: str) -> None: Delete a custom table by name. :param table_schema_name: Schema name of the table (e.g. ``"new_MyTestTable"`` or ``"account"``). - :type table_schema_name: ``str`` + :type table_schema_name: :class:`str` :raises ~PowerPlatform.Dataverse._core.errors.MetadataError: If the table does not exist or deletion fails. @@ -506,7 +506,7 @@ def list_tables(self) -> list[str]: List all custom tables in the Dataverse environment. :return: List of custom table names. - :rtype: ``list[str]`` + :rtype: :class:`list` of :class:`str` Example: List all custom tables:: @@ -526,13 +526,13 @@ def create_columns( Create one or more columns on an existing table using a schema-style mapping. :param table_schema_name: Schema name of the table (e.g. ``"new_MyTestTable"``). - :type table_schema_name: ``str`` + :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``, ``int``, ``decimal``, ``float``, ``datetime``, and ``bool``. Enum subclasses (IntEnum preferred) generate a local option set and can specify localized labels via ``__labels__``. - :type columns: ``Dict[str, Any]`` + :type columns: :class:`dict` mapping :class:`str` to :class:`typing.Any` :returns: Schema names for the columns that were created. - :rtype: ``list[str]`` + :rtype: :class:`list` of :class:`str` Example: Create two columns on the custom table:: @@ -559,11 +559,11 @@ def delete_columns( Delete one or more columns from a table. :param table_schema_name: Schema name of the table (e.g. ``"new_MyTestTable"``). - :type table_schema_name: ``str`` + :type table_schema_name: :class:`str` :param columns: Column name or list of column names to remove. Must include customization prefix value (e.g. ``"new_TestColumn"``). - :type columns: ``str`` | ``list[str]`` + :type columns: :class:`str` or :class:`list` of :class:`str` :returns: Schema names for the columns that were removed. - :rtype: ``list[str]`` + :rtype: :class:`list` of :class:`str` Example: Remove two custom columns by schema name: @@ -593,24 +593,24 @@ def upload_file( Upload a file to a Dataverse file column. :param table_schema_name: Schema name of the table, e.g. ``"account"`` or ``"new_MyTestTable"``. - :type table_schema_name: ``str`` + :type table_schema_name: :class:`str` :param record_id: GUID of the target record. - :type record_id: ``str`` + :type record_id: :class:`str` :param file_name_attribute: Logical name of the file column attribute. - :type file_name_attribute: ``str`` + :type file_name_attribute: :class:`str` :param path: Local filesystem path to the file. The stored filename will be the basename of this path. - :type path: ``str`` + :type path: :class:`str` :param mode: Upload strategy: ``"auto"`` (default), ``"small"``, or ``"chunk"``. Auto mode selects small or chunked upload based on file size. - :type mode: ``str`` or ``None`` + :type mode: :class:`str` or None :param mime_type: Explicit MIME type to store with the file (e.g. ``"application/pdf"``). If not provided, the MIME type may be inferred from the file extension. - :type mime_type: ``str`` or ``None`` + :type mime_type: :class:`str` or None :param if_none_match: When True (default), sends ``If-None-Match: null`` header to only succeed if the column is currently empty. Set False to always overwrite using ``If-Match: *``. Used for small and chunk modes only. - :type if_none_match: ``bool`` + :type if_none_match: :class:`bool` :raises ~PowerPlatform.Dataverse._core.errors.HttpError: If the upload fails or the file column is not empty when ``if_none_match=True``. @@ -664,10 +664,10 @@ def flush_cache(self, kind) -> int: Future kinds (e.g. ``"entityset"``, ``"primaryid"``) may be added without breaking this signature. - :type kind: ``str`` + :type kind: :class:`str` :return: Number of cache entries removed. - :rtype: ``int`` + :rtype: :class:`int` Example: Clear the picklist cache:: From 2b2dcd7b4899d28a819861da328e83e005fa3826 Mon Sep 17 00:00:00 2001 From: Max Wang Date: Mon, 17 Nov 2025 13:12:56 -0800 Subject: [PATCH 3/8] update _ placement --- README.md | 2 +- examples/basic/functional_testing.py | 2 +- examples/basic/installation_example.py | 6 +- src/PowerPlatform/Dataverse/_core/errors.py | 10 +- src/PowerPlatform/Dataverse/_core/http.py | 2 +- src/PowerPlatform/Dataverse/client.py | 26 +-- .../Dataverse/{_core => core}/__init__.py | 0 .../{_core/auth.py => core/_auth.py} | 8 +- .../error_codes.py => core/_error_codes.py} | 4 +- src/PowerPlatform/Dataverse/core/_http.py | 79 +++++++ .../Dataverse/{_core => core}/config.py | 6 +- src/PowerPlatform/Dataverse/core/errors.py | 196 ++++++++++++++++++ .../Dataverse/{_data => data}/__init__.py | 0 .../{_data/odata.py => data/_odata.py} | 28 +-- .../{_data/upload.py => data/_upload.py} | 2 +- .../{_extensions => extensions}/__init__.py | 0 .../Dataverse/{_models => models}/__init__.py | 0 .../Dataverse/{_utils => utils}/__init__.py | 0 tests/conftest.py | 6 +- tests/unit/core/test_http_errors.py | 10 +- .../unit/data/test_enum_optionset_payload.py | 4 +- tests/unit/data/test_logical_crud.py | 8 +- tests/unit/data/test_sql_parse.py | 4 +- 23 files changed, 339 insertions(+), 64 deletions(-) rename src/PowerPlatform/Dataverse/{_core => core}/__init__.py (100%) rename src/PowerPlatform/Dataverse/{_core/auth.py => core/_auth.py} (88%) rename src/PowerPlatform/Dataverse/{_core/error_codes.py => core/_error_codes.py} (97%) create mode 100644 src/PowerPlatform/Dataverse/core/_http.py rename src/PowerPlatform/Dataverse/{_core => core}/config.py (86%) create mode 100644 src/PowerPlatform/Dataverse/core/errors.py rename src/PowerPlatform/Dataverse/{_data => data}/__init__.py (100%) rename src/PowerPlatform/Dataverse/{_data/odata.py => data/_odata.py} (98%) rename src/PowerPlatform/Dataverse/{_data/upload.py => data/_upload.py} (99%) rename src/PowerPlatform/Dataverse/{_extensions => extensions}/__init__.py (100%) rename src/PowerPlatform/Dataverse/{_models => models}/__init__.py (100%) rename src/PowerPlatform/Dataverse/{_utils => utils}/__init__.py (100%) diff --git a/README.md b/README.md index c9d10de..aca86c1 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ The client raises structured exceptions for different error scenarios: ```python from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse._core.errors import HttpError, ValidationError +from PowerPlatform.Dataverse.core.errors import HttpError, ValidationError try: client.get("account", "invalid-id") diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index a7d8550..886aca6 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -31,7 +31,7 @@ # Import SDK components (assumes installation is already validated) from PowerPlatform.Dataverse.client import DataverseClient -from PowerPlatform.Dataverse._core.errors import HttpError, MetadataError +from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError from azure.identity import InteractiveBrowserCredential diff --git a/examples/basic/installation_example.py b/examples/basic/installation_example.py index f02ea42..28dc791 100644 --- a/examples/basic/installation_example.py +++ b/examples/basic/installation_example.py @@ -76,15 +76,15 @@ def validate_imports(): print(f" ✅ Client class: PowerPlatform.Dataverse.client.DataverseClient") # Test submodule imports - from PowerPlatform.Dataverse._core.errors import HttpError, MetadataError + from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError print(f" ✅ Core errors: HttpError, MetadataError") - from PowerPlatform.Dataverse._core.config import DataverseConfig + from PowerPlatform.Dataverse.core.config import DataverseConfig print(f" ✅ Core config: DataverseConfig") - from PowerPlatform.Dataverse._data.odata import ODataClient + from PowerPlatform.Dataverse.data._odata import ODataClient print(f" ✅ Data layer: ODataClient") diff --git a/src/PowerPlatform/Dataverse/_core/errors.py b/src/PowerPlatform/Dataverse/_core/errors.py index 7ac1236..4fd4adb 100644 --- a/src/PowerPlatform/Dataverse/_core/errors.py +++ b/src/PowerPlatform/Dataverse/_core/errors.py @@ -4,11 +4,11 @@ """ Structured Dataverse exception hierarchy. -This module provides :class:`~PowerPlatform.Dataverse._core.errors.DataverseError` and -specialized :class:`~PowerPlatform.Dataverse._core.errors.ValidationError`, -:class:`~PowerPlatform.Dataverse._core.errors.MetadataError`, -:class:`~PowerPlatform.Dataverse._core.errors.SQLParseError`, and -:class:`~PowerPlatform.Dataverse._core.errors.HttpError` for validation, metadata, +This module provides :class:`~PowerPlatform.Dataverse.core.errors.DataverseError` and +specialized :class:`~PowerPlatform.Dataverse.core.errors.ValidationError`, +:class:`~PowerPlatform.Dataverse.core.errors.MetadataError`, +:class:`~PowerPlatform.Dataverse.core.errors.SQLParseError`, and +:class:`~PowerPlatform.Dataverse.core.errors.HttpError` for validation, metadata, SQL parsing, and Web API HTTP failures. """ diff --git a/src/PowerPlatform/Dataverse/_core/http.py b/src/PowerPlatform/Dataverse/_core/http.py index f4968ea..5c4ee2e 100644 --- a/src/PowerPlatform/Dataverse/_core/http.py +++ b/src/PowerPlatform/Dataverse/_core/http.py @@ -4,7 +4,7 @@ """ HTTP client with automatic retry logic and timeout handling. -This module provides :class:`~PowerPlatform.Dataverse._core.http.HttpClient`, a wrapper +This module provides :class:`~PowerPlatform.Dataverse.core._http.HttpClient`, a wrapper around the requests library that adds configurable retry behavior for transient network errors and intelligent timeout management based on HTTP method types. """ diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 4d00fae..564d3dd 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -7,9 +7,9 @@ from azure.core.credentials import TokenCredential -from ._core.auth import AuthManager -from ._core.config import DataverseConfig -from ._data.odata import ODataClient +from .core._auth import AuthManager +from .core.config import DataverseConfig +from .data._odata import ODataClient class DataverseClient: @@ -18,7 +18,7 @@ class DataverseClient: This client provides a simple, stable interface for interacting with Dataverse environments through the Web API. It handles authentication via Azure Identity and delegates HTTP operations - to an internal :class:`~PowerPlatform.Dataverse._data.odata.ODataClient`. + to an internal :class:`~PowerPlatform.Dataverse.data._odata.ODataClient`. Key capabilities: - OData CRUD operations: create, read, update, delete records @@ -32,8 +32,8 @@ class DataverseClient: :param credential: Azure Identity credential for authentication. :type credential: ~azure.core.credentials.TokenCredential :param config: Optional configuration for language, timeouts, and retries. - If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse._core.config.DataverseConfig.from_env`. - :type config: ~PowerPlatform.Dataverse._core.config.DataverseConfig or None + If not provided, defaults are loaded from :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`. + :type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig or None :raises ValueError: If ``base_url`` is missing or empty after trimming. @@ -89,7 +89,7 @@ def _get_odata(self) -> ODataClient: deferring construction until the first API call. :return: The lazily-initialized low-level client used to perform HTTP requests. - :rtype: ~PowerPlatform.Dataverse._data.odata.ODataClient + :rtype: ~PowerPlatform.Dataverse.data._odata.ODataClient """ if self._odata is None: self._odata = ODataClient( @@ -362,8 +362,8 @@ def query_sql(self, sql: str): :return: List of result row dictionaries. Returns an empty list if no rows match. :rtype: :class:`list` of :class:`dict` - :raises ~PowerPlatform.Dataverse._core.errors.SQLParseError: If the SQL query uses unsupported syntax. - :raises ~PowerPlatform.Dataverse._core.errors.HttpError: If the Web API returns an error. + :raises ~PowerPlatform.Dataverse.core.errors.SQLParseError: If the SQL query uses unsupported syntax. + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the Web API returns an error. .. note:: The SQL support is limited to read-only queries. Complex joins, subqueries, and certain SQL functions may not be supported. Consult the Dataverse documentation for the current feature set. @@ -443,7 +443,7 @@ class ItemStatus(IntEnum): ``entity_set_name``, ``table_logical_name``, ``metadata_id``, and ``columns_created``. :rtype: :class:`dict` - :raises ~PowerPlatform.Dataverse._core.errors.MetadataError: If table creation fails or the schema is invalid. + :raises ~PowerPlatform.Dataverse.core.errors.MetadataError: If table creation fails or the schema is invalid. Example: Create a table with simple columns:: @@ -488,7 +488,7 @@ def delete_table(self, table_schema_name: str) -> None: :param table_schema_name: Schema name of the table (e.g. ``"new_MyTestTable"`` or ``"account"``). :type table_schema_name: :class:`str` - :raises ~PowerPlatform.Dataverse._core.errors.MetadataError: If the table does not exist or deletion fails. + :raises ~PowerPlatform.Dataverse.core.errors.MetadataError: If the table does not exist or deletion fails. .. warning:: This operation is irreversible and will delete all records in the table along @@ -612,7 +612,7 @@ def upload_file( ``If-Match: *``. Used for small and chunk modes only. :type if_none_match: :class:`bool` - :raises ~PowerPlatform.Dataverse._core.errors.HttpError: If the upload fails or the file column is not empty + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the upload fails or the file column is not empty when ``if_none_match=True``. :raises FileNotFoundError: If the specified file path does not exist. @@ -642,7 +642,7 @@ def upload_file( """ od = self._get_odata() entity_set = od._entity_set_from_schema_name(table_schema_name) - od.upload_file( + od._upload_file( entity_set, record_id, file_name_attribute, diff --git a/src/PowerPlatform/Dataverse/_core/__init__.py b/src/PowerPlatform/Dataverse/core/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/_core/__init__.py rename to src/PowerPlatform/Dataverse/core/__init__.py diff --git a/src/PowerPlatform/Dataverse/_core/auth.py b/src/PowerPlatform/Dataverse/core/_auth.py similarity index 88% rename from src/PowerPlatform/Dataverse/_core/auth.py rename to src/PowerPlatform/Dataverse/core/_auth.py index 9a3e416..7cc6075 100644 --- a/src/PowerPlatform/Dataverse/_core/auth.py +++ b/src/PowerPlatform/Dataverse/core/_auth.py @@ -4,8 +4,8 @@ """ Authentication helpers for Dataverse. -This module provides :class:`~PowerPlatform.Dataverse._core.auth.AuthManager`, a thin wrapper over any Azure Identity -``TokenCredential`` for acquiring OAuth2 access tokens, and :class:`~PowerPlatform.Dataverse._core.auth.TokenPair` for +This module provides :class:`~PowerPlatform.Dataverse.core._auth.AuthManager`, a thin wrapper over any Azure Identity +``TokenCredential`` for acquiring OAuth2 access tokens, and :class:`~PowerPlatform.Dataverse.core._auth.TokenPair` for storing the acquired token alongside its scope. """ @@ -45,14 +45,14 @@ def __init__(self, credential: TokenCredential) -> None: raise TypeError("credential must implement azure.core.credentials.TokenCredential.") self.credential: TokenCredential = credential - def acquire_token(self, scope: str) -> TokenPair: + def _acquire_token(self, scope: str) -> TokenPair: """ Acquire an access token for the specified OAuth2 scope. :param scope: OAuth2 scope string, typically ``"https://.crm.dynamics.com/.default"``. :type scope: :class:`str` :return: Token pair containing the scope and access token. - :rtype: ~PowerPlatform.Dataverse._core.auth.TokenPair + :rtype: ~PowerPlatform.Dataverse.core._auth.TokenPair :raises ~azure.core.exceptions.ClientAuthenticationError: If token acquisition fails. """ token = self.credential.get_token(scope) diff --git a/src/PowerPlatform/Dataverse/_core/error_codes.py b/src/PowerPlatform/Dataverse/core/_error_codes.py similarity index 97% rename from src/PowerPlatform/Dataverse/_core/error_codes.py rename to src/PowerPlatform/Dataverse/core/_error_codes.py index a60d1a2..1270319 100644 --- a/src/PowerPlatform/Dataverse/_core/error_codes.py +++ b/src/PowerPlatform/Dataverse/core/_error_codes.py @@ -77,7 +77,7 @@ TRANSIENT_STATUS = {429, 502, 503, 504} -def http_subcode(status: int) -> str: +def _http_subcode(status: int) -> str: """ Convert HTTP status code to error subcode string. @@ -89,7 +89,7 @@ def http_subcode(status: int) -> str: return HTTP_STATUS_TO_SUBCODE.get(status, f"http_{status}") -def is_transient_status(status: int) -> bool: +def _is_transient_status(status: int) -> bool: """ Check if an HTTP status code indicates a transient error that may succeed on retry. diff --git a/src/PowerPlatform/Dataverse/core/_http.py b/src/PowerPlatform/Dataverse/core/_http.py new file mode 100644 index 0000000..aa44af5 --- /dev/null +++ b/src/PowerPlatform/Dataverse/core/_http.py @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +HTTP client with automatic retry logic and timeout handling. + +This module provides :class:`~PowerPlatform.Dataverse.core._http.HttpClient`, a wrapper +around the requests library that adds configurable retry behavior for transient +network errors and intelligent timeout management based on HTTP method types. +""" + +from __future__ import annotations + +import time +from typing import Any, Optional + +import requests + + +class HttpClient: + """ + HTTP client with configurable retry logic and timeout handling. + + Provides automatic retry behavior for transient failures and default timeout + management for different HTTP methods. + + :param retries: Maximum number of retry attempts for transient errors. Default is 5. + :type retries: :class:`int` | None + :param backoff: Base delay in seconds between retry attempts. Default is 0.5. + :type backoff: :class:`float` | None + :param timeout: Default request timeout in seconds. If None, uses per-method defaults. + :type timeout: :class:`float` | None + """ + + def __init__( + self, + retries: Optional[int] = None, + backoff: Optional[float] = None, + timeout: Optional[float] = None, + ) -> None: + self.max_attempts = retries if retries is not None else 5 + self.base_delay = backoff if backoff is not None else 0.5 + self.default_timeout: Optional[float] = timeout + + def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response: + """ + Execute an HTTP request with automatic retry logic and timeout management. + + Applies default timeouts based on HTTP method (120s for POST/DELETE, 10s for others) + and retries on network errors with exponential backoff. + + :param method: HTTP method (GET, POST, PUT, DELETE, etc.). + :type method: :class:`str` + :param url: Target URL for the request. + :type url: :class:`str` + :param kwargs: Additional arguments passed to ``requests.request()``, including headers, data, etc. + :return: HTTP response object. + :rtype: :class:`requests.Response` + :raises requests.exceptions.RequestException: If all retry attempts fail. + """ + # If no timeout is provided, use the user-specified default timeout if set; + # otherwise, apply per-method defaults (120s for POST/DELETE, 10s for others). + if "timeout" not in kwargs: + if self.default_timeout is not None: + kwargs["timeout"] = self.default_timeout + else: + m = (method or "").lower() + kwargs["timeout"] = 120 if m in ("post", "delete") else 10 + + # Small backoff retry on network errors only + for attempt in range(self.max_attempts): + try: + return requests.request(method, url, **kwargs) + except requests.exceptions.RequestException: + if attempt == self.max_attempts - 1: + raise + delay = self.base_delay * (2**attempt) + time.sleep(delay) + continue diff --git a/src/PowerPlatform/Dataverse/_core/config.py b/src/PowerPlatform/Dataverse/core/config.py similarity index 86% rename from src/PowerPlatform/Dataverse/_core/config.py rename to src/PowerPlatform/Dataverse/core/config.py index 77963f3..19c7a57 100644 --- a/src/PowerPlatform/Dataverse/_core/config.py +++ b/src/PowerPlatform/Dataverse/core/config.py @@ -4,9 +4,9 @@ """ Dataverse client configuration. -Provides :class:`~PowerPlatform.Dataverse._core.config.DataverseConfig`, a lightweight +Provides :class:`~PowerPlatform.Dataverse.core.config.DataverseConfig`, a lightweight immutable container for locale and (reserved) HTTP tuning options plus the -convenience constructor :meth:`~PowerPlatform.Dataverse._core.config.DataverseConfig.from_env`. +convenience constructor :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`. """ from __future__ import annotations @@ -43,7 +43,7 @@ def from_env(cls) -> "DataverseConfig": Create a configuration instance with default settings. :return: Configuration instance with default values. - :rtype: ~PowerPlatform.Dataverse._core.config.DataverseConfig + :rtype: ~PowerPlatform.Dataverse.core.config.DataverseConfig """ # Environment-free defaults return cls( diff --git a/src/PowerPlatform/Dataverse/core/errors.py b/src/PowerPlatform/Dataverse/core/errors.py new file mode 100644 index 0000000..4fd4adb --- /dev/null +++ b/src/PowerPlatform/Dataverse/core/errors.py @@ -0,0 +1,196 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Structured Dataverse exception hierarchy. + +This module provides :class:`~PowerPlatform.Dataverse.core.errors.DataverseError` and +specialized :class:`~PowerPlatform.Dataverse.core.errors.ValidationError`, +:class:`~PowerPlatform.Dataverse.core.errors.MetadataError`, +:class:`~PowerPlatform.Dataverse.core.errors.SQLParseError`, and +:class:`~PowerPlatform.Dataverse.core.errors.HttpError` for validation, metadata, +SQL parsing, and Web API HTTP failures. +""" + +from __future__ import annotations +from typing import Any, Dict, Optional +import datetime as _dt + + +class DataverseError(Exception): + """ + Base structured exception for the Dataverse SDK. + + :param message: Human-readable error message. + :type message: :class:`str` + :param code: Error category code (e.g. ``"validation_error"``, ``"http_error"``). + :type code: :class:`str` + :param subcode: Optional subcategory or specific error identifier. + :type subcode: :class:`str` | None + :param status_code: Optional HTTP status code if the error originated from an HTTP response. + :type status_code: :class:`int` | None + :param details: Optional dictionary containing additional diagnostic information. + :type details: :class:`dict` | None + :param source: Error source, either ``"client"`` or ``"server"``. + :type source: :class:`str` + :param is_transient: Whether the error is potentially transient and may succeed on retry. + :type is_transient: :class:`bool` + """ + + def __init__( + self, + message: str, + code: str, + subcode: Optional[str] = None, + status_code: Optional[int] = None, + details: Optional[Dict[str, Any]] = None, + source: Optional[str] = None, + is_transient: bool = False, + ) -> None: + super().__init__(message) + self.message = message + self.code = code + self.subcode = subcode + self.status_code = status_code + self.details = details or {} + self.source = source or "client" + self.is_transient = is_transient + self.timestamp = _dt.datetime.now(_dt.timezone.utc).isoformat().replace("+00:00", "Z") + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the error to a dictionary representation. + + :return: Dictionary containing all error properties. + :rtype: :class:`dict` + """ + return { + "message": self.message, + "code": self.code, + "subcode": self.subcode, + "status_code": self.status_code, + "details": self.details, + "source": self.source, + "is_transient": self.is_transient, + "timestamp": self.timestamp, + } + + def __repr__(self) -> str: # pragma: no cover + return f"{self.__class__.__name__}(code={self.code!r}, subcode={self.subcode!r}, message={self.message!r})" + + +class ValidationError(DataverseError): + """ + Exception raised for client-side validation failures. + + :param message: Human-readable validation error message. + :type message: :class:`str` + :param subcode: Optional specific validation error identifier. + :type subcode: :class:`str` | None + :param details: Optional dictionary with additional validation context. + :type details: :class:`dict` | None + """ + + def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None): + super().__init__(message, code="validation_error", subcode=subcode, details=details, source="client") + + +class MetadataError(DataverseError): + """ + Exception raised for metadata operation failures. + + :param message: Human-readable metadata error message. + :type message: :class:`str` + :param subcode: Optional specific metadata error identifier. + :type subcode: :class:`str` | None + :param details: Optional dictionary with additional metadata context. + :type details: :class:`dict` | None + """ + + def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None): + super().__init__(message, code="metadata_error", subcode=subcode, details=details, source="client") + + +class SQLParseError(DataverseError): + """ + Exception raised for SQL query parsing failures. + + :param message: Human-readable SQL parsing error message. + :type message: :class:`str` + :param subcode: Optional specific SQL parsing error identifier. + :type subcode: :class:`str` | None + :param details: Optional dictionary with SQL query context and parse information. + :type details: :class:`dict` | None + """ + + def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None): + super().__init__(message, code="sql_parse_error", subcode=subcode, details=details, source="client") + + +class HttpError(DataverseError): + """ + Exception raised for HTTP request failures from the Dataverse Web API. + + :param message: Human-readable HTTP error message, typically from the API error response. + :type message: :class:`str` + :param status_code: HTTP status code (e.g. 400, 404, 500). + :type status_code: :class:`int` + :param is_transient: Whether the error is transient (429, 503, 504) and may succeed on retry. + :type is_transient: :class:`bool` + :param subcode: Optional HTTP status category (e.g. ``"4xx"``, ``"5xx"``). + :type subcode: :class:`str` | None + :param service_error_code: Optional Dataverse-specific error code from the API response. + :type service_error_code: :class:`str` | None + :param correlation_id: Optional correlation ID for tracking requests across services. + :type correlation_id: :class:`str` | None + :param request_id: Optional request ID from the API response headers. + :type request_id: :class:`str` | None + :param traceparent: Optional W3C trace context for distributed tracing. + :type traceparent: :class:`str` | None + :param body_excerpt: Optional excerpt of the response body for diagnostics. + :type body_excerpt: :class:`str` | None + :param retry_after: Optional number of seconds to wait before retrying (from Retry-After header). + :type retry_after: :class:`int` | None + :param details: Optional additional diagnostic details. + :type details: :class:`dict` | None + """ + + def __init__( + self, + message: str, + status_code: int, + is_transient: bool = False, + subcode: Optional[str] = None, + service_error_code: Optional[str] = None, + correlation_id: Optional[str] = None, + request_id: Optional[str] = None, + traceparent: Optional[str] = None, + body_excerpt: Optional[str] = None, + retry_after: Optional[int] = None, + details: Optional[Dict[str, Any]] = None, + ) -> None: + d = details or {} + if service_error_code is not None: + d["service_error_code"] = service_error_code + if correlation_id is not None: + d["correlation_id"] = correlation_id + if request_id is not None: + d["request_id"] = request_id + if traceparent is not None: + d["traceparent"] = traceparent + if body_excerpt is not None: + d["body_excerpt"] = body_excerpt + if retry_after is not None: + d["retry_after"] = retry_after + super().__init__( + message, + code="http_error", + subcode=subcode, + status_code=status_code, + details=d, + source="server", + is_transient=is_transient, + ) + + +__all__ = ["DataverseError", "HttpError", "ValidationError", "MetadataError", "SQLParseError"] diff --git a/src/PowerPlatform/Dataverse/_data/__init__.py b/src/PowerPlatform/Dataverse/data/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/_data/__init__.py rename to src/PowerPlatform/Dataverse/data/__init__.py diff --git a/src/PowerPlatform/Dataverse/_data/odata.py b/src/PowerPlatform/Dataverse/data/_odata.py similarity index 98% rename from src/PowerPlatform/Dataverse/_data/odata.py rename to src/PowerPlatform/Dataverse/data/_odata.py index b6a3d7e..9106682 100644 --- a/src/PowerPlatform/Dataverse/_data/odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -14,12 +14,12 @@ from datetime import datetime, timezone import importlib.resources as ir -from .._core.http import HttpClient -from .upload import ODataFileUpload -from .._core.errors import * -from .._core.error_codes import ( - http_subcode, - is_transient_status, +from ..core._http import HttpClient +from ._upload import ODataFileUpload +from ..core.errors import * +from ..core._error_codes import ( + _http_subcode, + _is_transient_status, VALIDATION_SQL_NOT_STRING, VALIDATION_SQL_EMPTY, METADATA_ENTITYSET_NOT_FOUND, @@ -81,12 +81,12 @@ def __init__( Sets up authentication, base URL, configuration, and internal caches. - :param auth: Authentication manager providing ``acquire_token(scope)`` that returns an object with ``access_token``. - :type auth: ~PowerPlatform.Dataverse._core.auth.AuthManager + :param auth: Authentication manager providing ``_acquire_token(scope)`` that returns an object with ``access_token``. + :type auth: ~PowerPlatform.Dataverse.core._auth.AuthManager :param base_url: Organization base URL (e.g. ``"https://.crm.dynamics.com"``). :type base_url: ``str`` :param config: Optional Dataverse configuration (HTTP retry, backoff, timeout, language code). If omitted ``DataverseConfig.from_env()`` is used. - :type config: ~PowerPlatform.Dataverse._core.config.DataverseConfig | ``None`` + :type config: ~PowerPlatform.Dataverse.core.config.DataverseConfig | ``None`` :raises ValueError: If ``base_url`` is empty after stripping. """ self.auth = auth @@ -97,7 +97,7 @@ def __init__( self.config = ( config or __import__( - "PowerPlatform.Dataverse._core.config", fromlist=["DataverseConfig"] + "PowerPlatform.Dataverse.core.config", fromlist=["DataverseConfig"] ).DataverseConfig.from_env() ) self._http = HttpClient( @@ -116,7 +116,7 @@ def __init__( def _headers(self) -> Dict[str, str]: """Build standard OData headers with bearer auth.""" scope = f"{self.base_url}/.default" - token = self.auth.acquire_token(scope).access_token + token = self.auth._acquire_token(scope).access_token return { "Authorization": f"Bearer {token}", "Accept": "application/json", @@ -135,7 +135,7 @@ def _merge_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, return merged def _raw_request(self, method: str, url: str, **kwargs): - return self._http.request(method, url, **kwargs) + return self._http._request(method, url, **kwargs) def _request(self, method: str, url: str, *, expected: tuple[int, ...] = (200, 201, 202, 204), **kwargs): headers_in = kwargs.pop("headers", None) @@ -163,7 +163,7 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = (200, 2 except Exception: pass sc = r.status_code - subcode = http_subcode(sc) + subcode = _http_subcode(sc) correlation_id = headers.get("x-ms-correlation-request-id") or headers.get("x-ms-correlation-id") request_id = ( headers.get("x-ms-client-request-id") or headers.get("request-id") or headers.get("x-ms-request-id") @@ -176,7 +176,7 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = (200, 2 retry_after = int(ra) except Exception: retry_after = None - is_transient = is_transient_status(sc) + is_transient = _is_transient_status(sc) raise HttpError( msg, status_code=sc, diff --git a/src/PowerPlatform/Dataverse/_data/upload.py b/src/PowerPlatform/Dataverse/data/_upload.py similarity index 99% rename from src/PowerPlatform/Dataverse/_data/upload.py rename to src/PowerPlatform/Dataverse/data/_upload.py index c90f062..d4c5b5a 100644 --- a/src/PowerPlatform/Dataverse/_data/upload.py +++ b/src/PowerPlatform/Dataverse/data/_upload.py @@ -11,7 +11,7 @@ class ODataFileUpload: """File upload capabilities (small + chunk) with auto selection.""" - def upload_file( + def _upload_file( self, entity_set: str, record_id: str, diff --git a/src/PowerPlatform/Dataverse/_extensions/__init__.py b/src/PowerPlatform/Dataverse/extensions/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/_extensions/__init__.py rename to src/PowerPlatform/Dataverse/extensions/__init__.py diff --git a/src/PowerPlatform/Dataverse/_models/__init__.py b/src/PowerPlatform/Dataverse/models/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/_models/__init__.py rename to src/PowerPlatform/Dataverse/models/__init__.py diff --git a/src/PowerPlatform/Dataverse/_utils/__init__.py b/src/PowerPlatform/Dataverse/utils/__init__.py similarity index 100% rename from src/PowerPlatform/Dataverse/_utils/__init__.py rename to src/PowerPlatform/Dataverse/utils/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py index 428ccc2..8532e06 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import pytest from unittest.mock import Mock -from PowerPlatform.Dataverse._core.config import DataverseConfig +from PowerPlatform.Dataverse.core.config import DataverseConfig @pytest.fixture @@ -18,7 +18,7 @@ def dummy_auth(): """Mock authentication object for testing.""" class DummyAuth: - def acquire_token(self, scope): + def _acquire_token(self, scope): class Token: access_token = "test_token_12345" @@ -37,7 +37,7 @@ def test_config(): def mock_http_client(): """Mock HTTP client for unit tests.""" mock = Mock() - mock.request.return_value = Mock() + mock._request.return_value = Mock() return mock diff --git a/tests/unit/core/test_http_errors.py b/tests/unit/core/test_http_errors.py index 2fc55c8..a69e7df 100644 --- a/tests/unit/core/test_http_errors.py +++ b/tests/unit/core/test_http_errors.py @@ -2,13 +2,13 @@ # Licensed under the MIT license. import pytest -from PowerPlatform.Dataverse._core.errors import HttpError -from PowerPlatform.Dataverse._core.error_codes import HTTP_404, HTTP_429, HTTP_500 -from PowerPlatform.Dataverse._data.odata import ODataClient +from PowerPlatform.Dataverse.core.errors import HttpError +from PowerPlatform.Dataverse.core._error_codes import HTTP_404, HTTP_429, HTTP_500 +from PowerPlatform.Dataverse.data._odata import ODataClient class DummyAuth: - def acquire_token(self, scope): + def _acquire_token(self, scope): class T: access_token = "x" @@ -19,7 +19,7 @@ class DummyHTTP: def __init__(self, responses): self._responses = responses - def request(self, method, url, **kwargs): + def _request(self, method, url, **kwargs): if not self._responses: raise AssertionError("No more responses") status, headers, body = self._responses.pop(0) diff --git a/tests/unit/data/test_enum_optionset_payload.py b/tests/unit/data/test_enum_optionset_payload.py index 0e7e803..ba3b097 100644 --- a/tests/unit/data/test_enum_optionset_payload.py +++ b/tests/unit/data/test_enum_optionset_payload.py @@ -4,11 +4,11 @@ import pytest from enum import Enum, IntEnum -from PowerPlatform.Dataverse._data.odata import ODataClient +from PowerPlatform.Dataverse.data._odata import ODataClient class DummyAuth: - def acquire_token(self, scope): # pragma: no cover - simple stub + def _acquire_token(self, scope): # pragma: no cover - simple stub class T: access_token = "token" diff --git a/tests/unit/data/test_logical_crud.py b/tests/unit/data/test_logical_crud.py index 063b6a0..335296b 100644 --- a/tests/unit/data/test_logical_crud.py +++ b/tests/unit/data/test_logical_crud.py @@ -3,12 +3,12 @@ import types import pytest -from PowerPlatform.Dataverse._data.odata import ODataClient -from PowerPlatform.Dataverse._core.errors import MetadataError +from PowerPlatform.Dataverse.data._odata import ODataClient +from PowerPlatform.Dataverse.core.errors import MetadataError class DummyAuth: - def acquire_token(self, scope): + def _acquire_token(self, scope): class T: access_token = "x" @@ -20,7 +20,7 @@ def __init__(self, responses): self._responses = responses self.calls = [] - def request(self, method, url, **kwargs): + def _request(self, method, url, **kwargs): self.calls.append((method, url, kwargs)) if not self._responses: raise AssertionError("No more dummy responses configured") diff --git a/tests/unit/data/test_sql_parse.py b/tests/unit/data/test_sql_parse.py index 587f713..cce5ee8 100644 --- a/tests/unit/data/test_sql_parse.py +++ b/tests/unit/data/test_sql_parse.py @@ -2,11 +2,11 @@ # Licensed under the MIT license. import pytest -from PowerPlatform.Dataverse._data.odata import ODataClient +from PowerPlatform.Dataverse.data._odata import ODataClient class DummyAuth: - def acquire_token(self, scope): + def _acquire_token(self, scope): class T: access_token = "x" # no real token needed for parsing tests From 5433056aa0a82a7c66c7248cd65189ff585c13a0 Mon Sep 17 00:00:00 2001 From: Max Wang Date: Mon, 17 Nov 2025 13:14:43 -0800 Subject: [PATCH 4/8] cleanup --- src/PowerPlatform/Dataverse/_core/errors.py | 196 -------------------- src/PowerPlatform/Dataverse/_core/http.py | 79 -------- 2 files changed, 275 deletions(-) delete mode 100644 src/PowerPlatform/Dataverse/_core/errors.py delete mode 100644 src/PowerPlatform/Dataverse/_core/http.py diff --git a/src/PowerPlatform/Dataverse/_core/errors.py b/src/PowerPlatform/Dataverse/_core/errors.py deleted file mode 100644 index 4fd4adb..0000000 --- a/src/PowerPlatform/Dataverse/_core/errors.py +++ /dev/null @@ -1,196 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -""" -Structured Dataverse exception hierarchy. - -This module provides :class:`~PowerPlatform.Dataverse.core.errors.DataverseError` and -specialized :class:`~PowerPlatform.Dataverse.core.errors.ValidationError`, -:class:`~PowerPlatform.Dataverse.core.errors.MetadataError`, -:class:`~PowerPlatform.Dataverse.core.errors.SQLParseError`, and -:class:`~PowerPlatform.Dataverse.core.errors.HttpError` for validation, metadata, -SQL parsing, and Web API HTTP failures. -""" - -from __future__ import annotations -from typing import Any, Dict, Optional -import datetime as _dt - - -class DataverseError(Exception): - """ - Base structured exception for the Dataverse SDK. - - :param message: Human-readable error message. - :type message: :class:`str` - :param code: Error category code (e.g. ``"validation_error"``, ``"http_error"``). - :type code: :class:`str` - :param subcode: Optional subcategory or specific error identifier. - :type subcode: :class:`str` | None - :param status_code: Optional HTTP status code if the error originated from an HTTP response. - :type status_code: :class:`int` | None - :param details: Optional dictionary containing additional diagnostic information. - :type details: :class:`dict` | None - :param source: Error source, either ``"client"`` or ``"server"``. - :type source: :class:`str` - :param is_transient: Whether the error is potentially transient and may succeed on retry. - :type is_transient: :class:`bool` - """ - - def __init__( - self, - message: str, - code: str, - subcode: Optional[str] = None, - status_code: Optional[int] = None, - details: Optional[Dict[str, Any]] = None, - source: Optional[str] = None, - is_transient: bool = False, - ) -> None: - super().__init__(message) - self.message = message - self.code = code - self.subcode = subcode - self.status_code = status_code - self.details = details or {} - self.source = source or "client" - self.is_transient = is_transient - self.timestamp = _dt.datetime.now(_dt.timezone.utc).isoformat().replace("+00:00", "Z") - - def to_dict(self) -> Dict[str, Any]: - """ - Convert the error to a dictionary representation. - - :return: Dictionary containing all error properties. - :rtype: :class:`dict` - """ - return { - "message": self.message, - "code": self.code, - "subcode": self.subcode, - "status_code": self.status_code, - "details": self.details, - "source": self.source, - "is_transient": self.is_transient, - "timestamp": self.timestamp, - } - - def __repr__(self) -> str: # pragma: no cover - return f"{self.__class__.__name__}(code={self.code!r}, subcode={self.subcode!r}, message={self.message!r})" - - -class ValidationError(DataverseError): - """ - Exception raised for client-side validation failures. - - :param message: Human-readable validation error message. - :type message: :class:`str` - :param subcode: Optional specific validation error identifier. - :type subcode: :class:`str` | None - :param details: Optional dictionary with additional validation context. - :type details: :class:`dict` | None - """ - - def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None): - super().__init__(message, code="validation_error", subcode=subcode, details=details, source="client") - - -class MetadataError(DataverseError): - """ - Exception raised for metadata operation failures. - - :param message: Human-readable metadata error message. - :type message: :class:`str` - :param subcode: Optional specific metadata error identifier. - :type subcode: :class:`str` | None - :param details: Optional dictionary with additional metadata context. - :type details: :class:`dict` | None - """ - - def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None): - super().__init__(message, code="metadata_error", subcode=subcode, details=details, source="client") - - -class SQLParseError(DataverseError): - """ - Exception raised for SQL query parsing failures. - - :param message: Human-readable SQL parsing error message. - :type message: :class:`str` - :param subcode: Optional specific SQL parsing error identifier. - :type subcode: :class:`str` | None - :param details: Optional dictionary with SQL query context and parse information. - :type details: :class:`dict` | None - """ - - def __init__(self, message: str, *, subcode: Optional[str] = None, details: Optional[Dict[str, Any]] = None): - super().__init__(message, code="sql_parse_error", subcode=subcode, details=details, source="client") - - -class HttpError(DataverseError): - """ - Exception raised for HTTP request failures from the Dataverse Web API. - - :param message: Human-readable HTTP error message, typically from the API error response. - :type message: :class:`str` - :param status_code: HTTP status code (e.g. 400, 404, 500). - :type status_code: :class:`int` - :param is_transient: Whether the error is transient (429, 503, 504) and may succeed on retry. - :type is_transient: :class:`bool` - :param subcode: Optional HTTP status category (e.g. ``"4xx"``, ``"5xx"``). - :type subcode: :class:`str` | None - :param service_error_code: Optional Dataverse-specific error code from the API response. - :type service_error_code: :class:`str` | None - :param correlation_id: Optional correlation ID for tracking requests across services. - :type correlation_id: :class:`str` | None - :param request_id: Optional request ID from the API response headers. - :type request_id: :class:`str` | None - :param traceparent: Optional W3C trace context for distributed tracing. - :type traceparent: :class:`str` | None - :param body_excerpt: Optional excerpt of the response body for diagnostics. - :type body_excerpt: :class:`str` | None - :param retry_after: Optional number of seconds to wait before retrying (from Retry-After header). - :type retry_after: :class:`int` | None - :param details: Optional additional diagnostic details. - :type details: :class:`dict` | None - """ - - def __init__( - self, - message: str, - status_code: int, - is_transient: bool = False, - subcode: Optional[str] = None, - service_error_code: Optional[str] = None, - correlation_id: Optional[str] = None, - request_id: Optional[str] = None, - traceparent: Optional[str] = None, - body_excerpt: Optional[str] = None, - retry_after: Optional[int] = None, - details: Optional[Dict[str, Any]] = None, - ) -> None: - d = details or {} - if service_error_code is not None: - d["service_error_code"] = service_error_code - if correlation_id is not None: - d["correlation_id"] = correlation_id - if request_id is not None: - d["request_id"] = request_id - if traceparent is not None: - d["traceparent"] = traceparent - if body_excerpt is not None: - d["body_excerpt"] = body_excerpt - if retry_after is not None: - d["retry_after"] = retry_after - super().__init__( - message, - code="http_error", - subcode=subcode, - status_code=status_code, - details=d, - source="server", - is_transient=is_transient, - ) - - -__all__ = ["DataverseError", "HttpError", "ValidationError", "MetadataError", "SQLParseError"] diff --git a/src/PowerPlatform/Dataverse/_core/http.py b/src/PowerPlatform/Dataverse/_core/http.py deleted file mode 100644 index 5c4ee2e..0000000 --- a/src/PowerPlatform/Dataverse/_core/http.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -""" -HTTP client with automatic retry logic and timeout handling. - -This module provides :class:`~PowerPlatform.Dataverse.core._http.HttpClient`, a wrapper -around the requests library that adds configurable retry behavior for transient -network errors and intelligent timeout management based on HTTP method types. -""" - -from __future__ import annotations - -import time -from typing import Any, Optional - -import requests - - -class HttpClient: - """ - HTTP client with configurable retry logic and timeout handling. - - Provides automatic retry behavior for transient failures and default timeout - management for different HTTP methods. - - :param retries: Maximum number of retry attempts for transient errors. Default is 5. - :type retries: :class:`int` | None - :param backoff: Base delay in seconds between retry attempts. Default is 0.5. - :type backoff: :class:`float` | None - :param timeout: Default request timeout in seconds. If None, uses per-method defaults. - :type timeout: :class:`float` | None - """ - - def __init__( - self, - retries: Optional[int] = None, - backoff: Optional[float] = None, - timeout: Optional[float] = None, - ) -> None: - self.max_attempts = retries if retries is not None else 5 - self.base_delay = backoff if backoff is not None else 0.5 - self.default_timeout: Optional[float] = timeout - - def request(self, method: str, url: str, **kwargs: Any) -> requests.Response: - """ - Execute an HTTP request with automatic retry logic and timeout management. - - Applies default timeouts based on HTTP method (120s for POST/DELETE, 10s for others) - and retries on network errors with exponential backoff. - - :param method: HTTP method (GET, POST, PUT, DELETE, etc.). - :type method: :class:`str` - :param url: Target URL for the request. - :type url: :class:`str` - :param kwargs: Additional arguments passed to ``requests.request()``, including headers, data, etc. - :return: HTTP response object. - :rtype: :class:`requests.Response` - :raises requests.exceptions.RequestException: If all retry attempts fail. - """ - # If no timeout is provided, use the user-specified default timeout if set; - # otherwise, apply per-method defaults (120s for POST/DELETE, 10s for others). - if "timeout" not in kwargs: - if self.default_timeout is not None: - kwargs["timeout"] = self.default_timeout - else: - m = (method or "").lower() - kwargs["timeout"] = 120 if m in ("post", "delete") else 10 - - # Small backoff retry on network errors only - for attempt in range(self.max_attempts): - try: - return requests.request(method, url, **kwargs) - except requests.exceptions.RequestException: - if attempt == self.max_attempts - 1: - raise - delay = self.base_delay * (2**attempt) - time.sleep(delay) - continue From 68b46a98066536a200c30e916bb0993ce4ae092a Mon Sep 17 00:00:00 2001 From: zhaodongwang-msft Date: Mon, 17 Nov 2025 13:16:52 -0800 Subject: [PATCH 5/8] Update src/PowerPlatform/Dataverse/client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/PowerPlatform/Dataverse/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 564d3dd..fa7228f 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -162,7 +162,7 @@ def update( :param table_schema_name: Schema name of the table (e.g. ``"account"`` or ``"new_MyTestTable"``). :type table_schema_name: :class:`str` :param ids: Single GUID string or list of GUID strings to update. - :type ids: :class:`str` or :class:`list` of :class:`str` + :type ids: :class:`str` or :class:`list` of :class:`str` :param changes: Dictionary of changes for single/broadcast mode, or list of dictionaries for paired mode. When ``ids`` is a list and ``changes`` is a single dict, the same changes are broadcast to all records. When both are lists, they must From f1ca31703b482788d19f1e7787e713eb977feb12 Mon Sep 17 00:00:00 2001 From: Max Wang Date: Mon, 17 Nov 2025 13:26:20 -0800 Subject: [PATCH 6/8] add class underscore --- examples/basic/installation_example.py | 4 ++-- src/PowerPlatform/Dataverse/client.py | 16 ++++++++-------- src/PowerPlatform/Dataverse/core/_auth.py | 14 +++++++------- src/PowerPlatform/Dataverse/core/_http.py | 4 ++-- src/PowerPlatform/Dataverse/data/_odata.py | 10 +++++----- src/PowerPlatform/Dataverse/data/_upload.py | 2 +- tests/unit/core/test_http_errors.py | 4 ++-- tests/unit/data/test_enum_optionset_payload.py | 8 ++++---- tests/unit/data/test_logical_crud.py | 4 ++-- tests/unit/data/test_sql_parse.py | 4 ++-- 10 files changed, 35 insertions(+), 35 deletions(-) diff --git a/examples/basic/installation_example.py b/examples/basic/installation_example.py index 28dc791..360dcd4 100644 --- a/examples/basic/installation_example.py +++ b/examples/basic/installation_example.py @@ -84,9 +84,9 @@ def validate_imports(): print(f" ✅ Core config: DataverseConfig") - from PowerPlatform.Dataverse.data._odata import ODataClient + from PowerPlatform.Dataverse.data._odata import _ODataClient - print(f" ✅ Data layer: ODataClient") + print(f" ✅ Data layer: _ODataClient") # Test Azure Identity import from azure.identity import InteractiveBrowserCredential diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 564d3dd..d7d6a47 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -7,9 +7,9 @@ from azure.core.credentials import TokenCredential -from .core._auth import AuthManager +from .core._auth import _AuthManager from .core.config import DataverseConfig -from .data._odata import ODataClient +from .data._odata import _ODataClient class DataverseClient: @@ -18,7 +18,7 @@ class DataverseClient: This client provides a simple, stable interface for interacting with Dataverse environments through the Web API. It handles authentication via Azure Identity and delegates HTTP operations - to an internal :class:`~PowerPlatform.Dataverse.data._odata.ODataClient`. + to an internal :class:`~PowerPlatform.Dataverse.data._odata._ODataClient`. Key capabilities: - OData CRUD operations: create, read, update, delete records @@ -74,14 +74,14 @@ def __init__( credential: TokenCredential, config: Optional[DataverseConfig] = None, ) -> None: - self.auth = AuthManager(credential) + self.auth = _AuthManager(credential) self._base_url = (base_url or "").rstrip("/") if not self._base_url: raise ValueError("base_url is required.") self._config = config or DataverseConfig.from_env() - self._odata: Optional[ODataClient] = None + self._odata: Optional[_ODataClient] = None - def _get_odata(self) -> ODataClient: + def _get_odata(self) -> _ODataClient: """ Get or create the internal OData client instance. @@ -89,10 +89,10 @@ def _get_odata(self) -> ODataClient: deferring construction until the first API call. :return: The lazily-initialized low-level client used to perform HTTP requests. - :rtype: ~PowerPlatform.Dataverse.data._odata.ODataClient + :rtype: ~PowerPlatform.Dataverse.data._odata._ODataClient """ if self._odata is None: - self._odata = ODataClient( + self._odata = _ODataClient( self.auth, self._base_url, self._config, diff --git a/src/PowerPlatform/Dataverse/core/_auth.py b/src/PowerPlatform/Dataverse/core/_auth.py index 7cc6075..f5dcca6 100644 --- a/src/PowerPlatform/Dataverse/core/_auth.py +++ b/src/PowerPlatform/Dataverse/core/_auth.py @@ -4,8 +4,8 @@ """ Authentication helpers for Dataverse. -This module provides :class:`~PowerPlatform.Dataverse.core._auth.AuthManager`, a thin wrapper over any Azure Identity -``TokenCredential`` for acquiring OAuth2 access tokens, and :class:`~PowerPlatform.Dataverse.core._auth.TokenPair` for +This module provides :class:`~PowerPlatform.Dataverse.core._auth._AuthManager`, a thin wrapper over any Azure Identity +``TokenCredential`` for acquiring OAuth2 access tokens, and :class:`~PowerPlatform.Dataverse.core._auth._TokenPair` for storing the acquired token alongside its scope. """ @@ -17,7 +17,7 @@ @dataclass -class TokenPair: +class _TokenPair: """ Container for an OAuth2 access token and its associated resource scope. @@ -31,7 +31,7 @@ class TokenPair: access_token: str -class AuthManager: +class _AuthManager: """ Azure Identity-based authentication manager for Dataverse. @@ -45,15 +45,15 @@ def __init__(self, credential: TokenCredential) -> None: raise TypeError("credential must implement azure.core.credentials.TokenCredential.") self.credential: TokenCredential = credential - def _acquire_token(self, scope: str) -> TokenPair: + def _acquire_token(self, scope: str) -> _TokenPair: """ Acquire an access token for the specified OAuth2 scope. :param scope: OAuth2 scope string, typically ``"https://.crm.dynamics.com/.default"``. :type scope: :class:`str` :return: Token pair containing the scope and access token. - :rtype: ~PowerPlatform.Dataverse.core._auth.TokenPair + :rtype: ~PowerPlatform.Dataverse.core._auth._TokenPair :raises ~azure.core.exceptions.ClientAuthenticationError: If token acquisition fails. """ token = self.credential.get_token(scope) - return TokenPair(resource=scope, access_token=token.token) + return _TokenPair(resource=scope, access_token=token.token) diff --git a/src/PowerPlatform/Dataverse/core/_http.py b/src/PowerPlatform/Dataverse/core/_http.py index aa44af5..43f6889 100644 --- a/src/PowerPlatform/Dataverse/core/_http.py +++ b/src/PowerPlatform/Dataverse/core/_http.py @@ -4,7 +4,7 @@ """ HTTP client with automatic retry logic and timeout handling. -This module provides :class:`~PowerPlatform.Dataverse.core._http.HttpClient`, a wrapper +This module provides :class:`~PowerPlatform.Dataverse.core._http._HttpClient`, a wrapper around the requests library that adds configurable retry behavior for transient network errors and intelligent timeout management based on HTTP method types. """ @@ -17,7 +17,7 @@ import requests -class HttpClient: +class _HttpClient: """ HTTP client with configurable retry logic and timeout handling. diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 9106682..8eda7ad 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -14,8 +14,8 @@ from datetime import datetime, timezone import importlib.resources as ir -from ..core._http import HttpClient -from ._upload import ODataFileUpload +from ..core._http import _HttpClient +from ._upload import _ODataFileUpload from ..core.errors import * from ..core._error_codes import ( _http_subcode, @@ -37,7 +37,7 @@ _GUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") -class ODataClient(ODataFileUpload): +class _ODataClient(_ODataFileUpload): """Dataverse Web API client: CRUD, SQL-over-API, and table metadata helpers.""" @staticmethod @@ -82,7 +82,7 @@ def __init__( Sets up authentication, base URL, configuration, and internal caches. :param auth: Authentication manager providing ``_acquire_token(scope)`` that returns an object with ``access_token``. - :type auth: ~PowerPlatform.Dataverse.core._auth.AuthManager + :type auth: ~PowerPlatform.Dataverse.core._auth._AuthManager :param base_url: Organization base URL (e.g. ``"https://.crm.dynamics.com"``). :type base_url: ``str`` :param config: Optional Dataverse configuration (HTTP retry, backoff, timeout, language code). If omitted ``DataverseConfig.from_env()`` is used. @@ -100,7 +100,7 @@ def __init__( "PowerPlatform.Dataverse.core.config", fromlist=["DataverseConfig"] ).DataverseConfig.from_env() ) - self._http = HttpClient( + self._http = _HttpClient( retries=self.config.http_retries, backoff=self.config.http_backoff, timeout=self.config.http_timeout, diff --git a/src/PowerPlatform/Dataverse/data/_upload.py b/src/PowerPlatform/Dataverse/data/_upload.py index d4c5b5a..d82efb5 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 _ODataFileUpload: """File upload capabilities (small + chunk) with auto selection.""" def _upload_file( diff --git a/tests/unit/core/test_http_errors.py b/tests/unit/core/test_http_errors.py index a69e7df..137aea5 100644 --- a/tests/unit/core/test_http_errors.py +++ b/tests/unit/core/test_http_errors.py @@ -4,7 +4,7 @@ import pytest from PowerPlatform.Dataverse.core.errors import HttpError from PowerPlatform.Dataverse.core._error_codes import HTTP_404, HTTP_429, HTTP_500 -from PowerPlatform.Dataverse.data._odata import ODataClient +from PowerPlatform.Dataverse.data._odata import _ODataClient class DummyAuth: @@ -49,7 +49,7 @@ def json_fail(): return r -class MockClient(ODataClient): +class MockClient(_ODataClient): def __init__(self, responses): super().__init__(DummyAuth(), "https://org.example", None) self._http = DummyHTTP(responses) diff --git a/tests/unit/data/test_enum_optionset_payload.py b/tests/unit/data/test_enum_optionset_payload.py index ba3b097..2392599 100644 --- a/tests/unit/data/test_enum_optionset_payload.py +++ b/tests/unit/data/test_enum_optionset_payload.py @@ -4,7 +4,7 @@ import pytest from enum import Enum, IntEnum -from PowerPlatform.Dataverse.data._odata import ODataClient +from PowerPlatform.Dataverse.data._odata import _ODataClient class DummyAuth: @@ -16,18 +16,18 @@ class T: class DummyConfig: - """Minimal config stub providing attributes ODataClient.__init__ expects.""" + """Minimal config stub providing attributes _ODataClient.__init__ expects.""" def __init__(self, language_code=1033): self.language_code = language_code - # HTTP settings referenced during ODataClient construction + # HTTP settings referenced during _ODataClient construction self.http_retries = 0 self.http_backoff = 0 self.http_timeout = 5 def _make_client(lang=1033): - return ODataClient(DummyAuth(), "https://org.example", DummyConfig(language_code=lang)) + return _ODataClient(DummyAuth(), "https://org.example", DummyConfig(language_code=lang)) def _labels_for(option): diff --git a/tests/unit/data/test_logical_crud.py b/tests/unit/data/test_logical_crud.py index 335296b..2096a4d 100644 --- a/tests/unit/data/test_logical_crud.py +++ b/tests/unit/data/test_logical_crud.py @@ -3,7 +3,7 @@ import types import pytest -from PowerPlatform.Dataverse.data._odata import ODataClient +from PowerPlatform.Dataverse.data._odata import _ODataClient from PowerPlatform.Dataverse.core.errors import MetadataError @@ -43,7 +43,7 @@ def json_func(): return resp -class MockableClient(ODataClient): +class MockableClient(_ODataClient): def __init__(self, responses): super().__init__(DummyAuth(), "https://org.example", None) self._http = DummyHTTPClient(responses) diff --git a/tests/unit/data/test_sql_parse.py b/tests/unit/data/test_sql_parse.py index cce5ee8..efbf606 100644 --- a/tests/unit/data/test_sql_parse.py +++ b/tests/unit/data/test_sql_parse.py @@ -2,7 +2,7 @@ # Licensed under the MIT license. import pytest -from PowerPlatform.Dataverse.data._odata import ODataClient +from PowerPlatform.Dataverse.data._odata import _ODataClient class DummyAuth: @@ -14,7 +14,7 @@ class T: def _client(): - return ODataClient(DummyAuth(), "https://org.example", None) + return _ODataClient(DummyAuth(), "https://org.example", None) def test_basic_from(): From 28ea0c4683b0ad8d3ee2346f69a42e7d3a426694 Mon Sep 17 00:00:00 2001 From: zhaodongwang-msft Date: Mon, 17 Nov 2025 13:46:51 -0800 Subject: [PATCH 7/8] Update src/PowerPlatform/Dataverse/client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/PowerPlatform/Dataverse/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index da66f54..734b433 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -167,7 +167,7 @@ def update( for paired mode. When ``ids`` is a list and ``changes`` is a single dict, the same changes are broadcast to all records. When both are lists, they must have equal length for one-to-one mapping. - :type changes: :class:`dict` or :class:`list` of :class:`dict` + :type changes: :class:`dict` or :class:`list` of :class:`dict` :raises TypeError: If ``ids`` is not str or list[str], or if ``changes`` type doesn't match usage pattern. From d4cc58c1d446ca07a4de36385dd8caee3c85195b Mon Sep 17 00:00:00 2001 From: Max Wang Date: Mon, 17 Nov 2025 13:51:18 -0800 Subject: [PATCH 8/8] update indentation --- src/PowerPlatform/Dataverse/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 734b433..2fb11e8 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -160,7 +160,7 @@ def update( 3. Paired updates: ``update("account", [id1, id2], [changes1, changes2])`` - one-to-one mapping :param table_schema_name: Schema name of the table (e.g. ``"account"`` or ``"new_MyTestTable"``). - :type table_schema_name: :class:`str` + :type table_schema_name: :class:`str` :param ids: Single GUID string or list of GUID strings to update. :type ids: :class:`str` or :class:`list` of :class:`str` :param changes: Dictionary of changes for single/broadcast mode, or list of dictionaries