4 Commits
Author SHA1 Message Date
orrisroot 8e03f7a7f4 docs(changelog): update older release histories with actual changes
Historically, several versions in the changelog only had generic
version bump messages. This update fills in the missing details of
actual features and bug fixes by referencing the Git commit log.

- Add specific change logs for versions v1.3.3 through v1.3.15
- Include missing details for features like new command-line options
- Document bug fixes for API pagination, normalization, and downloads
2026-07-03 01:29:32 +09:00
orrisroot d59a150b4f chore(release): bump version to 1.3.18
Bump the package version to 1.3.18, upgrade dependencies,
consolidate module exports, add a unit test suite, and document
all changes.

- Bump package version to 1.3.18 in pyproject.toml
- Upgrade pydantic-settings to 2.14.2 and pyright to 1.1.411
- Consolidate package exports in mdrsclient/__init__.py
- Add a comprehensive unit test suite in tests/test_commands.py
- Document testing execution and add full history in CHANGELOG.md
2026-07-02 23:47:57 +09:00
orrisroot 7f6d496654 docs(readme): document custom configuration usage in library API
Update the Python API Usage section in README.md to demonstrate how to
initialize MdrsClient with InMemoryConfig to avoid creating local
config files.
2026-07-02 23:31:08 +09:00
orrisroot b95fc0cd7d refactor(config): abstract config storage and enable dependency injection
Abstract the configuration storage mechanism to allow using custom
configurations, such as in-memory setups, when using the tool as
a library. This aligns the configuration architecture with the
session cache abstraction.

- Define ConfigInterface protocol and InMemoryConfig class
- Make CacheFile, InMemoryCache, ConfigFile, and InMemoryConfig
  explicitly inherit their interfaces
- Update MdrsService and MdrsClient to accept customizable
  config_class and config instances
- Add validation to check remote parameter consistency in
  create_connection
- Remove unused imports across command files
2026-07-02 23:30:33 +09:00
13 changed files with 666 additions and 45 deletions
+143
View File
@@ -0,0 +1,143 @@
# Changelog
All notable changes to this project will be documented in this file.
## [1.3.18] - 2026-07-02
### Added
- Added a comprehensive unit test suite in `tests/test_commands.py` checking registration, parsing, and execution flow of all 16 commands.
### Refactored
- Abstracted configuration storage (introducing `ConfigInterface`, `InMemoryConfig`, and updating `ConfigFile`) to enable dependency injection.
- Modularized transfer operations (upload and download) to decouple them from the service layer.
- Decoupled commands from direct file system configurations and migrated all subcommands to use abstract config classes.
### Changed
- Upgraded dependencies including `pydantic-settings` to `2.14.2` and `pyright` to `1.1.411`.
### Fixed
- Fixed duplicate `__all__` definitions in package initialization file `mdrsclient/__init__.py` that caused `__version__` export to be overwritten.
## [1.3.17] - 2026-07-02
### Refactored
- Decoupled core logic from CLI interface and introduced `MdrsClient` service layer to improve library portability.
- Migrated all CLI commands to utilize `MdrsClient` for execution.
### Added
- Abstract authentication state using `CacheInterface` and `InMemoryCache`.
## [1.3.16] - 2026-06-12
### Fixed
- Retrieve the full `Folder` object from `FoldersApi` instead of using the `FolderSimple` returned by `find_sub_folder` when resolving DOI subfolders. This fixes a type checker error under the upgraded pyright and avoids a potential AttributeError at runtime due to `FolderSimple` lacking the `path` attribute.
### Changed
- Upgraded dependencies and bumped version to 1.3.16 in pyproject.toml.
## [1.3.15] - 2026-05-01
### Fixed
- Apply NFC normalization to filenames and folder names sent to the server.
## [1.3.14] - 2026-04-17
### Changed
- Simplified `config list` command (removed `-l`/`--long` option, always display URL).
- Renamed `--quick` option to `--quiet` for `ls` subcommand.
### Added
- Added subcommand aliases for config commands (e.g. `ls` alias for list, `rm` alias for delete).
- Added `version` command.
## [1.3.13] - 2025-07-02
### Fixed
- Fixed pagination logic for the `file.list` API.
## [1.3.12] - 2025-05-20
### Fixed
- Fixed bug where file downloading was skipped incorrectly when `-s`/`--skip-if-file-exists` option was present.
## [1.3.11] - 2025-01-21
### Fixed
- Follow-up fixes for User API specification changes.
## [1.3.10] - 2024-12-23
### Added
- Delete broken files and show a summary when a file download fails.
### Changed
- Updated dependency libraries.
## [1.3.9] - 2024-10-23
### Fixed
- Fixed compatibility with Python 3.10.
## [1.3.8] - 2024-09-18
### Added
- Implemented `-s`/`--skip-if-file-exists` option for `download` command.
### Fixed
- Added exception handling for unexpected responses from the server.
## [1.3.7] - 2024-07-22
### Added
- Implemented `--exclude` argument for download subcommand.
## [1.3.6] - 2024-07-08
### Added
- Support cancelling recursive downloads if downloading some files fails.
## [1.3.5] - 2024-07-08
### Added
- Added authorization token validation checks for file download operations.
### Removed
- Removed unnecessary debug code.
## [1.3.4] - 2024-07-04
### Added
- Added some aliases for config sub command.
### Fixed
- Fixed bug when uploading large files.
## [1.3.3] - 2024-02-13
### Added
- Implemented `-s`/`--skip-if-file-exists` option for `upload` command.
## [1.3.2] - 2024-02-09
### Added
- Added `-u` and `-p` options to login command.
## [1.3.1] - 2023-12-20
### Fixed
- Fixed bug to resolve local files for recursive file upload.
## [1.3.0] - 2023-12-18
### Changed
- Removed debug comments.
## [1.2.0] - 2023-10-04
### Changed
- Follow-up recent specification changes about folder access level.
## [1.1.1] - 2023-07-26
### Changed
- Set destination folder name using name attribute of folder copy API.
+22 -4
View File
@@ -213,15 +213,21 @@ You can also use this package as a Python library to programmatically interact w
```python ```python
from mdrsclient.client import MdrsClient from mdrsclient.client import MdrsClient
from mdrsclient.cache import InMemoryCache from mdrsclient.cache import InMemoryCache
from mdrsclient.config import InMemoryConfig
# 1. Setup in-memory configuration and cache to avoid local state files (e.g., config.ini, cache/*.json)
config = InMemoryConfig("neurodata")
config.url = "https://neurodata.riken.jp/api"
# 1. Setup client with an in-memory cache to avoid local `.mdrsclient` state files
cache = InMemoryCache() cache = InMemoryCache()
client = MdrsClient.from_remote("neurodata", cache=cache)
# 2. Login to the remote server # 2. Initialize client with custom configuration and cache
client = MdrsClient.from_remote("neurodata", cache=cache, config=config)
# 3. Login to the remote server
client.login("username", "password") client.login("username", "password")
# 3. Use service methods # 4. Use service methods
labs = client.get_laboratories() labs = client.get_laboratories()
metadata = client.metadata("neurodata:/NIU/Repository/") metadata = client.metadata("neurodata:/NIU/Repository/")
@@ -230,3 +236,15 @@ client.upload("/path/to/local/data", "neurodata:/NIU/Repository/TEST/", is_recur
client.download("neurodata:/NIU/Repository/TEST/data", "/path/to/local", is_recursive=True) client.download("neurodata:/NIU/Repository/TEST/data", "/path/to/local", is_recursive=True)
``` ```
## Testing
You can run the unit test suite using the standard library `unittest` discover runner:
```shell
.venv/bin/python -m unittest discover tests
```
## Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the full change history.
+1 -4
View File
@@ -1,7 +1,4 @@
from mdrsclient.__version__ import __version__ from mdrsclient.__version__ import __version__
__all__ = ["__version__"]
from mdrsclient.client import MdrsClient from mdrsclient.client import MdrsClient
__all__ = ["MdrsClient"] __all__ = ["__version__", "MdrsClient"]
+2 -2
View File
@@ -66,7 +66,7 @@ class CacheInterface(Protocol):
def laboratories(self, laboratories: Laboratories) -> None: ... def laboratories(self, laboratories: Laboratories) -> None: ...
class InMemoryCache: class InMemoryCache(CacheInterface):
def __init__(self) -> None: def __init__(self) -> None:
self.__data = CacheData() self.__data = CacheData()
@@ -105,7 +105,7 @@ class InMemoryCache:
self.__data.laboratories = laboratories self.__data.laboratories = laboratories
class CacheFile: class CacheFile(CacheInterface):
__serial: int __serial: int
__cache_dir: str __cache_dir: str
__cache_file: str __cache_file: str
+11 -16
View File
@@ -4,6 +4,7 @@ from unicodedata import normalize
from mdrsclient.api import DoiApi, FilesApi, FoldersApi, LaboratoriesApi, UsersApi from mdrsclient.api import DoiApi, FilesApi, FoldersApi, LaboratoriesApi, UsersApi
from mdrsclient.cache import CacheInterface from mdrsclient.cache import CacheInterface
from mdrsclient.config import ConfigInterface
from mdrsclient.connection import MDRSConnection from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import IllegalArgumentException, MDRSException, UnauthorizedException, UnexpectedException from mdrsclient.exceptions import IllegalArgumentException, MDRSException, UnauthorizedException, UnexpectedException
from mdrsclient.models import File, Folder, Laboratory, Token, User from mdrsclient.models import File, Folder, Laboratory, Token, User
@@ -14,12 +15,14 @@ from mdrsclient.services import MdrsService
class MdrsClient(MdrsService): class MdrsClient(MdrsService):
"""Service layer client for MDRS.""" """Service layer client for MDRS."""
def __init__(self, connection: MDRSConnection): def __init__(self, connection: MDRSConnection, config_class: type[ConfigInterface] | None = None):
super().__init__(connection) super().__init__(connection, config_class)
@classmethod @classmethod
def from_remote(cls, remote: str, cache: CacheInterface | None = None) -> "MdrsClient": def from_remote(
return cls(cls.create_connection(remote, cache)) 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: def mkdir(self, remote_path: str) -> None:
remote, laboratory_name, r_path = self.parse_remote_host_with_path(remote_path) remote, laboratory_name, r_path = self.parse_remote_host_with_path(remote_path)
@@ -208,36 +211,28 @@ class MdrsClient(MdrsService):
return f"mdrs {__version__}" return f"mdrs {__version__}"
def config_create(self, remote: str, url: str) -> None: def config_create(self, remote: str, url: str) -> None:
from mdrsclient.config import ConfigFile
remote = self.parse_remote_host(remote) remote = self.parse_remote_host(remote)
config = ConfigFile(remote) config = self.config_class(remote)
if config.url is not None: if config.url is not None:
raise IllegalArgumentException(f"Remote host `{remote}` is already exists.") raise IllegalArgumentException(f"Remote host `{remote}` is already exists.")
else: else:
config.url = url config.url = url
def config_update(self, remote: str, url: str) -> None: def config_update(self, remote: str, url: str) -> None:
from mdrsclient.config import ConfigFile
remote = self.parse_remote_host(remote) remote = self.parse_remote_host(remote)
config = ConfigFile(remote) config = self.config_class(remote)
if config.url is None: if config.url is None:
raise IllegalArgumentException(f"Remote host `{remote}` is not exists.") raise IllegalArgumentException(f"Remote host `{remote}` is not exists.")
else: else:
config.url = url config.url = url
def config_list(self) -> list: def config_list(self) -> list:
from mdrsclient.config import ConfigFile config = self.config_class("")
config = ConfigFile("")
return config.list() return config.list()
def config_delete(self, remote: str) -> None: def config_delete(self, remote: str) -> None:
from mdrsclient.config import ConfigFile
remote = self.parse_remote_host(remote) remote = self.parse_remote_host(remote)
config = ConfigFile(remote) config = self.config_class(remote)
if config.url is None: if config.url is None:
raise IllegalArgumentException(f"Remote host `{remote}` is not exists.") raise IllegalArgumentException(f"Remote host `{remote}` is not exists.")
else: else:
-4
View File
@@ -2,11 +2,7 @@ import getpass
from argparse import Namespace from argparse import Namespace
from typing import Any from typing import Any
from mdrsclient.api import UsersApi
from mdrsclient.commands.base import BaseCommand from mdrsclient.commands.base import BaseCommand
from mdrsclient.config import ConfigFile
from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import MissingConfigurationException
class LoginCommand(BaseCommand): class LoginCommand(BaseCommand):
-3
View File
@@ -2,9 +2,6 @@ from argparse import Namespace
from typing import Any from typing import Any
from mdrsclient.commands.base import BaseCommand from mdrsclient.commands.base import BaseCommand
from mdrsclient.config import ConfigFile
from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import MissingConfigurationException
class LogoutCommand(BaseCommand): class LogoutCommand(BaseCommand):
-3
View File
@@ -2,9 +2,6 @@ from argparse import Namespace
from typing import Any, Final from typing import Any, Final
from mdrsclient.commands.base import BaseCommand from mdrsclient.commands.base import BaseCommand
from mdrsclient.config import ConfigFile
from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import MissingConfigurationException
class WhoamiCommand(BaseCommand): class WhoamiCommand(BaseCommand):
+52 -2
View File
@@ -1,6 +1,7 @@
import configparser import configparser
import os import os
from typing import Final import threading
from typing import Final, Protocol, runtime_checkable
import validators import validators
@@ -9,7 +10,56 @@ from mdrsclient.settings import CONFIG_DIRNAME
from mdrsclient.utils import FileLock from mdrsclient.utils import FileLock
class ConfigFile: @runtime_checkable
class ConfigInterface(Protocol):
remote: str
def list(self) -> list[tuple[str, str]]: ...
@property
def url(self) -> str | None: ...
@url.setter
def url(self, url: str) -> None: ...
@url.deleter
def url(self) -> None: ...
class InMemoryConfig(ConfigInterface):
__configs: dict[str, str] = {}
__lock: threading.Lock = threading.Lock()
remote: str
def __init__(self, remote: str) -> None:
self.remote = remote
def list(self) -> list[tuple[str, str]]:
with self.__lock:
return list(self.__configs.items())
@property
def url(self) -> str | None:
with self.__lock:
return self.__configs.get(self.remote)
@url.setter
def url(self, url: str) -> None:
if not validators.url(url):
raise IllegalArgumentException("malformed URI sequence")
with self.__lock:
self.__configs[self.remote] = url
@url.deleter
def url(self) -> None:
with self.__lock:
if self.remote in self.__configs:
del self.__configs[self.remote]
@classmethod
def clear(cls) -> None:
with cls.__lock:
cls.__configs.clear()
class ConfigFile(ConfigInterface):
OPTION_URL: Final[str] = "url" OPTION_URL: Final[str] = "url"
CONFIG_FILENAME: Final[str] = "config.ini" CONFIG_FILENAME: Final[str] = "config.ini"
remote: str remote: str
+13 -4
View File
@@ -5,7 +5,7 @@ from unicodedata import normalize
from mdrsclient.api import DoiApi, FilesApi, FoldersApi, LaboratoriesApi, UsersApi from mdrsclient.api import DoiApi, FilesApi, FoldersApi, LaboratoriesApi, UsersApi
from mdrsclient.cache import CacheInterface from mdrsclient.cache import CacheInterface
from mdrsclient.config import ConfigFile from mdrsclient.config import ConfigFile, ConfigInterface
from mdrsclient.connection import MDRSConnection from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import ( from mdrsclient.exceptions import (
IllegalArgumentException, IllegalArgumentException,
@@ -18,12 +18,21 @@ from mdrsclient.utils import page_num_from_url
class MdrsService: class MdrsService:
def __init__(self, connection: MDRSConnection): config_class: type[ConfigInterface] = ConfigFile
def __init__(self, connection: MDRSConnection, config_class: type[ConfigInterface] | None = None):
self.connection = connection self.connection = connection
if config_class is not None:
self.config_class = config_class
@classmethod @classmethod
def create_connection(cls, remote: str, cache: CacheInterface | None = None) -> MDRSConnection: def create_connection(
config = ConfigFile(remote) cls, remote: str, cache: CacheInterface | None = None, config: ConfigInterface | None = None
) -> MDRSConnection:
if config is None:
config = ConfigFile(remote)
elif config.remote != remote:
raise IllegalArgumentException("Remote host parameter mismatch.")
if config.url is None: if config.url is None:
raise MissingConfigurationException(f"Remote host `{remote}` is not found.") raise MissingConfigurationException(f"Remote host `{remote}` is not found.")
return MDRSConnection(config.remote, config.url, cache=cache) return MDRSConnection(config.remote, config.url, cache=cache)
+3 -3
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "mdrs-client-python" name = "mdrs-client-python"
version = "1.3.17" version = "1.3.18"
description = "The mdrs-client-python is python library and a command-line client for up- and downloading files to and from MDRS based repository." 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 <yoshihiro.okumura@riken.jp>"] authors = ["Yoshihiro OKUMURA <yoshihiro.okumura@riken.jp>"]
license = "MIT" license = "MIT"
@@ -28,7 +28,7 @@ requests = "^2.34.2"
requests-toolbelt = "^1.0.0" requests-toolbelt = "^1.0.0"
python-dotenv = "^1.1.0" python-dotenv = "^1.1.0"
pydantic = "^2.13.4" pydantic = "^2.13.4"
pydantic-settings = "^2.14.1" pydantic-settings = "^2.14.2"
PyJWT = "^2.13.0" PyJWT = "^2.13.0"
validators = "^0.35.0" validators = "^0.35.0"
@@ -37,7 +37,7 @@ black = "^26.5.1"
flake8 = "^7.2.0" flake8 = "^7.2.0"
Flake8-pyproject = "^1.2.3" Flake8-pyproject = "^1.2.3"
isort = "^8.0.1" isort = "^8.0.1"
pyright = "^1.1.401" pyright = "^1.1.411"
[tool.poetry.scripts] [tool.poetry.scripts]
mdrs = 'mdrsclient.__main__:main' mdrs = 'mdrsclient.__main__:main'
+1
View File
@@ -0,0 +1 @@
# Mark tests directory as a Python package
+418
View File
@@ -0,0 +1,418 @@
import argparse
import json
import unittest
from io import StringIO
from unittest.mock import MagicMock, patch
from mdrsclient.client import MdrsClient
from mdrsclient.commands import (
ChaclCommand,
ConfigCommand,
CpCommand,
DownloadCommand,
FileMetadataCommand,
LabsCommand,
LoginCommand,
LogoutCommand,
LsCommand,
MetadataCommand,
MkdirCommand,
MvCommand,
RmCommand,
UploadCommand,
VersionCommand,
WhoamiCommand,
)
from mdrsclient.models import Folder, Laboratory
class TestCommands(unittest.TestCase):
def parse_args(self, cmd_class, args_list):
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(title="subcommands")
cmd_class.register(subparsers)
return parser.parse_args(args_list)
def test_version_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.version.return_value = "mdrs 1.3.17"
args = self.parse_args(VersionCommand, ["version"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
self.assertEqual(fake_out.getvalue().strip(), "mdrs 1.3.17")
mock_client_class.assert_called_once_with(None)
mock_client.version.assert_called_once()
def test_login_command_with_args(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
mock_client_class.parse_remote_host.return_value = "myremote"
args = self.parse_args(LoginCommand, ["login", "-u", "myuser", "-p", "mypass", "myremote"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
self.assertIn("Login Successful", fake_out.getvalue())
mock_client_class.parse_remote_host.assert_called_once_with("myremote")
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.login.assert_called_once_with("myuser", "mypass")
def test_login_command_interactive(self):
with (
patch("mdrsclient.client.MdrsClient") as mock_client_class,
patch("builtins.input", return_value="myuser_int") as mock_input,
patch("getpass.getpass", return_value="mypass_int") as mock_getpass,
):
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
mock_client_class.parse_remote_host.return_value = "myremote"
args = self.parse_args(LoginCommand, ["login", "myremote"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
self.assertIn("Login Successful", fake_out.getvalue())
mock_input.assert_called_once_with("Username: ")
mock_getpass.assert_called_once_with("Password: ")
mock_client.login.assert_called_once_with("myuser_int", "mypass_int")
def test_logout_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
mock_client_class.parse_remote_host.return_value = "myremote"
args = self.parse_args(LogoutCommand, ["logout", "myremote"])
args.func(args)
mock_client_class.parse_remote_host.assert_called_once_with("myremote")
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.logout.assert_called_once()
def test_whoami_command_logged_in(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
mock_client_class.parse_remote_host.return_value = "myremote"
mock_client.connection.token = None
mock_user = MagicMock()
mock_user.username = "test_user"
mock_client.whoami.return_value = mock_user
args = self.parse_args(WhoamiCommand, ["whoami", "myremote"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
self.assertEqual(fake_out.getvalue().strip(), "test_user")
mock_client_class.parse_remote_host.assert_called_once_with("myremote")
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.whoami.assert_called_once()
def test_whoami_command_anonymous(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
mock_client_class.parse_remote_host.return_value = "myremote"
mock_client.connection.token = None
mock_client.whoami.side_effect = Exception("Not logged in")
args = self.parse_args(WhoamiCommand, ["whoami", "myremote"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
self.assertEqual(fake_out.getvalue().strip(), "(Anonymous)")
def test_labs_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
mock_client_class.parse_remote_host.return_value = "myremote"
mock_lab = MagicMock()
mock_lab.id = 1
mock_lab.name = "lab_name"
mock_lab.pi_name = "pi_name"
mock_lab.full_name = "full_name"
mock_client.get_laboratories.return_value = [mock_lab]
args = self.parse_args(LabsCommand, ["labs", "myremote"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
output = fake_out.getvalue()
self.assertIn("Name", output)
self.assertIn("lab_name", output)
self.assertIn("pi_name", output)
mock_client_class.parse_remote_host.assert_called_once_with("myremote")
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.get_laboratories.assert_called_once()
def test_ls_command_plain(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock(spec=MdrsClient)
mock_client_class.from_remote.return_value = mock_client
mock_folder = Folder(
id="folder_id",
pid=None,
name="root",
access_level=1,
lock=False,
size=0,
laboratory_id=1,
description="",
created_at="2026-07-02T00:00:00Z",
updated_at="2026-07-02T00:00:00Z",
restrict_opened_at=None,
metadata=[],
sub_folders=[],
path="/root",
)
mock_lab = Laboratory(id=1, name="mylab", pi_name="pi_name", full_name="full_name")
mock_client.resolve_folder.return_value = (mock_folder, mock_lab)
mock_file = MagicMock()
mock_file.name = "file.txt"
mock_file.size = 100
mock_file.updated_at_name = "2026-07-02"
mock_client.find_files.return_value = [mock_file]
args = self.parse_args(LsCommand, ["ls", "myremote:/mylab/"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
output = fake_out.getvalue()
self.assertIn("Type", output)
self.assertIn("file.txt", output)
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.resolve_folder.assert_called_once_with("myremote:/mylab/", None)
mock_client.find_files.assert_called_once_with(mock_folder.id)
def test_ls_command_json(self):
with (
patch("mdrsclient.client.MdrsClient") as mock_client_class,
patch("mdrsclient.commands.ls.FoldersApi") as mock_folders_api_class,
):
mock_folders_api = MagicMock()
mock_folders_api_class.return_value = mock_folders_api
mock_folders_api.metadata.return_value = {"folder_meta": "val"}
mock_client = MagicMock(spec=MdrsClient)
mock_client_class.from_remote.return_value = mock_client
mock_client.connection = MagicMock()
mock_folder = Folder(
id="folder_id",
pid="parent_id",
name="root",
access_level=1,
lock=False,
size=0,
laboratory_id=1,
description="Root folder",
created_at="2026-07-02T00:00:00Z",
updated_at="2026-07-02T00:00:00Z",
restrict_opened_at=None,
metadata=[],
sub_folders=[],
path="/root",
)
mock_lab = Laboratory(id=1, name="mylab", pi_name="pi_name", full_name="full_name")
mock_client.resolve_folder.return_value = (mock_folder, mock_lab)
mock_client.connection.laboratories.find_by_id.return_value = mock_lab
mock_file = MagicMock()
mock_file.id = "file_id"
mock_file.name = "file.txt"
mock_file.type = "text"
mock_file.size = 100
mock_file.description = "A file"
mock_file.metadata = {}
mock_file.download_url = "download/file"
mock_file.created_at = "2026-07-02T00:00:00Z"
mock_file.updated_at = "2026-07-02T00:00:00Z"
mock_client.find_files.return_value = [mock_file]
args = self.parse_args(LsCommand, ["ls", "-J", "myremote:/mylab/"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
output = fake_out.getvalue()
parsed_json = json.loads(output)
self.assertEqual(parsed_json["name"], "root")
self.assertEqual(parsed_json["files"][0]["name"], "file.txt")
def test_mkdir_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
args = self.parse_args(MkdirCommand, ["mkdir", "myremote:/mylab/newfolder"])
args.func(args)
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.mkdir.assert_called_once_with("myremote:/mylab/newfolder")
def test_rm_command_file(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
args = self.parse_args(RmCommand, ["rm", "myremote:/mylab/file.txt"])
args.func(args)
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.rm.assert_called_once_with("myremote:/mylab/file.txt", False)
def test_rm_command_recursive(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
args = self.parse_args(RmCommand, ["rm", "-r", "myremote:/mylab/folder"])
args.func(args)
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.rm.assert_called_once_with("myremote:/mylab/folder", True)
def test_cp_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
args = self.parse_args(CpCommand, ["cp", "myremote:/mylab/src", "myremote:/mylab/dest"])
args.func(args)
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.cp.assert_called_once_with("myremote:/mylab/src", "myremote:/mylab/dest", False)
def test_mv_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
args = self.parse_args(MvCommand, ["mv", "myremote:/mylab/src", "myremote:/mylab/dest"])
args.func(args)
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.mv.assert_called_once_with("myremote:/mylab/src", "myremote:/mylab/dest")
def test_chacl_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
args = self.parse_args(ChaclCommand, ["chacl", "-r", "-p", "secret", "private", "myremote:/mylab/"])
args.func(args)
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.chacl.assert_called_once_with("myremote:/mylab/", 1, True, "secret")
def test_metadata_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
mock_client.metadata.return_value = {"meta_key": "meta_val"}
args = self.parse_args(MetadataCommand, ["metadata", "-p", "secret", "myremote:/mylab/"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
parsed_json = json.loads(fake_out.getvalue())
self.assertEqual(parsed_json["meta_key"], "meta_val")
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.metadata.assert_called_once_with("myremote:/mylab/", "secret")
def test_file_metadata_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
mock_client.file_metadata.return_value = {"file_meta": "val"}
args = self.parse_args(FileMetadataCommand, ["file-metadata", "-p", "secret", "myremote:/mylab/file.txt"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
parsed_json = json.loads(fake_out.getvalue())
self.assertEqual(parsed_json["file_meta"], "val")
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.file_metadata.assert_called_once_with("myremote:/mylab/file.txt", "secret")
def test_upload_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
args = self.parse_args(UploadCommand, ["upload", "-r", "-s", "local_file.txt", "myremote:/mylab/"])
args.func(args)
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.upload.assert_called_once_with("local_file.txt", "myremote:/mylab/", True, True)
def test_download_command(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.from_remote.return_value = mock_client
args = self.parse_args(
DownloadCommand, ["download", "-r", "-s", "-p", "pass", "-e", "ex1", "myremote:/mylab/", "local_dir"]
)
args.func(args)
mock_client_class.from_remote.assert_called_once_with("myremote")
mock_client.download.assert_called_once_with("myremote:/mylab/", "local_dir", True, True, "pass", ["ex1"])
def test_config_create(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
args = self.parse_args(ConfigCommand, ["config", "create", "myremote", "http://example.com"])
args.func(args)
mock_client_class.assert_called_once_with(None)
mock_client.config_create.assert_called_once_with("myremote", "http://example.com")
def test_config_update(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
args = self.parse_args(ConfigCommand, ["config", "update", "myremote", "http://example.com"])
args.func(args)
mock_client_class.assert_called_once_with(None)
mock_client.config_update.assert_called_once_with("myremote", "http://example.com")
def test_config_delete(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
args = self.parse_args(ConfigCommand, ["config", "delete", "myremote"])
args.func(args)
mock_client_class.assert_called_once_with(None)
mock_client.config_delete.assert_called_once_with("myremote")
def test_config_list(self):
with patch("mdrsclient.client.MdrsClient") as mock_client_class:
mock_client = MagicMock()
mock_client_class.return_value = mock_client
mock_client.config_list.return_value = [("remote1", "url1"), ("remote2", "url2")]
args = self.parse_args(ConfigCommand, ["config", "list"])
with patch("sys.stdout", new=StringIO()) as fake_out:
args.func(args)
self.assertEqual(fake_out.getvalue(), "remote1:\turl1\nremote2:\turl2\n")
mock_client_class.assert_called_once_with(None)
mock_client.config_list.assert_called_once()