Skip to content

Commit d3a9b37

Browse files
tpellissierclaude
andcommitted
Add client.files namespace for file upload operations
- Create FileOperations class with upload() method in operations/files.py - Deprecate client.upload_file() with DeprecationWarning pointing to client.files.upload() - Update file_upload example, README, and both SKILL files - Add namespace delegation tests and deprecation test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4dad4bd commit d3a9b37

8 files changed

Lines changed: 276 additions & 124 deletions

File tree

.claude/skills/dataverse-sdk-use/SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Use the PowerPlatform Dataverse Client Python SDK to interact with Microsoft Dat
2121
- `client.records` -- CRUD and OData queries
2222
- `client.query` -- query and search operations
2323
- `client.tables` -- table metadata, columns, and relationships
24+
- `client.files` -- file upload operations
2425

2526
### Bulk Operations
2627
The SDK supports Dataverse's native bulk operations: Pass lists to `create()`, `update()` for automatic bulk processing, for `delete()`, set `use_bulk_delete` when passing lists to use bulk operation
@@ -267,11 +268,11 @@ client.tables.delete_relationship(result["relationship_id"])
267268

268269
```python
269270
# Upload file to a file column
270-
client.upload_file(
271-
table_schema_name="account",
271+
client.files.upload(
272+
table="account",
272273
record_id=account_id,
273-
file_name_attribute="new_Document", # If the file column doesn't exist, it will be created automatically
274-
path="/path/to/document.pdf"
274+
file_column="new_Document", # If the file column doesn't exist, it will be created automatically
275+
path="/path/to/document.pdf",
275276
)
276277
```
277278

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ The SDK provides a simple, pythonic interface for Dataverse operations:
112112
| Concept | Description |
113113
|---------|-------------|
114114
| **DataverseClient** | Main entry point; provides `records`, `query`, and `tables` namespaces |
115-
| **Namespaces** | Operations are organized into `client.records` (CRUD & OData queries), `client.query` (query & search), and `client.tables` (metadata) |
115+
| **Namespaces** | Operations are organized into `client.records` (CRUD & OData queries), `client.query` (query & search), `client.tables` (metadata), and `client.files` (file uploads) |
116116
| **Records** | Dataverse records represented as Python dictionaries with column schema names |
117117
| **Schema names** | Use table schema names (`"account"`, `"new_MyTestTable"`) and column schema names (`"name"`, `"new_MyTestColumn"`). See: [Table definitions in Microsoft Dataverse](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/entity-metadata) |
118118
| **Bulk Operations** | Efficient bulk processing for multiple records with automatic optimization |
@@ -326,11 +326,11 @@ result = client.tables.create_lookup_field(
326326

327327
```python
328328
# Upload a file to a record
329-
client.upload_file(
330-
table_schema_name="account",
331-
record_id=account_id,
332-
file_name_attribute="new_Document", # If the file column doesn't exist, it will be created automatically
333-
path="/path/to/document.pdf"
329+
client.files.upload(
330+
"account",
331+
account_id,
332+
"new_Document", # If the file column doesn't exist, it will be created automatically
333+
"/path/to/document.pdf",
334334
)
335335
```
336336

examples/advanced/file_upload.py

Lines changed: 28 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -89,68 +89,35 @@ def file_sha256(path: Path): # returns (hex_digest, size_bytes)
8989
return None, None
9090

9191

92-
def generate_test_pdf(size_mb: int = 10) -> Path:
93-
"""Generate a dummy PDF file of specified size for testing purposes."""
94-
try:
95-
from reportlab.pdfgen import canvas # type: ignore # noqa: WPS433
96-
from reportlab.lib.pagesizes import letter # type: ignore # noqa: WPS433
97-
except ImportError:
98-
# Fallback: generate a simple binary file with PDF headers
99-
test_file = Path(__file__).resolve().parent / f"test_dummy_{size_mb}mb.pdf"
100-
target_size = size_mb * 1024 * 1024
101-
102-
# Minimal PDF structure
103-
pdf_header = b"%PDF-1.4\n"
104-
pdf_body = b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
105-
pdf_body += b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
106-
pdf_body += b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"
107-
108-
# Fill with dummy data to reach target size
109-
current_size = len(pdf_header) + len(pdf_body)
110-
padding_needed = target_size - current_size - 50 # Reserve space for trailer
111-
padding = b"% " + (b"padding " * (padding_needed // 8))[:padding_needed] + b"\n"
112-
113-
pdf_trailer = b"xref\n0 4\ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n0\n%%EOF\n"
114-
115-
with test_file.open("wb") as f:
116-
f.write(pdf_header)
117-
f.write(pdf_body)
118-
f.write(padding)
119-
f.write(pdf_trailer)
120-
121-
print({"test_pdf_generated": str(test_file), "size_mb": test_file.stat().st_size / (1024 * 1024)})
122-
return test_file
123-
124-
# ReportLab available - generate proper PDF
125-
test_file = Path(__file__).resolve().parent / f"test_dummy_{size_mb}mb.pdf"
126-
c = canvas.Canvas(str(test_file), pagesize=letter)
92+
def generate_test_file(size_mb: int = 10) -> Path:
93+
"""Generate a dummy file of specified size for testing purposes.
12794
128-
# Add pages with content until we reach target size
95+
Creates a minimal PDF-like file with random padding to reach the target
96+
size. No external dependencies required.
97+
"""
98+
test_file = Path(__file__).resolve().parent / f"test_dummy_{size_mb}mb.pdf"
12999
target_size = size_mb * 1024 * 1024
130-
page_num = 0
131-
132-
while test_file.exists() is False or test_file.stat().st_size < target_size:
133-
page_num += 1
134-
c.drawString(100, 750, f"Test PDF - Page {page_num}")
135-
c.drawString(100, 730, f"Generated for file upload testing")
136100

137-
# Add some text to increase file size
138-
for i in range(50):
139-
c.drawString(50, 700 - (i * 12), f"Line {i}: " + "Sample text content " * 20)
101+
# Minimal PDF structure so the file is recognized as PDF
102+
pdf_header = b"%PDF-1.4\n"
103+
pdf_body = b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
104+
pdf_body += b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
105+
pdf_body += b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n"
140106

141-
c.showPage()
107+
# Fill with dummy data to reach target size
108+
current_size = len(pdf_header) + len(pdf_body)
109+
padding_needed = target_size - current_size - 50 # Reserve space for trailer
110+
padding = b"% " + (b"padding " * (padding_needed // 8))[:padding_needed] + b"\n"
142111

143-
# Save periodically to check size
144-
if page_num % 10 == 0:
145-
c.save()
146-
if test_file.stat().st_size >= target_size:
147-
break
148-
c = canvas.Canvas(str(test_file), pagesize=letter)
112+
pdf_trailer = b"xref\n0 4\ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n0\n%%EOF\n"
149113

150-
if not test_file.exists() or test_file.stat().st_size < target_size:
151-
c.save()
114+
with test_file.open("wb") as f:
115+
f.write(pdf_header)
116+
f.write(pdf_body)
117+
f.write(padding)
118+
f.write(pdf_trailer)
152119

153-
print({"test_pdf_generated": str(test_file), "size_mb": test_file.stat().st_size / (1024 * 1024)})
120+
print({"test_file_generated": str(test_file), "size_mb": test_file.stat().st_size / (1024 * 1024)})
154121
return test_file
155122

156123

@@ -228,8 +195,8 @@ def ensure_table():
228195

229196
# --------------------------- Shared dataset helpers ---------------------------
230197
_DATASET_INFO_CACHE = {} # cache dict: file_path -> (path, size_bytes, sha256_hex)
231-
_GENERATED_TEST_FILE = generate_test_pdf(10) # track generated file for cleanup
232-
_GENERATED_TEST_FILE_8MB = generate_test_pdf(8) # track 8MB replacement file for cleanup
198+
_GENERATED_TEST_FILE = generate_test_file(10) # track generated file for cleanup
199+
_GENERATED_TEST_FILE_8MB = generate_test_file(8) # track 8MB replacement file for cleanup
233200

234201

235202
def get_dataset_info(file_path: Path):
@@ -248,7 +215,7 @@ def get_dataset_info(file_path: Path):
248215
try:
249216
DATASET_FILE, small_file_size, src_hash = get_dataset_info(_GENERATED_TEST_FILE)
250217
backoff(
251-
lambda: client.upload_file(
218+
lambda: client.files.upload(
252219
table_schema_name,
253220
record_id,
254221
small_file_attr_schema,
@@ -282,7 +249,7 @@ def get_dataset_info(file_path: Path):
282249
print("Small single-request upload demo - REPLACE with 8MB file:")
283250
replacement_file, replace_size_small, replace_hash_small = get_dataset_info(_GENERATED_TEST_FILE_8MB)
284251
backoff(
285-
lambda: client.upload_file(
252+
lambda: client.files.upload(
286253
table_schema_name,
287254
record_id,
288255
small_file_attr_schema,
@@ -320,7 +287,7 @@ def get_dataset_info(file_path: Path):
320287
try:
321288
DATASET_FILE, src_size_chunk, src_hash_chunk = get_dataset_info(_GENERATED_TEST_FILE)
322289
backoff(
323-
lambda: client.upload_file(
290+
lambda: client.files.upload(
324291
table_schema_name,
325292
record_id,
326293
chunk_file_attr_schema,
@@ -351,7 +318,7 @@ def get_dataset_info(file_path: Path):
351318
print("Streaming chunk upload demo - REPLACE with 8MB file:")
352319
replacement_file, replace_size_chunk, replace_hash_chunk = get_dataset_info(_GENERATED_TEST_FILE_8MB)
353320
backoff(
354-
lambda: client.upload_file(
321+
lambda: client.files.upload(
355322
table_schema_name,
356323
record_id,
357324
chunk_file_attr_schema,

src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Use the PowerPlatform Dataverse Client Python SDK to interact with Microsoft Dat
2121
- `client.records` -- CRUD and OData queries
2222
- `client.query` -- query and search operations
2323
- `client.tables` -- table metadata, columns, and relationships
24+
- `client.files` -- file upload operations
2425

2526
### Bulk Operations
2627
The SDK supports Dataverse's native bulk operations: Pass lists to `create()`, `update()` for automatic bulk processing, for `delete()`, set `use_bulk_delete` when passing lists to use bulk operation
@@ -267,11 +268,11 @@ client.tables.delete_relationship(result["relationship_id"])
267268

268269
```python
269270
# Upload file to a file column
270-
client.upload_file(
271-
table_schema_name="account",
271+
client.files.upload(
272+
table="account",
272273
record_id=account_id,
273-
file_name_attribute="new_Document", # If the file column doesn't exist, it will be created automatically
274-
path="/path/to/document.pdf"
274+
file_column="new_Document", # If the file column doesn't exist, it will be created automatically
275+
path="/path/to/document.pdf",
275276
)
276277
```
277278

src/PowerPlatform/Dataverse/client.py

Lines changed: 26 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .data._odata import _ODataClient
1515
from .operations.records import RecordOperations
1616
from .operations.query import QueryOperations
17+
from .operations.files import FileOperations
1718
from .operations.tables import TableOperations
1819

1920

@@ -56,6 +57,7 @@ class DataverseClient:
5657
- ``client.records`` -- create, update, delete, and get records (single or paginated queries)
5758
- ``client.query`` -- query and search operations
5859
- ``client.tables`` -- table and column metadata management
60+
- ``client.files`` -- file upload operations
5961
6062
Example:
6163
Create a client and perform basic operations::
@@ -101,6 +103,7 @@ def __init__(
101103
self.records = RecordOperations(self)
102104
self.query = QueryOperations(self)
103105
self.tables = TableOperations(self)
106+
self.files = FileOperations(self)
104107

105108
def _get_odata(self) -> _ODataClient:
106109
"""
@@ -665,67 +668,41 @@ def upload_file(
665668
if_none_match: bool = True,
666669
) -> None:
667670
"""
671+
.. note::
672+
Deprecated. Use :meth:`~PowerPlatform.Dataverse.operations.files.FileOperations.upload` instead.
673+
668674
Upload a file to a Dataverse file column.
669675
670-
:param table_schema_name: Schema name of the table, e.g. ``"account"`` or ``"new_MyTestTable"``.
676+
:param table_schema_name: Schema name of the table.
671677
:type table_schema_name: :class:`str`
672678
:param record_id: GUID of the target record.
673679
:type record_id: :class:`str`
674-
:param file_name_attribute: Schema name of the file column attribute (e.g., ``"new_Document"``). If the column doesn't exist, it will be created automatically.
680+
:param file_name_attribute: Schema name of the file column attribute.
675681
:type file_name_attribute: :class:`str`
676-
:param path: Local filesystem path to the file. The stored filename will be
677-
the basename of this path.
682+
:param path: Local filesystem path to the file.
678683
:type path: :class:`str`
679684
:param mode: Upload strategy: ``"auto"`` (default), ``"small"``, or ``"chunk"``.
680-
Auto mode selects small or chunked upload based on file size.
681685
:type mode: :class:`str` or None
682-
:param mime_type: Explicit MIME type to store with the file (e.g. ``"application/pdf"``).
683-
If not provided, the MIME type may be inferred from the file extension.
686+
:param mime_type: Explicit MIME type to store with the file.
684687
:type mime_type: :class:`str` or None
685-
:param if_none_match: When True (default), sends ``If-None-Match: null`` header to only
686-
succeed if the column is currently empty. Set False to always overwrite using
687-
``If-Match: *``. Used for small and chunk modes only.
688+
:param if_none_match: When True (default), only succeed if the column is
689+
currently empty.
688690
:type if_none_match: :class:`bool`
689-
690-
:raises ~PowerPlatform.Dataverse.core.errors.HttpError: If the upload fails or the file column is not empty
691-
when ``if_none_match=True``.
692-
:raises FileNotFoundError: If the specified file path does not exist.
693-
694-
.. note::
695-
Large files are automatically chunked to avoid request size limits. The chunk mode performs multiple requests with resumable upload support.
696-
697-
Example:
698-
Upload a PDF file::
699-
700-
client.upload_file(
701-
table_schema_name="account",
702-
record_id=account_id,
703-
file_name_attribute="new_Contract",
704-
path="/path/to/contract.pdf",
705-
mime_type="application/pdf"
706-
)
707-
708-
Upload with auto mode selection::
709-
710-
client.upload_file(
711-
table_schema_name="email",
712-
record_id=email_id,
713-
file_name_attribute="new_Attachment",
714-
path="/path/to/large_file.zip",
715-
mode="auto"
716-
)
717691
"""
718-
with self._scoped_odata() as od:
719-
od._upload_file(
720-
table_schema_name,
721-
record_id,
722-
file_name_attribute,
723-
path,
724-
mode=mode,
725-
mime_type=mime_type,
726-
if_none_match=if_none_match,
727-
)
728-
return None
692+
warnings.warn(
693+
"client.upload_file() is deprecated. Use client.files.upload() instead.",
694+
DeprecationWarning,
695+
stacklevel=2,
696+
)
697+
self.files.upload(
698+
table_schema_name,
699+
record_id,
700+
file_name_attribute,
701+
path,
702+
mode=mode,
703+
mime_type=mime_type,
704+
if_none_match=if_none_match,
705+
)
729706

730707
# Cache utilities
731708
def flush_cache(self, kind) -> int:

0 commit comments

Comments
 (0)