From d59a150b4f8027a0b4eac5ec545bd2745569e591 Mon Sep 17 00:00:00 2001 From: Yoshihiro OKUMURA Date: Thu, 2 Jul 2026 23:46:56 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 129 +++++++++++++ README.md | 12 ++ mdrsclient/__init__.py | 5 +- pyproject.toml | 6 +- tests/__init__.py | 1 + tests/test_commands.py | 418 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 564 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/__init__.py create mode 100644 tests/test_commands.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9188331 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,129 @@ +# 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 + +### Changed +- Bumped package version to 1.3.15. + +## [1.3.14] - 2026-04-17 + +### Changed +- Simplified `config list` command (removed `-l`/`--long` option, always display URL). + +### Added +- Added subcommand aliases for config commands (e.g. `ls` alias for list, `rm` alias for delete). + +## [1.3.13] - 2025-07-02 + +### Changed +- Bumped package version to 1.3.13. + +## [1.3.12] - 2025-05-20 + +### Changed +- Bumped package version to 1.3.12. + +## [1.3.11] - 2025-01-21 + +### Changed +- Bumped package version to 1.3.11. + +## [1.3.10] - 2024-12-23 + +### Changed +- Bumped package version to 1.3.10. + +## [1.3.9] - 2024-10-23 + +### Fixed +- Fixed compatibility with Python 3.10. + +## [1.3.8] - 2024-09-18 + +### Changed +- Bumped package version to 1.3.8. + +## [1.3.7] - 2024-07-22 + +### Added +- Implemented `--exclude` argument for download subcommand. + +## [1.3.6] - 2024-07-08 + +### Changed +- Bumped package version to 1.3.6. + +## [1.3.5] - 2024-07-08 + +### Changed +- Bumped package version to 1.3.5. + +## [1.3.4] - 2024-07-04 + +### Added +- Added some aliases for config sub command. + +## [1.3.3] - 2024-02-13 + +### Changed +- Bumped package version to 1.3.3. + +## [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. diff --git a/README.md b/README.md index af6bec5..8f69cb6 100644 --- a/README.md +++ b/README.md @@ -236,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) ``` +## 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. + diff --git a/mdrsclient/__init__.py b/mdrsclient/__init__.py index 754757d..1f0295d 100644 --- a/mdrsclient/__init__.py +++ b/mdrsclient/__init__.py @@ -1,7 +1,4 @@ from mdrsclient.__version__ import __version__ - -__all__ = ["__version__"] - from mdrsclient.client import MdrsClient -__all__ = ["MdrsClient"] +__all__ = ["__version__", "MdrsClient"] diff --git a/pyproject.toml b/pyproject.toml index 0e799a7..d26f46c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] 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." authors = ["Yoshihiro OKUMURA "] license = "MIT" @@ -28,7 +28,7 @@ requests = "^2.34.2" requests-toolbelt = "^1.0.0" python-dotenv = "^1.1.0" pydantic = "^2.13.4" -pydantic-settings = "^2.14.1" +pydantic-settings = "^2.14.2" PyJWT = "^2.13.0" validators = "^0.35.0" @@ -37,7 +37,7 @@ black = "^26.5.1" flake8 = "^7.2.0" Flake8-pyproject = "^1.2.3" isort = "^8.0.1" -pyright = "^1.1.401" +pyright = "^1.1.411" [tool.poetry.scripts] mdrs = 'mdrsclient.__main__:main' diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..50f83c0 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Mark tests directory as a Python package diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..9342b59 --- /dev/null +++ b/tests/test_commands.py @@ -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()