A request can wait to be served for longer than the access token it was sent with lives, and comes back refused for a token that was valid when it left. Uploads that take minutes make that wait ordinary. - tell an expired token apart from any other refusal by the code the API returns, and retry only that one, once - prefer a token another process left behind over minting a second: it saves a round trip, and a rotating provider would retire one that is still in use. Assign it rather than read it, since the setter is what rewrites the session header the retry will carry - settle the token before the request so the one compared afterwards is the one that was actually sent - report an overloaded server when the second attempt is refused too, and let API errors out of the upload path with their own type
166 lines
6.6 KiB
Python
166 lines
6.6 KiB
Python
import mimetypes
|
|
import os
|
|
from typing import Any, Final
|
|
from unicodedata import normalize
|
|
|
|
from pydantic import TypeAdapter
|
|
from pydantic.dataclasses import dataclass
|
|
from requests_toolbelt.multipart.encoder import MultipartEncoder
|
|
|
|
from mdrsclient.api.base import BaseApi
|
|
from mdrsclient.api.utils import retry_on_expired_token, token_check
|
|
from mdrsclient.exceptions import MDRSException, UnexpectedException
|
|
from mdrsclient.models import File
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FilesApiCreateResponse:
|
|
id: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FilesApiListResponse:
|
|
count: int
|
|
next: str | None
|
|
previous: str | None
|
|
results: list[File]
|
|
|
|
|
|
class FilesApi(BaseApi):
|
|
ENTRYPOINT: Final[str] = "v3/files/"
|
|
FALLBACK_MIMETYPE: Final[str] = "application/octet-stream"
|
|
|
|
@retry_on_expired_token
|
|
def list(self, folder_id: str, page_num: int) -> FilesApiListResponse:
|
|
url = self.ENTRYPOINT
|
|
token_check(self.connection)
|
|
params: dict[str, str | int] = {"folder_id": folder_id, "page": page_num}
|
|
response = self.connection.get(url, params=params)
|
|
self._raise_response_error(response)
|
|
return TypeAdapter(FilesApiListResponse).validate_python(response.json())
|
|
|
|
@retry_on_expired_token
|
|
def retrieve(self, id: str) -> File:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT + id + "/"
|
|
token_check(self.connection)
|
|
response = self.connection.get(url)
|
|
self._raise_response_error(response)
|
|
return TypeAdapter(File).validate_python(response.json())
|
|
|
|
@retry_on_expired_token
|
|
def create(self, folder_id: str, path: str) -> str:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT
|
|
token_check(self.connection)
|
|
data: dict[str, str | int] | MultipartEncoder = {}
|
|
try:
|
|
with open(os.path.realpath(path), mode="rb") as fp:
|
|
data = MultipartEncoder(
|
|
fields={
|
|
"folder_id": folder_id,
|
|
"file": (normalize("NFC", os.path.basename(path)), fp, self._get_mime_type(path)),
|
|
}
|
|
)
|
|
response = self.connection.post(url, data=data, headers={"Content-Type": data.content_type})
|
|
self._raise_response_error(response)
|
|
ret = TypeAdapter(FilesApiCreateResponse).validate_python(response.json())
|
|
except OSError:
|
|
raise UnexpectedException(f"Could not open `{path}` file.")
|
|
except MemoryError:
|
|
raise UnexpectedException("Out of memory.")
|
|
except MDRSException:
|
|
# Already says what went wrong, and the caller may want to act on the kind.
|
|
raise
|
|
except Exception as e:
|
|
raise UnexpectedException("Unspecified error.") from e
|
|
return ret.id
|
|
|
|
@retry_on_expired_token
|
|
def update(self, file: File, path: str | None) -> bool:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT + file.id + "/"
|
|
token_check(self.connection)
|
|
data: dict[str, str | int] | MultipartEncoder = {}
|
|
if path is not None:
|
|
# update file body
|
|
try:
|
|
with open(os.path.realpath(path), mode="rb") as fp:
|
|
data = MultipartEncoder(
|
|
fields={"file": (normalize("NFC", os.path.basename(path)), fp, self._get_mime_type(path))}
|
|
)
|
|
response = self.connection.put(url, data=data, headers={"Content-Type": data.content_type})
|
|
except OSError:
|
|
raise UnexpectedException(f"Could not open `{path}` file.")
|
|
except MemoryError:
|
|
raise UnexpectedException("Out of memory.")
|
|
except Exception as e:
|
|
raise UnexpectedException("Unspecified error.") from e
|
|
else:
|
|
# update metadata
|
|
data = {"name": file.name, "description": file.description}
|
|
response = self.connection.put(url, data=data)
|
|
self._raise_response_error(response)
|
|
return True
|
|
|
|
@retry_on_expired_token
|
|
def destroy(self, file: File) -> bool:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT + file.id + "/"
|
|
token_check(self.connection)
|
|
response = self.connection.delete(url)
|
|
self._raise_response_error(response)
|
|
return True
|
|
|
|
@retry_on_expired_token
|
|
def move(self, file: File, folder_id: str, name: str) -> bool:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT + file.id + "/move/"
|
|
data: dict[str, str | int] = {"folder": folder_id, "name": name}
|
|
token_check(self.connection)
|
|
response = self.connection.post(url, data=data)
|
|
self._raise_response_error(response)
|
|
return True
|
|
|
|
@retry_on_expired_token
|
|
def copy(self, file: File, folder_id: str, name: str) -> bool:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT + file.id + "/copy/"
|
|
data: dict[str, str | int] = {"folder": folder_id, "name": name}
|
|
token_check(self.connection)
|
|
response = self.connection.post(url, data=data)
|
|
self._raise_response_error(response)
|
|
return True
|
|
|
|
@retry_on_expired_token
|
|
def metadata(self, file: File) -> dict[str, Any]:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = self.ENTRYPOINT + file.id + "/metadata/"
|
|
token_check(self.connection)
|
|
response = self.connection.get(url)
|
|
self._raise_response_error(response)
|
|
return response.json()
|
|
|
|
@retry_on_expired_token
|
|
def download(self, file: File, path: str) -> bool:
|
|
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
|
url = file.download_url
|
|
token_check(self.connection)
|
|
response = self.connection.get(url, stream=True)
|
|
self._raise_response_error(response)
|
|
try:
|
|
with open(path, "wb") as f:
|
|
for chunk in response.iter_content(chunk_size=4096):
|
|
if chunk:
|
|
f.write(chunk)
|
|
f.flush()
|
|
except PermissionError:
|
|
print(f"Cannot create file `{path}`: Permission denied.")
|
|
return True
|
|
|
|
def _get_mime_type(self, path: str) -> str:
|
|
mt = mimetypes.guess_type(path)
|
|
if mt:
|
|
return mt[0] or self.FALLBACK_MIMETYPE
|
|
return self.FALLBACK_MIMETYPE
|