From ed934343e5f25bd3c0e63afdb22bd012281eb000 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Wed, 10 Dec 2025 15:01:46 +0100 Subject: [PATCH 1/6] feat: add file upload, download, and delete operations to DataverseClient --- src/PowerPlatform/Dataverse/client.py | 74 +++++++++++++++++++ .../data/{_upload.py => _file_operations.py} | 52 ++++++++++++- src/PowerPlatform/Dataverse/data/_odata.py | 4 +- 3 files changed, 124 insertions(+), 6 deletions(-) rename src/PowerPlatform/Dataverse/data/{_upload.py => _file_operations.py} (79%) diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index 2fb11e8..e840ae5 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -653,6 +653,80 @@ def upload_file( ) return None + # File download + def download_file( + self, + table_schema_name: str, + record_id: str, + file_name_attribute: str, + ) -> tuple[str, bytes]: + """ + Download a file from a Dataverse file column. + :param table_schema_name: Schema name of the table, e.g. ``"account"`` or ``"new_MyTestTable"``. + :type table_schema_name: str + :param record_id: GUID of the record + :type record_id: str + :param file_name_attribute: Logical name of the file column attribute. + :type file_name_attribute: str + + :return: Tuple with file name and file content. + + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the download fails or the file column is empty + + Example: + Download a PDF file:: + + client.download_file( + table_schema_name="account", + record_id=account_id, + file_name_attribute="new_contract" + ) + + """ + od = self._get_odata() + entity_set = od._entity_set_from_schema_name(table_schema_name) + return od._download_file( + entity_set, + record_id, + file_name_attribute, + ) + + # File delete + def delete_file( + self, + table_schema_name: str, + record_id: str, + file_name_attribute: str, + ) -> None: + """ + Delete a file from a Dataverse file column. + :param table_schema_name: Schema name of the table, e.g. ``"account"`` or ``"new_MyTestTable"``. + :param record_id: GUID of the record + :param file_name_attribute: Logical name of the file column attribute. + + + :return: None + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the delete fails + + Example: + Delete a file:: + + client.delete_file( + table_schema_name="account", + record_id=account_id, + file_name_attribute="new_contract" + ) + + """ + od = self._get_odata() + entity_set = od._entity_set_from_schema_name(table_schema_name) + od._delete_file( + entity_set, + record_id, + file_name_attribute, + ) + + # Cache utilities def flush_cache(self, kind) -> int: """ diff --git a/src/PowerPlatform/Dataverse/data/_upload.py b/src/PowerPlatform/Dataverse/data/_file_operations.py similarity index 79% rename from src/PowerPlatform/Dataverse/data/_upload.py rename to src/PowerPlatform/Dataverse/data/_file_operations.py index d82efb5..8ba0e31 100644 --- a/src/PowerPlatform/Dataverse/data/_upload.py +++ b/src/PowerPlatform/Dataverse/data/_file_operations.py @@ -1,15 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""File upload helpers.""" +"""File operations helpers.""" from __future__ import annotations from typing import Optional -class _ODataFileUpload: - """File upload capabilities (small + chunk) with auto selection.""" +class _ODataFileOperations: + """Provides file management capabilities including upload, download, and delete operations.""" def _upload_file( self, @@ -127,7 +127,8 @@ def _upload_file_chunk( None Returns nothing on success. Any failure raises an exception. """ - import os, math + import os + import math from urllib.parse import quote if not record_id: @@ -176,3 +177,46 @@ def _upload_file_chunk( self._request("patch", location, headers=c_headers, data=chunk, expected=(206, 204)) uploaded_bytes += len(chunk) return None + + def _download_file( + self, + entity_set: str, + record_id: str, + file_name_attribute: str, + ) -> tuple[str, bytes]: + """ + Download a file from a Dataverse file column. + :param entity_set: Source entity set (plural logical name), e.g. "accounts". + :param record_id: GUID of the record + :param file_name_attribute: Logical name of the file column attribute. + + :return: Tuple with file name and file content. + """ + + key = self._format_key(record_id) + url = f"{self.api}/{entity_set}{key}/{file_name_attribute}/$value" + response = self._request("get", url) + file_name = response.headers.get('x-ms-file-name') + if file_name is None: + raise ValueError("Response is missing the 'x-ms-file-name' header. The file column may be empty or the server did not return the expected header.") + return file_name, response.content + def _delete_file( + self, + entity_set: str, + record_id: str, + file_name_attribute: str, + ) -> None: + """ + Delete a file from a Dataverse file column. + :param entity_set: Target entity set (plural logical name), e.g. "accounts". + :param record_id: GUID of the record + :param file_name_attribute: Logical name of the file column attribute. + + :return: None + """ + + key = self._format_key(record_id) + url = f"{self.api}/{entity_set}{key}/{file_name_attribute}" + self._request("delete", url) + + return None \ No newline at end of file diff --git a/src/PowerPlatform/Dataverse/data/_odata.py b/src/PowerPlatform/Dataverse/data/_odata.py index 8eda7ad..881fad7 100644 --- a/src/PowerPlatform/Dataverse/data/_odata.py +++ b/src/PowerPlatform/Dataverse/data/_odata.py @@ -15,7 +15,7 @@ import importlib.resources as ir from ..core._http import _HttpClient -from ._upload import _ODataFileUpload +from ._file_operations import _ODataFileOperations from ..core.errors import * from ..core._error_codes import ( _http_subcode, @@ -37,7 +37,7 @@ _GUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") -class _ODataClient(_ODataFileUpload): +class _ODataClient(_ODataFileOperations): """Dataverse Web API client: CRUD, SQL-over-API, and table metadata helpers.""" @staticmethod From e997e247fc75aba6eff18e01a800acc2a53f2e75 Mon Sep 17 00:00:00 2001 From: pkontek Date: Wed, 10 Dec 2025 15:15:02 +0100 Subject: [PATCH 2/6] Update src/PowerPlatform/Dataverse/data/_file_operations.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/PowerPlatform/Dataverse/data/_file_operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowerPlatform/Dataverse/data/_file_operations.py b/src/PowerPlatform/Dataverse/data/_file_operations.py index 8ba0e31..b48129e 100644 --- a/src/PowerPlatform/Dataverse/data/_file_operations.py +++ b/src/PowerPlatform/Dataverse/data/_file_operations.py @@ -195,7 +195,7 @@ def _download_file( key = self._format_key(record_id) url = f"{self.api}/{entity_set}{key}/{file_name_attribute}/$value" - response = self._request("get", url) + response = self._request("get", url, expected=(200,)) file_name = response.headers.get('x-ms-file-name') if file_name is None: raise ValueError("Response is missing the 'x-ms-file-name' header. The file column may be empty or the server did not return the expected header.") From bbfaebc6750239012a91a368a0770fcccdc31390 Mon Sep 17 00:00:00 2001 From: pkontek Date: Wed, 10 Dec 2025 15:15:39 +0100 Subject: [PATCH 3/6] Update src/PowerPlatform/Dataverse/data/_file_operations.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/PowerPlatform/Dataverse/data/_file_operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowerPlatform/Dataverse/data/_file_operations.py b/src/PowerPlatform/Dataverse/data/_file_operations.py index b48129e..6dd7ab4 100644 --- a/src/PowerPlatform/Dataverse/data/_file_operations.py +++ b/src/PowerPlatform/Dataverse/data/_file_operations.py @@ -217,6 +217,6 @@ def _delete_file( key = self._format_key(record_id) url = f"{self.api}/{entity_set}{key}/{file_name_attribute}" - self._request("delete", url) + self._request("delete", url, expected=(204,)) return None \ No newline at end of file From 796104acbb532bf2dd176e62c4c096b1b3d2ee3d Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Wed, 10 Dec 2025 15:17:16 +0100 Subject: [PATCH 4/6] fix: handle missing 'x-ms-file-name' header in _download_file method --- src/PowerPlatform/Dataverse/data/_file_operations.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PowerPlatform/Dataverse/data/_file_operations.py b/src/PowerPlatform/Dataverse/data/_file_operations.py index 6dd7ab4..7f03d24 100644 --- a/src/PowerPlatform/Dataverse/data/_file_operations.py +++ b/src/PowerPlatform/Dataverse/data/_file_operations.py @@ -200,6 +200,7 @@ def _download_file( if file_name is None: raise ValueError("Response is missing the 'x-ms-file-name' header. The file column may be empty or the server did not return the expected header.") return file_name, response.content + def _delete_file( self, entity_set: str, From 3a9dde891adb6f1a4da8cd62baa699fe884a45b8 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Wed, 10 Dec 2025 15:18:36 +0100 Subject: [PATCH 5/6] refactor: remove unnecessary blank line in DataverseClient class --- src/PowerPlatform/Dataverse/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index e840ae5..d68b93f 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -725,7 +725,6 @@ def delete_file( record_id, file_name_attribute, ) - # Cache utilities def flush_cache(self, kind) -> int: From 216e07ce8b9d3e3f27116d10f5421b54366d367d Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Mon, 15 Dec 2025 09:40:15 +0100 Subject: [PATCH 6/6] fix: improve formatting and error message clarity in file operations --- src/PowerPlatform/Dataverse/client.py | 4 ++-- .../Dataverse/data/_file_operations.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index d68b93f..5b85064 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -706,7 +706,7 @@ def delete_file( :return: None - :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the delete fails + :raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the delete fails Example: Delete a file:: @@ -725,7 +725,7 @@ def delete_file( record_id, file_name_attribute, ) - + # Cache utilities def flush_cache(self, kind) -> int: """ diff --git a/src/PowerPlatform/Dataverse/data/_file_operations.py b/src/PowerPlatform/Dataverse/data/_file_operations.py index 7f03d24..ab58c28 100644 --- a/src/PowerPlatform/Dataverse/data/_file_operations.py +++ b/src/PowerPlatform/Dataverse/data/_file_operations.py @@ -195,12 +195,14 @@ def _download_file( key = self._format_key(record_id) url = f"{self.api}/{entity_set}{key}/{file_name_attribute}/$value" - response = self._request("get", url, expected=(200,)) - file_name = response.headers.get('x-ms-file-name') + response = self._request("get", url, expected=(200,)) + file_name = response.headers.get("x-ms-file-name") if file_name is None: - raise ValueError("Response is missing the 'x-ms-file-name' header. The file column may be empty or the server did not return the expected header.") + raise ValueError( + "Response is missing the 'x-ms-file-name' header. The file column may be empty or the server did not return the expected header." + ) return file_name, response.content - + def _delete_file( self, entity_set: str, @@ -218,6 +220,6 @@ def _delete_file( key = self._format_key(record_id) url = f"{self.api}/{entity_set}{key}/{file_name_attribute}" - self._request("delete", url, expected=(204,)) + self._request("delete", url, expected=(204,)) - return None \ No newline at end of file + return None