diff --git a/README.md b/README.md index d1b14f3..aca86c1 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 d850b7b..886aca6 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 eee2ccb..360dcd4 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 @@ -83,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 @@ -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/client.py b/src/PowerPlatform/Dataverse/client.py index 0a0c320..2fb11e8 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 @@ -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( @@ -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, @@ -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``. @@ -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, @@ -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:: diff --git a/src/PowerPlatform/Dataverse/core/auth.py b/src/PowerPlatform/Dataverse/core/_auth.py similarity index 76% rename from src/PowerPlatform/Dataverse/core/auth.py rename to src/PowerPlatform/Dataverse/core/_auth.py index e5619e4..f5dcca6 100644 --- a/src/PowerPlatform/Dataverse/core/auth.py +++ b/src/PowerPlatform/Dataverse/core/_auth.py @@ -1,37 +1,37 @@ # 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 @dataclass -class TokenPair: +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 -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: ``str`` + :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/error_codes.py b/src/PowerPlatform/Dataverse/core/_error_codes.py similarity index 93% rename from src/PowerPlatform/Dataverse/core/error_codes.py rename to src/PowerPlatform/Dataverse/core/_error_codes.py index 23a99a7..1270319 100644 --- a/src/PowerPlatform/Dataverse/core/error_codes.py +++ b/src/PowerPlatform/Dataverse/core/_error_codes.py @@ -77,19 +77,19 @@ 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. :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}") -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. @@ -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/http.py b/src/PowerPlatform/Dataverse/core/_http.py similarity index 86% rename from src/PowerPlatform/Dataverse/core/http.py rename to src/PowerPlatform/Dataverse/core/_http.py index 057b135..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. @@ -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__( @@ -42,7 +42,7 @@ def __init__( 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: + def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response: """ Execute an HTTP request with automatic retry logic and timeout management. @@ -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/core/config.py b/src/PowerPlatform/Dataverse/core/config.py index 02785e6..19c7a57 100644 --- a/src/PowerPlatform/Dataverse/core/config.py +++ b/src/PowerPlatform/Dataverse/core/config.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from __future__ import annotations - """ Dataverse client configuration. @@ -11,6 +9,8 @@ convenience constructor :meth:`~PowerPlatform.Dataverse.core.config.DataverseConfig.from_env`. """ +from __future__ import annotations + from dataclasses import dataclass from typing import Optional @@ -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/errors.py b/src/PowerPlatform/Dataverse/core/errors.py index 9fa2d0c..4fd4adb 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/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 aafb85d..8eda7ad 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._http import _HttpClient +from ._upload import _ODataFileUpload from ..core.errors import * -from ..core.error_codes import ( - http_subcode, - is_transient_status, +from ..core._error_codes import ( + _http_subcode, + _is_transient_status, VALIDATION_SQL_NOT_STRING, VALIDATION_SQL_EMPTY, METADATA_ENTITYSET_NOT_FOUND, @@ -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 @@ -81,8 +81,8 @@ 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. @@ -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, @@ -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 93% rename from src/PowerPlatform/Dataverse/data/upload.py rename to src/PowerPlatform/Dataverse/data/_upload.py index 9458164..d82efb5 100644 --- a/src/PowerPlatform/Dataverse/data/upload.py +++ b/src/PowerPlatform/Dataverse/data/_upload.py @@ -8,10 +8,10 @@ from typing import Optional -class ODataFileUpload: +class _ODataFileUpload: """File upload capabilities (small + chunk) with auto selection.""" - def upload_file( + def _upload_file( self, entity_set: str, record_id: str, @@ -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/tests/conftest.py b/tests/conftest.py index b223af7..8532e06 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 622663a..137aea5 100644 --- a/tests/unit/core/test_http_errors.py +++ b/tests/unit/core/test_http_errors.py @@ -3,12 +3,12 @@ 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._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) @@ -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 f548943..2392599 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" @@ -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 5a8cb70..2096a4d 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.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") @@ -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 c0feaa5..efbf606 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 @@ -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():