Skip to content

Commit ebdc4c1

Browse files
feat: Add Client for File Service (#65)
1 parent 8bc8f63 commit ebdc4c1

16 files changed

+758
-0
lines changed

docs/api_reference.rst

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ API Reference
1212
api_reference/testmonitor
1313
api_reference/dataframe
1414
api_reference/spec
15+
api_reference/file
1516

1617
Indices and tables
1718
------------------

docs/api_reference/file.rst

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
.. _api_tag_page:
2+
3+
nisystemlink.clients.file
4+
======================
5+
6+
.. autoclass:: nisystemlink.clients.file.FileClient
7+
:exclude-members: __init__
8+
9+
.. automethod:: __init__
10+
.. automethod:: api_info
11+
.. automethod:: get_files
12+
.. automethod:: delete_file
13+
.. automethod:: delete_files
14+
.. automethod:: upload_file
15+
.. automethod:: download_file
16+
.. automethod:: update_metadata
17+
18+
.. automodule:: nisystemlink.clients.file.models
19+
:members:
20+
:imported-members:

docs/getting_started.rst

+27
Original file line numberDiff line numberDiff line change
@@ -155,5 +155,32 @@ Create and Query Specifications
155155
Update and Delete Specifications
156156

157157
.. literalinclude:: ../examples/spec/update_and_delete_specs.py
158+
:language: python
159+
:linenos:
160+
161+
162+
File API
163+
-------
164+
165+
Overview
166+
~~~~~~~~
167+
168+
The :class:`.FileClient` class is the primary entry point of the File API.
169+
170+
When constructing a :class:`.FileClient`, you can pass an
171+
:class:`.HttpConfiguration` (like one retrieved from the
172+
:class:`.HttpConfigurationManager`), or let :class:`.FileClient` use the
173+
default connection. The default connection depends on your environment.
174+
175+
With a :class:`.FileClient` object, you can:
176+
177+
* Get the list of files, download and delete files
178+
179+
Examples
180+
~~~~~~~~
181+
182+
Get the metadata of a File using its Id and download it.
183+
184+
.. literalinclude:: ../examples/file/download_file.py
158185
:language: python
159186
:linenos:

examples/file/download_file.py

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Example to download a file from SystemLink."""
2+
3+
from shutil import copyfileobj
4+
5+
from nisystemlink.clients.file import FileClient
6+
7+
client = FileClient()
8+
9+
file_id = "a55adc7f-5068-4202-9d70-70ca6a06bee9"
10+
11+
# Fetch the file metadata to get the name
12+
files = client.get_files(ids=[file_id])
13+
14+
if not files.available_files:
15+
raise Exception(f"File ID {file_id} not found.")
16+
17+
18+
file_name = "Untitled"
19+
20+
file_properties = files.available_files[0].properties
21+
22+
if file_properties:
23+
file_name = file_properties["Name"]
24+
25+
# Download the file using FileId with content inline
26+
content = client.download_file(id=file_id)
27+
28+
# Write the content to a file
29+
with open(file_name, "wb") as f:
30+
copyfileobj(content, f)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from nisystemlink.clients.core.helpers import IteratorFileLike
2+
from requests.models import Response
3+
4+
5+
def file_like_response_handler(response: Response) -> IteratorFileLike:
6+
"""Response handler for File-Like content."""
7+
return IteratorFileLike(response.iter_content(chunk_size=4096))

nisystemlink/clients/file/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from ._file_client import FileClient
2+
3+
# flake8: noqa
+260
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
"""Implementation of FileClient."""
2+
3+
import json
4+
from typing import BinaryIO, Dict, List, Optional
5+
6+
from nisystemlink.clients import core
7+
from nisystemlink.clients.core._uplink._base_client import BaseClient
8+
from nisystemlink.clients.core._uplink._file_like_response import (
9+
file_like_response_handler,
10+
)
11+
from nisystemlink.clients.core._uplink._methods import (
12+
delete,
13+
get,
14+
post,
15+
response_handler,
16+
)
17+
from nisystemlink.clients.core.helpers import IteratorFileLike
18+
from requests.models import Response
19+
from uplink import Body, Field, params, Part, Path, Query, retry
20+
21+
from . import models
22+
23+
24+
def _file_uri_response_handler(response: Response) -> str:
25+
"""Response handler for File URI response. Extracts ID from URI."""
26+
resp = response.json()
27+
uri: str = resp["uri"]
28+
# Split the uri by '/' and get the last part
29+
parts = uri.split("/")
30+
return parts[-1]
31+
32+
33+
@retry(when=retry.when.status(429), stop=retry.stop.after_attempt(5))
34+
class FileClient(BaseClient):
35+
def __init__(self, configuration: Optional[core.HttpConfiguration] = None):
36+
"""Initialize an instance.
37+
38+
Args:
39+
configuration: Defines the web server to connect to and information about
40+
how to connect. If not provided, the
41+
:class:`HttpConfigurationManager <nisystemlink.clients.core.HttpConfigurationManager>`
42+
is used to obtain the configuration.
43+
44+
Raises:
45+
ApiException: if unable to communicate with the File Service.
46+
"""
47+
if configuration is None:
48+
configuration = core.HttpConfigurationManager.get_configuration()
49+
50+
super().__init__(configuration, "/nifile/v1/")
51+
52+
@get("")
53+
def api_info(self) -> models.V1Operations:
54+
"""Get information about available API operations.
55+
56+
Returns:
57+
Information about available API operations.
58+
59+
Raises:
60+
ApiException: if unable to communicate with the File Service.
61+
"""
62+
63+
@get(
64+
"service-groups/Default/files",
65+
args=[
66+
Query,
67+
Query,
68+
Query(name="orderBy"),
69+
Query(name="orderByDescending"),
70+
Query(name="id"),
71+
],
72+
)
73+
def __get_files(
74+
self,
75+
skip: int = 0,
76+
take: int = 0,
77+
order_by: Optional[str] = None,
78+
order_by_descending: Optional[str] = "false",
79+
ids: Optional[str] = None,
80+
) -> models.FileQueryResponse:
81+
"""Lists available files on the SystemLink File service.
82+
Use the skip and take parameters to return paged responses.
83+
The orderBy and orderByDescending fields can be used to manage sorting the list by metadata objects.
84+
85+
Args:
86+
skip: How many files to skip in the result when paging. Defaults to 0.
87+
take: How many files to return in the result, or 0 to use a default defined by the service.
88+
Defaults to 0.
89+
order_by: The name of the metadata key to sort by. Defaults to None.
90+
order_by_descending: The elements in the list are sorted ascending if "false"
91+
and descending if "true". Defaults to "false".
92+
ids: Comma-separated list of file IDs to search by. Defaults to None.
93+
94+
Returns:
95+
File Query Response
96+
97+
Raises:
98+
ApiException: if unable to communicate with the File Service.
99+
"""
100+
101+
def get_files(
102+
self,
103+
skip: int = 0,
104+
take: int = 0,
105+
order_by: Optional[models.FileQueryOrderBy] = None,
106+
order_by_descending: Optional[bool] = False,
107+
ids: Optional[List[str]] = None,
108+
) -> models.FileQueryResponse:
109+
"""Lists available files on the SystemLink File service.
110+
Use the skip and take parameters to return paged responses.
111+
The orderBy and orderByDescending fields can be used to manage sorting the list by metadata objects.
112+
113+
Args:
114+
skip: How many files to skip in the result when paging. Defaults to 0.
115+
take: How many files to return in the result, or 0 to use a default defined by the service.
116+
Defaults to 0.
117+
order_by: The name of the metadata key to sort by. Defaults to None.
118+
order_by_descending: The elements in the list are sorted ascending if False
119+
and descending if True. Defaults to False.
120+
ids: List of file IDs to search by. Defaults to None.
121+
122+
Returns:
123+
File Query Response
124+
125+
Raises:
126+
ApiException: if unable to communicate with the File Service.
127+
"""
128+
# Uplink does not support enum serializing into str
129+
# workaround as the service expects lower case `true` and `false`
130+
# uplink serializes bools to `True` and `False`
131+
order_by_str = order_by.value if order_by is not None else None
132+
order_by_desc_str = "true" if order_by_descending else "false"
133+
134+
if ids:
135+
ids_str = ",".join(ids)
136+
else:
137+
ids_str = ""
138+
139+
resp = self.__get_files(
140+
skip=skip,
141+
take=take,
142+
order_by=order_by_str,
143+
order_by_descending=order_by_desc_str,
144+
ids=ids_str,
145+
)
146+
147+
return resp
148+
149+
@params({"force": True}) # type: ignore
150+
@delete("service-groups/Default/files/{id}", args=[Path])
151+
def delete_file(self, id: str) -> None:
152+
"""Deletes the file indicated by the `file_id`.
153+
154+
Args:
155+
id: The ID of the file.
156+
157+
Raises:
158+
ApiException: if unable to communicate with the File Service.
159+
"""
160+
161+
@params({"force": True}) # type: ignore
162+
@post("service-groups/Default/delete-files", args=[Field])
163+
def delete_files(self, ids: List[str]) -> None:
164+
"""Delete multiple files.
165+
166+
Args:
167+
ids: List of unique IDs of Files.
168+
169+
Raises:
170+
ApiException: if unable to communicate with the File Service.
171+
"""
172+
173+
@params({"inline": True}) # type: ignore
174+
@response_handler(file_like_response_handler)
175+
@get("service-groups/Default/files/{id}/data", args=[Path])
176+
def download_file(self, id: str) -> IteratorFileLike:
177+
"""Downloads a file from the SystemLink File service.
178+
179+
Args:
180+
id: The ID of the file.
181+
182+
Yields:
183+
A file-like object for reading the exported data.
184+
185+
Raises:
186+
ApiException: if unable to communicate with the File Service.
187+
"""
188+
189+
@response_handler(_file_uri_response_handler)
190+
@post("service-groups/Default/upload-files")
191+
def __upload_file(
192+
self,
193+
file: Part,
194+
metadata: Part = None,
195+
id: Part = None,
196+
workspace: Query = None,
197+
) -> str:
198+
"""Uploads a file using multipart/form-data headers to send the file payload in the HTTP body.
199+
200+
Args:
201+
file: The file to upload.
202+
metadata: JSON Dictionary with key/value pairs
203+
id: Specify an unique (among all file) 24-digit Hex string ID of the file once it is uploaded.
204+
Defaults to None.
205+
workspace: The id of the workspace the file belongs to. Defaults to None.
206+
207+
Returns:
208+
ID of uploaded file.
209+
210+
Raises:
211+
ApiException: if unable to communicate with the File Service.
212+
"""
213+
214+
def upload_file(
215+
self,
216+
file: BinaryIO,
217+
metadata: Optional[Dict[str, str]] = None,
218+
id: Optional[str] = None,
219+
workspace: Optional[str] = None,
220+
) -> str:
221+
"""Uploads a file to the File Service.
222+
223+
Args:
224+
file: The file to upload.
225+
metadata: File Metadata as dictionary.
226+
id: Specify an unique (among all file) 24-digit Hex string ID of the file once it is uploaded.
227+
Defaults to None.
228+
workspace: The id of the workspace the file belongs to. Defaults to None.
229+
230+
Returns:
231+
ID of uploaded file.
232+
233+
Raises:
234+
ApiException: if unable to communicate with the File Service.
235+
"""
236+
if metadata:
237+
metadata_str = json.dumps(metadata)
238+
else:
239+
metadata_str = None
240+
241+
file_id = self.__upload_file(
242+
file=file,
243+
metadata=metadata_str,
244+
id=id,
245+
workspace=workspace,
246+
)
247+
248+
return file_id
249+
250+
@post("service-groups/Default/files/{id}/update-metadata", args=[Body, Path])
251+
def update_metadata(self, metadata: models.UpdateMetadataRequest, id: str) -> None:
252+
"""Updates an existing file's metadata with the specified metadata properties.
253+
254+
Args:
255+
metadata: File's metadata and options for updating it.
256+
id: ID of the file to update Metadata.
257+
258+
Raises:
259+
ApiException: if unable to communicate with the File Service.
260+
"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from ._file_metadata import FileMetadata
2+
from ._file_query_order_by import FileQueryOrderBy
3+
from ._file_query_response import FileQueryResponse
4+
from ._link import Link
5+
from ._operations import V1Operations
6+
from ._update_metadata import UpdateMetadataRequest
7+
8+
# flake8: noqa

0 commit comments

Comments
 (0)