Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/api_reference/file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ nisystemlink.clients.file
.. automethod:: upload_file
.. automethod:: download_file
.. automethod:: update_metadata
.. automethod:: start_upload_session
.. automethod:: append_to_upload_session
Comment thread
RSam-NI marked this conversation as resolved.
.. automethod:: finish_upload_session

.. automodule:: nisystemlink.clients.file.models
:members:
Expand Down
3 changes: 2 additions & 1 deletion docs/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ default connection. The default connection depends on your environment.

With a :class:`.FileClient` object, you can:

* Get the list of files, query and search for files, download and delete files
* Get the list of files, query and search for files, download and delete files.
* Start upload sessions, upload file chunks, and finish sessions for large file uploads.

Examples
~~~~~~~~
Expand Down
78 changes: 78 additions & 0 deletions nisystemlink/clients/file/_file_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,81 @@ def update_metadata(self, metadata: models.UpdateMetadataRequest, id: str) -> No
Raises:
ApiException: if unable to communicate with the File Service.
"""

@post(
"service-groups/Default/upload-sessions/start", args=[Query(name="workspace")]
)
def start_upload_session(
self, workspace: str | None = None
) -> models.UploadSessionStartResponse:
"""Start an upload session for uploading a file in chunks.

Args:
workspace: The id of the workspace the file belongs to. Defaults to None.

Returns:
Upload session information including the session ID.

Raises:
ApiException: if unable to communicate with the File Service.
"""

@post(
"service-groups/Default/upload-sessions/append",
args=[
Query(name="sessionId"),
Query(name="chunk"),
Part(name="file"),
Query(name="close"),
],
)
@response_handler(lambda response: None)
def append_to_upload_session(
self,
session_id: str,
chunk_index: int,
chunk: BinaryIO,
close: bool = False,
) -> None:
"""Append a chunk to an upload session.

The chunk needs to be 10485760 bytes (10 MB), unless the close parameter is true,
which means that it is the last chunk of the file content. The chunks can be uploaded
concurrently, as long as all chunk uploads are completed before finalizing.

Args:
session_id: The id of the upload session.
chunk_index: The 1-based index of the chunk to be uploaded.
file: The chunk data to upload.
close: Set the current chunk as the last chunk to be uploaded. Defaults to False.

Raises:
ApiException: if unable to communicate with the File Service.
"""

@response_handler(_file_uri_response_handler)
@post(
"service-groups/Default/upload-sessions/finish",
args=[Query(name="sessionId"), Field(name="name"), Field(name="properties")],
)
def finish_upload_session(
self,
session_id: str,
name: str,
properties: Dict[str, str],
) -> str:
"""Finish an upload session and make the file visible in SystemLink.

This will trigger file events, such as routines.

Args:
session_id: The id of the upload session.
name: The name of the file.
properties: The properties of the file.

Returns:
ID of the uploaded file.

Raises:
ApiException: if unable to communicate with the File Service.
"""
1 change: 1 addition & 0 deletions nisystemlink/clients/file/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
from ._search_files_response import SearchFilesResponse
from ._base_file_response import BaseFileResponse, TotalCount, TotalCountRelation
from ._base_file_request import BaseFileRequest
from ._upload_session_start_response import UploadSessionStartResponse

# flake8: noqa
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from datetime import datetime

from nisystemlink.clients.core._uplink._json_model import JsonModel
from pydantic import Field


class UploadSessionStartResponse(JsonModel):
"""Response model for starting an upload session."""

session_id: str = Field(alias="id")
"""
The id created for the upload session.
"""

created_at: datetime
"""
The date and time the upload session has started.
"""
Comment thread
RSam-NI marked this conversation as resolved.
102 changes: 102 additions & 0 deletions tests/integration/file/test_file_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io
import string
from datetime import datetime
from io import BytesIO
from random import choices, randint
from typing import BinaryIO

Expand Down Expand Up @@ -435,3 +436,104 @@ def test__search_files__filter_returns_no_results(self, client: FileClient):
assert len(response.available_files) == 0
assert response.total_count is not None
assert response.total_count.value == 0

def test__start_upload_session__with_invalid_workspace__raises(
self, client: FileClient
):
invalid_workspace_id = "invalid-workspace-id"

with pytest.raises(ApiException):
client.start_upload_session(workspace=invalid_workspace_id)

def test__append_to_upload_session__uploads_file_in_chunks(
self, client: FileClient
):
# Create a test file with known content
test_content = b"A" * 10485760 + b"B" * 5000000 # 10 MB + 5 MB
chunk_size = 10485760 # 10 MB

# Start upload session
session_response = client.start_upload_session()

# Verify session response
assert session_response is not None
assert session_response.session_id is not None
assert isinstance(session_response.session_id, str)
assert session_response.created_at is not None
assert isinstance(session_response.created_at, datetime)

session_id = session_response.session_id
file_id = None

try:
# Upload first chunk
first_chunk = BytesIO(test_content[:chunk_size])
client.append_to_upload_session(
session_id=session_id, chunk_index=1, chunk=first_chunk
)

# Upload second chunk (last chunk)
second_chunk = BytesIO(test_content[chunk_size:])
client.append_to_upload_session(
session_id=session_id, chunk_index=2, chunk=second_chunk, close=True
)

# Finish the upload session
file_name = f"{PREFIX}chunked_upload_test.bin"
file_id = client.finish_upload_session(
session_id=session_id,
name=file_name,
properties={
"Test": "ChunkedUpload",
"Description": "Test file from chunked upload",
},
)
Comment thread
BKnight760 marked this conversation as resolved.

# Verify the file was created with correct metadata
files = client.get_files(ids=[file_id])
assert files.total_count == 1
assert len(files.available_files) == 1
assert files.available_files[0].id == file_id
assert files.available_files[0].properties is not None
assert files.available_files[0].properties.get("Name") == file_name
assert files.available_files[0].properties.get("Test") == "ChunkedUpload"
assert (
files.available_files[0].properties.get("Description")
== "Test file from chunked upload"
)

# Verify file content
downloaded_data = client.download_file(id=file_id)
assert downloaded_data.read() == test_content
except ApiException:
# Finish the upload session if it failed during chunk upload
if not file_id:
file_name = f"{PREFIX}chunked_upload_test.bin"
try:
file_id = client.finish_upload_session(
session_id=session_id,
name=file_name,
properties={"Name": file_name, "Test": "ChunkedUpload"},
)
except ApiException:
pass

raise
finally:
# Clean up
if file_id:
try:
client.delete_file(id=file_id)
except Exception:
pass # Ignore cleanup errors

def test__finish_upload_session__invalid_session_id_raises(
self, client: FileClient, invalid_file_id: str
):
file_name = f"{PREFIX}invalid_session.txt"
properties = {"Name": file_name}

with pytest.raises(ApiException):
client.finish_upload_session(
session_id=invalid_file_id, name=file_name, properties=properties
)