From 7bfd93d38f932e4d11ab965fae97d048570e7c12 Mon Sep 17 00:00:00 2001 From: tpellissier Date: Mon, 26 Jan 2026 11:13:21 -0800 Subject: [PATCH 1/5] Add fluent .with_response_details() API for telemetry access Phase 1 SDK restructure: All methods return OperationResult with fluent .with_response_details() API for optional telemetry access. Changes: - Add OperationResult wrapper with dunder methods for transparent behavior - Add RequestTelemetryData and DataverseResponse result types - Unwrap OperationResult in update/delete to allow operation chaining - Rename internal type alias to _ODataRequestResult for clarity - Standardize all _odata.py method return type annotations - Add telemetry printing to walkthrough example Co-Authored-By: Claude Opus 4.5 --- examples/advanced/walkthrough.py | 71 ++-- src/PowerPlatform/Dataverse/client.py | 236 ++++++++----- src/PowerPlatform/Dataverse/core/results.py | 244 ++++++++++++++ src/PowerPlatform/Dataverse/data/_odata.py | 280 +++++++++------- tests/unit/data/test_logical_crud.py | 16 +- tests/unit/test_client.py | 91 +++-- tests/unit/test_results.py | 349 ++++++++++++++++++++ 7 files changed, 1034 insertions(+), 253 deletions(-) create mode 100644 src/PowerPlatform/Dataverse/core/results.py create mode 100644 tests/unit/test_results.py diff --git a/examples/advanced/walkthrough.py b/examples/advanced/walkthrough.py index 6016f3e..c96119a 100644 --- a/examples/advanced/walkthrough.py +++ b/examples/advanced/walkthrough.py @@ -32,6 +32,14 @@ def log_call(description): print(f"\n-> {description}") +def print_telemetry(telemetry): + """Print telemetry IDs from a response.""" + print(f" Telemetry:") + print(f" client_request_id: {telemetry.get('client_request_id')}") + print(f" correlation_id: {telemetry.get('correlation_id')}") + print(f" service_request_id: {telemetry.get('service_request_id')}") + + # Define enum for priority picklist class Priority(IntEnum): LOW = 1 @@ -52,9 +60,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): result = op() if attempts > 1: retry_count = attempts - 1 - print( - f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." - ) + print(f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total.") return result except Exception as ex: # noqa: BLE001 last = ex @@ -62,9 +68,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if last: if attempts: retry_count = max(attempts - 1, 0) - print( - f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." - ) + print(f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total.") raise last @@ -104,12 +108,13 @@ def main(): table_name = "new_WalkthroughDemo" log_call(f"client.get_table_info('{table_name}')") - table_info = backoff(lambda: client.get_table_info(table_name)) + table_info_result = backoff(lambda: client.get_table_info(table_name)) + print_telemetry(table_info_result.with_response_details().telemetry) - if table_info: - print(f"[OK] Table already exists: {table_info.get('table_schema_name')}") - print(f" Logical Name: {table_info.get('table_logical_name')}") - print(f" Entity Set: {table_info.get('entity_set_name')}") + if table_info_result.value: + print(f"[OK] Table already exists: {table_info_result.get('table_schema_name')}") + print(f" Logical Name: {table_info_result.get('table_logical_name')}") + print(f" Entity Set: {table_info_result.get('entity_set_name')}") else: log_call(f"client.create_table('{table_name}', columns={{...}})") columns = { @@ -119,9 +124,10 @@ def main(): "new_Completed": "bool", "new_Priority": Priority, } - table_info = backoff(lambda: client.create_table(table_name, columns)) - print(f"[OK] Created table: {table_info.get('table_schema_name')}") - print(f" Columns created: {', '.join(table_info.get('columns_created', []))}") + table_info_result = backoff(lambda: client.create_table(table_name, columns)) + print(f"[OK] Created table: {table_info_result.get('table_schema_name')}") + print(f" Columns created: {', '.join(table_info_result.get('columns_created', []))}") + print_telemetry(table_info_result.with_response_details().telemetry) # ============================================================================ # 3. CREATE OPERATIONS @@ -139,8 +145,10 @@ def main(): "new_Completed": False, "new_Priority": Priority.MEDIUM, } - id1 = backoff(lambda: client.create(table_name, single_record))[0] + create_result = backoff(lambda: client.create(table_name, single_record)) + id1 = create_result[0] print(f"[OK] Created single record: {id1}") + print_telemetry(create_result.with_response_details().telemetry) # Multiple create log_call(f"client.create('{table_name}', [{{...}}, {{...}}, {{...}}])") @@ -169,6 +177,7 @@ def main(): ] ids = backoff(lambda: client.create(table_name, multiple_records)) print(f"[OK] Created {len(ids)} records: {ids}") + print_telemetry(ids.with_response_details().telemetry) # ============================================================================ # 4. READ OPERATIONS @@ -195,6 +204,7 @@ def main(): indent=2, ) ) + print_telemetry(record.with_response_details().telemetry) # Multiple read with filter log_call(f"client.get('{table_name}', filter='new_quantity gt 5')") @@ -215,14 +225,16 @@ def main(): # Single update log_call(f"client.update('{table_name}', '{id1}', {{...}})") - backoff(lambda: client.update(table_name, id1, {"new_Quantity": 100})) + update_result = backoff(lambda: client.update(table_name, id1, {"new_Quantity": 100})) updated = backoff(lambda: client.get(table_name, id1)) print(f"[OK] Updated single record new_Quantity: {updated.get('new_quantity')}") + print_telemetry(update_result.with_response_details().telemetry) # Multiple update (broadcast same change) log_call(f"client.update('{table_name}', [{len(ids)} IDs], {{...}})") - backoff(lambda: client.update(table_name, ids, {"new_Completed": True})) + multi_update_result = backoff(lambda: client.update(table_name, ids, {"new_Completed": True})) print(f"[OK] Updated {len(ids)} records to new_Completed=True") + print_telemetry(multi_update_result.with_response_details().telemetry) # ============================================================================ # 6. PAGING DEMO @@ -245,6 +257,7 @@ def main(): ] paging_ids = backoff(lambda: client.create(table_name, paging_records)) print(f"[OK] Created {len(paging_ids)} records for paging demo") + print_telemetry(paging_ids.with_response_details().telemetry) # Query with paging log_call(f"client.get('{table_name}', page_size=5)") @@ -264,10 +277,11 @@ def main(): log_call(f"client.query_sql('SELECT new_title, new_quantity FROM {table_name} WHERE new_completed = 1')") sql = f"SELECT new_title, new_quantity FROM new_walkthroughdemo WHERE new_completed = 1" try: - results = backoff(lambda: client.query_sql(sql)) - print(f"[OK] SQL query returned {len(results)} completed records:") - for result in results[:5]: # Show first 5 + sql_result = backoff(lambda: client.query_sql(sql)) + print(f"[OK] SQL query returned {len(sql_result)} completed records:") + for result in sql_result[:5]: # Show first 5 print(f" - new_Title='{result.get('new_title')}', new_Quantity={result.get('new_quantity')}") + print_telemetry(sql_result.with_response_details().telemetry) except Exception as e: print(f"[WARN] SQL query failed (known server-side bug): {str(e)}") @@ -286,11 +300,13 @@ def main(): "new_Completed": False, "new_Priority": "High", # String label instead of int } - label_id = backoff(lambda: client.create(table_name, label_record))[0] + label_create_result = backoff(lambda: client.create(table_name, label_record)) + label_id = label_create_result[0] retrieved = backoff(lambda: client.get(table_name, label_id)) print(f"[OK] Created record with string label 'High' for new_Priority") print(f" new_Priority stored as integer: {retrieved.get('new_priority')}") print(f" new_Priority@FormattedValue: {retrieved.get('new_priority@OData.Community.Display.V1.FormattedValue')}") + print_telemetry(label_create_result.with_response_details().telemetry) # ============================================================================ # 9. COLUMN MANAGEMENT @@ -302,11 +318,13 @@ def main(): log_call(f"client.create_columns('{table_name}', {{'new_Notes': 'string'}})") created_cols = backoff(lambda: client.create_columns(table_name, {"new_Notes": "string"})) print(f"[OK] Added column: {created_cols[0]}") + print_telemetry(created_cols.with_response_details().telemetry) # Delete the column we just added log_call(f"client.delete_columns('{table_name}', ['new_Notes'])") - backoff(lambda: client.delete_columns(table_name, ["new_Notes"])) + delete_cols_result = backoff(lambda: client.delete_columns(table_name, ["new_Notes"])) print(f"[OK] Deleted column: new_Notes") + print_telemetry(delete_cols_result.with_response_details().telemetry) # ============================================================================ # 10. DELETE OPERATIONS @@ -317,14 +335,16 @@ def main(): # Single delete log_call(f"client.delete('{table_name}', '{id1}')") - backoff(lambda: client.delete(table_name, id1)) + delete_result = backoff(lambda: client.delete(table_name, id1)) print(f"[OK] Deleted single record: {id1}") + print_telemetry(delete_result.with_response_details().telemetry) # Multiple delete (delete the paging demo records) log_call(f"client.delete('{table_name}', [{len(paging_ids)} IDs])") job_id = backoff(lambda: client.delete(table_name, paging_ids)) print(f"[OK] Bulk delete job started: {job_id}") print(f" (Deleting {len(paging_ids)} paging demo records)") + print_telemetry(job_id.with_response_details().telemetry) # ============================================================================ # 11. CLEANUP @@ -335,11 +355,12 @@ def main(): log_call(f"client.delete_table('{table_name}')") try: - backoff(lambda: client.delete_table(table_name)) + delete_table_result = backoff(lambda: client.delete_table(table_name)) print(f"[OK] Deleted table: {table_name}") + print_telemetry(delete_table_result.with_response_details().telemetry) except Exception as ex: # noqa: BLE001 code = getattr(getattr(ex, "response", None), "status_code", None) - if (isinstance(ex, (requests.exceptions.HTTPError, MetadataError)) and code == 404): + if isinstance(ex, (requests.exceptions.HTTPError, MetadataError)) and code == 404: print(f"[OK] Table removed: {table_name}") else: raise diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 84bd5d4..c63e704 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -10,6 +10,7 @@ from .core._auth import _AuthManager from .core.config import DataverseConfig +from .core.results import OperationResult, RequestTelemetryData from .data._odata import _ODataClient @@ -108,7 +109,9 @@ def _scoped_odata(self) -> Iterator[_ODataClient]: yield od # ---------------- Unified CRUD: create/update/delete ---------------- - def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dict[str, Any]]]) -> List[str]: + def create( + self, table_schema_name: str, records: Union[Dict[str, Any], List[Dict[str, Any]]] + ) -> OperationResult[List[str]]: """ Create one or more records by table name. @@ -118,8 +121,10 @@ def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dic Each dictionary should contain column schema names as keys. :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: :class:`list` of :class:`str` + :return: OperationResult containing the list of created record GUIDs. The result + can be used directly (supports iteration, indexing, length) or call + ``.with_response_details()`` to access telemetry data. + :rtype: :class:`OperationResult` [: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. @@ -129,35 +134,37 @@ def create(self, table_schema_name: str, records: Union[Dict[str, Any], List[Dic client = DataverseClient(base_url, credential) ids = client.create("account", {"name": "Contoso"}) - print(f"Created: {ids[0]}") + print(f"Created: {ids[0]}") # Works via __getitem__ - Create multiple records:: + Create multiple records and iterate:: - records = [ - {"name": "Contoso"}, - {"name": "Fabrikam"} - ] + records = [{"name": "Contoso"}, {"name": "Fabrikam"}] ids = client.create("account", records) - print(f"Created {len(ids)} accounts") + for id in ids: # Works via __iter__ + print(id) + + Access telemetry data:: + + response = client.create("account", {"name": "Test"}).with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: entity_set = od._entity_set_from_schema_name(table_schema_name) if isinstance(records, dict): - rid = od._create(entity_set, table_schema_name, records) - # _create returns str on single input + rid, metadata = od._create(entity_set, table_schema_name, records) if not isinstance(rid, str): raise TypeError("_create (single) did not return GUID string") - return [rid] + return OperationResult([rid], metadata) if isinstance(records, list): - ids = od._create_multiple(entity_set, table_schema_name, records) + ids, metadata = od._create_multiple(entity_set, table_schema_name, records) if not isinstance(ids, list) or not all(isinstance(x, str) for x in ids): raise TypeError("_create (multi) did not return list[str]") - return ids + return OperationResult(ids, metadata) raise TypeError("records must be dict or list[dict]") def update( self, table_schema_name: str, ids: Union[str, List[str]], changes: Union[Dict[str, Any], List[Dict[str, Any]]] - ) -> None: + ) -> OperationResult[None]: """ Update one or more records. @@ -177,6 +184,10 @@ def update( have equal length for one-to-one mapping. :type changes: :class:`dict` or :class:`list` of :class:`dict` + :return: OperationResult containing None. Call ``.with_response_details()`` to access + telemetry data from the update request. + :rtype: :class:`OperationResult` [None] + :raises TypeError: If ``ids`` is not str or list[str], or if ``changes`` type doesn't match usage pattern. .. note:: @@ -191,32 +202,31 @@ def update( client.update("account", [id1, id2, id3], {"statecode": 1}) - Update multiple records with different values:: + Access telemetry data:: - ids = [id1, id2] - changes = [ - {"name": "Updated Name 1"}, - {"name": "Updated Name 2"} - ] - client.update("account", ids, changes) + response = client.update("account", id, {"name": "New"}).with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: + # Unwrap OperationResult if passed directly from create() + if isinstance(ids, OperationResult): + ids = ids.value if isinstance(ids, str): if not isinstance(changes, dict): raise TypeError("For single id, changes must be a dict") - od._update(table_schema_name, ids, changes) # discard representation - return None + _, metadata = od._update(table_schema_name, ids, changes) + return OperationResult(None, metadata) if not isinstance(ids, list): raise TypeError("ids must be str or list[str]") - od._update_by_ids(table_schema_name, ids, changes) - return None + _, metadata = od._update_by_ids(table_schema_name, ids, changes) + return OperationResult(None, metadata) def delete( self, table_schema_name: str, ids: Union[str, List[str]], use_bulk_delete: bool = True, - ) -> Optional[str]: + ) -> OperationResult[Optional[str]]: """ Delete one or more records by GUID. @@ -231,33 +241,47 @@ def delete( :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: :class:`str` or None + :return: OperationResult containing the BulkDelete job ID when deleting multiple + records via BulkDelete; otherwise contains ``None``. Call ``.with_response_details()`` + to access telemetry data. + :rtype: :class:`OperationResult` [:class:`str` or None] Example: Delete a single record:: client.delete("account", account_id) - Delete multiple records:: + Delete multiple records and get job ID:: + + result = client.delete("account", [id1, id2, id3]) + job_id = result.value # Access the job ID directly - job_id = client.delete("account", [id1, id2, id3]) + Access telemetry data:: + + response = client.delete("account", id).with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: + # Unwrap OperationResult if passed directly from create() + if isinstance(ids, OperationResult): + ids = ids.value if isinstance(ids, str): - od._delete(table_schema_name, ids) - return None + _, metadata = od._delete(table_schema_name, ids) + return OperationResult(None, metadata) if not isinstance(ids, list): raise TypeError("ids must be str or list[str]") if not ids: - return None + return OperationResult(None, RequestTelemetryData()) if not all(isinstance(rid, str) for rid in ids): raise TypeError("ids must contain string GUIDs") if use_bulk_delete: - return od._delete_multiple(table_schema_name, ids) + job_id, metadata = od._delete_multiple(table_schema_name, ids) + return OperationResult(job_id, metadata) + # Sequential deletes - capture metadata from the last delete + metadata = RequestTelemetryData() for rid in ids: - od._delete(table_schema_name, rid) - return None + _, metadata = od._delete(table_schema_name, rid) + return OperationResult(None, metadata) def get( self, @@ -269,11 +293,11 @@ def get( top: Optional[int] = None, expand: Optional[List[str]] = None, page_size: Optional[int] = None, - ) -> Union[Dict[str, Any], Iterable[List[Dict[str, Any]]]]: + ) -> Union[OperationResult[Dict[str, Any]], Iterable[List[Dict[str, Any]]]]: """ Fetch a single record by ID or query multiple records. - When ``record_id`` is provided, returns a single record dictionary. + When ``record_id`` is provided, returns an OperationResult containing a single record dictionary. 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"``). @@ -293,9 +317,12 @@ def get( :param page_size: Optional number of records per page for pagination. :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: :class:`dict` or :class:`collections.abc.Iterable` of :class:`list` of :class:`dict` + :return: When ``record_id`` is provided, returns an OperationResult containing the record dict. + The result supports dict-like access (e.g., ``result["name"]``) or call + ``.with_response_details()`` to access telemetry data. + When querying multiple records, returns a generator yielding lists of record + dictionaries (one list per page). + :rtype: :class:`OperationResult` [:class:`dict`] or :class:`collections.abc.Iterable` of :class:`list` of :class:`dict` :raises TypeError: If ``record_id`` is provided but not a string. @@ -303,7 +330,13 @@ def get( Fetch a single record:: record = client.get("account", record_id=account_id, select=["name", "telephone1"]) - print(record["name"]) + print(record["name"]) # Works via __getitem__ + + Fetch single record with telemetry:: + + response = client.get("account", record_id=account_id).with_response_details() + print(f"Record: {response.result['name']}") + print(f"Request ID: {response.telemetry['client_request_id']}") Query multiple records with filtering (note: exact logical names in filter):: @@ -340,11 +373,12 @@ def get( if not isinstance(record_id, str): raise TypeError("record_id must be str") with self._scoped_odata() as od: - return od._get( + record, metadata = od._get( table_schema_name, record_id, select=select, ) + return OperationResult(record, metadata) def _paged() -> Iterable[List[Dict[str, Any]]]: with self._scoped_odata() as od: @@ -361,7 +395,7 @@ def _paged() -> Iterable[List[Dict[str, Any]]]: return _paged() # SQL via Web API sql parameter - def query_sql(self, sql: str): + def query_sql(self, sql: str) -> OperationResult[List[Dict[str, Any]]]: """ Execute a read-only SQL query using the Dataverse Web API ``?sql`` capability. @@ -372,8 +406,9 @@ def query_sql(self, sql: str): :param sql: Supported SQL SELECT statement. :type sql: :class:`str` - :return: List of result row dictionaries. Returns an empty list if no rows match. - :rtype: :class:`list` of :class:`dict` + :return: OperationResult containing list of result row dictionaries. Returns an empty list if no rows match. + Call ``.with_response_details()`` to access telemetry data. + :rtype: :class:`OperationResult` [: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. @@ -393,22 +428,28 @@ def query_sql(self, sql: str): sql = "SELECT a.name, a.telephone1 FROM account AS a WHERE a.statecode = 0" results = client.query_sql(sql) + + Access telemetry data:: + + response = client.query_sql(sql).with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: - return od._query_sql(sql) + result, metadata = od._query_sql(sql) + return OperationResult(result, metadata) # Table metadata helpers - def get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]: + def get_table_info(self, table_schema_name: str) -> OperationResult[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: :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: :class:`dict` or None + :return: OperationResult containing dictionary with table metadata (keys: ``table_schema_name``, + ``table_logical_name``, ``entity_set_name``, ``metadata_id``) or None if not found. + Call ``.with_response_details()`` to access telemetry data. + :rtype: :class:`OperationResult` [:class:`dict` or None] Example: Retrieve table metadata:: @@ -417,9 +458,15 @@ def get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]: if info: print(f"Logical name: {info['table_logical_name']}") print(f"Entity set: {info['entity_set_name']}") + + Access telemetry data:: + + response = client.get_table_info("account").with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: - return od._get_table_info(table_schema_name) + result, metadata = od._get_table_info(table_schema_name) + return OperationResult(result, metadata) def create_table( self, @@ -427,7 +474,7 @@ def create_table( columns: Dict[str, Any], solution_unique_name: Optional[str] = None, primary_column_schema_name: Optional[str] = None, - ) -> Dict[str, Any]: + ) -> OperationResult[Dict[str, Any]]: """ Create a simple custom table with specified columns. @@ -454,9 +501,10 @@ class ItemStatus(IntEnum): :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: :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: :class:`dict` + :return: OperationResult containing dictionary with table metadata (keys: ``table_schema_name``, + ``entity_set_name``, ``table_logical_name``, ``metadata_id``, ``columns_created``). + Call ``.with_response_details()`` to access telemetry data. + :rtype: :class:`OperationResult` [:class:`dict`] :raises ~PowerPlatform.Dataverse.core.errors.MetadataError: If table creation fails or the schema is invalid. @@ -481,29 +529,30 @@ class ItemStatus(IntEnum): print(f"Created table: {result['table_schema_name']}") print(f"Columns: {result['columns_created']}") - Create a table with a custom primary column name:: + Access telemetry data:: - result = client.create_table( - "new_Product", - {"new_Price": "decimal"}, - primary_column_schema_name="new_ProductName" - ) + response = client.create_table("new_Test", {"new_Col": "string"}).with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: - return od._create_table( + result, metadata = od._create_table( table_schema_name, columns, solution_unique_name, primary_column_schema_name, ) + return OperationResult(result, metadata) - def delete_table(self, table_schema_name: str) -> None: + def delete_table(self, table_schema_name: str) -> OperationResult[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: :class:`str` + :return: OperationResult containing None. Call ``.with_response_details()`` to access telemetry data. + :rtype: :class:`OperationResult` [None] + :raises ~PowerPlatform.Dataverse.core.errors.MetadataError: If the table does not exist or deletion fails. .. warning:: @@ -514,16 +563,23 @@ def delete_table(self, table_schema_name: str) -> None: Delete a custom table:: client.delete_table("new_MyTestTable") + + Access telemetry data:: + + response = client.delete_table("new_MyTestTable").with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: - od._delete_table(table_schema_name) + _, metadata = od._delete_table(table_schema_name) + return OperationResult(None, metadata) - def list_tables(self) -> list[str]: + def list_tables(self) -> OperationResult[List[Dict[str, Any]]]: """ List all custom tables in the Dataverse environment. - :return: List of custom table names. - :rtype: :class:`list` of :class:`str` + :return: OperationResult containing list of table metadata. Call ``.with_response_details()`` + to access telemetry data. + :rtype: :class:`OperationResult` [:class:`list` of :class:`dict`] Example: List all custom tables:: @@ -531,15 +587,21 @@ def list_tables(self) -> list[str]: tables = client.list_tables() for table in tables: print(table) + + Access telemetry data:: + + response = client.list_tables().with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: - return od._list_tables() + result, metadata = od._list_tables() + return OperationResult(result, metadata) def create_columns( self, table_schema_name: str, columns: Dict[str, Any], - ) -> List[str]: + ) -> OperationResult[List[str]]: """ Create one or more columns on an existing table using a schema-style mapping. @@ -549,8 +611,9 @@ def create_columns( ``"string"`` (alias: ``"text"``), ``"int"`` (alias: ``"integer"``), ``"decimal"`` (alias: ``"money"``), ``"float"`` (alias: ``"double"``), ``"datetime"`` (alias: ``"date"``), and ``"bool"`` (alias: ``"boolean"``). Enum subclasses (IntEnum preferred) generate a local option set and can specify localized labels via ``__labels__``. :type columns: :class:`dict` mapping :class:`str` to :class:`typing.Any` - :returns: Schema names for the columns that were created. - :rtype: :class:`list` of :class:`str` + :returns: OperationResult containing schema names for the columns that were created. + Call ``.with_response_details()`` to access telemetry data. + :rtype: :class:`OperationResult` [:class:`list` of :class:`str`] Example: Create two columns on the custom table:: @@ -562,18 +625,24 @@ def create_columns( }, ) print(created) # ['new_Scratch', 'new_Flags'] + + Access telemetry data:: + + response = client.create_columns("new_Test", {"new_Col": "string"}).with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: - return od._create_columns( + result, metadata = od._create_columns( table_schema_name, columns, ) + return OperationResult(result, metadata) def delete_columns( self, table_schema_name: str, columns: Union[str, List[str]], - ) -> List[str]: + ) -> OperationResult[List[str]]: """ Delete one or more columns from a table. @@ -581,8 +650,9 @@ def delete_columns( :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: :class:`str` or :class:`list` of :class:`str` - :returns: Schema names for the columns that were removed. - :rtype: :class:`list` of :class:`str` + :returns: OperationResult containing schema names for the columns that were removed. + Call ``.with_response_details()`` to access telemetry data. + :rtype: :class:`OperationResult` [:class:`list` of :class:`str`] Example: Remove two custom columns by schema name: @@ -591,12 +661,18 @@ def delete_columns( ["new_Scratch", "new_Flags"], ) print(removed) # ['new_Scratch', 'new_Flags'] + + Access telemetry data:: + + response = client.delete_columns("new_Test", ["new_Col"]).with_response_details() + print(f"Request ID: {response.telemetry['client_request_id']}") """ with self._scoped_odata() as od: - return od._delete_columns( + result, metadata = od._delete_columns( table_schema_name, columns, ) + return OperationResult(result, metadata) # File upload def upload_file( diff --git a/src/PowerPlatform/Dataverse/core/results.py b/src/PowerPlatform/Dataverse/core/results.py new file mode 100644 index 0000000..cf6af00 --- /dev/null +++ b/src/PowerPlatform/Dataverse/core/results.py @@ -0,0 +1,244 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Result types for the Dataverse SDK. + +This module provides the foundational result types that enable the fluent +`.with_response_details()` API pattern for accessing telemetry data. + +Classes: + RequestMetadata: Immutable HTTP request/response metadata for diagnostics. + DataverseResponse: Standard response object with result and telemetry. + OperationResult: Wrapper enabling fluent .with_response_details() pattern. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, TypeVar, Generic, Iterator, Dict, Optional + +T = TypeVar("T") + + +@dataclass(frozen=True) +class RequestTelemetryData: + """ + Telemetry data from HTTP requests for diagnostics. + + This immutable dataclass captures correlation IDs from HTTP requests + for debugging and distributed tracing purposes. + + :param client_request_id: Client-generated request ID sent in outbound headers. + :type client_request_id: :class:`str` | None + :param correlation_id: Client-generated correlation ID for tracking requests within an SDK call. + :type correlation_id: :class:`str` | None + :param service_request_id: Server-side request ID from Dataverse (x-ms-service-request-id). + :type service_request_id: :class:`str` | None + """ + + client_request_id: Optional[str] = None + correlation_id: Optional[str] = None + service_request_id: Optional[str] = None + + +@dataclass +class DataverseResponse(Generic[T]): + """ + Standard response object for all Dataverse operations. + + Provides consistent structure with operation result and telemetry data. + + :param result: The operation result (record IDs, records, tables, etc.) + :type result: T + :param telemetry: Dictionary containing request correlation IDs for diagnostics. + Keys include: ``service_request_id``, ``client_request_id``, ``correlation_id``. + :type telemetry: :class:`dict` + + Example: + Access result and telemetry after a create operation:: + + response = client.create("account", [{"name": "A"}]).with_response_details() + print(response.result) # ['guid-123'] + print(response.telemetry['service_request_id']) # 'abc-123...' + print(response.telemetry['client_request_id']) # 'xyz-456...' + """ + + result: T + telemetry: Dict[str, Any] = field(default_factory=dict) + + +class OperationResult(Generic[T]): + """ + Wrapper enabling fluent .with_response_details() pattern. + + By default, ``OperationResult`` acts like the underlying result value, + supporting iteration, indexing, equality comparison, and string conversion. + Call ``.with_response_details()`` to get a ``DataverseResponse`` with telemetry. + + :param result: The operation result value. + :type result: T + :param telemetry_data: Request telemetry data containing correlation IDs. + :type telemetry_data: :class:`RequestTelemetryData` + + Example: + Default behavior (acts like the result):: + + # Returns OperationResult[List[str]] + ids = client.create("account", [{"name": "A"}, {"name": "B"}]) + print(ids[0]) # Works via __getitem__ + for id in ids: # Works via __iter__ + print(id) + + Detailed response with telemetry:: + + response = client.create("account", [{"name": "A"}]).with_response_details() + print(response.telemetry["client_request_id"]) + """ + + def __init__(self, result: T, telemetry_data: RequestTelemetryData) -> None: + """ + Initialize an OperationResult. + + :param result: The operation result value. + :type result: T + :param telemetry_data: Request telemetry data containing correlation IDs. + :type telemetry_data: :class:`RequestTelemetryData` + """ + self._result = result + self._telemetry_data = telemetry_data + + @property + def value(self) -> T: + """ + Direct access to the result value. + + :return: The underlying result value. + :rtype: T + """ + return self._result + + def with_response_details(self) -> DataverseResponse[T]: + """ + Return detailed response with telemetry. + + :return: A DataverseResponse containing the result and telemetry dictionary. + :rtype: :class:`DataverseResponse` + + Example: + >>> response = result.with_response_details() + >>> print(response.result) + >>> print(response.telemetry['service_request_id']) + """ + telemetry = { + "client_request_id": self._telemetry_data.client_request_id, + "correlation_id": self._telemetry_data.correlation_id, + "service_request_id": self._telemetry_data.service_request_id, + } + return DataverseResponse(result=self._result, telemetry=telemetry) + + # Dunder methods for transparent default behavior + + def __iter__(self) -> Iterator: + """ + Support iteration over the result. + + If the result is a list or tuple, iterates over its elements. + Otherwise, iterates over a single-element list containing the result. + + :return: Iterator over the result. + :rtype: :class:`Iterator` + """ + if isinstance(self._result, (list, tuple)): + return iter(self._result) + return iter([self._result]) + + def __getitem__(self, key: Any) -> Any: + """ + Support indexing into the result. + + :param key: Index or key to access. + :return: Element at the specified index/key. + """ + return self._result[key] # type: ignore[index] + + def __len__(self) -> int: + """ + Return the length of the result. + + For lists, tuples, and dicts, returns their length. + For other types, returns 1. + + :return: Length of the result. + :rtype: :class:`int` + """ + if isinstance(self._result, (list, tuple, dict)): + return len(self._result) + return 1 + + def __str__(self) -> str: + """ + Return string representation of the result. + + :return: String representation. + :rtype: :class:`str` + """ + return str(self._result) + + def __repr__(self) -> str: + """ + Return detailed string representation. + + :return: Detailed representation. + :rtype: :class:`str` + """ + return f"OperationResult({self._result!r})" + + def __eq__(self, other: object) -> bool: + """ + Compare equality with another value. + + If comparing with another OperationResult, compares the underlying results. + Otherwise, compares the result directly with the other value. + + :param other: Value to compare with. + :return: True if equal, False otherwise. + :rtype: :class:`bool` + """ + if isinstance(other, OperationResult): + return self._result == other._result + return self._result == other + + def __bool__(self) -> bool: + """ + Return truthiness of the result. + + :return: True if the result is truthy, False otherwise. + :rtype: :class:`bool` + """ + return bool(self._result) + + def __getattr__(self, name: str) -> Any: + """ + Delegate attribute access to the underlying result. + + This enables calling methods like .get() on dict results transparently. + + :param name: Attribute name to access. + :return: Attribute from the underlying result. + :raises AttributeError: If the result doesn't have the attribute. + """ + return getattr(self._result, name) + + def __contains__(self, item: Any) -> bool: + """ + Support 'in' operator for membership testing. + + :param item: Item to check for membership. + :return: True if item is in the result. + :rtype: :class:`bool` + """ + return item in self._result # type: ignore[operator] + + +__all__ = ["RequestTelemetryData", "DataverseResponse", "OperationResult"] diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 7c5fc6c..59989ae 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -21,6 +21,7 @@ from ..core._http import _HttpClient from ._upload import _ODataFileUpload from ..core.errors import * +from ..core.results import RequestTelemetryData from ..core._error_codes import ( _http_subcode, _is_transient_status, @@ -41,6 +42,11 @@ _CALL_SCOPE_CORRELATION_ID: ContextVar[Optional[str]] = ContextVar("_CALL_SCOPE_CORRELATION_ID", default=None) _DEFAULT_EXPECTED_STATUSES: tuple[int, ...] = (200, 201, 202, 204) +# Type alias for request results with metadata +from typing import Tuple + +_ODataRequestResult = Tuple[Any, RequestTelemetryData] + @dataclass class _RequestContext: @@ -186,7 +192,18 @@ def _merge_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, def _raw_request(self, method: str, url: str, **kwargs): return self._http._request(method, url, **kwargs) - def _request(self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAULT_EXPECTED_STATUSES, **kwargs): + def _request( + self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAULT_EXPECTED_STATUSES, **kwargs + ) -> _ODataRequestResult: + """Execute an HTTP request and return (response, RequestTelemetryData) tuple. + + :param method: HTTP method (GET, POST, PATCH, DELETE). + :param url: Request URL. + :param expected: Tuple of expected HTTP status codes. + :param kwargs: Additional request arguments. + :return: Tuple of (response, RequestTelemetryData). + :raises HttpError: If the response status code is not in expected. + """ request_context = _RequestContext.build( method, url, @@ -196,9 +213,23 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAUL ) r = self._raw_request(request_context.method, request_context.url, **request_context.kwargs) - if r.status_code in request_context.expected: - return r + + # Extract metadata from response headers and request context response_headers = getattr(r, "headers", {}) or {} + service_request_id = ( + response_headers.get("x-ms-service-request-id") + or response_headers.get("req_id") + or response_headers.get("x-ms-request-id") + ) + metadata = RequestTelemetryData( + client_request_id=request_context.headers.get("x-ms-client-request-id"), + correlation_id=request_context.headers.get("x-ms-correlation-id"), + service_request_id=service_request_id, + ) + + if r.status_code in request_context.expected: + return (r, metadata) + body_excerpt = (getattr(r, "text", "") or "")[:200] svc_code = None msg = f"HTTP {r.status_code}" @@ -219,11 +250,6 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAUL pass sc = r.status_code subcode = _http_subcode(sc) - request_id = ( - response_headers.get("x-ms-service-request-id") - or response_headers.get("req_id") - or response_headers.get("x-ms-request-id") - ) traceparent = response_headers.get("traceparent") ra = response_headers.get("Retry-After") retry_after = None @@ -238,13 +264,9 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAUL status_code=sc, subcode=subcode, service_error_code=svc_code, - correlation_id=request_context.headers.get( - "x-ms-correlation-id" - ), # this is a value set on client side, although it's logged on server side too - client_request_id=request_context.headers.get( - "x-ms-client-request-id" - ), # this is a value set on client side, although it's logged on server side too - service_request_id=request_id, + correlation_id=metadata.correlation_id, + client_request_id=metadata.client_request_id, + service_request_id=metadata.service_request_id, traceparent=traceparent, body_excerpt=body_excerpt, retry_after=retry_after, @@ -252,8 +274,8 @@ def _request(self, method: str, url: str, *, expected: tuple[int, ...] = _DEFAUL ) # --- CRUD Internal functions --- - def _create(self, entity_set: str, table_schema_name: str, record: Dict[str, Any]) -> str: - """Create a single record and return its GUID. + def _create(self, entity_set: str, table_schema_name: str, record: Dict[str, Any]) -> _ODataRequestResult: + """Create a single record and return (GUID, metadata) tuple. :param entity_set: Resolved entity set (plural) name. :type entity_set: ``str`` @@ -262,8 +284,8 @@ def _create(self, entity_set: str, table_schema_name: str, record: Dict[str, Any :param record: Attribute payload mapped by logical column names. :type record: ``dict[str, Any]`` - :return: Created record GUID. - :rtype: ``str`` + :return: Tuple of (created record GUID, request metadata). + :rtype: ``tuple[str, RequestTelemetryData]`` .. note:: Relies on ``OData-EntityId`` (canonical) or ``Location`` response header. No response body parsing is performed. Raises ``RuntimeError`` if neither header contains a GUID. @@ -272,24 +294,26 @@ def _create(self, entity_set: str, table_schema_name: str, record: Dict[str, Any record = self._lowercase_keys(record) record = self._convert_labels_to_ints(table_schema_name, record) url = f"{self.api}/{entity_set}" - r = self._request("post", url, json=record) + r, metadata = self._request("post", url, json=record) ent_loc = r.headers.get("OData-EntityId") or r.headers.get("OData-EntityID") if ent_loc: m = _GUID_RE.search(ent_loc) if m: - return m.group(0) + return (m.group(0), metadata) loc = r.headers.get("Location") if loc: m = _GUID_RE.search(loc) if m: - return m.group(0) + return (m.group(0), metadata) header_keys = ", ".join(sorted(r.headers.keys())) raise RuntimeError( f"Create response missing GUID in OData-EntityId/Location headers (status={getattr(r,'status_code', '?')}). Headers: {header_keys}" ) - def _create_multiple(self, entity_set: str, table_schema_name: str, records: List[Dict[str, Any]]) -> List[str]: + def _create_multiple( + self, entity_set: str, table_schema_name: str, records: List[Dict[str, Any]] + ) -> _ODataRequestResult: """Create multiple records using the collection-bound ``CreateMultiple`` action. :param entity_set: Resolved entity set (plural) name. @@ -299,8 +323,8 @@ def _create_multiple(self, entity_set: str, table_schema_name: str, records: Lis :param records: Payload dictionaries mapped by column schema names. :type records: ``list[dict[str, Any]]`` - :return: List of created record GUIDs (may be empty if response lacks IDs). - :rtype: ``list[str]`` + :return: Tuple of (list of created record GUIDs, request metadata). IDs may be empty if response lacks them. + :rtype: ``tuple[list[str], RequestTelemetryData]`` .. note:: Logical type stamping: if any payload omits ``@odata.type`` the client injects ``Microsoft.Dynamics.CRM.``. If all payloads already include ``@odata.type`` no modification occurs. @@ -325,17 +349,17 @@ def _create_multiple(self, entity_set: str, table_schema_name: str, records: Lis # Bound action form: POST {entity_set}/Microsoft.Dynamics.CRM.CreateMultiple url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.CreateMultiple" # The action currently returns only Ids; no need to request representation. - r = self._request("post", url, json=payload) + r, metadata = self._request("post", url, json=payload) try: body = r.json() if r.text else {} except ValueError: body = {} if not isinstance(body, dict): - return [] + return ([], metadata) # Expected: { "Ids": [guid, ...] } ids = body.get("Ids") if isinstance(ids, list): - return [i for i in ids if isinstance(i, str)] + return ([i for i in ids if isinstance(i, str)], metadata) value = body.get("value") if isinstance(value, list): @@ -348,8 +372,8 @@ def _create_multiple(self, entity_set: str, table_schema_name: str, records: Lis if isinstance(k, str) and k.lower().endswith("id") and isinstance(v, str) and len(v) >= 32: out.append(v) break - return out - return [] + return (out, metadata) + return ([], metadata) # --- Derived helpers for high-level client ergonomics --- def _primary_id_attr(self, table_schema_name: str) -> str: @@ -369,7 +393,7 @@ def _primary_id_attr(self, table_schema_name: str) -> str: def _update_by_ids( self, table_schema_name: str, ids: List[str], changes: Union[Dict[str, Any], List[Dict[str, Any]]] - ) -> None: + ) -> _ODataRequestResult: """Update many records by GUID list using the collection-bound ``UpdateMultiple`` action. :param table_schema_name: Schema name of the table. @@ -379,19 +403,19 @@ def _update_by_ids( :param changes: Broadcast patch (``dict``) applied to all IDs, or list of per-record patches (1:1 with ``ids``). :type changes: ``dict`` | ``list[dict]`` - :return: ``None`` - :rtype: ``None`` + :return: Tuple of (None, request metadata). + :rtype: ``tuple[None, RequestTelemetryData]`` """ if not isinstance(ids, list): raise TypeError("ids must be list[str]") if not ids: - return None + return (None, RequestTelemetryData()) pk_attr = self._primary_id_attr(table_schema_name) entity_set = self._entity_set_from_schema_name(table_schema_name) if isinstance(changes, dict): batch = [{pk_attr: rid, **changes} for rid in ids] - self._update_multiple(entity_set, table_schema_name, batch) - return None + _, metadata = self._update_multiple(entity_set, table_schema_name, batch) + return (None, metadata) if not isinstance(changes, list): raise TypeError("changes must be dict or list[dict]") if len(changes) != len(ids): @@ -401,27 +425,27 @@ def _update_by_ids( if not isinstance(patch, dict): raise TypeError("Each patch must be a dict") batch.append({pk_attr: rid, **patch}) - self._update_multiple(entity_set, table_schema_name, batch) - return None + _, metadata = self._update_multiple(entity_set, table_schema_name, batch) + return (None, metadata) def _delete_multiple( self, table_schema_name: str, ids: List[str], - ) -> Optional[str]: + ) -> _ODataRequestResult: """Delete many records by GUID list via the ``BulkDelete`` action. - :param logical_name: Logical (singular) entity name. - :type logical_name: ``str`` + :param table_schema_name: Schema name of the table. + :type table_schema_name: ``str`` :param ids: GUIDs of records to delete. :type ids: ``list[str]`` - :return: BulkDelete asynchronous job identifier when executed in bulk; ``None`` if no IDs provided or single deletes performed. - :rtype: ``str`` | ``None`` + :return: Tuple of (BulkDelete job identifier or None, request metadata). + :rtype: ``tuple[str | None, RequestTelemetryData]`` """ targets = [rid for rid in ids if rid] if not targets: - return None + return (None, RequestTelemetryData()) value_objects = [{"Value": rid, "Type": "System.Guid"} for rid in targets] pk_attr = self._primary_id_attr(table_schema_name) @@ -464,7 +488,7 @@ def _delete_multiple( } url = f"{self.api}/BulkDelete" - response = self._request("post", url, json=payload, expected=(200, 202, 204)) + response, metadata = self._request("post", url, json=payload, expected=(200, 202, 204)) job_id = None try: @@ -474,7 +498,7 @@ def _delete_multiple( if isinstance(body, dict): job_id = body.get("JobId") - return job_id + return (job_id, metadata) def _format_key(self, key: str) -> str: k = key.strip() @@ -493,7 +517,7 @@ def esc(match): return f"({k})" return f"({k})" - def _update(self, table_schema_name: str, key: str, data: Dict[str, Any]) -> None: + def _update(self, table_schema_name: str, key: str, data: Dict[str, Any]) -> _ODataRequestResult: """Update an existing record by GUID. :param table_schema_name: Schema name of the table. @@ -502,17 +526,20 @@ def _update(self, table_schema_name: str, key: str, data: Dict[str, Any]) -> Non :type key: ``str`` :param data: Partial entity payload (attributes to patch). :type data: ``dict[str, Any]`` - :return: ``None`` - :rtype: ``None`` + :return: Tuple of (None, request metadata). + :rtype: ``tuple[None, RequestTelemetryData]`` """ # Lowercase all keys to match Dataverse LogicalName expectations data = self._lowercase_keys(data) data = self._convert_labels_to_ints(table_schema_name, data) entity_set = self._entity_set_from_schema_name(table_schema_name) url = f"{self.api}/{entity_set}{self._format_key(key)}" - r = self._request("patch", url, headers={"If-Match": "*"}, json=data) + _, metadata = self._request("patch", url, headers={"If-Match": "*"}, json=data) + return (None, metadata) - def _update_multiple(self, entity_set: str, table_schema_name: str, records: List[Dict[str, Any]]) -> None: + def _update_multiple( + self, entity_set: str, table_schema_name: str, records: List[Dict[str, Any]] + ) -> _ODataRequestResult: """Bulk update existing records via the collection-bound ``UpdateMultiple`` action. :param entity_set: Resolved entity set (plural) name. @@ -521,8 +548,8 @@ def _update_multiple(self, entity_set: str, table_schema_name: str, records: Lis :type table_schema_name: ``str`` :param records: List of patch dictionaries. Each must include the true primary key attribute (e.g. ``accountid``) and one or more fields to update. :type records: ``list[dict[str, Any]]`` - :return: ``None`` - :rtype: ``None`` + :return: Tuple of (None, request metadata). + :rtype: ``tuple[None, RequestTelemetryData]`` .. note:: - Endpoint: ``POST /{entity_set}/Microsoft.Dynamics.CRM.UpdateMultiple`` with body ``{"Targets": [...]}``. @@ -551,11 +578,11 @@ def _update_multiple(self, entity_set: str, table_schema_name: str, records: Lis payload = {"Targets": enriched} url = f"{self.api}/{entity_set}/Microsoft.Dynamics.CRM.UpdateMultiple" - r = self._request("post", url, json=payload) + _, metadata = self._request("post", url, json=payload) # Intentionally ignore response content: no stable contract for IDs across environments. - return None + return (None, metadata) - def _delete(self, table_schema_name: str, key: str) -> None: + def _delete(self, table_schema_name: str, key: str) -> _ODataRequestResult: """Delete a record by GUID. :param table_schema_name: Schema name of the table. @@ -563,14 +590,15 @@ def _delete(self, table_schema_name: str, key: str) -> None: :param key: Record GUID (with or without parentheses) :type key: ``str`` - :return: ``None`` - :rtype: ``None`` + :return: Tuple of (None, request metadata). + :rtype: ``tuple[None, RequestTelemetryData]`` """ entity_set = self._entity_set_from_schema_name(table_schema_name) url = f"{self.api}/{entity_set}{self._format_key(key)}" - self._request("delete", url, headers={"If-Match": "*"}) + _, metadata = self._request("delete", url, headers={"If-Match": "*"}) + return (None, metadata) - def _get(self, table_schema_name: str, key: str, select: Optional[List[str]] = None) -> Dict[str, Any]: + def _get(self, table_schema_name: str, key: str, select: Optional[List[str]] = None) -> _ODataRequestResult: """Retrieve a single record. :param table_schema_name: Schema name of the table. @@ -580,8 +608,8 @@ def _get(self, table_schema_name: str, key: str, select: Optional[List[str]] = N :param select: Columns to select; joined with commas into $select. :type select: ``list[str]`` | ``None`` - :return: Retrieved record dictionary (may be empty if no selected attributes). - :rtype: ``dict[str, Any]`` + :return: Tuple of (retrieved record dictionary, request metadata). + :rtype: ``tuple[dict[str, Any], RequestTelemetryData]`` """ params = {} if select: @@ -589,8 +617,8 @@ def _get(self, table_schema_name: str, key: str, select: Optional[List[str]] = N params["$select"] = ",".join(select) entity_set = self._entity_set_from_schema_name(table_schema_name) url = f"{self.api}/{entity_set}{self._format_key(key)}" - r = self._request("get", url, params=params) - return r.json() + r, metadata = self._request("get", url, params=params) + return (r.json(), metadata) def _get_multiple( self, @@ -621,6 +649,10 @@ def _get_multiple( :return: Iterator yielding pages (each page is a ``list`` of record dicts). :rtype: ``Iterable[list[dict[str, Any]]]`` + + .. note:: + This method is a generator and does not return metadata directly. + For paginated queries, metadata is captured per-request but not surfaced. """ extra_headers: Dict[str, str] = {} @@ -631,7 +663,7 @@ def _get_multiple( def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: headers = extra_headers if extra_headers else None - r = self._request("get", url, headers=headers, params=params) + r, _ = self._request("get", url, headers=headers, params=params) try: return r.json() except ValueError: @@ -672,14 +704,14 @@ def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[st next_link = data.get("@odata.nextLink") or data.get("odata.nextLink") if isinstance(data, dict) else None # --------------------------- SQL Custom API ------------------------- - def _query_sql(self, sql: str) -> list[dict[str, Any]]: + def _query_sql(self, sql: str) -> _ODataRequestResult: """Execute a read-only SQL SELECT using the Dataverse Web API ``?sql=`` capability. :param sql: Single SELECT statement within the supported subset. :type sql: ``str`` - :return: Result rows (empty list if none). - :rtype: ``list[dict[str, Any]]`` + :return: Tuple of (result rows, telemetry metadata). + :rtype: ``tuple[list[dict[str, Any]], RequestTelemetryData]`` :raises ValidationError: If ``sql`` is not a ``str`` or is empty. :raises MetadataError: If logical table name resolution fails. @@ -700,20 +732,20 @@ def _query_sql(self, sql: str) -> list[dict[str, Any]]: # Issue GET /{entity_set}?sql= url = f"{self.api}/{entity_set}" params = {"sql": sql} - r = self._request("get", url, params=params) + r, metadata = self._request("get", url, params=params) try: body = r.json() except ValueError: - return [] + return [], metadata if isinstance(body, dict): value = body.get("value") if isinstance(value, list): # Ensure dict rows only - return [row for row in value if isinstance(row, dict)] + return [row for row in value if isinstance(row, dict)], metadata # Fallbacks: if body itself is a list if isinstance(body, list): - return [row for row in body if isinstance(row, dict)] - return [] + return [row for row in body if isinstance(row, dict)], metadata + return [], metadata @staticmethod def _extract_logical_table(sql: str) -> str: @@ -756,7 +788,7 @@ def _entity_set_from_schema_name(self, table_schema_name: str) -> str: "$select": "LogicalName,EntitySetName,PrimaryIdAttribute", "$filter": f"LogicalName eq '{logical_escaped}'", } - r = self._request("get", url, params=params) + r, _ = self._request("get", url, params=params) try: body = r.json() items = body.get("value", []) if isinstance(body, dict) else [] @@ -807,12 +839,15 @@ def _get_entity_by_table_schema_name( self, table_schema_name: str, headers: Optional[Dict[str, str]] = None, - ) -> Optional[Dict[str, Any]]: + ) -> _ODataRequestResult: """Get entity metadata by table schema name. Case-insensitive. Note: LogicalName is stored lowercase in Dataverse, so we lowercase the input for case-insensitive matching. The response includes SchemaName, LogicalName, EntitySetName, and MetadataId. + + :return: Tuple of (entity metadata or None, telemetry metadata). + :rtype: ``tuple[dict[str, Any] | None, RequestTelemetryData]`` """ url = f"{self.api}/EntityDefinitions" # LogicalName is stored lowercase, so we lowercase the input for lookup @@ -822,9 +857,9 @@ def _get_entity_by_table_schema_name( "$select": "MetadataId,LogicalName,SchemaName,EntitySetName", "$filter": f"LogicalName eq '{logical_escaped}'", } - r = self._request("get", url, params=params, headers=headers) + r, metadata = self._request("get", url, params=params, headers=headers) items = r.json().get("value", []) - return items[0] if items else None + return (items[0] if items else None), metadata def _create_entity( self, @@ -832,7 +867,7 @@ def _create_entity( display_name: str, attributes: List[Dict[str, Any]], solution_unique_name: Optional[str] = None, - ) -> Dict[str, Any]: + ) -> _ODataRequestResult: url = f"{self.api}/EntityDefinitions" payload = { "@odata.type": "Microsoft.Dynamics.CRM.EntityMetadata", @@ -849,8 +884,8 @@ def _create_entity( params = None if solution_unique_name: params = {"SolutionUniqueName": solution_unique_name} - self._request("post", url, json=payload, params=params) - ent = self._get_entity_by_table_schema_name( + _, metadata = self._request("post", url, json=payload, params=params) + ent, _ = self._get_entity_by_table_schema_name( table_schema_name, headers={"Consistency": "Strong"}, ) @@ -860,7 +895,7 @@ def _create_entity( ) if not ent.get("MetadataId"): raise RuntimeError(f"MetadataId missing after creating entity '{table_schema_name}'.") - return ent + return ent, metadata def _get_attribute_metadata( self, @@ -884,7 +919,7 @@ def _get_attribute_metadata( "$select": ",".join(select_fields), "$filter": f"SchemaName eq '{attr_escaped}'", } - r = self._request("get", url, params=params) + r, _ = self._request("get", url, params=params) try: body = r.json() if r.text else {} except ValueError: @@ -1060,7 +1095,7 @@ def _optionset_map(self, table_schema_name: str, attr_logical: str) -> Optional[ backoff_seconds = 0.4 for attempt in range(1, max_attempts + 1): try: - r_type = self._request("get", url_type) + r_type, _ = self._request("get", url_type) break except HttpError as err: if getattr(err, "status_code", None) == 404: @@ -1094,7 +1129,7 @@ def _optionset_map(self, table_schema_name: str, attr_logical: str) -> Optional[ r_opts = None for attempt in range(1, max_attempts + 1): try: - r_opts = self._request("get", cast_url) + r_opts, _ = self._request("get", cast_url) break except HttpError as err: if getattr(err, "status_code", None) == 404: @@ -1241,52 +1276,52 @@ def _attribute_payload( } return None - def _get_table_info(self, table_schema_name: str) -> Optional[Dict[str, Any]]: + def _get_table_info(self, table_schema_name: str) -> _ODataRequestResult: """Return basic metadata for a custom table if it exists. :param table_schema_name: Schema name of the table. :type table_schema_name: ``str`` - :return: Metadata summary or ``None`` if not found. - :rtype: ``dict[str, Any]`` | ``None`` + :return: Tuple of (metadata summary or None, telemetry metadata). + :rtype: ``tuple[dict[str, Any] | None, RequestTelemetryData]`` """ - ent = self._get_entity_by_table_schema_name(table_schema_name) + ent, metadata = self._get_entity_by_table_schema_name(table_schema_name) if not ent: - return None + return None, metadata return { "table_schema_name": ent.get("SchemaName") or table_schema_name, "table_logical_name": ent.get("LogicalName"), "entity_set_name": ent.get("EntitySetName"), "metadata_id": ent.get("MetadataId"), "columns_created": [], - } + }, metadata - def _list_tables(self) -> List[Dict[str, Any]]: + def _list_tables(self) -> _ODataRequestResult: """List all non-private tables (``IsPrivate eq false``). - :return: Metadata entries for non-private tables (may be empty). - :rtype: ``list[dict[str, Any]]`` + :return: Tuple of (metadata entries for non-private tables, telemetry metadata). + :rtype: ``tuple[list[dict[str, Any]], RequestTelemetryData]`` :raises HttpError: If the metadata request fails. """ url = f"{self.api}/EntityDefinitions" params = {"$filter": "IsPrivate eq false"} - r = self._request("get", url, params=params) - return r.json().get("value", []) + r, metadata = self._request("get", url, params=params) + return r.json().get("value", []), metadata - def _delete_table(self, table_schema_name: str) -> None: + def _delete_table(self, table_schema_name: str) -> _ODataRequestResult: """Delete a table by schema name. :param table_schema_name: Schema name of the table. :type table_schema_name: ``str`` - :return: ``None`` - :rtype: ``None`` + :return: Tuple of (None, telemetry metadata). + :rtype: ``tuple[None, RequestTelemetryData]`` :raises MetadataError: If the table does not exist. :raises HttpError: If the delete request fails. """ - ent = self._get_entity_by_table_schema_name(table_schema_name) + ent, _ = self._get_entity_by_table_schema_name(table_schema_name) if not ent or not ent.get("MetadataId"): raise MetadataError( f"Table '{table_schema_name}' not found.", @@ -1294,7 +1329,8 @@ def _delete_table(self, table_schema_name: str) -> None: ) metadata_id = ent["MetadataId"] url = f"{self.api}/EntityDefinitions({metadata_id})" - r = self._request("delete", url) + _, metadata = self._request("delete", url) + return None, metadata def _create_table( self, @@ -1302,7 +1338,7 @@ def _create_table( schema: Dict[str, Any], solution_unique_name: Optional[str] = None, primary_column_schema_name: Optional[str] = None, - ) -> Dict[str, Any]: + ) -> _ODataRequestResult: """Create a custom table with specified columns. :param table_schema_name: Schema name of the table. @@ -1314,8 +1350,8 @@ def _create_table( :param primary_column_schema_name: Optional primary column schema name. :type primary_column_schema_name: ``str`` | ``None`` - :return: Metadata summary for the created table including created column schema names. - :rtype: ``dict[str, Any]`` + :return: Tuple of (metadata summary, telemetry metadata). + :rtype: ``tuple[dict[str, Any], RequestTelemetryData]`` :raises MetadataError: If the table already exists. :raises ValueError: If a column type is unsupported or ``solution_unique_name`` is empty. @@ -1323,7 +1359,7 @@ def _create_table( :raises HttpError: If underlying HTTP requests fail. """ # Check if table already exists (case-insensitive) - ent = self._get_entity_by_table_schema_name(table_schema_name) + ent, _ = self._get_entity_by_table_schema_name(table_schema_name) if ent: raise MetadataError( f"Table '{table_schema_name}' already exists.", @@ -1356,7 +1392,7 @@ def _create_table( if not solution_unique_name: raise ValueError("solution_unique_name cannot be empty") - metadata = self._create_entity( + entity_metadata, telemetry = self._create_entity( table_schema_name=table_schema_name, display_name=table_schema_name, attributes=attributes, @@ -1365,17 +1401,17 @@ def _create_table( return { "table_schema_name": table_schema_name, - "table_logical_name": metadata.get("LogicalName"), - "entity_set_name": metadata.get("EntitySetName"), - "metadata_id": metadata.get("MetadataId"), + "table_logical_name": entity_metadata.get("LogicalName"), + "entity_set_name": entity_metadata.get("EntitySetName"), + "metadata_id": entity_metadata.get("MetadataId"), "columns_created": created_cols, - } + }, telemetry def _create_columns( self, table_schema_name: str, columns: Dict[str, Any], - ) -> List[str]: + ) -> _ODataRequestResult: """Create new columns on an existing table. :param table_schema_name: Schema name of the table. @@ -1383,8 +1419,8 @@ def _create_columns( :param columns: Mapping of column schema name -> type spec (``str`` or ``Enum`` subclass). :type columns: ``dict[str, Any]`` - :return: List of created column schema names. - :rtype: ``list[str]`` + :return: Tuple of (list of created column schema names, telemetry metadata). + :rtype: ``tuple[list[str], RequestTelemetryData]`` :raises TypeError: If ``columns`` is not a non-empty dict. :raises MetadataError: If the target table does not exist. @@ -1394,7 +1430,7 @@ def _create_columns( if not isinstance(columns, dict) or not columns: raise TypeError("columns must be a non-empty dict[name -> type]") - ent = self._get_entity_by_table_schema_name(table_schema_name) + ent, _ = self._get_entity_by_table_schema_name(table_schema_name) if not ent or not ent.get("MetadataId"): raise MetadataError( f"Table '{table_schema_name}' not found.", @@ -1404,6 +1440,7 @@ def _create_columns( metadata_id = ent.get("MetadataId") created: List[str] = [] needs_picklist_flush = False + last_metadata = RequestTelemetryData() for column_name, column_type in columns.items(): payload = self._attribute_payload(column_name, column_type) @@ -1411,7 +1448,7 @@ def _create_columns( raise ValueError(f"Unsupported column type '{column_type}' for '{column_name}'.") url = f"{self.api}/EntityDefinitions({metadata_id})/Attributes" - self._request("post", url, json=payload) + _, last_metadata = self._request("post", url, json=payload) created.append(column_name) @@ -1421,13 +1458,13 @@ def _create_columns( if needs_picklist_flush: self._flush_cache("picklist") - return created + return created, last_metadata def _delete_columns( self, table_schema_name: str, columns: Union[str, List[str]], - ) -> List[str]: + ) -> _ODataRequestResult: """Delete one or more columns from a table. :param table_schema_name: Schema name of the table. @@ -1435,8 +1472,8 @@ def _delete_columns( :param columns: Single column name or list of column names :type columns: ``str`` | ``list[str]`` - :return: List of deleted column schema names (empty if none removed). - :rtype: ``list[str]`` + :return: Tuple of (list of deleted column schema names, telemetry metadata). + :rtype: ``tuple[list[str], RequestTelemetryData]`` :raises TypeError: If ``columns`` is neither a ``str`` nor ``list[str]``. :raises ValueError: If any provided column name is empty. @@ -1455,7 +1492,7 @@ def _delete_columns( if not isinstance(name, str) or not name.strip(): raise ValueError("column names must be non-empty strings") - ent = self._get_entity_by_table_schema_name(table_schema_name) + ent, _ = self._get_entity_by_table_schema_name(table_schema_name) if not ent or not ent.get("MetadataId"): raise MetadataError( f"Table '{table_schema_name}' not found.", @@ -1467,6 +1504,7 @@ def _delete_columns( metadata_id = ent.get("MetadataId") deleted: List[str] = [] needs_picklist_flush = False + last_metadata = RequestTelemetryData() for column_name in names: attr_meta = self._get_attribute_metadata(metadata_id, column_name, extra_select="@odata.type,AttributeType") @@ -1481,7 +1519,7 @@ def _delete_columns( raise RuntimeError(f"Metadata incomplete for column '{column_name}' (missing MetadataId).") attr_url = f"{self.api}/EntityDefinitions({metadata_id})/Attributes({attr_metadata_id})" - self._request("delete", attr_url, headers={"If-Match": "*"}) + _, last_metadata = self._request("delete", attr_url, headers={"If-Match": "*"}) attr_type = attr_meta.get("@odata.type") or attr_meta.get("AttributeType") if isinstance(attr_type, str): @@ -1494,7 +1532,7 @@ def _delete_columns( if needs_picklist_flush: self._flush_cache("picklist") - return deleted + return deleted, last_metadata # ---------------------- Cache maintenance ------------------------- def _flush_cache( diff --git a/tests/unit/data/test_logical_crud.py b/tests/unit/data/test_logical_crud.py index 2096a4d..81909eb 100644 --- a/tests/unit/data/test_logical_crud.py +++ b/tests/unit/data/test_logical_crud.py @@ -78,12 +78,13 @@ def test_single_create_update_delete_get(): ] c = MockableClient(responses) entity_set = c._entity_set_from_schema_name("account") - rid = c._create(entity_set, "account", {"name": "Acme"}) + rid, metadata = c._create(entity_set, "account", {"name": "Acme"}) assert rid == guid - rec = c._get("account", rid, select=["accountid", "name"]) + assert metadata is not None # metadata is captured + rec, _ = c._get("account", rid, select=["accountid", "name"]) assert rec["accountid"] == guid and rec["name"] == "Acme" - c._update("account", rid, {"telephone1": "555"}) # returns None - c._delete("account", rid) # returns None + c._update("account", rid, {"telephone1": "555"}) # returns (None, metadata) + c._delete("account", rid) # returns (None, metadata) def test_bulk_create_and_update(): @@ -98,10 +99,11 @@ def test_bulk_create_and_update(): ] c = MockableClient(responses) entity_set = c._entity_set_from_schema_name("account") - ids = c._create_multiple(entity_set, "account", [{"name": "A"}, {"name": "B"}]) + ids, metadata = c._create_multiple(entity_set, "account", [{"name": "A"}, {"name": "B"}]) assert ids == [g1, g2] - c._update_by_ids("account", ids, {"statecode": 1}) # broadcast - c._update_by_ids("account", ids, [{"name": "A1"}, {"name": "B1"}]) # per-record + assert metadata is not None # metadata is captured + c._update_by_ids("account", ids, {"statecode": 1}) # broadcast - returns (None, metadata) + c._update_by_ids("account", ids, [{"name": "A1"}, {"name": "B1"}]) # per-record - returns (None, metadata) def test_get_multiple_paging(): diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e765ba0..af6ef66 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -7,6 +7,7 @@ from azure.core.credentials import TokenCredential from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.core.results import RequestTelemetryData class TestDataverseClient(unittest.TestCase): @@ -26,43 +27,69 @@ def setUp(self): def test_create_single(self): """Test create method with a single record.""" # Setup mock return values - # _create must return a GUID string - self.client._odata._create.return_value = "00000000-0000-0000-0000-000000000000" + # _create must return a (GUID, RequestTelemetryData) tuple + mock_metadata = RequestTelemetryData(client_request_id="test-123") + self.client._odata._create.return_value = ("00000000-0000-0000-0000-000000000000", mock_metadata) # _entity_set_from_schema_name should return the plural entity set name self.client._odata._entity_set_from_schema_name.return_value = "accounts" # Execute test - self.client.create("account", {"name": "Contoso Ltd"}) + result = self.client.create("account", {"name": "Contoso Ltd"}) # Verify # Ensure _entity_set_from_schema_name was called and its result ("accounts") was passed to _create self.client._odata._create.assert_called_once_with("accounts", "account", {"name": "Contoso Ltd"}) + # Result should be OperationResult that acts like a list + self.assertEqual(result[0], "00000000-0000-0000-0000-000000000000") + # Can also access telemetry via with_response_details() + response = result.with_response_details() + self.assertEqual(response.telemetry["client_request_id"], "test-123") def test_create_multiple(self): """Test create method with multiple records.""" payloads = [{"name": "Company A"}, {"name": "Company B"}, {"name": "Company C"}] # Setup mock return values - # _create_multiple must return a list of GUID strings - self.client._odata._create_multiple.return_value = [ - "00000000-0000-0000-0000-000000000001", - "00000000-0000-0000-0000-000000000002", - "00000000-0000-0000-0000-000000000003", - ] + # _create_multiple must return a (list of GUID strings, RequestTelemetryData) tuple + mock_metadata = RequestTelemetryData(client_request_id="test-456") + self.client._odata._create_multiple.return_value = ( + [ + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003", + ], + mock_metadata, + ) self.client._odata._entity_set_from_schema_name.return_value = "accounts" # Execute test - self.client.create("account", payloads) + result = self.client.create("account", payloads) # Verify self.client._odata._create_multiple.assert_called_once_with("accounts", "account", payloads) + # Result should be OperationResult that acts like a list + self.assertEqual(len(result), 3) + self.assertEqual(result[0], "00000000-0000-0000-0000-000000000001") + # Can iterate + ids = list(result) + self.assertEqual(len(ids), 3) def test_update_single(self): """Test update method with a single record.""" - self.client.update("account", "00000000-0000-0000-0000-000000000000", {"telephone1": "555-0199"}) + # _update returns (None, RequestTelemetryData) tuple + mock_metadata = RequestTelemetryData(client_request_id="test-789") + self.client._odata._update.return_value = (None, mock_metadata) + + result = self.client.update("account", "00000000-0000-0000-0000-000000000000", {"telephone1": "555-0199"}) + self.client._odata._update.assert_called_once_with( "account", "00000000-0000-0000-0000-000000000000", {"telephone1": "555-0199"} ) + # Result is OperationResult with None value + self.assertIsNone(result.value) + # Can access telemetry + response = result.with_response_details() + self.assertEqual(response.telemetry["client_request_id"], "test-789") def test_update_multiple(self): """Test update method with multiple records (broadcast).""" @@ -72,13 +99,25 @@ def test_update_multiple(self): ] changes = {"statecode": 1} - self.client.update("account", ids, changes) + # _update_by_ids returns (None, RequestTelemetryData) tuple + mock_metadata = RequestTelemetryData(client_request_id="test-update-multi") + self.client._odata._update_by_ids.return_value = (None, mock_metadata) + + result = self.client.update("account", ids, changes) + self.client._odata._update_by_ids.assert_called_once_with("account", ids, changes) + self.assertIsNone(result.value) def test_delete_single(self): """Test delete method with a single record.""" - self.client.delete("account", "00000000-0000-0000-0000-000000000000") + # _delete returns (None, RequestTelemetryData) tuple + mock_metadata = RequestTelemetryData(client_request_id="test-delete") + self.client._odata._delete.return_value = (None, mock_metadata) + + result = self.client.delete("account", "00000000-0000-0000-0000-000000000000") + self.client._odata._delete.assert_called_once_with("account", "00000000-0000-0000-0000-000000000000") + self.assertIsNone(result.value) def test_delete_multiple(self): """Test delete method with multiple records.""" @@ -86,24 +125,36 @@ def test_delete_multiple(self): "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002", ] - # Mock return value for bulk delete job ID - self.client._odata._delete_multiple.return_value = "job-guid-123" + # Mock return value for bulk delete: (job_id, RequestTelemetryData) tuple + mock_metadata = RequestTelemetryData(client_request_id="test-bulk-delete") + self.client._odata._delete_multiple.return_value = ("job-guid-123", mock_metadata) - job_id = self.client.delete("account", ids) + result = self.client.delete("account", ids) self.client._odata._delete_multiple.assert_called_once_with("account", ids) - self.assertEqual(job_id, "job-guid-123") + # Result is OperationResult containing the job ID + self.assertEqual(result.value, "job-guid-123") + # Can compare directly with raw value + self.assertEqual(result, "job-guid-123") def test_get_single(self): """Test get method with a single record ID.""" - # Setup mock return value + # Setup mock return value: (record, RequestTelemetryData) tuple expected_record = {"accountid": "00000000-0000-0000-0000-000000000000", "name": "Contoso"} - self.client._odata._get.return_value = expected_record + mock_metadata = RequestTelemetryData(client_request_id="test-get") + self.client._odata._get.return_value = (expected_record, mock_metadata) result = self.client.get("account", "00000000-0000-0000-0000-000000000000") self.client._odata._get.assert_called_once_with("account", "00000000-0000-0000-0000-000000000000", select=None) - self.assertEqual(result, expected_record) + # Result is OperationResult that supports dict-like access + self.assertEqual(result["accountid"], "00000000-0000-0000-0000-000000000000") + self.assertEqual(result["name"], "Contoso") + # Can also access the full value + self.assertEqual(result.value, expected_record) + # Can access telemetry + response = result.with_response_details() + self.assertEqual(response.telemetry["client_request_id"], "test-get") def test_get_multiple(self): """Test get method for querying multiple records.""" diff --git a/tests/unit/test_results.py b/tests/unit/test_results.py new file mode 100644 index 0000000..7d8e5ba --- /dev/null +++ b/tests/unit/test_results.py @@ -0,0 +1,349 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Unit tests for core result types.""" + +import pytest + +from PowerPlatform.Dataverse.core.results import ( + RequestTelemetryData, + DataverseResponse, + OperationResult, +) + + +class TestRequestTelemetryData: + """Tests for RequestTelemetryData dataclass.""" + + def test_default_values(self): + """RequestTelemetryData should have None defaults for all fields.""" + metadata = RequestTelemetryData() + assert metadata.client_request_id is None + assert metadata.correlation_id is None + assert metadata.service_request_id is None + + def test_with_values(self): + """RequestTelemetryData should store provided values.""" + metadata = RequestTelemetryData( + client_request_id="client-123", + correlation_id="corr-456", + service_request_id="svc-789", + ) + assert metadata.client_request_id == "client-123" + assert metadata.correlation_id == "corr-456" + assert metadata.service_request_id == "svc-789" + + def test_is_frozen(self): + """RequestTelemetryData should be immutable (frozen=True).""" + metadata = RequestTelemetryData(client_request_id="test") + with pytest.raises(AttributeError): + metadata.client_request_id = "new-value" # type: ignore[misc] + + +class TestDataverseResponse: + """Tests for DataverseResponse dataclass.""" + + def test_default_telemetry(self): + """DataverseResponse should have empty dict as default telemetry.""" + response = DataverseResponse(result="test-result") + assert response.result == "test-result" + assert response.telemetry == {} + + def test_with_telemetry(self): + """DataverseResponse should store provided telemetry.""" + telemetry = { + "client_request_id": "client-123", + "correlation_id": "corr-456", + "service_request_id": "svc-789", + } + response = DataverseResponse(result=["id1", "id2"], telemetry=telemetry) + assert response.result == ["id1", "id2"] + assert response.telemetry == telemetry + + def test_generic_typing(self): + """DataverseResponse should support generic typing.""" + # String result + str_response: DataverseResponse[str] = DataverseResponse(result="single-id") + assert str_response.result == "single-id" + + # List result + list_response: DataverseResponse[list] = DataverseResponse(result=["id1", "id2"]) + assert list_response.result == ["id1", "id2"] + + +class TestOperationResult: + """Tests for OperationResult class.""" + + @pytest.fixture + def sample_telemetry_data(self): + """Create sample metadata for tests.""" + return RequestTelemetryData( + client_request_id="client-123", + correlation_id="corr-456", + service_request_id="svc-789", + ) + + def test_value_property(self, sample_telemetry_data): + """OperationResult.value should return the underlying result.""" + result = OperationResult(result="test-value", telemetry_data=sample_telemetry_data) + assert result.value == "test-value" + + def test_with_response_details(self, sample_telemetry_data): + """with_response_details() should return DataverseResponse with telemetry.""" + result = OperationResult(result=["id1", "id2"], telemetry_data=sample_telemetry_data) + response = result.with_response_details() + + assert isinstance(response, DataverseResponse) + assert response.result == ["id1", "id2"] + assert response.telemetry["client_request_id"] == "client-123" + assert response.telemetry["correlation_id"] == "corr-456" + assert response.telemetry["service_request_id"] == "svc-789" + + def test_with_response_details_none_telemetry(self): + """with_response_details() should handle None telemetry values.""" + telemetry_data = RequestTelemetryData() # All None + result = OperationResult(result="test", telemetry_data=telemetry_data) + response = result.with_response_details() + + assert response.telemetry["client_request_id"] is None + assert response.telemetry["correlation_id"] is None + assert response.telemetry["service_request_id"] is None + + +class TestOperationResultIteration: + """Tests for OperationResult iteration behavior.""" + + @pytest.fixture + def sample_telemetry_data(self): + return RequestTelemetryData() + + def test_iter_with_list(self, sample_telemetry_data): + """Iteration over OperationResult with list result should yield list elements.""" + result = OperationResult(result=["id1", "id2", "id3"], telemetry_data=sample_telemetry_data) + items = list(result) + assert items == ["id1", "id2", "id3"] + + def test_iter_with_tuple(self, sample_telemetry_data): + """Iteration over OperationResult with tuple result should yield tuple elements.""" + result = OperationResult(result=("a", "b"), telemetry_data=sample_telemetry_data) + items = list(result) + assert items == ["a", "b"] + + def test_iter_with_single_value(self, sample_telemetry_data): + """Iteration over OperationResult with single value should yield that value.""" + result = OperationResult(result="single-id", telemetry_data=sample_telemetry_data) + items = list(result) + assert items == ["single-id"] + + def test_for_loop_iteration(self, sample_telemetry_data): + """OperationResult should work in for loops.""" + result = OperationResult(result=["a", "b", "c"], telemetry_data=sample_telemetry_data) + collected = [] + for item in result: + collected.append(item) + assert collected == ["a", "b", "c"] + + +class TestOperationResultIndexing: + """Tests for OperationResult indexing behavior.""" + + @pytest.fixture + def sample_telemetry_data(self): + return RequestTelemetryData() + + def test_getitem_with_list(self, sample_telemetry_data): + """Indexing OperationResult with list result should work.""" + result = OperationResult(result=["id1", "id2", "id3"], telemetry_data=sample_telemetry_data) + assert result[0] == "id1" + assert result[1] == "id2" + assert result[2] == "id3" + assert result[-1] == "id3" + + def test_getitem_with_dict(self, sample_telemetry_data): + """Indexing OperationResult with dict result should work.""" + result = OperationResult(result={"name": "Contoso", "id": "123"}, telemetry_data=sample_telemetry_data) + assert result["name"] == "Contoso" + assert result["id"] == "123" + + def test_dict_get_method(self, sample_telemetry_data): + """Dict .get() method should work via __getattr__ delegation.""" + result = OperationResult(result={"name": "Contoso", "id": "123"}, telemetry_data=sample_telemetry_data) + assert result.get("name") == "Contoso" + assert result.get("missing") is None + assert result.get("missing", "default") == "default" + + def test_getitem_slice(self, sample_telemetry_data): + """Slicing OperationResult with list result should work.""" + result = OperationResult(result=["a", "b", "c", "d"], telemetry_data=sample_telemetry_data) + assert result[1:3] == ["b", "c"] + + +class TestOperationResultLength: + """Tests for OperationResult length behavior.""" + + @pytest.fixture + def sample_telemetry_data(self): + return RequestTelemetryData() + + def test_len_with_list(self, sample_telemetry_data): + """len() on OperationResult with list should return list length.""" + result = OperationResult(result=["a", "b", "c"], telemetry_data=sample_telemetry_data) + assert len(result) == 3 + + def test_len_with_tuple(self, sample_telemetry_data): + """len() on OperationResult with tuple should return tuple length.""" + result = OperationResult(result=(1, 2), telemetry_data=sample_telemetry_data) + assert len(result) == 2 + + def test_len_with_dict(self, sample_telemetry_data): + """len() on OperationResult with dict should return dict length.""" + result = OperationResult(result={"a": 1, "b": 2}, telemetry_data=sample_telemetry_data) + assert len(result) == 2 + + def test_len_with_single_value(self, sample_telemetry_data): + """len() on OperationResult with single value should return 1.""" + result = OperationResult(result="single", telemetry_data=sample_telemetry_data) + assert len(result) == 1 + + def test_len_with_empty_list(self, sample_telemetry_data): + """len() on OperationResult with empty list should return 0.""" + result = OperationResult(result=[], telemetry_data=sample_telemetry_data) + assert len(result) == 0 + + +class TestOperationResultStringConversion: + """Tests for OperationResult string conversion.""" + + @pytest.fixture + def sample_telemetry_data(self): + return RequestTelemetryData() + + def test_str_with_string(self, sample_telemetry_data): + """str() on OperationResult should return string of result.""" + result = OperationResult(result="test-id", telemetry_data=sample_telemetry_data) + assert str(result) == "test-id" + + def test_str_with_list(self, sample_telemetry_data): + """str() on OperationResult with list should return string of list.""" + result = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data) + assert str(result) == "['a', 'b']" + + def test_repr(self, sample_telemetry_data): + """repr() on OperationResult should show class name and result.""" + result = OperationResult(result=["id1"], telemetry_data=sample_telemetry_data) + assert repr(result) == "OperationResult(['id1'])" + + +class TestOperationResultEquality: + """Tests for OperationResult equality comparison.""" + + @pytest.fixture + def sample_telemetry_data(self): + return RequestTelemetryData() + + def test_eq_with_same_result(self, sample_telemetry_data): + """OperationResult should equal another with same result.""" + result1 = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data) + result2 = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data) + assert result1 == result2 + + def test_eq_with_different_result(self, sample_telemetry_data): + """OperationResult should not equal another with different result.""" + result1 = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data) + result2 = OperationResult(result=["c", "d"], telemetry_data=sample_telemetry_data) + assert result1 != result2 + + def test_eq_with_raw_value(self, sample_telemetry_data): + """OperationResult should equal the raw result value.""" + result = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data) + assert result == ["a", "b"] + + def test_eq_with_string(self, sample_telemetry_data): + """OperationResult with string result should equal that string.""" + result = OperationResult(result="test-id", telemetry_data=sample_telemetry_data) + assert result == "test-id" + + +class TestOperationResultBool: + """Tests for OperationResult boolean conversion.""" + + @pytest.fixture + def sample_telemetry_data(self): + return RequestTelemetryData() + + def test_bool_truthy_string(self, sample_telemetry_data): + """OperationResult with non-empty string should be truthy.""" + result = OperationResult(result="id", telemetry_data=sample_telemetry_data) + assert bool(result) is True + + def test_bool_truthy_list(self, sample_telemetry_data): + """OperationResult with non-empty list should be truthy.""" + result = OperationResult(result=["a"], telemetry_data=sample_telemetry_data) + assert bool(result) is True + + def test_bool_falsy_empty_string(self, sample_telemetry_data): + """OperationResult with empty string should be falsy.""" + result = OperationResult(result="", telemetry_data=sample_telemetry_data) + assert bool(result) is False + + def test_bool_falsy_empty_list(self, sample_telemetry_data): + """OperationResult with empty list should be falsy.""" + result = OperationResult(result=[], telemetry_data=sample_telemetry_data) + assert bool(result) is False + + def test_bool_falsy_none(self, sample_telemetry_data): + """OperationResult with None should be falsy.""" + result = OperationResult(result=None, telemetry_data=sample_telemetry_data) + assert bool(result) is False + + def test_in_if_statement(self, sample_telemetry_data): + """OperationResult should work in if statements.""" + result_truthy = OperationResult(result=["id"], telemetry_data=sample_telemetry_data) + result_falsy = OperationResult(result=[], telemetry_data=sample_telemetry_data) + + if result_truthy: + passed_truthy = True + else: + passed_truthy = False + + if result_falsy: + passed_falsy = True + else: + passed_falsy = False + + assert passed_truthy is True + assert passed_falsy is False + + +class TestOperationResultUsagePatterns: + """Tests for common usage patterns.""" + + @pytest.fixture + def sample_telemetry_data(self): + return RequestTelemetryData( + client_request_id="client-123", + correlation_id="corr-456", + service_request_id="svc-789", + ) + + def test_single_create_indexing(self, sample_telemetry_data): + """Single create pattern: ids = client.create(...) then ids[0].""" + ids = OperationResult(result=["guid-123"], telemetry_data=sample_telemetry_data) + assert ids[0] == "guid-123" + + def test_multi_create_iteration(self, sample_telemetry_data): + """Multi-create pattern: iterate over created IDs.""" + ids = OperationResult(result=["guid-1", "guid-2"], telemetry_data=sample_telemetry_data) + collected = [] + for id in ids: + collected.append(id) + assert collected == ["guid-1", "guid-2"] + + def test_telemetry_access(self, sample_telemetry_data): + """Access telemetry via with_response_details().""" + ids = OperationResult(result=["guid-1", "guid-2"], telemetry_data=sample_telemetry_data) + response = ids.with_response_details() + assert response.result == ["guid-1", "guid-2"] + assert response.telemetry["client_request_id"] == "client-123" + assert response.telemetry["correlation_id"] == "corr-456" + assert response.telemetry["service_request_id"] == "svc-789" From f937a74e2c3f2bd9be69282e48822d467360eb00 Mon Sep 17 00:00:00 2001 From: tpellissier Date: Mon, 26 Jan 2026 11:48:44 -0800 Subject: [PATCH 2/5] Enable per-page telemetry access for paginated get() queries Extends the fluent .with_response_details() API to support paginated queries. Each page batch is now wrapped in OperationResult, allowing telemetry access per page while maintaining backward compatibility. Changes: - Add __add__ and __radd__ to OperationResult for batch concatenation - Modify _get_multiple to yield (batch, metadata) tuples - Update get() to wrap each page in OperationResult - Add per-page telemetry printing to walkthrough example - Add tests for concatenation and per-page telemetry access Backward compatibility: - Existing iteration patterns work unchanged via OperationResult delegation - Batch concatenation (batch1 + batch2) returns raw list as expected Co-Authored-By: Claude Opus 4.5 --- examples/advanced/walkthrough.py | 2 + src/PowerPlatform/Dataverse/client.py | 25 +++++-- src/PowerPlatform/Dataverse/core/results.py | 24 +++++++ src/PowerPlatform/Dataverse/data/_odata.py | 28 ++++---- tests/unit/data/test_logical_crud.py | 8 ++- tests/unit/test_client.py | 66 ++++++++++++++++- tests/unit/test_results.py | 78 +++++++++++++++++++++ 7 files changed, 204 insertions(+), 27 deletions(-) diff --git a/examples/advanced/walkthrough.py b/examples/advanced/walkthrough.py index c96119a..ab9a9a3 100644 --- a/examples/advanced/walkthrough.py +++ b/examples/advanced/walkthrough.py @@ -212,6 +212,7 @@ def main(): records_iterator = backoff(lambda: client.get(table_name, filter="new_quantity gt 5")) for page in records_iterator: all_records.extend(page) + print_telemetry(page.with_response_details().telemetry) print(f"[OK] Found {len(all_records)} records with new_quantity > 5") for rec in all_records: print(f" - new_Title='{rec.get('new_title')}', new_Quantity={rec.get('new_quantity')}") @@ -266,6 +267,7 @@ def main(): for page_num, page in enumerate(paging_iterator, start=1): record_ids = [r.get("new_walkthroughdemoid")[:8] + "..." for r in page] print(f" Page {page_num}: {len(page)} records - IDs: {record_ids}") + print_telemetry(page.with_response_details().telemetry) # ============================================================================ # 7. SQL QUERY diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index c63e704..a0d0abf 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -293,7 +293,7 @@ def get( top: Optional[int] = None, expand: Optional[List[str]] = None, page_size: Optional[int] = None, - ) -> Union[OperationResult[Dict[str, Any]], Iterable[List[Dict[str, Any]]]]: + ) -> Union[OperationResult[Dict[str, Any]], Iterable[OperationResult[List[Dict[str, Any]]]]]: """ Fetch a single record by ID or query multiple records. @@ -320,9 +320,11 @@ def get( :return: When ``record_id`` is provided, returns an OperationResult containing the record dict. The result supports dict-like access (e.g., ``result["name"]``) or call ``.with_response_details()`` to access telemetry data. - When querying multiple records, returns a generator yielding lists of record - dictionaries (one list per page). - :rtype: :class:`OperationResult` [:class:`dict`] or :class:`collections.abc.Iterable` of :class:`list` of :class:`dict` + When querying multiple records, returns a generator yielding OperationResult objects, + each containing a list of record dictionaries (one list per page). Each batch supports + iteration and indexing directly, or call ``.with_response_details()`` to access + that page's telemetry data. + :rtype: :class:`OperationResult` [:class:`dict`] or :class:`collections.abc.Iterable` of :class:`OperationResult` [:class:`list` of :class:`dict`] :raises TypeError: If ``record_id`` is provided but not a string. @@ -368,6 +370,14 @@ def get( page_size=50 ): print(f"Batch size: {len(batch)}") + + Query with per-page telemetry access:: + + for batch in client.get("account", filter="statecode eq 0"): + response = batch.with_response_details() + print(f"Page request ID: {response.telemetry['service_request_id']}") + for account in response.result: + print(account["name"]) """ if record_id is not None: if not isinstance(record_id, str): @@ -380,9 +390,9 @@ def get( ) return OperationResult(record, metadata) - def _paged() -> Iterable[List[Dict[str, Any]]]: + def _paged() -> Iterable[OperationResult[List[Dict[str, Any]]]]: with self._scoped_odata() as od: - yield from od._get_multiple( + for batch, metadata in od._get_multiple( table_schema_name, select=select, filter=filter, @@ -390,7 +400,8 @@ def _paged() -> Iterable[List[Dict[str, Any]]]: top=top, expand=expand, page_size=page_size, - ) + ): + yield OperationResult(batch, metadata) return _paged() diff --git a/src/PowerPlatform/Dataverse/core/results.py b/src/PowerPlatform/Dataverse/core/results.py index cf6af00..cf2a5b4 100644 --- a/src/PowerPlatform/Dataverse/core/results.py +++ b/src/PowerPlatform/Dataverse/core/results.py @@ -240,5 +240,29 @@ def __contains__(self, item: Any) -> bool: """ return item in self._result # type: ignore[operator] + def __add__(self, other: Any) -> Any: + """ + Support concatenation with + operator. + + When combining OperationResults (e.g., concatenating batches), returns + the raw combined result since there's no meaningful single telemetry + to preserve for the combined value. + + :param other: Value to concatenate with. + :return: Combined result (raw value, not wrapped in OperationResult). + """ + if isinstance(other, OperationResult): + return self._result + other._result # type: ignore[operator] + return self._result + other # type: ignore[operator] + + def __radd__(self, other: Any) -> Any: + """ + Support right-hand concatenation (e.g., [] + result). + + :param other: Left-hand value to concatenate with. + :return: Combined result (raw value, not wrapped in OperationResult). + """ + return other + self._result # type: ignore[operator] + __all__ = ["RequestTelemetryData", "DataverseResponse", "OperationResult"] diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 59989ae..c24bdeb 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -629,8 +629,8 @@ def _get_multiple( top: Optional[int] = None, expand: Optional[List[str]] = None, page_size: Optional[int] = None, - ) -> Iterable[List[Dict[str, Any]]]: - """Iterate records from an entity set, yielding one page (list of dicts) at a time. + ) -> Iterable[_ODataRequestResult]: + """Iterate records from an entity set, yielding one page with telemetry at a time. :param table_schema_name: Schema name of the table. :type table_schema_name: ``str`` @@ -647,12 +647,8 @@ def _get_multiple( :param page_size: Per-page size hint via ``Prefer: odata.maxpagesize``. :type page_size: ``int`` | ``None`` - :return: Iterator yielding pages (each page is a ``list`` of record dicts). - :rtype: ``Iterable[list[dict[str, Any]]]`` - - .. note:: - This method is a generator and does not return metadata directly. - For paginated queries, metadata is captured per-request but not surfaced. + :return: Iterator yielding tuples of (page records, telemetry) for each page. + :rtype: ``Iterable[tuple[list[dict[str, Any]], RequestTelemetryData]]`` """ extra_headers: Dict[str, str] = {} @@ -661,13 +657,13 @@ def _get_multiple( if ps > 0: extra_headers["Prefer"] = f"odata.maxpagesize={ps}" - def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Tuple[Dict[str, Any], RequestTelemetryData]: headers = extra_headers if extra_headers else None - r, _ = self._request("get", url, headers=headers, params=params) + r, metadata = self._request("get", url, headers=headers, params=params) try: - return r.json() + return r.json(), metadata except ValueError: - return {} + return {}, metadata entity_set = self._entity_set_from_schema_name(table_schema_name) base_url = f"{self.api}/{entity_set}" @@ -687,20 +683,20 @@ def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[st if top is not None: params["$top"] = int(top) - data = _do_request(base_url, params=params) + data, metadata = _do_request(base_url, params=params) items = data.get("value") if isinstance(data, dict) else None if isinstance(items, list) and items: - yield [x for x in items if isinstance(x, dict)] + yield [x for x in items if isinstance(x, dict)], metadata next_link = None if isinstance(data, dict): next_link = data.get("@odata.nextLink") or data.get("odata.nextLink") while next_link: - data = _do_request(next_link) + data, metadata = _do_request(next_link) items = data.get("value") if isinstance(data, dict) else None if isinstance(items, list) and items: - yield [x for x in items if isinstance(x, dict)] + yield [x for x in items if isinstance(x, dict)], metadata next_link = data.get("@odata.nextLink") or data.get("odata.nextLink") if isinstance(data, dict) else None # --------------------------- SQL Custom API ------------------------- diff --git a/tests/unit/data/test_logical_crud.py b/tests/unit/data/test_logical_crud.py index 81909eb..57309fb 100644 --- a/tests/unit/data/test_logical_crud.py +++ b/tests/unit/data/test_logical_crud.py @@ -119,7 +119,13 @@ def test_get_multiple_paging(): ] c = MockableClient(responses) pages = list(c._get_multiple("account", select=["accountid"], page_size=1)) - assert pages == [[{"accountid": "1"}], [{"accountid": "2"}]] + # _get_multiple now returns (batch, metadata) tuples + assert len(pages) == 2 + assert pages[0][0] == [{"accountid": "1"}] + assert pages[1][0] == [{"accountid": "2"}] + # Each page has telemetry metadata + assert pages[0][1].client_request_id is not None + assert pages[1][1].client_request_id is not None def test_unknown_table_schema_name_raises(): diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index af6ef66..b079f9a 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -158,9 +158,10 @@ def test_get_single(self): def test_get_multiple(self): """Test get method for querying multiple records.""" - # Setup mock return value (iterator) + # Setup mock return value (iterator of (batch, metadata) tuples) expected_batch = [{"accountid": "1", "name": "A"}, {"accountid": "2", "name": "B"}] - self.client._odata._get_multiple.return_value = iter([expected_batch]) + mock_metadata = RequestTelemetryData(client_request_id="test-page-1") + self.client._odata._get_multiple.return_value = iter([(expected_batch, mock_metadata)]) # Execute query result_iterator = self.client.get("account", filter="statecode eq 0", top=10) @@ -177,4 +178,63 @@ def test_get_multiple(self): expand=None, page_size=None, ) - self.assertEqual(results, [expected_batch]) + # Each batch is now wrapped in OperationResult + self.assertEqual(len(results), 1) + # Can iterate/index the batch directly (OperationResult delegates) + self.assertEqual(results[0][0], {"accountid": "1", "name": "A"}) + self.assertEqual(list(results[0]), expected_batch) + # Can access telemetry via with_response_details() + response = results[0].with_response_details() + self.assertEqual(response.telemetry["client_request_id"], "test-page-1") + + def test_get_multiple_pagination_with_telemetry(self): + """Test get method returns per-page telemetry for paginated results.""" + # Setup mock with multiple pages + batch1 = [{"accountid": "1"}, {"accountid": "2"}] + batch2 = [{"accountid": "3"}, {"accountid": "4"}] + metadata1 = RequestTelemetryData(client_request_id="page-1", service_request_id="svc-1") + metadata2 = RequestTelemetryData(client_request_id="page-2", service_request_id="svc-2") + self.client._odata._get_multiple.return_value = iter([ + (batch1, metadata1), + (batch2, metadata2), + ]) + + # Execute query + results = list(self.client.get("account")) + + # Verify we got two pages + self.assertEqual(len(results), 2) + + # First page telemetry + response1 = results[0].with_response_details() + self.assertEqual(response1.result, batch1) + self.assertEqual(response1.telemetry["client_request_id"], "page-1") + self.assertEqual(response1.telemetry["service_request_id"], "svc-1") + + # Second page telemetry + response2 = results[1].with_response_details() + self.assertEqual(response2.result, batch2) + self.assertEqual(response2.telemetry["client_request_id"], "page-2") + self.assertEqual(response2.telemetry["service_request_id"], "svc-2") + + def test_get_multiple_batch_concatenation(self): + """Test that batches can be concatenated with + operator.""" + # Setup mock with multiple pages + batch1 = [{"id": "1"}, {"id": "2"}] + batch2 = [{"id": "3"}, {"id": "4"}] + metadata = RequestTelemetryData() + self.client._odata._get_multiple.return_value = iter([ + (batch1, metadata), + (batch2, metadata), + ]) + + # Execute query and concatenate batches + batches = list(self.client.get("account")) + all_records = batches[0] + batches[1] + + # Verify concatenation works + self.assertEqual(len(all_records), 4) + self.assertEqual(all_records[0]["id"], "1") + self.assertEqual(all_records[3]["id"], "4") + # Result is raw list, not OperationResult + self.assertIsInstance(all_records, list) diff --git a/tests/unit/test_results.py b/tests/unit/test_results.py index 7d8e5ba..b3f34c2 100644 --- a/tests/unit/test_results.py +++ b/tests/unit/test_results.py @@ -347,3 +347,81 @@ def test_telemetry_access(self, sample_telemetry_data): assert response.telemetry["client_request_id"] == "client-123" assert response.telemetry["correlation_id"] == "corr-456" assert response.telemetry["service_request_id"] == "svc-789" + + +class TestOperationResultConcatenation: + """Tests for OperationResult concatenation with + operator.""" + + @pytest.fixture + def sample_telemetry_data(self): + return RequestTelemetryData( + client_request_id="client-123", + correlation_id="corr-456", + service_request_id="svc-789", + ) + + def test_add_two_operation_results(self, sample_telemetry_data): + """Adding two OperationResults should concatenate their results.""" + result1 = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data) + result2 = OperationResult(result=["c", "d"], telemetry_data=sample_telemetry_data) + combined = result1 + result2 + assert combined == ["a", "b", "c", "d"] + # Result should be raw list, not OperationResult + assert isinstance(combined, list) + + def test_add_operation_result_with_list(self, sample_telemetry_data): + """Adding OperationResult with a list should work.""" + result = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data) + combined = result + ["c", "d"] + assert combined == ["a", "b", "c", "d"] + assert isinstance(combined, list) + + def test_radd_list_with_operation_result(self, sample_telemetry_data): + """Right-hand addition: list + OperationResult should work.""" + result = OperationResult(result=["c", "d"], telemetry_data=sample_telemetry_data) + combined = ["a", "b"] + result + assert combined == ["a", "b", "c", "d"] + assert isinstance(combined, list) + + def test_add_empty_lists(self, sample_telemetry_data): + """Adding empty OperationResults should return empty list.""" + result1 = OperationResult(result=[], telemetry_data=sample_telemetry_data) + result2 = OperationResult(result=[], telemetry_data=sample_telemetry_data) + combined = result1 + result2 + assert combined == [] + assert isinstance(combined, list) + + def test_add_with_empty_list(self, sample_telemetry_data): + """Adding OperationResult with empty list should work.""" + result = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data) + combined = result + [] + assert combined == ["a", "b"] + + def test_radd_empty_list(self, sample_telemetry_data): + """Right-hand addition with empty list should work.""" + result = OperationResult(result=["a", "b"], telemetry_data=sample_telemetry_data) + combined = [] + result + assert combined == ["a", "b"] + + def test_concatenate_multiple_batches(self, sample_telemetry_data): + """Simulate combining multiple page batches.""" + batch1 = OperationResult(result=[{"id": "1"}, {"id": "2"}], telemetry_data=sample_telemetry_data) + batch2 = OperationResult(result=[{"id": "3"}, {"id": "4"}], telemetry_data=sample_telemetry_data) + batch3 = OperationResult(result=[{"id": "5"}], telemetry_data=sample_telemetry_data) + + all_records = batch1 + batch2 + batch3 + assert len(all_records) == 5 + assert all_records[0]["id"] == "1" + assert all_records[4]["id"] == "5" + + def test_string_concatenation(self, sample_telemetry_data): + """String concatenation should work.""" + result = OperationResult(result="Hello ", telemetry_data=sample_telemetry_data) + combined = result + "World" + assert combined == "Hello World" + + def test_radd_string_concatenation(self, sample_telemetry_data): + """Right-hand string concatenation should work.""" + result = OperationResult(result="World", telemetry_data=sample_telemetry_data) + combined = "Hello " + result + assert combined == "Hello World" From f113f7660c8425b104b776c198f2ac8bfc1f9cfc Mon Sep 17 00:00:00 2001 From: tpellissier Date: Mon, 26 Jan 2026 14:19:45 -0800 Subject: [PATCH 3/5] Apply black formatting Co-Authored-By: Claude Opus 4.5 --- src/PowerPlatform/Dataverse/data/_odata.py | 4 +++- tests/unit/test_client.py | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index c24bdeb..a64671c 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -657,7 +657,9 @@ def _get_multiple( if ps > 0: extra_headers["Prefer"] = f"odata.maxpagesize={ps}" - def _do_request(url: str, *, params: Optional[Dict[str, Any]] = None) -> Tuple[Dict[str, Any], RequestTelemetryData]: + def _do_request( + url: str, *, params: Optional[Dict[str, Any]] = None + ) -> Tuple[Dict[str, Any], RequestTelemetryData]: headers = extra_headers if extra_headers else None r, metadata = self._request("get", url, headers=headers, params=params) try: diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index b079f9a..ede8ecb 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -194,10 +194,12 @@ def test_get_multiple_pagination_with_telemetry(self): batch2 = [{"accountid": "3"}, {"accountid": "4"}] metadata1 = RequestTelemetryData(client_request_id="page-1", service_request_id="svc-1") metadata2 = RequestTelemetryData(client_request_id="page-2", service_request_id="svc-2") - self.client._odata._get_multiple.return_value = iter([ - (batch1, metadata1), - (batch2, metadata2), - ]) + self.client._odata._get_multiple.return_value = iter( + [ + (batch1, metadata1), + (batch2, metadata2), + ] + ) # Execute query results = list(self.client.get("account")) @@ -223,10 +225,12 @@ def test_get_multiple_batch_concatenation(self): batch1 = [{"id": "1"}, {"id": "2"}] batch2 = [{"id": "3"}, {"id": "4"}] metadata = RequestTelemetryData() - self.client._odata._get_multiple.return_value = iter([ - (batch1, metadata), - (batch2, metadata), - ]) + self.client._odata._get_multiple.return_value = iter( + [ + (batch1, metadata), + (batch2, metadata), + ] + ) # Execute query and concatenate batches batches = list(self.client.get("account")) From 7a3e6bcab37d3edd35bad30b59e7235e38d231ed Mon Sep 17 00:00:00 2001 From: tpellissier Date: Mon, 26 Jan 2026 14:33:42 -0800 Subject: [PATCH 4/5] Fix PR review comments: docstring typo and import location - Fix docstring in results.py: RequestMetadata -> RequestTelemetryData - Move Tuple import to top of _odata.py with other typing imports Co-Authored-By: Claude Opus 4.5 --- src/PowerPlatform/Dataverse/core/results.py | 2 +- src/PowerPlatform/Dataverse/data/_odata.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/PowerPlatform/Dataverse/core/results.py b/src/PowerPlatform/Dataverse/core/results.py index cf2a5b4..9e0efcf 100644 --- a/src/PowerPlatform/Dataverse/core/results.py +++ b/src/PowerPlatform/Dataverse/core/results.py @@ -8,7 +8,7 @@ `.with_response_details()` API pattern for accessing telemetry data. Classes: - RequestMetadata: Immutable HTTP request/response metadata for diagnostics. + RequestTelemetryData: Immutable HTTP request/response metadata for diagnostics. DataverseResponse: Standard response object with result and telemetry. OperationResult: Wrapper enabling fluent .with_response_details() pattern. """ diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index a64671c..e4d38eb 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional, List, Union, Iterable, Callable +from typing import Any, Dict, Optional, List, Union, Iterable, Callable, Tuple from enum import Enum from dataclasses import dataclass, field import unicodedata @@ -43,8 +43,6 @@ _DEFAULT_EXPECTED_STATUSES: tuple[int, ...] = (200, 201, 202, 204) # Type alias for request results with metadata -from typing import Tuple - _ODataRequestResult = Tuple[Any, RequestTelemetryData] From 7b712a973bcb44aa020f2288903b7e5cfb8c5186 Mon Sep 17 00:00:00 2001 From: tpellissier Date: Tue, 27 Jan 2026 11:30:30 -0800 Subject: [PATCH 5/5] Fix tuple unpacking for _request() calls returning telemetry data Update internal _request() call sites to unpack (response, telemetry) tuples after Phase 1 changes. Also use duck typing instead of isinstance(x, list) for OperationResult compatibility. Co-Authored-By: Claude Opus 4.5 --- examples/advanced/file_upload.py | 25 +++++++++----------- examples/basic/functional_testing.py | 26 ++++++--------------- src/PowerPlatform/Dataverse/data/_upload.py | 2 +- 3 files changed, 19 insertions(+), 34 deletions(-) diff --git a/examples/advanced/file_upload.py b/examples/advanced/file_upload.py index d3499b7..b2b0d5f 100644 --- a/examples/advanced/file_upload.py +++ b/examples/advanced/file_upload.py @@ -68,6 +68,7 @@ def log(call: str): _FILE_HASH_CACHE = {} ATTRIBUTE_VISIBILITY_DELAYS = (0, 3, 10, 20, 35, 50, 70, 90, 120) + def file_sha256(path: Path): # returns (hex_digest, size_bytes) try: m = _FILE_HASH_CACHE.get(path) @@ -166,9 +167,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): result = op() if attempts > 1: retry_count = attempts - 1 - print( - f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total." - ) + print(f" [INFO] Backoff succeeded after {retry_count} retry(s); waited {total_delay}s total.") return result except Exception as ex: # noqa: BLE001 last = ex @@ -176,9 +175,7 @@ def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)): if last: if attempts: retry_count = max(attempts - 1, 0) - print( - f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total." - ) + print(f" [WARN] Backoff exhausted after {retry_count} retry(s); waited {total_delay}s total.") raise last @@ -227,7 +224,7 @@ def ensure_file_attribute_generic(schema_name: str, label: str, key_prefix: str) f"{odata.api}/EntityDefinitions({meta_id})/Attributes?$select=SchemaName&$filter=" f"SchemaName eq '{schema_name}'" ) - r = backoff(lambda: odata._request("get", url), delays=ATTRIBUTE_VISIBILITY_DELAYS) + r, _ = backoff(lambda: odata._request("get", url), delays=ATTRIBUTE_VISIBILITY_DELAYS) val = [] try: val = r.json().get("value", []) @@ -255,7 +252,7 @@ def ensure_file_attribute_generic(schema_name: str, label: str, key_prefix: str) } try: url = f"{odata.api}/EntityDefinitions({meta_id})/Attributes" - backoff(lambda: odata._request("post", url, json=payload), delays=ATTRIBUTE_VISIBILITY_DELAYS) + backoff(lambda: odata._request("post", url, json=payload)[0], delays=ATTRIBUTE_VISIBILITY_DELAYS) print({f"{key_prefix}_file_attribute_created": True}) time.sleep(2) return True @@ -285,7 +282,7 @@ def wait_for_attribute_visibility(logical_name: str, label: str): time.sleep(delay) waited += delay try: - resp = odata._request("get", probe_url) + resp, _ = odata._request("get", probe_url) try: resp.json() except Exception: # noqa: BLE001 @@ -313,7 +310,7 @@ def wait_for_attribute_visibility(logical_name: str, label: str): payload = {name_attr: "File Sample Record"} log(f"client.create('{table_schema_name}', payload)") created_ids = backoff(lambda: client.create(table_schema_name, payload)) - if isinstance(created_ids, list) and created_ids: + if created_ids and len(created_ids) > 0: record_id = created_ids[0] else: raise RuntimeError("Unexpected create return; expected list[str] with at least one GUID") @@ -363,7 +360,7 @@ def get_dataset_info(file_path: Path): dl_url_single = ( f"{odata.api}/{entity_set}({record_id})/{small_file_attr_logical}/$value" # raw entity_set URL OK ) - resp_single = backoff(lambda: odata._request("get", dl_url_single)) + resp_single, _ = backoff(lambda: odata._request("get", dl_url_single)) content_single = resp_single.content or b"" import hashlib # noqa: WPS433 @@ -393,7 +390,7 @@ def get_dataset_info(file_path: Path): ) ) print({"small_replace_upload_completed": True, "small_replace_source_size": replace_size_small}) - resp_single_replace = backoff(lambda: odata._request("get", dl_url_single)) + resp_single_replace, _ = backoff(lambda: odata._request("get", dl_url_single)) content_single_replace = resp_single_replace.content or b"" downloaded_hash_replace = hashlib.sha256(content_single_replace).hexdigest() if content_single_replace else None hash_match_replace = ( @@ -435,7 +432,7 @@ def get_dataset_info(file_path: Path): dl_url_chunk = ( f"{odata.api}/{entity_set}({record_id})/{chunk_file_attr_logical}/$value" # raw entity_set for download ) - resp_chunk = backoff(lambda: odata._request("get", dl_url_chunk)) + resp_chunk, _ = backoff(lambda: odata._request("get", dl_url_chunk)) content_chunk = resp_chunk.content or b"" import hashlib # noqa: WPS433 @@ -464,7 +461,7 @@ def get_dataset_info(file_path: Path): ) ) print({"chunk_replace_upload_completed": True}) - resp_chunk_replace = backoff(lambda: odata._request("get", dl_url_chunk)) + resp_chunk_replace, _ = backoff(lambda: odata._request("get", dl_url_chunk)) content_chunk_replace = resp_chunk_replace.content or b"" dst_hash_chunk_replace = hashlib.sha256(content_chunk_replace).hexdigest() if content_chunk_replace else None hash_match_chunk_replace = ( diff --git a/examples/basic/functional_testing.py b/examples/basic/functional_testing.py index 93f3c9d..0f9ea55 100644 --- a/examples/basic/functional_testing.py +++ b/examples/basic/functional_testing.py @@ -91,22 +91,16 @@ def wait_for_table_metadata( odata._entity_set_from_schema_name(table_schema_name) if attempt > 1: - print( - f" [OK] Table metadata available after {attempt} attempts." - ) + print(f" [OK] Table metadata available after {attempt} attempts.") return info except Exception: pass if attempt < retries: - print( - f" Waiting for table metadata to publish (attempt {attempt}/{retries})..." - ) + print(f" Waiting for table metadata to publish (attempt {attempt}/{retries})...") time.sleep(delay_seconds) - raise RuntimeError( - "Table metadata did not become available in time. Please retry later." - ) + raise RuntimeError("Table metadata did not become available in time. Please retry later.") def ensure_test_table(client: DataverseClient) -> Dict[str, Any]: @@ -190,7 +184,7 @@ def test_create_record(client: DataverseClient, table_info: Dict[str, Any]) -> s continue raise - if isinstance(created_ids, list) and created_ids: + if created_ids and len(created_ids) > 0: record_id = created_ids[0] print(f"[OK] Record created successfully!") print(f" Record ID: {record_id}") @@ -229,9 +223,7 @@ def test_read_record(client: DataverseClient, table_info: Dict[str, Any], record break except HttpError as err: if getattr(err, "status_code", None) == 404 and attempt < retries: - print( - f" Record not queryable yet (attempt {attempt}/{retries}). Retrying in {delay_seconds}s..." - ) + print(f" Record not queryable yet (attempt {attempt}/{retries}). Retrying in {delay_seconds}s...") time.sleep(delay_seconds) continue raise @@ -301,9 +293,7 @@ def test_query_records(client: DataverseClient, table_info: Dict[str, Any]) -> N break except HttpError as err: if getattr(err, "status_code", None) == 404 and attempt < retries: - print( - f" Query retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s..." - ) + print(f" Query retry {attempt}/{retries} after metadata 404 ({err}). Waiting {delay_seconds}s...") time.sleep(delay_seconds) continue raise @@ -373,9 +363,7 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor print("[OK] Test table deleted successfully (404 reported).") break if attempt < retries: - print( - f" Table delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s..." - ) + print(f" Table delete retry {attempt}/{retries} after error ({err}). Waiting {delay_seconds}s...") time.sleep(delay_seconds) continue print(f"[WARN] Failed to delete test table: {err}") diff --git a/src/PowerPlatform/Dataverse/data/_upload.py b/src/PowerPlatform/Dataverse/data/_upload.py index d82efb5..1bd1fa7 100644 --- a/src/PowerPlatform/Dataverse/data/_upload.py +++ b/src/PowerPlatform/Dataverse/data/_upload.py @@ -145,7 +145,7 @@ def _upload_file_chunk( headers["If-None-Match"] = "null" else: headers["If-Match"] = "*" - r_init = self._request("patch", init_url, headers=headers, data=b"") + r_init, _ = self._request("patch", init_url, headers=headers, data=b"") location = r_init.headers.get("Location") or r_init.headers.get("location") if not location: raise RuntimeError("Missing Location header with sessiontoken for chunked upload")