refactor: extract MdrsClient service layer for library portability

To improve the tool's portability as a Python library, the core logic
has been decoupled from the CLI interface. This allows developers to
programmatically interact with MDRS without relying on CLI-specific
argument parsing or local file-based caches.

- Introduce `MdrsClient` service layer to handle core operations.
- Abstract authentication state using `CacheInterface` and `InMemoryCache`.
- Migrate all CLI commands to utilize `MdrsClient` for execution.
- Separate `Doi` data model from API responses and move to `models/doi.py`.
- Update `README.md` to include Python API usage examples.
- Bump package version to 1.3.17.
This commit is contained in:
2026-07-02 13:07:18 +09:00
parent 809140dfbc
commit 36cad6db52
28 changed files with 736 additions and 215 deletions
+1 -1
View File
@@ -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:
+5 -7
View File
@@ -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)
+13 -5
View File
@@ -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
+5 -50
View File
@@ -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)
+20 -2
View File
@@ -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.")
+4 -8
View File
@@ -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))
+5 -7
View File
@@ -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']}}"
)
+6 -11
View File
@@ -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")
+5 -6
View File
@@ -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()
+12 -1
View File
@@ -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)
+4 -4
View File
@@ -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))
+5 -12
View File
@@ -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)
+5 -48
View File
@@ -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)
+5 -20
View File
@@ -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)
+12 -1
View File
@@ -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)
+5 -1
View File
@@ -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())
+11 -8
View File
@@ -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)