Skip to content

Commit a92e9d9

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/relationship-info-model
2 parents b7f7306 + ca5517e commit a92e9d9

9 files changed

Lines changed: 295 additions & 149 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
@@ -301,11 +302,11 @@ client.tables.delete_relationship(result["relationship_id"])
301302

302303
```python
303304
# Upload file to a file column
304-
client.upload_file(
305-
table_schema_name="account",
305+
client.files.upload(
306+
table="account",
306307
record_id=account_id,
307-
file_name_attribute="new_Document", # If the file column doesn't exist, it will be created automatically
308-
path="/path/to/document.pdf"
308+
file_column="new_Document", # If the file column doesn't exist, it will be created automatically
309+
path="/path/to/document.pdf",
309310
)
310311
```
311312

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ The SDK provides a simple, pythonic interface for Dataverse operations:
112112

113113
| Concept | Description |
114114
|---------|-------------|
115-
| **DataverseClient** | Main entry point; provides `records`, `query`, and `tables` namespaces |
116-
| **Namespaces** | Operations are organized into `client.records` (CRUD & OData queries), `client.query` (query & search), and `client.tables` (metadata) |
115+
| **DataverseClient** | Main entry point; provides `records`, `query`, `tables`, and `files` namespaces |
116+
| **Namespaces** | Operations are organized into `client.records` (CRUD & OData queries), `client.query` (query & search), `client.tables` (metadata), and `client.files` (file uploads) |
117117
| **Records** | Dataverse records represented as Python dictionaries with column schema names |
118118
| **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) |
119119
| **Bulk Operations** | Efficient bulk processing for multiple records with automatic optimization |
@@ -378,11 +378,11 @@ result = client.tables.create_lookup_field(
378378

379379
```python
380380
# Upload a file to a record
381-
client.upload_file(
382-
table_schema_name="account",
383-
record_id=account_id,
384-
file_name_attribute="new_Document", # If the file column doesn't exist, it will be created automatically
385-
path="/path/to/document.pdf"
381+
client.files.upload(
382+
"account",
383+
account_id,
384+
"new_Document", # If the file column doesn't exist, it will be created automatically
385+
"/path/to/document.pdf",
386386
)
387387
```
388388

examples/advanced/file_upload.py

Lines changed: 41 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -89,68 +89,25 @@ 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)
127-
128-
# Add pages with content until we reach target size
129-
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")
92+
def generate_test_file(size_mb: int = 10) -> Path:
93+
"""Generate a dummy text file of specified size for testing purposes.
13694
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)
140-
141-
c.showPage()
142-
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)
95+
Creates a plain text file with repeating content to reach the target
96+
size. No external dependencies required.
97+
"""
98+
test_file = Path(__file__).resolve().parent / f"test_dummy_{size_mb}mb.txt"
99+
target_size = size_mb * 1024 * 1024
149100

150-
if not test_file.exists() or test_file.stat().st_size < target_size:
151-
c.save()
101+
line = b"The quick brown fox jumps over the lazy dog. " * 2 + b"\n"
102+
with test_file.open("wb") as f:
103+
written = 0
104+
while written < target_size:
105+
chunk = line * min(1000, (target_size - written) // len(line) + 1)
106+
chunk = chunk[: target_size - written]
107+
f.write(chunk)
108+
written += len(chunk)
152109

153-
print({"test_pdf_generated": str(test_file), "size_mb": test_file.stat().st_size / (1024 * 1024)})
110+
print({"test_file_generated": str(test_file), "size_mb": test_file.stat().st_size / (1024 * 1024)})
154111
return test_file
155112

156113

@@ -228,8 +185,8 @@ def ensure_table():
228185

229186
# --------------------------- Shared dataset helpers ---------------------------
230187
_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
188+
_GENERATED_TEST_FILE = generate_test_file(10) # track generated file for cleanup
189+
_GENERATED_TEST_FILE_8MB = generate_test_file(8) # track 8MB replacement file for cleanup
233190

234191

235192
def get_dataset_info(file_path: Path):
@@ -248,11 +205,11 @@ def get_dataset_info(file_path: Path):
248205
try:
249206
DATASET_FILE, small_file_size, src_hash = get_dataset_info(_GENERATED_TEST_FILE)
250207
backoff(
251-
lambda: client.upload_file(
252-
table_schema_name,
253-
record_id,
254-
small_file_attr_schema,
255-
str(DATASET_FILE),
208+
lambda: client.files.upload(
209+
table=table_schema_name,
210+
record_id=record_id,
211+
file_column=small_file_attr_schema,
212+
path=str(DATASET_FILE),
256213
mode="small",
257214
)
258215
)
@@ -282,12 +239,13 @@ def get_dataset_info(file_path: Path):
282239
print("Small single-request upload demo - REPLACE with 8MB file:")
283240
replacement_file, replace_size_small, replace_hash_small = get_dataset_info(_GENERATED_TEST_FILE_8MB)
284241
backoff(
285-
lambda: client.upload_file(
286-
table_schema_name,
287-
record_id,
288-
small_file_attr_schema,
289-
str(replacement_file),
242+
lambda: client.files.upload(
243+
table=table_schema_name,
244+
record_id=record_id,
245+
file_column=small_file_attr_schema,
246+
path=str(replacement_file),
290247
mode="small",
248+
if_none_match=False,
291249
)
292250
)
293251
print({"small_replace_upload_completed": True, "small_replace_source_size": replace_size_small})
@@ -316,15 +274,15 @@ def get_dataset_info(file_path: Path):
316274

317275
# --------------------------- Chunk (streaming) upload demo ---------------------------
318276
if run_chunk:
319-
print("Streaming chunk upload demo (upload_file_chunk):")
277+
print("Streaming chunk upload demo (mode='chunk'):")
320278
try:
321279
DATASET_FILE, src_size_chunk, src_hash_chunk = get_dataset_info(_GENERATED_TEST_FILE)
322280
backoff(
323-
lambda: client.upload_file(
324-
table_schema_name,
325-
record_id,
326-
chunk_file_attr_schema,
327-
str(DATASET_FILE),
281+
lambda: client.files.upload(
282+
table=table_schema_name,
283+
record_id=record_id,
284+
file_column=chunk_file_attr_schema,
285+
path=str(DATASET_FILE),
328286
mode="chunk",
329287
)
330288
)
@@ -351,12 +309,13 @@ def get_dataset_info(file_path: Path):
351309
print("Streaming chunk upload demo - REPLACE with 8MB file:")
352310
replacement_file, replace_size_chunk, replace_hash_chunk = get_dataset_info(_GENERATED_TEST_FILE_8MB)
353311
backoff(
354-
lambda: client.upload_file(
355-
table_schema_name,
356-
record_id,
357-
chunk_file_attr_schema,
358-
str(replacement_file),
312+
lambda: client.files.upload(
313+
table=table_schema_name,
314+
record_id=record_id,
315+
file_column=chunk_file_attr_schema,
316+
path=str(replacement_file),
359317
mode="chunk",
318+
if_none_match=False,
360319
)
361320
)
362321
print({"chunk_replace_upload_completed": True})

examples/basic/installation_example.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from PowerPlatform.Dataverse.operations.records import RecordOperations
6464
from PowerPlatform.Dataverse.operations.query import QueryOperations
6565
from PowerPlatform.Dataverse.operations.tables import TableOperations
66+
from PowerPlatform.Dataverse.operations.files import FileOperations
6667

6768

6869
def validate_imports():
@@ -123,17 +124,19 @@ def validate_client_methods(DataverseClient):
123124
print("\nValidating Client Methods...")
124125
print("-" * 50)
125126

126-
# Validate namespace API: client.records, client.query, client.tables
127+
# Validate namespace API: client.records, client.query, client.tables, client.files
127128
expected_namespaces = {
128129
"records": ["create", "get", "update", "delete"],
129-
"query": ["get", "sql"],
130+
"query": ["sql"],
130131
"tables": ["create", "get", "list", "delete", "add_columns", "remove_columns"],
132+
"files": ["upload"],
131133
}
132134

133135
ns_classes = {
134136
"records": RecordOperations,
135137
"query": QueryOperations,
136138
"tables": TableOperations,
139+
"files": FileOperations,
137140
}
138141

139142
missing_methods = []

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
@@ -301,11 +302,11 @@ client.tables.delete_relationship(result["relationship_id"])
301302

302303
```python
303304
# Upload file to a file column
304-
client.upload_file(
305-
table_schema_name="account",
305+
client.files.upload(
306+
table="account",
306307
record_id=account_id,
307-
file_name_attribute="new_Document", # If the file column doesn't exist, it will be created automatically
308-
path="/path/to/document.pdf"
308+
file_column="new_Document", # If the file column doesn't exist, it will be created automatically
309+
path="/path/to/document.pdf",
309310
)
310311
```
311312

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)