diff --git a/README.md b/README.md index 6e8ce7c..57f9759 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The mdrs-client-python is python library and a command-line client for up- and d poetry install ``` -## Example Usage +## CLI Usage ### config create @@ -205,3 +205,28 @@ Show the help message and exit ```shell mdrs -h ``` + +## Python API Usage + +You can also use this package as a Python library to programmatically interact with MDRS repositories. + +```python +from mdrsclient.client import MdrsClient +from mdrsclient.cache import InMemoryCache + +# 1. Setup client with an in-memory cache to avoid local `.mdrsclient` state files +cache = InMemoryCache() +client = MdrsClient.from_remote("neurodata", cache=cache) + +# 2. Login to the remote server +client.login("username", "password") + +# 3. Use service methods +labs = client.get_laboratories() +metadata = client.metadata("neurodata:/NIU/Repository/") + +# Transfer files programmatically +client.upload("/path/to/local/data", "neurodata:/NIU/Repository/TEST/", is_recursive=True) +client.download("neurodata:/NIU/Repository/TEST/data", "/path/to/local", is_recursive=True) +``` + diff --git a/mdrsclient/__init__.py b/mdrsclient/__init__.py index 9835614..754757d 100644 --- a/mdrsclient/__init__.py +++ b/mdrsclient/__init__.py @@ -1,3 +1,7 @@ from mdrsclient.__version__ import __version__ __all__ = ["__version__"] + +from mdrsclient.client import MdrsClient + +__all__ = ["MdrsClient"] diff --git a/mdrsclient/__version__.py b/mdrsclient/__version__.py index 6beae3c..c5d56ed 100644 --- a/mdrsclient/__version__.py +++ b/mdrsclient/__version__.py @@ -1,5 +1,9 @@ -from importlib.metadata import version +import importlib.metadata + +try: + __version__ = importlib.metadata.version("mdrs-client-python") +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0-dev" -__version__ = version("mdrs-client-python") __all__ = ["__version__"] diff --git a/mdrsclient/api/doi.py b/mdrsclient/api/doi.py index 7b0e6e6..8a9a7b8 100644 --- a/mdrsclient/api/doi.py +++ b/mdrsclient/api/doi.py @@ -5,36 +5,29 @@ from pydantic.dataclasses import dataclass from mdrsclient.api.base import BaseApi from mdrsclient.api.utils import token_check +from mdrsclient.models.doi import Doi @dataclass(frozen=True) -class DoiFolderRef: - """Nested folder reference returned inside a DOI response. - - The DOI endpoint only returns the folder ``id``; ``laboratory_id`` must be - obtained by subsequently calling the folder retrieve endpoint. - """ - +class DoiRetrieveFolderRef: id: str @dataclass(frozen=True) -class DoiResponse: - """Response from GET v3/doi/{id}/.""" - - # The internal DOI suffix ID returned as a string (e.g. "20260429-001"). +class DoiRetrieveResponse: id: str doi: str - folder: DoiFolderRef + folder: DoiRetrieveFolderRef class DoiApi(BaseApi): ENTRYPOINT: Final[str] = "v3/doi/" - def retrieve(self, doi_id: str) -> DoiResponse: + def retrieve(self, doi_id: str) -> Doi: """Retrieve the folder associated with a DOI suffix ID (GET v3/doi/{id}/).""" url = self.ENTRYPOINT + doi_id + "/" token_check(self.connection) response = self.connection.get(url) self._raise_response_error(response) - return TypeAdapter(DoiResponse).validate_python(response.json()) + api_resp = TypeAdapter(DoiRetrieveResponse).validate_python(response.json()) + return Doi(id=api_resp.id, doi=api_resp.doi, folder_id=api_resp.folder.id) diff --git a/mdrsclient/cache.py b/mdrsclient/cache.py index 967d981..7eae530 100644 --- a/mdrsclient/cache.py +++ b/mdrsclient/cache.py @@ -2,6 +2,7 @@ import dataclasses import hashlib import json import os +from typing import Protocol, runtime_checkable from pydantic import TypeAdapter, ValidationError from pydantic.dataclasses import dataclass @@ -16,7 +17,7 @@ from mdrsclient.utils import FileLock class CacheData: user: User | None = None token: Token | None = None - laboratories: Laboratories = Laboratories() + laboratories: Laboratories = dataclasses.field(default_factory=Laboratories) digest: str = "" def clear(self) -> None: @@ -43,6 +44,67 @@ class CacheData: ).hexdigest() +@runtime_checkable +class CacheInterface(Protocol): + @property + def token(self) -> Token | None: ... + @token.setter + def token(self, token: Token) -> None: ... + @token.deleter + def token(self) -> None: ... + + @property + def user(self) -> User | None: ... + @user.setter + def user(self, user: User) -> None: ... + @user.deleter + def user(self) -> None: ... + + @property + def laboratories(self) -> Laboratories: ... + @laboratories.setter + def laboratories(self, laboratories: Laboratories) -> None: ... + + +class InMemoryCache: + def __init__(self) -> None: + self.__data = CacheData() + + @property + def token(self) -> Token | None: + return self.__data.token + + @token.setter + def token(self, token: Token) -> None: + self.__data.token = token + + @token.deleter + def token(self) -> None: + if self.__data.token is not None: + self.__data.token = None + + @property + def user(self) -> User | None: + return self.__data.user + + @user.setter + def user(self, user: User) -> None: + self.__data.user = user + + @user.deleter + def user(self) -> None: + if self.__data.user is not None: + self.__data.user = None + + @property + def laboratories(self) -> Laboratories: + return self.__data.laboratories + + @laboratories.setter + def laboratories(self, laboratories: Laboratories) -> None: + self.__data.laboratories = laboratories + + class CacheFile: __serial: int __cache_dir: str diff --git a/mdrsclient/client.py b/mdrsclient/client.py new file mode 100644 index 0000000..0ac1bb3 --- /dev/null +++ b/mdrsclient/client.py @@ -0,0 +1,266 @@ +import os +from concurrent.futures import ThreadPoolExecutor +from typing import Any +from unicodedata import normalize + +from mdrsclient.api import DoiApi, FilesApi, FoldersApi, LaboratoriesApi, UsersApi +from mdrsclient.commands.base import BaseCommand +from mdrsclient.connection import MDRSConnection +from mdrsclient.exceptions import IllegalArgumentException, MDRSException, UnauthorizedException, UnexpectedException +from mdrsclient.models import File, Folder, Laboratory, Token, User +from mdrsclient.models.file import find_file +from mdrsclient.settings import CONCURRENT + + +class MdrsClient: + """Service layer client for MDRS.""" + + def __init__(self, connection: MDRSConnection): + self.connection = connection + + @classmethod + def from_remote(cls, remote: str) -> "MdrsClient": + return cls(BaseCommand._create_connection(remote)) + + def login(self, username: str, password: str) -> tuple[Token, User]: + user_api = UsersApi(self.connection) + token = user_api.token(username, password) + self.connection.token = token + user = user_api.current() + self.connection.user = user + return token, user + + def logout(self) -> None: + self.connection.logout() + + def whoami(self) -> User: + user_api = UsersApi(self.connection) + return user_api.current() + + def get_laboratories(self) -> list[Laboratory]: + laboratory_api = LaboratoriesApi(self.connection) + labs = laboratory_api.list() + self.connection.laboratories = labs + return list(labs) + + def mkdir(self, remote_path: str) -> None: + remote, laboratory_name, r_path = BaseCommand._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 = BaseCommand._find_laboratory(self.connection, laboratory_name) + parent_folder = BaseCommand._find_folder(self.connection, laboratory, r_dirname) + files = BaseCommand._find_files(self.connection, 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 = BaseCommand._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 = BaseCommand._find_laboratory(self.connection, laboratory_name) + parent_folder = BaseCommand._find_folder(self.connection, laboratory, r_dirname) + parent_files = BaseCommand._find_files(self.connection, 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 = BaseCommand._resolve_folder(self.connection, remote_path, password) + files = BaseCommand._find_files(self.connection, folder.id) + return folder, files + + def cp(self, src_path: str, dest_path: str, is_recursive: bool = False) -> None: + s_remote, s_laboratory_name, s_path = BaseCommand._parse_remote_host_with_path(src_path) + d_remote, d_laboratory_name, d_path = BaseCommand._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_dirname = os.path.dirname(s_path) + s_basename = os.path.basename(s_path) + 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 = BaseCommand._find_laboratory(self.connection, s_laboratory_name) + s_parent_folder = BaseCommand._find_folder(self.connection, laboratory, s_dirname) + s_parent_files = BaseCommand._find_files(self.connection, s_parent_folder.id) + d_parent_folder = BaseCommand._find_folder(self.connection, laboratory, d_dirname) + d_parent_files = BaseCommand._find_files(self.connection, d_parent_folder.id) + s_file = find_file(s_parent_files, s_basename) + if s_file is not None: + d_file = find_file(d_parent_files, d_basename) + if d_file is not None: + raise IllegalArgumentException(f"File `{d_basename}` already exists.") + d_sub_folder = d_parent_folder.find_sub_folder(d_basename) + if d_sub_folder is not None: + raise IllegalArgumentException(f"Cannot overwrite non-folder `{d_basename}` with folder `{d_path}`.") + file_api = FilesApi(self.connection) + if s_parent_folder.id != d_parent_folder.id or d_basename != s_basename: + file_api.copy(s_file, d_parent_folder.id, normalize("NFC", d_basename)) + else: + s_folder = s_parent_folder.find_sub_folder(s_basename) + if s_folder is None: + raise IllegalArgumentException(f"File or folder `{s_basename}` not found.") + if not is_recursive: + raise IllegalArgumentException(f"Cannot copy `{s_path}`: Is a folder.") + if find_file(d_parent_files, d_basename) is not None: + raise IllegalArgumentException(f"Cannot overwrite non-folder `{d_basename}` with folder `{s_path}`.") + d_folder = d_parent_folder.find_sub_folder(d_basename) + if d_folder is not None: + if d_folder.id == s_folder.id: + raise IllegalArgumentException(f"`{s_path}` and `{s_path}` are the same folder.") + raise IllegalArgumentException(f"Cannot move `{s_path}` to `{d_path}`: Folder not empty.") + folder_api = FoldersApi(self.connection) + if s_parent_folder.id != d_parent_folder.id or s_basename != d_basename: + folder_api.copy(s_folder, d_parent_folder.id, normalize("NFC", d_basename)) + + def mv(self, src_path: str, dest_path: str) -> None: + s_remote, s_laboratory_name, s_path = BaseCommand._parse_remote_host_with_path(src_path) + d_remote, d_laboratory_name, d_path = BaseCommand._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_dirname = os.path.dirname(s_path) + s_basename = os.path.basename(s_path) + 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 = BaseCommand._find_laboratory(self.connection, s_laboratory_name) + s_parent_folder = BaseCommand._find_folder(self.connection, laboratory, s_dirname) + s_parent_files = BaseCommand._find_files(self.connection, s_parent_folder.id) + d_parent_folder = BaseCommand._find_folder(self.connection, laboratory, d_dirname) + d_parent_files = BaseCommand._find_files(self.connection, d_parent_folder.id) + s_file = find_file(s_parent_files, s_basename) + if s_file is not None: + d_file = find_file(d_parent_files, d_basename) + if d_file is not None: + raise IllegalArgumentException(f"File `{d_basename}` already exists.") + d_sub_folder = d_parent_folder.find_sub_folder(d_basename) + if d_sub_folder is not None: + raise IllegalArgumentException(f"Cannot overwrite non-folder `{d_basename}` with folder `{d_path}`.") + file_api = FilesApi(self.connection) + if s_parent_folder.id != d_parent_folder.id or d_basename != s_basename: + file_api.move(s_file, d_parent_folder.id, normalize("NFC", d_basename)) + else: + s_folder = s_parent_folder.find_sub_folder(s_basename) + if s_folder is None: + raise IllegalArgumentException(f"File or folder `{s_basename}` not found.") + if find_file(d_parent_files, d_basename) is not None: + raise IllegalArgumentException(f"Cannot overwrite non-folder `{d_basename}` with folder `{s_path}`.") + d_folder = d_parent_folder.find_sub_folder(d_basename) + if d_folder is not None: + if d_folder.id == s_folder.id: + raise IllegalArgumentException(f"`{s_path}` and `{s_path}` are the same folder.") + raise IllegalArgumentException(f"Cannot move `{s_path}` to `{d_path}`: Folder not empty.") + folder_api = FoldersApi(self.connection) + if s_parent_folder.id != d_parent_folder.id or d_basename != s_basename: + folder_api.move(s_folder, d_parent_folder.id, normalize("NFC", 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 = BaseCommand._parse_remote_host_with_path(remote_path) + r_path = r_path.rstrip("/") + laboratory = BaseCommand._find_laboratory(self.connection, laboratory_name) + folder = BaseCommand._find_folder(self.connection, 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 = BaseCommand._resolve_folder(self.connection, 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 = BaseCommand._resolve_file(self.connection, remote_path, password) + files = BaseCommand._find_files(self.connection, 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 _create_connection(self, remote: str): + return self.connection + + def upload( + self, local_path: str, remote_path: str, is_recursive: bool = False, is_skip_if_exists: bool = False + ) -> None: + from mdrsclient.commands.upload import UploadCommand + + UploadCommand._upload_logic(self.connection, 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.commands.download import DownloadCommand + + DownloadCommand._download_logic( + self.connection, remote_path, local_path, is_recursive, is_skip_if_exists, password, excludes or [] + ) + + def ls_command( + self, + remote_path: str, + password: str | None = None, + is_json: bool = False, + is_recursive: bool = False, + is_quiet: bool = False, + ) -> None: + from mdrsclient.commands.ls import LsCommand + + LsCommand._ls_logic(self.connection, remote_path, password, is_json, is_recursive, is_quiet) + + def version(self) -> str: + from mdrsclient.__version__ import __version__ + + return f"mdrs {__version__}" + + def config_create(self, remote: str, url: str) -> None: + from mdrsclient.commands.config import ConfigCommand + + ConfigCommand.create(remote, url) + + def config_update(self, remote: str, url: str) -> None: + from mdrsclient.commands.config import ConfigCommand + + ConfigCommand.update(remote, url) + + def config_list(self) -> list: + from mdrsclient.config import ConfigFile + + config = ConfigFile("") + return config.list() + + def config_delete(self, remote: str) -> None: + from mdrsclient.commands.config import ConfigCommand + + ConfigCommand.delete(remote) diff --git a/mdrsclient/commands/base.py b/mdrsclient/commands/base.py index 6df4941..d60f4de 100644 --- a/mdrsclient/commands/base.py +++ b/mdrsclient/commands/base.py @@ -196,7 +196,7 @@ class BaseCommand(ABC): folder_api = FoldersApi(connection) # Retrieve full folder detail directly by ID; laboratory_id is here. - folder = folder_api.retrieve(doi_resp.folder.id) + folder = folder_api.retrieve(doi_resp.folder_id) if folder.lock: if password is None: diff --git a/mdrsclient/commands/chacl.py b/mdrsclient/commands/chacl.py index c6d1669..029264a 100644 --- a/mdrsclient/commands/chacl.py +++ b/mdrsclient/commands/chacl.py @@ -31,10 +31,8 @@ class ChaclCommand(BaseCommand): @classmethod def chacl(cls, remote_path: str, access_level: int, is_recursive: bool, password: str | None) -> None: - remote, laboratory_name, r_path = cls._parse_remote_host_with_path(remote_path) - r_path = r_path.rstrip("/") - connection = cls._create_connection(remote) - laboratory = cls._find_laboratory(connection, laboratory_name) - folder = cls._find_folder(connection, laboratory, r_path) - folder_api = FoldersApi(connection) - folder_api.acl(folder.id, access_level, is_recursive, password) + remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + client.chacl(remote_path, access_level, is_recursive, password) diff --git a/mdrsclient/commands/config.py b/mdrsclient/commands/config.py index c2c01c1..bb9ee6d 100644 --- a/mdrsclient/commands/config.py +++ b/mdrsclient/commands/config.py @@ -38,13 +38,17 @@ class ConfigCommand(BaseCommand): def func_create(cls, args: Namespace) -> None: remote = str(args.remote) url = str(args.url) - cls.create(remote, url) + from mdrsclient.client import MdrsClient + + MdrsClient(None).config_create(remote, url) @classmethod def func_update(cls, args: Namespace) -> None: remote = str(args.remote) url = str(args.url) - cls.update(remote, url) + from mdrsclient.client import MdrsClient + + MdrsClient(None).config_update(remote, url) @classmethod def func_list(cls, args: Namespace) -> None: @@ -53,7 +57,9 @@ class ConfigCommand(BaseCommand): @classmethod def func_delete(cls, args: Namespace) -> None: remote = str(args.remote) - cls.delete(remote) + from mdrsclient.client import MdrsClient + + MdrsClient(None).config_delete(remote) @classmethod def create(cls, remote: str, url: str) -> None: @@ -75,8 +81,10 @@ class ConfigCommand(BaseCommand): @classmethod def list(cls) -> None: - config = ConfigFile("") - for remote, url in config.list(): + from mdrsclient.client import MdrsClient + + client = MdrsClient(None) + for remote, url in client.config_list(): print(f"{remote}:\t{url}") @classmethod diff --git a/mdrsclient/commands/cp.py b/mdrsclient/commands/cp.py index a60a2e2..1697678 100644 --- a/mdrsclient/commands/cp.py +++ b/mdrsclient/commands/cp.py @@ -29,53 +29,8 @@ class CpCommand(BaseCommand): @classmethod def cp(cls, src_path: str, dest_path: str, is_recursive: bool) -> None: - s_remote, s_laboratory_name, s_path = cls._parse_remote_host_with_path(src_path) - d_remote, d_laboratory_name, d_path = cls._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_dirname = os.path.dirname(s_path) - s_basename = os.path.basename(s_path) - 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) - connection = cls._create_connection(s_remote) - laboratory = cls._find_laboratory(connection, s_laboratory_name) - s_parent_folder = cls._find_folder(connection, laboratory, s_dirname) - s_parent_files = cls._find_files(connection, s_parent_folder.id) - d_parent_folder = cls._find_folder(connection, laboratory, d_dirname) - d_parent_files = cls._find_files(connection, d_parent_folder.id) - s_file = find_file(s_parent_files, s_basename) - if s_file is not None: - # source is file - d_file = find_file(d_parent_files, d_basename) - if d_file is not None: - raise IllegalArgumentException(f"File `{d_basename}` already exists.") - d_sub_folder = d_parent_folder.find_sub_folder(d_basename) - if d_sub_folder is not None: - raise IllegalArgumentException(f"Cannot overwrite non-folder `{d_basename}` with folder `{d_path}`.") - file_api = FilesApi(connection) - if s_parent_folder.id != d_parent_folder.id or d_basename != s_basename: - file_api.copy(s_file, d_parent_folder.id, normalize("NFC", d_basename)) - else: - s_folder = s_parent_folder.find_sub_folder(s_basename) - if s_folder is None: - raise IllegalArgumentException(f"File or folder `{s_basename}` not found.") - # source is folder - if not is_recursive: - raise IllegalArgumentException(f"Cannot copy `{s_path}`: Is a folder.") - if find_file(d_parent_files, d_basename) is not None: - raise IllegalArgumentException(f"Cannot overwrite non-folder `{d_basename}` with folder `{s_path}`.") - d_folder = d_parent_folder.find_sub_folder(d_basename) - if d_folder is not None: - if d_folder.id == s_folder.id: - raise IllegalArgumentException(f"`{s_path}` and `{s_path}` are the same folder.") - raise IllegalArgumentException(f"Cannot move `{s_path}` to `{d_path}`: Folder not empty.") - folder_api = FoldersApi(connection) - if s_parent_folder.id != d_parent_folder.id or s_basename != d_basename: - folder_api.copy(s_folder, d_parent_folder.id, normalize("NFC", d_basename)) + remote = src_path.split(":", 1)[0] if ":" in src_path else "" + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + client.cp(src_path, dest_path, is_recursive) diff --git a/mdrsclient/commands/download.py b/mdrsclient/commands/download.py index 085d49d..d913cf7 100644 --- a/mdrsclient/commands/download.py +++ b/mdrsclient/commands/download.py @@ -67,12 +67,30 @@ class DownloadCommand(BaseCommand): is_skip_if_exists: bool, password: str | None, excludes: list[str], + ) -> None: + remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + client.download(remote_path, local_path, is_recursive, is_skip_if_exists, password, excludes) + return + + @classmethod + def _download_logic( + cls, + connection: MDRSConnection, + remote_path: str, + local_path: str, + is_recursive: bool, + is_skip_if_exists: bool, + password: str | None, + excludes: list[str], ) -> None: # Detect DOI path: "remote:10.xxxx/prefix.ID[/optional/sub/path]" path_component = remote_path.split(":", 1)[1] if ":" in remote_path else "" if cls._is_doi(path_component): remote, doi, subpath = cls._parse_doi_remote_host(remote_path) - connection = cls._create_connection(remote) + l_dirname = os.path.realpath(local_path) if not os.path.isdir(l_dirname): raise IllegalArgumentException(f"Local directory `{local_path}` not found.") @@ -126,7 +144,7 @@ class DownloadCommand(BaseCommand): r_path = r_path.rstrip("/") r_dirname = os.path.dirname(r_path) r_basename = os.path.basename(r_path) - connection = cls._create_connection(remote) + l_dirname = os.path.realpath(local_path) if not os.path.isdir(l_dirname): raise IllegalArgumentException(f"Local directory `{local_path}` not found.") diff --git a/mdrsclient/commands/file_metadata.py b/mdrsclient/commands/file_metadata.py index 79f6662..bb82ea0 100644 --- a/mdrsclient/commands/file_metadata.py +++ b/mdrsclient/commands/file_metadata.py @@ -26,12 +26,8 @@ class FileMetadataCommand(BaseCommand): @classmethod def file_metadata(cls, remote_path: str, password: str | None) -> None: remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" - connection = cls._create_connection(remote) - folder, laboratory, r_basename = cls._resolve_file(connection, remote_path, password) - files = cls._find_files(connection, folder.id) - file = find_file(files, r_basename) - if file is None: - raise IllegalArgumentException(f"File `{r_basename}` not found.") - file_api = FilesApi(connection) - metadata = file_api.metadata(file) + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + metadata = client.file_metadata(remote_path, password) print(json.dumps(metadata, ensure_ascii=False)) diff --git a/mdrsclient/commands/labs.py b/mdrsclient/commands/labs.py index 7861a83..a4793bb 100644 --- a/mdrsclient/commands/labs.py +++ b/mdrsclient/commands/labs.py @@ -19,11 +19,11 @@ class LabsCommand(BaseCommand): @classmethod def labs(cls, remote: str) -> None: - remote = cls._parse_remote_host(remote) - connection = cls._create_connection(remote) - laboratory_api = LaboratoriesApi(connection) - laboratories = laboratory_api.list() - connection.laboratories = laboratories + remote_host = cls._parse_remote_host(remote) + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote_host) + laboratories = client.get_laboratories() label = {"id": "ID", "name": "Name", "pi_name": "PI", "full_name": "Laboratory"} length: dict[str, int] = {} for key in label.keys(): @@ -34,7 +34,6 @@ class LabsCommand(BaseCommand): length["pi_name"] = max(length["pi_name"], len(laboratory.pi_name)) length["full_name"] = max(length["full_name"], len(laboratory.full_name)) header = ( - # f"{label['id']:{length['id']}}\t{label['name']:{length['name']}}\t" f"{label['name']:{length['name']}}\t" f"{label['pi_name']:{length['pi_name']}}\t{label['full_name']:{length['full_name']}}" ) @@ -42,7 +41,6 @@ class LabsCommand(BaseCommand): print("-" * len(header.expandtabs())) for laboratory in laboratories: print( - # f"{laboratory.id:{length['id']}}\t{laboratory.name:{length['name']}}\t" f"{laboratory.name:{length['name']}}\t" f"{laboratory.pi_name:{length['pi_name']}}\t{laboratory.full_name:{length['full_name']}}" ) diff --git a/mdrsclient/commands/login.py b/mdrsclient/commands/login.py index 738855f..a2ed315 100644 --- a/mdrsclient/commands/login.py +++ b/mdrsclient/commands/login.py @@ -21,20 +21,15 @@ class LoginCommand(BaseCommand): @classmethod def func(cls, args: Namespace) -> None: remote = str(args.remote) - username = str(args.username) if args.password else input("Username: ").strip() + username = str(args.username) if args.username else input("Username: ").strip() password = str(args.password) if args.password else getpass.getpass("Password: ").strip() cls.login(remote, username, password) @classmethod def login(cls, remote: str, username: str, password: str) -> None: - remote = cls._parse_remote_host(remote) - config = ConfigFile(remote) - if config.url is None: - raise MissingConfigurationException(f"Remote host `{remote}` is not found.") - connection = MDRSConnection(config.remote, config.url) - user_api = UsersApi(connection) - token = user_api.token(username, password) - connection.token = token - user = user_api.current() - connection.user = user + remote_host = cls._parse_remote_host(remote) + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote_host) + client.login(username, password) print("Login Successful") diff --git a/mdrsclient/commands/logout.py b/mdrsclient/commands/logout.py index 84e3585..055ba94 100644 --- a/mdrsclient/commands/logout.py +++ b/mdrsclient/commands/logout.py @@ -21,9 +21,8 @@ class LogoutCommand(BaseCommand): @classmethod def logout(cls, remote: str) -> None: - remote = cls._parse_remote_host(remote) - config = ConfigFile(remote) - if config.url is None: - raise MissingConfigurationException(f"Remote host `{remote}` is not found.") - connection = MDRSConnection(config.remote, config.url) - connection.logout() + remote_host = cls._parse_remote_host(remote) + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote_host) + client.logout() diff --git a/mdrsclient/commands/ls.py b/mdrsclient/commands/ls.py index 8e94333..cfdd75e 100644 --- a/mdrsclient/commands/ls.py +++ b/mdrsclient/commands/ls.py @@ -55,7 +55,18 @@ class LsCommand(BaseCommand): @classmethod def ls(cls, remote_path: str, password: str | None, is_json: bool, is_recursive: bool, is_quiet: bool) -> None: remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" - connection = cls._create_connection(remote) + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + client.ls_command(remote_path, password, is_json, is_recursive, is_quiet) + return + + @classmethod + def _ls_logic( + cls, connection, remote_path: str, password: str | None, is_json: bool, is_recursive: bool, is_quiet: bool + ) -> None: + remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" + folder, laboratory = cls._resolve_folder(connection, remote_path, password) laboratory_name = laboratory.name files = cls._find_files(connection, folder.id) diff --git a/mdrsclient/commands/metadata.py b/mdrsclient/commands/metadata.py index 2b0725a..8f9f3d3 100644 --- a/mdrsclient/commands/metadata.py +++ b/mdrsclient/commands/metadata.py @@ -23,8 +23,8 @@ class MetadataCommand(BaseCommand): @classmethod def metadata(cls, remote_path: str, password: str | None) -> None: remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" - connection = cls._create_connection(remote) - folder, laboratory = cls._resolve_folder(connection, remote_path, password) - folder_api = FoldersApi(connection) - metadata = folder_api.metadata(folder.id) + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + metadata = client.metadata(remote_path, password) print(json.dumps(metadata, ensure_ascii=False)) diff --git a/mdrsclient/commands/mkdir.py b/mdrsclient/commands/mkdir.py index 6184368..3fb9b35 100644 --- a/mdrsclient/commands/mkdir.py +++ b/mdrsclient/commands/mkdir.py @@ -23,15 +23,8 @@ class MkdirCommand(BaseCommand): @classmethod def mkdir(cls, remote_path: str) -> None: - remote, laboratory_name, r_path = cls._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) - connection = cls._create_connection(remote) - laboratory = cls._find_laboratory(connection, laboratory_name) - parent_folder = cls._find_folder(connection, laboratory, r_dirname) - files = cls._find_files(connection, 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(connection) - folder_api.create(normalize("NFC", r_basename), parent_folder.id) + remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + client.mkdir(remote_path) diff --git a/mdrsclient/commands/mv.py b/mdrsclient/commands/mv.py index e82eabf..5d12879 100644 --- a/mdrsclient/commands/mv.py +++ b/mdrsclient/commands/mv.py @@ -25,51 +25,8 @@ class MvCommand(BaseCommand): @classmethod def mv(cls, src_path: str, dest_path: str) -> None: - s_remote, s_laboratory_name, s_path = cls._parse_remote_host_with_path(src_path) - d_remote, d_laboratory_name, d_path = cls._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_dirname = os.path.dirname(s_path) - s_basename = os.path.basename(s_path) - 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) - connection = cls._create_connection(s_remote) - laboratory = cls._find_laboratory(connection, s_laboratory_name) - s_parent_folder = cls._find_folder(connection, laboratory, s_dirname) - s_parent_files = cls._find_files(connection, s_parent_folder.id) - d_parent_folder = cls._find_folder(connection, laboratory, d_dirname) - d_parent_files = cls._find_files(connection, d_parent_folder.id) - s_file = find_file(s_parent_files, s_basename) - if s_file is not None: - # source is file - d_file = find_file(d_parent_files, d_basename) - if d_file is not None: - raise IllegalArgumentException(f"File `{d_basename}` already exists.") - d_sub_folder = d_parent_folder.find_sub_folder(d_basename) - if d_sub_folder is not None: - raise IllegalArgumentException(f"Cannot overwrite non-folder `{d_basename}` with folder `{d_path}`.") - file_api = FilesApi(connection) - if s_parent_folder.id != d_parent_folder.id or d_basename != s_basename: - file_api.move(s_file, d_parent_folder.id, normalize("NFC", d_basename)) - else: - s_folder = s_parent_folder.find_sub_folder(s_basename) - if s_folder is None: - raise IllegalArgumentException(f"File or folder `{s_basename}` not found.") - # source is folder - if find_file(d_parent_files, d_basename) is not None: - raise IllegalArgumentException(f"Cannot overwrite non-folder `{d_basename}` with folder `{s_path}`.") - d_folder = d_parent_folder.find_sub_folder(d_basename) - if d_folder is not None: - if d_folder.id == s_folder.id: - raise IllegalArgumentException(f"`{s_path}` and `{s_path}` are the same folder.") - raise IllegalArgumentException(f"Cannot move `{s_path}` to `{d_path}`: Folder not empty.") - folder_api = FoldersApi(connection) - if s_parent_folder.id != d_parent_folder.id or d_basename != s_basename: - folder_api.move(s_folder, d_parent_folder.id, normalize("NFC", d_basename)) + remote = src_path.split(":", 1)[0] if ":" in src_path else "" + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + client.mv(src_path, dest_path) diff --git a/mdrsclient/commands/rm.py b/mdrsclient/commands/rm.py index 30e2b93..8118557 100644 --- a/mdrsclient/commands/rm.py +++ b/mdrsclient/commands/rm.py @@ -26,23 +26,8 @@ class RmCommand(BaseCommand): @classmethod def rm(cls, remote_path: str, is_recursive: bool) -> None: - remote, laboratory_name, r_path = cls._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) - connection = cls._create_connection(remote) - laboratory = cls._find_laboratory(connection, laboratory_name) - parent_folder = cls._find_folder(connection, laboratory, r_dirname) - parent_files = cls._find_files(connection, parent_folder.id) - file = find_file(parent_files, r_basename) - if file is not None: - file_api = FilesApi(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(connection) - folder_api.destroy(folder.id, True) + remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + client.rm(remote_path, is_recursive) diff --git a/mdrsclient/commands/upload.py b/mdrsclient/commands/upload.py index 36de969..6101050 100644 --- a/mdrsclient/commands/upload.py +++ b/mdrsclient/commands/upload.py @@ -49,11 +49,22 @@ class UploadCommand(BaseCommand): @classmethod def upload(cls, local_path: str, remote_path: str, is_recursive: bool, is_skip_if_exists: bool) -> None: + remote = remote_path.split(":", 1)[0] if ":" in remote_path else "" + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote) + client.upload(local_path, remote_path, is_recursive, is_skip_if_exists) + return + + @classmethod + def _upload_logic( + cls, connection, local_path: str, remote_path: str, is_recursive: bool, is_skip_if_exists: bool + ) -> None: remote, laboratory_name, r_path = cls._parse_remote_host_with_path(remote_path) l_path = os.path.abspath(local_path) if not os.path.exists(l_path): raise IllegalArgumentException(f"File or directory `{local_path}` not found.") - connection = cls._create_connection(remote) + laboratory = cls._find_laboratory(connection, laboratory_name) folder = cls._find_folder(connection, laboratory, r_path) files = cls._find_files(connection, folder.id) diff --git a/mdrsclient/commands/version.py b/mdrsclient/commands/version.py index 7970ff8..5a7f22f 100644 --- a/mdrsclient/commands/version.py +++ b/mdrsclient/commands/version.py @@ -17,4 +17,8 @@ class VersionCommand(BaseCommand): @classmethod def version(cls) -> None: - print(f"mdrs {__version__}") + from mdrsclient.client import MdrsClient + + # Client initialization is not strictly needed for version, but for consistency: + client = MdrsClient(None) + print(client.version()) diff --git a/mdrsclient/commands/whoami.py b/mdrsclient/commands/whoami.py index 60a2712..7a99e36 100644 --- a/mdrsclient/commands/whoami.py +++ b/mdrsclient/commands/whoami.py @@ -23,12 +23,15 @@ class WhoamiCommand(BaseCommand): @classmethod def whoami(cls, remote: str) -> None: - remote = cls._parse_remote_host(remote) - config = ConfigFile(remote) - if config.url is None: - raise MissingConfigurationException(f"Remote host `{remote}` is not found.") - connection = MDRSConnection(config.remote, config.url) - if connection.token is not None and connection.token.is_expired: - connection.logout() - username = connection.user.username if connection.user is not None else cls.ANONYMOUS_USERNAME + remote_host = cls._parse_remote_host(remote) + from mdrsclient.client import MdrsClient + + client = MdrsClient.from_remote(remote_host) + if client.connection.token is not None and client.connection.token.is_expired: + client.logout() + try: + user = client.whoami() + username = user.username if user is not None else cls.ANONYMOUS_USERNAME + except Exception: + username = cls.ANONYMOUS_USERNAME print(username) diff --git a/mdrsclient/connection.py b/mdrsclient/connection.py index 90718ec..ea411bb 100644 --- a/mdrsclient/connection.py +++ b/mdrsclient/connection.py @@ -9,7 +9,7 @@ from requests_toolbelt.multipart.encoder import MultipartEncoder from typing_extensions import Unpack from mdrsclient.__version__ import __version__ -from mdrsclient.cache import CacheFile +from mdrsclient.cache import CacheFile, CacheInterface from mdrsclient.exceptions import MissingConfigurationException from mdrsclient.models import Laboratories, Token, User @@ -39,14 +39,14 @@ class MDRSConnection: url: str session: Session lock: threading.Lock - __cache: CacheFile + __cache: CacheInterface - def __init__(self, remote: str, url: str) -> None: + def __init__(self, remote: str, url: str, cache: CacheInterface | None = None) -> None: super().__init__() self.url = url self.session = Session() self.lock = threading.Lock() - self.__cache = CacheFile(remote) + self.__cache = cache if cache is not None else CacheFile(remote) self.__prepare_headers() def get(self, url: str, **kwargs: Unpack[_KwArgsMDRSConnectionGet]) -> Response: diff --git a/mdrsclient/models/__init__.py b/mdrsclient/models/__init__.py index 5984baa..40705c0 100644 --- a/mdrsclient/models/__init__.py +++ b/mdrsclient/models/__init__.py @@ -1,3 +1,4 @@ +from mdrsclient.models.doi import Doi from mdrsclient.models.error import DRFStandardizedErrors from mdrsclient.models.file import File from mdrsclient.models.folder import Folder, FolderAccessLevel, FolderSimple @@ -6,6 +7,7 @@ from mdrsclient.models.user import Token, User __all__ = [ "DRFStandardizedErrors", + "Doi", "File", "Folder", "FolderAccessLevel", diff --git a/mdrsclient/models/doi.py b/mdrsclient/models/doi.py new file mode 100644 index 0000000..4b9d967 --- /dev/null +++ b/mdrsclient/models/doi.py @@ -0,0 +1,11 @@ +from pydantic.dataclasses import dataclass + + +@dataclass(frozen=True) +class Doi: + """Model representing a DOI entity (Response from GET v3/doi/{id}/).""" + + # The internal DOI suffix ID returned as a string (e.g. "20260429-001"). + id: str + doi: str + folder_id: str diff --git a/mdrsclient/services.py b/mdrsclient/services.py new file mode 100644 index 0000000..b4784d2 --- /dev/null +++ b/mdrsclient/services.py @@ -0,0 +1,223 @@ +from typing import Any +import os +import re +from unicodedata import normalize + +from mdrsclient.api import DoiApi, FilesApi, FoldersApi, LaboratoriesApi, UsersApi +from mdrsclient.config import ConfigFile +from mdrsclient.connection import MDRSConnection +from mdrsclient.exceptions import ( + IllegalArgumentException, + MissingConfigurationException, + UnauthorizedException, + UnexpectedException, +) +from mdrsclient.models import File, Folder, Laboratory, Token, User +from mdrsclient.utils import page_num_from_url + + +class MdrsService: + def __init__(self, connection: MDRSConnection): + self.connection = connection + + @classmethod + def create_connection(cls, remote: str) -> MDRSConnection: + config = ConfigFile(remote) + if config.url is None: + raise MissingConfigurationException(f"Remote host `{remote}` is not found.") + return MDRSConnection(config.remote, config.url) + + def login(self, username: str, password: str) -> tuple[Token, User]: + user_api = UsersApi(self.connection) + token = user_api.token(username, password) + self.connection.token = token + user = user_api.current() + self.connection.user = user + return token, user + + def logout(self) -> None: + self.connection.logout() + + def whoami(self) -> User: + user_api = UsersApi(self.connection) + return user_api.current() + + def get_laboratories(self) -> list[Laboratory]: + laboratory_api = LaboratoriesApi(self.connection) + labs = laboratory_api.list() + self.connection.laboratories = labs + return list(labs) + + def find_laboratory(self, name: str) -> Laboratory: + if self.connection.laboratories.empty() or (self.connection.token and self.connection.token.is_expired): + self.get_laboratories() + laboratory = self.connection.laboratories.find_by_name(name) + if laboratory is None: + raise IllegalArgumentException(f"Laboratory `{name}` not found.") + return laboratory + + def find_folder(self, laboratory: Laboratory, path: str, password: str | None = None) -> Folder: + folder_api = FoldersApi(self.connection) + folders = folder_api.list(laboratory.id, normalize("NFC", path)) + if len(folders) != 1: + raise UnexpectedException(f"Folder `{path}` not found.") + if folders[0].lock: + if password is None: + raise UnauthorizedException(f"Folder `{path}` is locked.") + folder_api.auth(folders[0].id, password) + return folder_api.retrieve(folders[0].id) + + def find_files(self, folder_id: str) -> list[File]: + files_api = FilesApi(self.connection) + page = 1 + results_file = [] + while page: + result = files_api.list(folder_id, page) + results_file.extend(result.results) + page = 0 + if result.next: + page = page_num_from_url(result.next) + return results_file + + @staticmethod + def is_doi(path_component: str) -> bool: + return path_component.startswith("10.") and "/" in path_component + + @staticmethod + def doi_suffix_id(doi: str) -> str: + doi = doi.rstrip("/") + slash_pos = doi.find("/") + if slash_pos == -1: + return doi + suffix = doi[slash_pos + 1 :] + dot_pos = suffix.rfind(".") + return suffix[dot_pos + 1 :] if dot_pos != -1 else suffix + + @staticmethod + def split_doi_and_subpath(doi_with_path: str) -> tuple[str, str]: + first_slash = doi_with_path.find("/") + if first_slash != -1: + after_suffix_start = first_slash + 1 + after_first = doi_with_path[after_suffix_start:] + second_slash = after_first.find("/") + if second_slash != -1: + doi_end = after_suffix_start + second_slash + doi = doi_with_path[:doi_end] + subpath = doi_with_path[doi_end:] + if subpath == "/": + return (doi, "") + else: + return (doi, subpath) + else: + return (doi_with_path, "") + else: + return (doi_with_path, "") + + @classmethod + def parse_remote_host(cls, path: str) -> str: + path_array = path.split(":") + remote_host = path_array[0] + if len(path_array) == 2 and path_array[1] != "" or len(path_array) > 2: + raise IllegalArgumentException("Invalid remote host") + return remote_host + + @classmethod + def parse_remote_host_with_path(cls, path: str) -> tuple[str, str, str]: + path = re.sub(r"//+|/\./+|/\.$", "/", path) + if re.search(r"/\.\./|/\.\.$", path) is not None: + raise IllegalArgumentException("Path traversal found.") + path_array = path.split(":") + if len(path_array) != 2: + raise IllegalArgumentException("Invalid remote host.") + remote_host = path_array[0] + folder_array = path_array[1].split("/") + is_absolute_path = folder_array[0] == "" + if not is_absolute_path: + raise IllegalArgumentException("Must be absolute paths.") + del folder_array[0] + if len(folder_array) == 0: + laboratory = "" + folder = "" + else: + laboratory = folder_array.pop(0) + folder = "/" + "/".join(folder_array) + return (remote_host, laboratory, folder) + + @classmethod + def parse_doi_remote_host(cls, path: str) -> tuple[str, str, str]: + parts = path.split(":", 1) + if len(parts) != 2: + raise IllegalArgumentException("remote_path must be in the form 'remote:10.xxxx/prefix.ID'") + remote, doi_with_path = parts + if not cls.is_doi(doi_with_path): + raise IllegalArgumentException(f"Path `{doi_with_path}` does not look like a DOI.") + doi, subpath = cls.split_doi_and_subpath(doi_with_path) + return (remote, doi, subpath) + + def find_folder_by_doi(self, doi: str, password: str | None = None) -> tuple[Folder, Laboratory]: + doi_clean = doi.rstrip("/") + doi_id = self.doi_suffix_id(doi_clean) + doi_api = DoiApi(self.connection) + doi_resp = doi_api.retrieve(doi_id) + + returned_doi = doi_resp.doi.rstrip("/") + if returned_doi.lower() != doi_clean.lower(): + raise IllegalArgumentException( + f"DOI mismatch: requested `{doi_clean}` but server returned `{returned_doi}`." + ) + + folder_api = FoldersApi(self.connection) + folder = folder_api.retrieve(doi_resp.folder_id) + + if folder.lock: + if password is None: + raise UnauthorizedException(f"Folder for DOI `{doi_clean}` is locked.") + folder_api.auth(doi_resp.folder.id, password) + + lab_api = LaboratoriesApi(self.connection) + labs = lab_api.list() + lab = labs.find_by_id(folder.laboratory_id) + if lab is None: + raise UnexpectedException(f"Laboratory with id {folder.laboratory_id} not found.") + + self.connection.laboratories = labs + return (folder, lab) + + def resolve_folder(self, remote_path: str, password: str | None = None) -> tuple[Folder, Laboratory]: + path_component = remote_path.split(":", 1)[1] if ":" in remote_path else "" + if self.is_doi(path_component): + remote, doi, subpath = self.parse_doi_remote_host(remote_path) + doi_folder, laboratory = self.find_folder_by_doi(doi, password) + if not subpath: + return (doi_folder, laboratory) + else: + abs_path = doi_folder.path.rstrip("/") + subpath + folder = self.find_folder(laboratory, abs_path, password) + return (folder, laboratory) + else: + remote, laboratory_name, r_path = self.parse_remote_host_with_path(remote_path) + laboratory = self.find_laboratory(laboratory_name) + folder = self.find_folder(laboratory, r_path, password) + return (folder, laboratory) + + def resolve_file(self, remote_path: str, password: str | None = None) -> tuple[Folder, Laboratory, str]: + path_component = remote_path.split(":", 1)[1] if ":" in remote_path else "" + if self.is_doi(path_component): + remote, doi, subpath = self.parse_doi_remote_host(remote_path) + doi_folder, laboratory = self.find_folder_by_doi(doi, password) + subpath_clean = subpath.rstrip("/") + if not subpath_clean: + raise IllegalArgumentException("DOI path must point to a file, not a folder.") + r_dirname = os.path.dirname(subpath_clean) + r_basename = os.path.basename(subpath_clean) + abs_path = doi_folder.path.rstrip("/") + r_dirname + parent_folder = self.find_folder(laboratory, abs_path, password) + return (parent_folder, laboratory, r_basename) + else: + 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, password) + return (parent_folder, laboratory, r_basename) diff --git a/pyproject.toml b/pyproject.toml index d013f13..0e799a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mdrs-client-python" -version = "1.3.16" +version = "1.3.17" description = "The mdrs-client-python is python library and a command-line client for up- and downloading files to and from MDRS based repository." authors = ["Yoshihiro OKUMURA "] license = "MIT"