8 Commits

Author SHA1 Message Date
orrisroot 809140dfbc fix(download): retrieve full folder to resolve type error
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.

Also upgrade dependencies and bump version to 1.3.16 in pyproject.toml.
2026-06-12 10:25:03 +09:00
orrisroot 5bdf837941 feat(doi): add DOI-based path access for commands
Support accessing repositories using DOI strings with optional subpaths
across ls, download, metadata, and file-metadata commands.

- Implement GET v3/doi/{id}/ API model and client calls
- Parse and resolve DOI paths into respective folder and files
- Extract common folder and file resolution logic to shared helpers
- Update README with example DOI-based shell commands
2026-06-12 01:28:40 +09:00
orrisroot 04c0003a61 fix(connection): support absolute download urls for public data 2026-06-11 21:04:28 +09:00
orrisroot 428be1289c chore(version): bump package version to 1.3.15
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 11:11:22 +09:00
orrisroot 4283481695 fix: apply NFC normalization to filenames and folder names sent to server
On macOS, local filenames and directory names may be in NFD encoding
(decomposed Unicode). Without normalization, files and folders are
created on the server with NFD names, inconsistent with the server's
NFC convention.

Apply normalize("NFC", ...) before sending names to the server in:
- FilesApi.create(): filename in multipart upload
- FilesApi.update(): filename in multipart upload
- UploadCommand: directory name in FoldersApi.create()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 12:25:59 +09:00
orrisroot ddb4300d85 feat(config): simplify list command and add subcommand aliases
- config list: remove -l/--long option, always display URL
- config list: add ls alias (already existed, kept)
- config delete: add rm alias (alongside existing remove alias)
- README: add config update, config list, config delete sections

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 18:59:07 +09:00
orrisroot 68670a6588 fix(ls): rename --quick to --quiet; add version command; bump to 1.3.14
- Fix ls -q long option name: --quick → --quiet (typo fix)
- Remove mdrsclient/VERSION file; read version via importlib.metadata
- Bump version 1.3.13 → 1.3.14
- Add Python 3.14 to supported classifiers; promote to Development Status 4 - Beta
- Add `version` subcommand (prints "mdrs <version>")
- Document `version` command in README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:41:37 +09:00
orrisroot 6d8fd0a598 update version to 1.3.13 2025-10-28 11:10:09 +09:00
24 changed files with 400 additions and 69 deletions
+52 -4
View File
@@ -18,6 +18,32 @@ Create remote host configuration
mdrs config create neurodata https://neurodata.riken.jp/api mdrs config create neurodata https://neurodata.riken.jp/api
``` ```
### config update
Update the URL of a registered remote host.
```shell
mdrs config update neurodata https://neurodata.riken.jp/api
```
### config list
List registered remote hosts.
```shell
mdrs config list
mdrs config ls
```
### config delete
Remove a registered remote host.
```shell
mdrs config delete neurodata
mdrs config rm neurodata
```
### login ### login
Login to remote host Login to remote host
@@ -56,13 +82,17 @@ mdrs labs neurodata:
### ls ### ls
List the folder contents List the folder contents. You can also specify a DOI path in the form `remote:10.xxxx/yyy.ID[/optional/subpath]`.
```shell ```shell
mdrs ls neurodata:/NIU/Repository/ mdrs ls neurodata:/NIU/Repository/
mdrs ls -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/ mdrs ls -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/
mdrs ls -r neurodata:/NIU/Repository/Dataset1/ mdrs ls -r neurodata:/NIU/Repository/Dataset1/
mdrs ls -J -r neurodata:/NIU/Repository/Dataset1/ mdrs ls -J -r neurodata:/NIU/Repository/Dataset1/
# DOI access examples:
mdrs ls neurodata:10.60178/cbs.20260429-001
mdrs ls "neurodata:10.60178/cbs.20260429-001/Figure 1"
``` ```
### mkdir ### mkdir
@@ -85,7 +115,7 @@ mdrs upload -r --skip-if-exists ./dataset neurodata:/NIU/Repository/TEST/
### download ### download
Download the file or folder Download the file or folder. You can also specify a DOI path.
```shell ```shell
mdrs download neurodata:/NIU/Repository/TEST/sample.dat ./ mdrs download neurodata:/NIU/Repository/TEST/sample.dat ./
@@ -93,6 +123,10 @@ mdrs download -r neurodata:/NIU/Repository/TEST/dataset/ ./
mdrs download -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.dat ./ mdrs download -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.dat ./
mdrs download -r --exclude /NIU/Repository/TEST/dataset/skip neurodata:/NIU/Repository/TEST/dataset/ ./ mdrs download -r --exclude /NIU/Repository/TEST/dataset/skip neurodata:/NIU/Repository/TEST/dataset/ ./
mdrs download -r --skip-if-exists neurodata:/NIU/Repository/TEST/dataset/ ./ mdrs download -r --skip-if-exists neurodata:/NIU/Repository/TEST/dataset/ ./
# DOI access examples:
mdrs download neurodata:10.60178/cbs.20260429-001/README_NeuroData.md ./
mdrs download -r "neurodata:10.60178/cbs.20260429-001/Figure 1" ./
``` ```
### mv ### mv
@@ -134,20 +168,34 @@ mdrs chacl pw_open -r -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open
### metadata ### metadata
Get a folder metadata Get a folder metadata. You can also specify a DOI path.
```shell ```shell
mdrs metadata neurodata:/NIU/Repository/TEST/ mdrs metadata neurodata:/NIU/Repository/TEST/
mdrs metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/ mdrs metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/
# DOI access examples:
mdrs metadata neurodata:10.60178/cbs.20260429-001
``` ```
### file-metadata ### file-metadata
Get the file metadata Get the file metadata. You can also specify a DOI path.
```shell ```shell
mdrs file-metadata neurodata:/NIU/Repository/TEST/dataset/sample.dat mdrs file-metadata neurodata:/NIU/Repository/TEST/dataset/sample.dat
mdrs file-metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.txt mdrs file-metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.txt
# DOI access examples:
mdrs file-metadata "neurodata:10.60178/cbs.20260429-001/Figure 1/Figure1v3.pdf"
```
### version
Show the tool name and version number
```shell
mdrs version
``` ```
### help ### help
-1
View File
@@ -1 +0,0 @@
1.3.13
+2
View File
@@ -17,6 +17,7 @@ from mdrsclient.commands import (
MvCommand, MvCommand,
RmCommand, RmCommand,
UploadCommand, UploadCommand,
VersionCommand,
WhoamiCommand, WhoamiCommand,
) )
from mdrsclient.exceptions import MDRSException from mdrsclient.exceptions import MDRSException
@@ -29,6 +30,7 @@ def main() -> None:
parsers = parser.add_subparsers(title="subcommands") parsers = parser.add_subparsers(title="subcommands")
ConfigCommand.register(parsers) ConfigCommand.register(parsers)
VersionCommand.register(parsers)
LoginCommand.register(parsers) LoginCommand.register(parsers)
LogoutCommand.register(parsers) LogoutCommand.register(parsers)
WhoamiCommand.register(parsers) WhoamiCommand.register(parsers)
+2 -5
View File
@@ -1,8 +1,5 @@
import os from importlib.metadata import version
here = os.path.realpath(os.path.dirname(__file__)) __version__ = version("mdrs-client-python")
with open(os.path.join(here, "VERSION")) as version_file:
__version__ = version_file.read().strip()
__all__ = ["__version__"] __all__ = ["__version__"]
+2
View File
@@ -1,9 +1,11 @@
from mdrsclient.api.doi import DoiApi
from mdrsclient.api.files import FilesApi from mdrsclient.api.files import FilesApi
from mdrsclient.api.folders import FoldersApi from mdrsclient.api.folders import FoldersApi
from mdrsclient.api.laboratories import LaboratoriesApi from mdrsclient.api.laboratories import LaboratoriesApi
from mdrsclient.api.users import UsersApi from mdrsclient.api.users import UsersApi
__all__ = [ __all__ = [
"DoiApi",
"FilesApi", "FilesApi",
"FoldersApi", "FoldersApi",
"LaboratoriesApi", "LaboratoriesApi",
+1 -6
View File
@@ -5,12 +5,7 @@ from pydantic import TypeAdapter
from requests import Response from requests import Response
from mdrsclient.connection import MDRSConnection from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import ( from mdrsclient.exceptions import BadRequestException, ForbiddenException, UnauthorizedException, UnexpectedException
BadRequestException,
ForbiddenException,
UnauthorizedException,
UnexpectedException,
)
from mdrsclient.models.error import DRFStandardizedErrors from mdrsclient.models.error import DRFStandardizedErrors
+40
View File
@@ -0,0 +1,40 @@
from typing import Final
from pydantic import TypeAdapter
from pydantic.dataclasses import dataclass
from mdrsclient.api.base import BaseApi
from mdrsclient.api.utils import token_check
@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.
"""
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").
id: str
doi: str
folder: DoiFolderRef
class DoiApi(BaseApi):
ENTRYPOINT: Final[str] = "v3/doi/"
def retrieve(self, doi_id: str) -> DoiResponse:
"""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())
+8 -2
View File
@@ -1,6 +1,7 @@
import mimetypes import mimetypes
import os import os
from typing import Any, Final from typing import Any, Final
from unicodedata import normalize
from pydantic import TypeAdapter from pydantic import TypeAdapter
from pydantic.dataclasses import dataclass from pydantic.dataclasses import dataclass
@@ -53,7 +54,10 @@ class FilesApi(BaseApi):
try: try:
with open(os.path.realpath(path), mode="rb") as fp: with open(os.path.realpath(path), mode="rb") as fp:
data = MultipartEncoder( data = MultipartEncoder(
fields={"folder_id": folder_id, "file": (os.path.basename(path), fp, self._get_mime_type(path))} fields={
"folder_id": folder_id,
"file": (normalize("NFC", os.path.basename(path)), fp, self._get_mime_type(path)),
}
) )
response = self.connection.post(url, data=data, headers={"Content-Type": data.content_type}) response = self.connection.post(url, data=data, headers={"Content-Type": data.content_type})
self._raise_response_error(response) self._raise_response_error(response)
@@ -75,7 +79,9 @@ class FilesApi(BaseApi):
# update file body # update file body
try: try:
with open(os.path.realpath(path), mode="rb") as fp: with open(os.path.realpath(path), mode="rb") as fp:
data = MultipartEncoder(fields={"file": (os.path.basename(path), fp, self._get_mime_type(path))}) data = MultipartEncoder(
fields={"file": (normalize("NFC", os.path.basename(path)), fp, self._get_mime_type(path))}
)
response = self.connection.put(url, data=data, headers={"Content-Type": data.content_type}) response = self.connection.put(url, data=data, headers={"Content-Type": data.content_type})
except OSError: except OSError:
raise UnexpectedException(f"Could not open `{path}` file.") raise UnexpectedException(f"Could not open `{path}` file.")
+2
View File
@@ -12,6 +12,7 @@ from mdrsclient.commands.mkdir import MkdirCommand
from mdrsclient.commands.mv import MvCommand from mdrsclient.commands.mv import MvCommand
from mdrsclient.commands.rm import RmCommand from mdrsclient.commands.rm import RmCommand
from mdrsclient.commands.upload import UploadCommand from mdrsclient.commands.upload import UploadCommand
from mdrsclient.commands.version import VersionCommand
from mdrsclient.commands.whoami import WhoamiCommand from mdrsclient.commands.whoami import WhoamiCommand
__all__ = [ __all__ = [
@@ -29,5 +30,6 @@ __all__ = [
"MvCommand", "MvCommand",
"RmCommand", "RmCommand",
"UploadCommand", "UploadCommand",
"VersionCommand",
"WhoamiCommand", "WhoamiCommand",
] ]
+171 -1
View File
@@ -1,9 +1,10 @@
import os
import re import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any
from unicodedata import normalize from unicodedata import normalize
from mdrsclient.api import FilesApi, FoldersApi, LaboratoriesApi from mdrsclient.api import DoiApi, FilesApi, FoldersApi, LaboratoriesApi
from mdrsclient.config import ConfigFile from mdrsclient.config import ConfigFile
from mdrsclient.connection import MDRSConnection from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import ( from mdrsclient.exceptions import (
@@ -95,3 +96,172 @@ class BaseCommand(ABC):
laboratory = folder_array.pop(0) laboratory = folder_array.pop(0)
folder = "/" + "/".join(folder_array) folder = "/" + "/".join(folder_array)
return (remote_host, laboratory, folder) return (remote_host, laboratory, folder)
# ------------------------------------------------------------------
# DOI helpers
# ------------------------------------------------------------------
@staticmethod
def _is_doi(path_component: str) -> bool:
"""Return True if path_component looks like a DOI string.
A DOI is recognised as a string that starts with ``10.`` and
contains a ``/``.
"""
return path_component.startswith("10.") and "/" in path_component
@staticmethod
def _doi_suffix_id(doi: str) -> str:
"""Extract the internal system ID from a full DOI string.
MDRS uses the segment after the last ``.`` in the suffix (the part
after the ``/``) as its identifier.
Example: ``10.xxxx/prefix.20230511-001`` → ``20230511-001``.
If there is no ``.`` in the suffix, the whole suffix is returned.
Trailing slashes are stripped before processing.
"""
# Strip any trailing slash first.
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]:
"""Split a DOI-with-optional-path string into (doi, subpath)."""
# Find the first '/' that separates registrant from suffix.
first_slash = doi_with_path.find("/")
if first_slash != -1:
after_suffix_start = first_slash + 1
after_first = doi_with_path[after_suffix_start:]
# Find the next '/' inside the suffix portion — this starts the subpath.
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:] # begins with "/"
# Treat a bare trailing slash as no subpath (root of DOI folder).
if subpath == "/":
return (doi, "")
else:
return (doi, subpath)
else:
# No second slash — the whole string is the DOI, no subpath.
return (doi_with_path, "")
else:
return (doi_with_path, "")
@classmethod
def _parse_doi_remote_host(cls, path: str) -> tuple[str, str, str]:
"""Parse ``remote:10.xxxx/prefix.ID[/optional/sub/path]`` into ``(remote, doi, subpath)``."""
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 (must start with '10.' and contain '/')."
)
doi, subpath = cls._split_doi_and_subpath(doi_with_path)
return (remote, doi, subpath)
@classmethod
def _find_folder_by_doi(
cls,
connection: MDRSConnection,
doi: str,
password: str | None = None,
) -> tuple[Folder, Laboratory]:
"""Resolve a DOI to a (Folder, Laboratory) pair.
Calls GET v3/doi/{id}/ to look up the folder ID, retrieves the full
folder detail (which carries ``laboratory_id``), and resolves the
laboratory from that field.
"""
doi_clean = doi.rstrip("/")
doi_id = cls._doi_suffix_id(doi_clean)
doi_api = DoiApi(connection)
doi_resp = doi_api.retrieve(doi_id)
# Verify the returned DOI matches the one supplied (case-insensitive).
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(connection)
# Retrieve full folder detail directly by ID; laboratory_id is here.
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)
# Resolve laboratory using laboratory_id from the full folder detail.
lab_api = LaboratoriesApi(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.")
connection.laboratories = labs
return (folder, lab)
@classmethod
def _resolve_folder(
cls,
connection: MDRSConnection,
remote_path: str,
password: str | None = None,
) -> tuple[Folder, Laboratory]:
"""Resolve any remote path (normal or DOI) into a (Folder, Laboratory) pair."""
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)
doi_folder, laboratory = cls._find_folder_by_doi(connection, doi, password)
if not subpath:
return (doi_folder, laboratory)
else:
abs_path = doi_folder.path.rstrip("/") + subpath
folder = cls._find_folder(connection, laboratory, abs_path, password)
return (folder, laboratory)
else:
remote, laboratory_name, r_path = cls._parse_remote_host_with_path(remote_path)
laboratory = cls._find_laboratory(connection, laboratory_name)
folder = cls._find_folder(connection, laboratory, r_path, password)
return (folder, laboratory)
@classmethod
def _resolve_file(
cls,
connection: MDRSConnection,
remote_path: str,
password: str | None = None,
) -> tuple[Folder, Laboratory, str]:
"""Resolve a remote path pointing to a file into the parent Folder, its Laboratory, and the file's basename."""
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)
doi_folder, laboratory = cls._find_folder_by_doi(connection, 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 = cls._find_folder(connection, laboratory, abs_path, password)
return (parent_folder, laboratory, r_basename)
else:
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)
laboratory = cls._find_laboratory(connection, laboratory_name)
parent_folder = cls._find_folder(connection, laboratory, r_dirname, password)
return (parent_folder, laboratory, r_basename)
+1 -1
View File
@@ -31,7 +31,7 @@ class ChaclCommand(BaseCommand):
@classmethod @classmethod
def chacl(cls, remote_path: str, access_level: int, is_recursive: bool, password: str | None) -> None: 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) remote, laboratory_name, r_path = cls._parse_remote_host_with_path(remote_path)
r_path = r_path.rstrip("/") r_path = r_path.rstrip("/")
connection = cls._create_connection(remote) connection = cls._create_connection(remote)
laboratory = cls._find_laboratory(connection, laboratory_name) laboratory = cls._find_laboratory(connection, laboratory_name)
+6 -9
View File
@@ -26,10 +26,11 @@ class ConfigCommand(BaseCommand):
update_parser.set_defaults(func=cls.func_update) update_parser.set_defaults(func=cls.func_update)
# config list # config list
list_parser = config_parsers.add_parser("list", help="list all the remote hosts", aliases=["ls"]) list_parser = config_parsers.add_parser("list", help="list all the remote hosts", aliases=["ls"])
list_parser.add_argument("-l", "--long", help="show the api url", action="store_true")
list_parser.set_defaults(func=cls.func_list) list_parser.set_defaults(func=cls.func_list)
# config delete # config delete
delete_parser = config_parsers.add_parser("delete", help="delete an existing remote host", aliases=["remove"]) delete_parser = config_parsers.add_parser(
"delete", help="delete an existing remote host", aliases=["remove", "rm"]
)
delete_parser.add_argument("remote", help="label of remote host") delete_parser.add_argument("remote", help="label of remote host")
delete_parser.set_defaults(func=cls.func_delete) delete_parser.set_defaults(func=cls.func_delete)
@@ -47,8 +48,7 @@ class ConfigCommand(BaseCommand):
@classmethod @classmethod
def func_list(cls, args: Namespace) -> None: def func_list(cls, args: Namespace) -> None:
is_long = bool(args.long) cls.list()
cls.list(is_long)
@classmethod @classmethod
def func_delete(cls, args: Namespace) -> None: def func_delete(cls, args: Namespace) -> None:
@@ -74,13 +74,10 @@ class ConfigCommand(BaseCommand):
config.url = url config.url = url
@classmethod @classmethod
def list(cls, is_long: bool) -> None: def list(cls) -> None:
config = ConfigFile("") config = ConfigFile("")
for remote, url in config.list(): for remote, url in config.list():
line = f"{remote}:" print(f"{remote}:\t{url}")
if is_long:
line += f"\t{url}"
print(line)
@classmethod @classmethod
def delete(cls, remote: str) -> None: def delete(cls, remote: str) -> None:
+2 -2
View File
@@ -29,8 +29,8 @@ class CpCommand(BaseCommand):
@classmethod @classmethod
def cp(cls, src_path: str, dest_path: str, is_recursive: bool) -> None: 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) 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) d_remote, d_laboratory_name, d_path = cls._parse_remote_host_with_path(dest_path)
if s_remote != d_remote: if s_remote != d_remote:
raise IllegalArgumentException("Remote host mismatched.") raise IllegalArgumentException("Remote host mismatched.")
if s_laboratory_name != d_laboratory_name: if s_laboratory_name != d_laboratory_name:
+55 -1
View File
@@ -68,7 +68,61 @@ class DownloadCommand(BaseCommand):
password: str | None, password: str | None,
excludes: list[str], excludes: list[str],
) -> None: ) -> None:
(remote, laboratory_name, r_path) = cls._parse_remote_host_with_path(remote_path) # 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.")
doi_folder, laboratory = cls._find_folder_by_doi(connection, doi, password)
subpath_clean = subpath.rstrip("/")
if not subpath_clean:
folder = doi_folder
is_folder = True
else:
r_dirname = os.path.dirname(subpath_clean)
r_basename = os.path.basename(subpath_clean)
abs_parent_path = doi_folder.path.rstrip("/") + r_dirname
r_parent_folder = cls._find_folder(connection, laboratory, abs_parent_path, password)
r_parent_files = cls._find_files(connection, r_parent_folder.id)
file = find_file(r_parent_files, r_basename)
if file is not None:
if cls.__check_excludes(excludes, laboratory, r_parent_folder, file):
return
context = DownloadContext(False, is_skip_if_exists, [])
l_path = os.path.join(l_dirname, r_basename)
context.files.append(DownloadFileInfo(file, l_path))
cls.__multiple_download(connection, context)
return
else:
folder_simple = r_parent_folder.find_sub_folder(r_basename)
if folder_simple is None:
raise IllegalArgumentException(f"File or folder `{subpath_clean}` not found.")
folder = FoldersApi(connection).retrieve(folder_simple.id)
is_folder = True
# For a DOI target the whole folder is the download target.
if not is_recursive:
# Non-recursive: download only the files at the top level of the DOI folder.
files = cls._find_files(connection, folder.id)
context = DownloadContext(False, is_skip_if_exists, [])
for file in files:
if cls.__check_excludes(excludes, laboratory, folder, file):
continue
l_path = os.path.join(l_dirname, file.name)
context.files.append(DownloadFileInfo(file, l_path))
cls.__multiple_download(connection, context)
return
folder_api = FoldersApi(connection)
cls.__multiple_download_pickup_recursive_files(
connection, folder_api, laboratory, folder.id, l_dirname, excludes, is_skip_if_exists
)
return
remote, laboratory_name, r_path = cls._parse_remote_host_with_path(remote_path)
r_path = r_path.rstrip("/") r_path = r_path.rstrip("/")
r_dirname = os.path.dirname(r_path) r_dirname = os.path.dirname(r_path)
r_basename = os.path.basename(r_path) r_basename = os.path.basename(r_path)
+2 -6
View File
@@ -25,13 +25,9 @@ class FileMetadataCommand(BaseCommand):
@classmethod @classmethod
def file_metadata(cls, remote_path: str, password: str | None) -> None: def file_metadata(cls, remote_path: str, password: str | None) -> None:
(remote, laboratory_name, r_path) = cls._parse_remote_host_with_path(remote_path) remote = remote_path.split(":", 1)[0] if ":" in remote_path else ""
r_path = r_path.rstrip("/")
r_dirname = os.path.dirname(r_path)
r_basename = os.path.basename(r_path)
connection = cls._create_connection(remote) connection = cls._create_connection(remote)
laboratory = cls._find_laboratory(connection, laboratory_name) folder, laboratory, r_basename = cls._resolve_file(connection, remote_path, password)
folder = cls._find_folder(connection, laboratory, r_dirname, password)
files = cls._find_files(connection, folder.id) files = cls._find_files(connection, folder.id)
file = find_file(files, r_basename) file = find_file(files, r_basename)
if file is None: if file is None:
+12 -12
View File
@@ -23,7 +23,7 @@ class LsCommandContext:
laboratory: Laboratory laboratory: Laboratory
password: str password: str
is_json: bool is_json: bool
is_quick: bool is_quiet: bool
is_recursive: bool is_recursive: bool
@@ -35,7 +35,7 @@ class LsCommand(BaseCommand):
ls_parser.add_argument("-J", "--json", help="turn on json output", action="store_true") ls_parser.add_argument("-J", "--json", help="turn on json output", action="store_true")
ls_parser.add_argument( ls_parser.add_argument(
"-q", "-q",
"--quick", "--quiet",
help="don't output header row. this option is forced if the -r option is specified", help="don't output header row. this option is forced if the -r option is specified",
action="store_true", action="store_true",
) )
@@ -49,25 +49,25 @@ class LsCommand(BaseCommand):
password = str(args.password) if args.password else None password = str(args.password) if args.password else None
is_json = bool(args.json) is_json = bool(args.json)
is_recursive = bool(args.recursive) is_recursive = bool(args.recursive)
is_quick = bool(args.quick) if not is_recursive else True is_quiet = bool(args.quiet) if not is_recursive else True
cls.ls(remote_path, password, is_json, is_recursive, is_quick) cls.ls(remote_path, password, is_json, is_recursive, is_quiet)
@classmethod @classmethod
def ls(cls, remote_path: str, password: str | None, is_json: bool, is_recursive: bool, is_quick: bool) -> None: def ls(cls, remote_path: str, password: str | None, is_json: bool, is_recursive: bool, is_quiet: bool) -> None:
(remote, laboratory_name, r_path) = cls._parse_remote_host_with_path(remote_path) remote = remote_path.split(":", 1)[0] if ":" in remote_path else ""
connection = cls._create_connection(remote) connection = cls._create_connection(remote)
laboratory = cls._find_laboratory(connection, laboratory_name) folder, laboratory = cls._resolve_folder(connection, remote_path, password)
laboratory_name = laboratory.name
files = cls._find_files(connection, folder.id)
context = LsCommandContext( context = LsCommandContext(
f"{remote}:/{laboratory_name}", f"{remote}:/{laboratory_name}",
connection, connection,
laboratory, laboratory,
password if password is not None else "", password if password is not None else "",
is_json, is_json,
is_quick, is_quiet,
is_recursive, is_recursive,
) )
folder = cls._find_folder(connection, laboratory, r_path, password)
files = cls._find_files(connection, folder.id)
if context.is_json: if context.is_json:
cls._ls_json(context, folder, files) cls._ls_json(context, folder, files)
else: else:
@@ -89,7 +89,7 @@ class LsCommand(BaseCommand):
} }
length: dict[str, int] = {} length: dict[str, int] = {}
for key in label.keys(): for key in label.keys():
length[key] = len(label[key]) if not context.is_quick else 0 length[key] = len(label[key]) if not context.is_quiet else 0
for sub_folder in folder.sub_folders: for sub_folder in folder.sub_folders:
sub_laboratory = context.connection.laboratories.find_by_id(sub_folder.laboratory_id) sub_laboratory = context.connection.laboratories.find_by_id(sub_folder.laboratory_id)
sub_laboratory_name = sub_laboratory.name if sub_laboratory is not None else "(invalid)" sub_laboratory_name = sub_laboratory.name if sub_laboratory is not None else "(invalid)"
@@ -114,7 +114,7 @@ class LsCommand(BaseCommand):
print(f"{context.prefix}{folder.path}:") print(f"{context.prefix}{folder.path}:")
print(f"total {sum(f.size for f in files)}") print(f"total {sum(f.size for f in files)}")
if not context.is_quick: if not context.is_quiet:
print(header) print(header)
print("-" * len(header.expandtabs())) print("-" * len(header.expandtabs()))
+2 -3
View File
@@ -22,10 +22,9 @@ class MetadataCommand(BaseCommand):
@classmethod @classmethod
def metadata(cls, remote_path: str, password: str | None) -> None: def metadata(cls, remote_path: str, password: str | None) -> None:
(remote, laboratory_name, r_path) = cls._parse_remote_host_with_path(remote_path) remote = remote_path.split(":", 1)[0] if ":" in remote_path else ""
connection = cls._create_connection(remote) connection = cls._create_connection(remote)
laboratory = cls._find_laboratory(connection, laboratory_name) folder, laboratory = cls._resolve_folder(connection, remote_path, password)
folder = cls._find_folder(connection, laboratory, r_path, password)
folder_api = FoldersApi(connection) folder_api = FoldersApi(connection)
metadata = folder_api.metadata(folder.id) metadata = folder_api.metadata(folder.id)
print(json.dumps(metadata, ensure_ascii=False)) print(json.dumps(metadata, ensure_ascii=False))
+1 -1
View File
@@ -23,7 +23,7 @@ class MkdirCommand(BaseCommand):
@classmethod @classmethod
def mkdir(cls, remote_path: str) -> None: def mkdir(cls, remote_path: str) -> None:
(remote, laboratory_name, r_path) = cls._parse_remote_host_with_path(remote_path) remote, laboratory_name, r_path = cls._parse_remote_host_with_path(remote_path)
r_path = r_path.rstrip("/") r_path = r_path.rstrip("/")
r_dirname = os.path.dirname(r_path) r_dirname = os.path.dirname(r_path)
r_basename = os.path.basename(r_path) r_basename = os.path.basename(r_path)
+2 -2
View File
@@ -25,8 +25,8 @@ class MvCommand(BaseCommand):
@classmethod @classmethod
def mv(cls, src_path: str, dest_path: str) -> None: 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) 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) d_remote, d_laboratory_name, d_path = cls._parse_remote_host_with_path(dest_path)
if s_remote != d_remote: if s_remote != d_remote:
raise IllegalArgumentException("Remote host mismatched.") raise IllegalArgumentException("Remote host mismatched.")
if s_laboratory_name != d_laboratory_name: if s_laboratory_name != d_laboratory_name:
+1 -1
View File
@@ -26,7 +26,7 @@ class RmCommand(BaseCommand):
@classmethod @classmethod
def rm(cls, remote_path: str, is_recursive: bool) -> None: def rm(cls, remote_path: str, is_recursive: bool) -> None:
(remote, laboratory_name, r_path) = cls._parse_remote_host_with_path(remote_path) remote, laboratory_name, r_path = cls._parse_remote_host_with_path(remote_path)
r_path = r_path.rstrip("/") r_path = r_path.rstrip("/")
r_dirname = os.path.dirname(r_path) r_dirname = os.path.dirname(r_path)
r_basename = os.path.basename(r_path) r_basename = os.path.basename(r_path)
+3 -2
View File
@@ -2,6 +2,7 @@ import os
from argparse import Namespace from argparse import Namespace
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from unicodedata import normalize
from pydantic.dataclasses import dataclass from pydantic.dataclasses import dataclass
@@ -48,7 +49,7 @@ class UploadCommand(BaseCommand):
@classmethod @classmethod
def upload(cls, local_path: str, remote_path: str, is_recursive: bool, is_skip_if_exists: bool) -> None: def upload(cls, 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) remote, laboratory_name, r_path = cls._parse_remote_host_with_path(remote_path)
l_path = os.path.abspath(local_path) l_path = os.path.abspath(local_path)
if not os.path.exists(l_path): if not os.path.exists(l_path):
raise IllegalArgumentException(f"File or directory `{local_path}` not found.") raise IllegalArgumentException(f"File or directory `{local_path}` not found.")
@@ -81,7 +82,7 @@ class UploadCommand(BaseCommand):
if folder_map.get(d_dirname) is None: if folder_map.get(d_dirname) is None:
d_folder = folder_map[d_parent_dirname].find_sub_folder(d_basename) d_folder = folder_map[d_parent_dirname].find_sub_folder(d_basename)
if d_folder is None: if d_folder is None:
d_folder_id = folder_api.create(d_basename, folder_map[d_parent_dirname].id) d_folder_id = folder_api.create(normalize("NFC", d_basename), folder_map[d_parent_dirname].id)
else: else:
d_folder_id = d_folder.id d_folder_id = d_folder.id
print(d_dirname) print(d_dirname)
+20
View File
@@ -0,0 +1,20 @@
from argparse import Namespace
from typing import Any
from mdrsclient.__version__ import __version__
from mdrsclient.commands.base import BaseCommand
class VersionCommand(BaseCommand):
@classmethod
def register(cls, parsers: Any) -> None:
version_parser = parsers.add_parser("version", help="show the version of this tool")
version_parser.set_defaults(func=cls.func)
@classmethod
def func(cls, args: Namespace) -> None:
cls.version()
@classmethod
def version(cls) -> None:
print(f"mdrs {__version__}")
+2
View File
@@ -92,6 +92,8 @@ class MDRSConnection:
self.__cache.laboratories = laboratories self.__cache.laboratories = laboratories
def __build_url(self, path: str) -> str: def __build_url(self, path: str) -> str:
if path.startswith("http://") or path.startswith("https://"):
return path
if self.url == "": if self.url == "":
raise MissingConfigurationException("remote host is not configured") raise MissingConfigurationException("remote host is not configured")
return f"{self.url}/{path}" return f"{self.url}/{path}"
+11 -10
View File
@@ -1,12 +1,12 @@
[tool.poetry] [tool.poetry]
name = "mdrs-client-python" name = "mdrs-client-python"
version = "1.3.13" version = "1.3.16"
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"
readme = "README.md" readme = "README.md"
classifiers=[ classifiers=[
"Development Status :: 3 - Alpha", "Development Status :: 4 - Beta",
"Environment :: Console", "Environment :: Console",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Intended Audience :: Science/Research", "Intended Audience :: Science/Research",
@@ -14,6 +14,7 @@ classifiers=[
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"OSI Approved :: MIT License", "OSI Approved :: MIT License",
"Topic :: Utilities", "Topic :: Utilities",
] ]
@@ -23,20 +24,20 @@ packages = [
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
requests = "^2.32.3" 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.11.4" pydantic = "^2.13.4"
pydantic-settings = "^2.9.1" pydantic-settings = "^2.14.1"
PyJWT = "^2.10.1" PyJWT = "^2.13.0"
validators = "^0.34.0" validators = "^0.35.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^24.10.0" black = "^26.5.1"
flake8 = "^7.2.0" flake8 = "^7.2.0"
Flake8-pyproject = "^1.2.3" Flake8-pyproject = "^1.2.3"
isort = "^5.13.2" isort = "^8.0.1"
pyright = "^1.1.400" pyright = "^1.1.401"
[tool.poetry.scripts] [tool.poetry.scripts]
mdrs = 'mdrsclient.__main__:main' mdrs = 'mdrsclient.__main__:main'