Files
mdrs-client-python/mdrsclient/client.py
T
orrisroot 4c9954c1fd fix: clear the type checker and linter findings
flake8 reported 49 findings and pyright 10, and two of them were real
bugs rather than matters of style. The rest were unused imports and
four functions over the complexity limit.

- Unlock a DOI folder with the id the DOI response carries, rather
  than an attribute the model does not have, which raised
  `AttributeError` on every locked DOI folder.
- Size the `ls` Size column from the sub-folder on the row rather than
  from its parent, which pushed the later columns out of line.
- Share the path resolution and the destination checks between `cp`
  and `mv`, and split the recursive download and the `ls` row
  printing, bringing all four functions under the complexity limit.
- Accept a client built without a connection, which `config` and
  `version` rely on, and report the reason if one is then asked for.
- Declare the config protocol's constructor for the type checker
  alone, so the protocol keeps its guard against being instantiated.
- Remove 41 unused imports, and let flake8 accept black's spacing.
- Point pyright at the project's own environment, without which it
  resolved no dependency and reported 124 findings that were not real.
- Cover the two fixes and the shared `cp`/`mv` paths with new tests.
2026-09-04 18:23:17 +09:00

254 lines
11 KiB
Python

import os
from dataclasses import dataclass
from unicodedata import normalize
from mdrsclient.api import FilesApi, FoldersApi
from mdrsclient.cache import CacheInterface
from mdrsclient.config import ConfigInterface
from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import IllegalArgumentException
from mdrsclient.models import File, Folder, FolderSimple, Laboratory
from mdrsclient.models.file import find_file
from mdrsclient.services import MdrsService
@dataclass(frozen=True)
class TransferEndpoints:
"""Both ends of a `cp` or `mv`, resolved: the parents, their contents, and the names."""
laboratory: Laboratory
s_path: str
s_basename: str
s_parent_folder: Folder
s_parent_files: list[File]
d_path: str
d_basename: str
d_parent_folder: Folder
d_parent_files: list[File]
def is_same_place(self) -> bool:
"""Whether the destination names what the source already is, leaving nothing to do."""
return self.s_parent_folder.id == self.d_parent_folder.id and self.s_basename == self.d_basename
class MdrsClient(MdrsService):
"""Service layer client for MDRS."""
def __init__(self, connection: MDRSConnection | None, config_class: type[ConfigInterface] | None = None):
super().__init__(connection, config_class)
@classmethod
def from_remote(
cls, remote: str, cache: CacheInterface | None = None, config: ConfigInterface | None = None
) -> "MdrsClient":
return cls(cls.create_connection(remote, cache, config))
def mkdir(self, remote_path: str) -> None:
remote, laboratory_name, r_path = self.parse_remote_host_with_path(remote_path)
r_path = r_path.rstrip("/")
r_dirname = os.path.dirname(r_path)
r_basename = os.path.basename(r_path)
laboratory = self.find_laboratory(laboratory_name)
parent_folder = self.find_folder(laboratory, r_dirname)
files = self.find_files(parent_folder.id)
if parent_folder.find_sub_folder(r_basename) is not None or find_file(files, r_basename) is not None:
raise IllegalArgumentException(f"Cannot create folder `{r_path}`: File exists.")
folder_api = FoldersApi(self.connection)
folder_api.create(normalize("NFC", r_basename), parent_folder.id)
def rm(self, remote_path: str, is_recursive: bool = False) -> None:
remote, laboratory_name, r_path = self.parse_remote_host_with_path(remote_path)
r_path = r_path.rstrip("/")
r_dirname = os.path.dirname(r_path)
r_basename = os.path.basename(r_path)
laboratory = self.find_laboratory(laboratory_name)
parent_folder = self.find_folder(laboratory, r_dirname)
parent_files = self.find_files(parent_folder.id)
file = find_file(parent_files, r_basename)
if file is not None:
file_api = FilesApi(self.connection)
file_api.destroy(file)
else:
folder = parent_folder.find_sub_folder(r_basename)
if folder is None:
raise IllegalArgumentException(f"Cannot remove `{r_path}`: No such file or folder.")
if not is_recursive:
raise IllegalArgumentException(f"Cannot remove `{r_path}`: Is a folder.")
folder_api = FoldersApi(self.connection)
folder_api.destroy(folder.id, True)
def ls(self, remote_path: str, password: str | None = None) -> tuple[Folder, list[File]]:
folder, laboratory = self.resolve_folder(remote_path, password)
files = self.find_files(folder.id)
return folder, files
def __resolve_transfer(self, src_path: str, dest_path: str) -> TransferEndpoints:
"""Resolve both ends of a transfer, refusing one that crosses a remote or a laboratory."""
s_remote, s_laboratory_name, s_path = self.parse_remote_host_with_path(src_path)
d_remote, d_laboratory_name, d_path = self.parse_remote_host_with_path(dest_path)
if s_remote != d_remote:
raise IllegalArgumentException("Remote host mismatched.")
if s_laboratory_name != d_laboratory_name:
raise IllegalArgumentException("Laboratory mismatched.")
s_path = s_path.rstrip("/")
s_basename = os.path.basename(s_path)
# A destination ending in a separator names a folder to put the source into, and
# keeps the source's own name.
if d_path.endswith("/"):
d_dirname = d_path
d_basename = s_basename
else:
d_dirname = os.path.dirname(d_path)
d_basename = os.path.basename(d_path)
laboratory = self.find_laboratory(s_laboratory_name)
s_parent_folder = self.find_folder(laboratory, os.path.dirname(s_path))
d_parent_folder = self.find_folder(laboratory, d_dirname)
return TransferEndpoints(
laboratory=laboratory,
s_path=s_path,
s_basename=s_basename,
s_parent_folder=s_parent_folder,
s_parent_files=self.find_files(s_parent_folder.id),
d_path=d_path,
d_basename=d_basename,
d_parent_folder=d_parent_folder,
d_parent_files=self.find_files(d_parent_folder.id),
)
@staticmethod
def __check_file_destination(ends: TransferEndpoints) -> None:
"""Refuse a destination that a file cannot take the place of."""
if find_file(ends.d_parent_files, ends.d_basename) is not None:
raise IllegalArgumentException(f"File `{ends.d_basename}` already exists.")
if ends.d_parent_folder.find_sub_folder(ends.d_basename) is not None:
raise IllegalArgumentException(
f"Cannot overwrite non-folder `{ends.d_basename}` with folder `{ends.d_path}`."
)
@staticmethod
def __check_folder_destination(ends: TransferEndpoints, s_folder: FolderSimple) -> None:
"""Refuse a destination that a folder cannot take the place of."""
if find_file(ends.d_parent_files, ends.d_basename) is not None:
raise IllegalArgumentException(
f"Cannot overwrite non-folder `{ends.d_basename}` with folder `{ends.s_path}`."
)
d_folder = ends.d_parent_folder.find_sub_folder(ends.d_basename)
if d_folder is not None:
if d_folder.id == s_folder.id:
raise IllegalArgumentException(f"`{ends.s_path}` and `{ends.s_path}` are the same folder.")
raise IllegalArgumentException(f"Cannot move `{ends.s_path}` to `{ends.d_path}`: Folder not empty.")
@staticmethod
def __find_source_folder(ends: TransferEndpoints) -> FolderSimple:
s_folder = ends.s_parent_folder.find_sub_folder(ends.s_basename)
if s_folder is None:
raise IllegalArgumentException(f"File or folder `{ends.s_basename}` not found.")
return s_folder
def cp(self, src_path: str, dest_path: str, is_recursive: bool = False) -> None:
ends = self.__resolve_transfer(src_path, dest_path)
s_file = find_file(ends.s_parent_files, ends.s_basename)
if s_file is not None:
self.__check_file_destination(ends)
if not ends.is_same_place():
FilesApi(self.connection).copy(s_file, ends.d_parent_folder.id, normalize("NFC", ends.d_basename))
return
s_folder = self.__find_source_folder(ends)
if not is_recursive:
raise IllegalArgumentException(f"Cannot copy `{ends.s_path}`: Is a folder.")
self.__check_folder_destination(ends, s_folder)
if not ends.is_same_place():
FoldersApi(self.connection).copy(s_folder, ends.d_parent_folder.id, normalize("NFC", ends.d_basename))
def mv(self, src_path: str, dest_path: str) -> None:
ends = self.__resolve_transfer(src_path, dest_path)
s_file = find_file(ends.s_parent_files, ends.s_basename)
if s_file is not None:
self.__check_file_destination(ends)
if not ends.is_same_place():
FilesApi(self.connection).move(s_file, ends.d_parent_folder.id, normalize("NFC", ends.d_basename))
return
s_folder = self.__find_source_folder(ends)
self.__check_folder_destination(ends, s_folder)
if not ends.is_same_place():
FoldersApi(self.connection).move(s_folder, ends.d_parent_folder.id, normalize("NFC", ends.d_basename))
def chacl(
self, remote_path: str, access_level: int, is_recursive: bool = False, password: str | None = None
) -> None:
remote, laboratory_name, r_path = self.parse_remote_host_with_path(remote_path)
r_path = r_path.rstrip("/")
laboratory = self.find_laboratory(laboratory_name)
folder = self.find_folder(laboratory, r_path)
folder_api = FoldersApi(self.connection)
folder_api.acl(folder.id, access_level, is_recursive, password)
def metadata(self, remote_path: str, password: str | None = None) -> dict:
folder, laboratory = self.resolve_folder(remote_path, password)
folder_api = FoldersApi(self.connection)
return folder_api.metadata(folder.id)
def file_metadata(self, remote_path: str, password: str | None = None) -> dict:
folder, laboratory, r_basename = self.resolve_file(remote_path, password)
files = self.find_files(folder.id)
file = find_file(files, r_basename)
if file is None:
raise IllegalArgumentException(f"File `{r_basename}` not found.")
file_api = FilesApi(self.connection)
return file_api.metadata(file)
def upload(
self, local_path: str, remote_path: str, is_recursive: bool = False, is_skip_if_exists: bool = False
) -> None:
from mdrsclient.transfer import Uploader
uploader = Uploader(self)
uploader.upload(local_path, remote_path, is_recursive, is_skip_if_exists)
def download(
self,
remote_path: str,
local_path: str,
is_recursive: bool = False,
is_skip_if_exists: bool = False,
password: str | None = None,
excludes: list[str] | None = None,
) -> None:
from mdrsclient.transfer import Downloader
downloader = Downloader(self)
downloader.download(remote_path, local_path, is_recursive, is_skip_if_exists, password, excludes)
def version(self) -> str:
from mdrsclient.__version__ import __version__
return f"mdrs {__version__}"
def config_create(self, remote: str, url: str) -> None:
remote = self.parse_remote_host(remote)
config = self.config_class(remote)
if config.url is not None:
raise IllegalArgumentException(f"Remote host `{remote}` is already exists.")
else:
config.url = url
def config_update(self, remote: str, url: str) -> None:
remote = self.parse_remote_host(remote)
config = self.config_class(remote)
if config.url is None:
raise IllegalArgumentException(f"Remote host `{remote}` is not exists.")
else:
config.url = url
def config_list(self) -> list:
config = self.config_class("")
return config.list()
def config_delete(self, remote: str) -> None:
remote = self.parse_remote_host(remote)
config = self.config_class(remote)
if config.url is None:
raise IllegalArgumentException(f"Remote host `{remote}` is not exists.")
else:
del config.url