Files
mdrs-client-python/mdrsclient/services.py
T
orrisroot 3081d7fbb3 fix: report a locked folder as FolderLockedException
Fetching a locked folder before its password is sent returns 200 with
only its listing fields, without metadata, sub_folders and path, so
FoldersApi.retrieve failed with a pydantic ValidationError. The DOI
route fetches the folder before it can see the lock, so a
password-protected DOI folder could not be opened even with its
password.

- Add FolderLockedException, a subclass of UnauthorizedException, and
  raise it from retrieve on that short record and from the lock checks.
- On a DOI folder, send the password and fetch it again when the first
  fetch finds it locked.
2026-11-13 19:01:56 +09:00

272 lines
11 KiB
Python

import os
import re
from unicodedata import normalize
from mdrsclient.api import DoiApi, FilesApi, FoldersApi, LaboratoriesApi, UsersApi
from mdrsclient.api.utils import token_check
from mdrsclient.cache import CacheInterface
from mdrsclient.config import ConfigFile, ConfigInterface
from mdrsclient.connection import MDRSConnection
from mdrsclient.exceptions import (
FolderLockedException,
IllegalArgumentException,
MissingConfigurationException,
UnexpectedException,
)
from mdrsclient.models import File, Folder, Laboratory, Token, User
from mdrsclient.utils import page_num_from_url
class MdrsService:
config_class: type[ConfigInterface] = ConfigFile
def __init__(self, connection: MDRSConnection | None, config_class: type[ConfigInterface] | None = None):
self.__connection = connection
if config_class is not None:
self.config_class = config_class
@property
def connection(self) -> MDRSConnection:
"""
The connection every remote operation goes through.
Optional to supply, because `config` and `version` do their work without a remote
and are reached through the same client. Asking for it when none was given is a
mistake in the caller, and says so rather than failing later on `None`.
"""
if self.__connection is None:
raise MissingConfigurationException("This operation requires a remote host.")
return self.__connection
@connection.setter
def connection(self, connection: MDRSConnection | None) -> None:
# Assignable as it always was: this is a property to check for absence on read,
# not to make the connection fixed once the client is built.
self.__connection = connection
@classmethod
def create_connection(
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:
raise MissingConfigurationException(f"Remote host `{remote}` is not found.")
return MDRSConnection(config.remote, config.url, cache=cache)
def login(self, username: str, password: str) -> tuple[Token, User]:
user_api = UsersApi(self.connection)
token = user_api.token(username, password)
self.connection.token = token
user = user_api.current()
self.connection.user = user
return token, user
def logout(self) -> None:
self.connection.logout()
def project_names(self) -> dict[int, str]:
"""
Names of the logged-in user's projects by bit_position, for labelling access levels.
Empty when nobody is logged in: the server names a project only to its members.
"""
token_check(self.connection)
if self.connection.token is None:
return {}
return UsersApi(self.connection).project_names()
def whoami(self) -> User:
user_api = UsersApi(self.connection)
return user_api.current()
def get_laboratories(self) -> list[Laboratory]:
laboratory_api = LaboratoriesApi(self.connection)
labs = laboratory_api.list()
self.connection.laboratories = labs
return list(labs)
def find_laboratory(self, name: str) -> Laboratory:
if self.connection.laboratories.empty() or (self.connection.token and self.connection.token.is_expired):
self.get_laboratories()
laboratory = self.connection.laboratories.find_by_name(name)
if laboratory is None:
raise IllegalArgumentException(f"Laboratory `{name}` not found.")
return laboratory
def find_folder(self, laboratory: Laboratory, path: str, password: str | None = None) -> Folder:
folder_api = FoldersApi(self.connection)
folders = folder_api.list(laboratory.id, normalize("NFC", path))
if len(folders) != 1:
raise UnexpectedException(f"Folder `{path}` not found.")
if folders[0].lock:
if password is None:
raise FolderLockedException(f"Folder `{path}` is locked.")
folder_api.auth(folders[0].id, password)
return folder_api.retrieve(folders[0].id)
def find_files(self, folder_id: str) -> list[File]:
files_api = FilesApi(self.connection)
page = 1
results_file = []
while page:
result = files_api.list(folder_id, page)
results_file.extend(result.results)
page = 0
if result.next:
page = page_num_from_url(result.next)
return results_file
@staticmethod
def is_doi(path_component: str) -> bool:
return path_component.startswith("10.") and "/" in path_component
@staticmethod
def doi_suffix_id(doi: str) -> str:
doi = doi.rstrip("/")
slash_pos = doi.find("/")
if slash_pos == -1:
return doi
suffix = doi[slash_pos + 1 :]
dot_pos = suffix.rfind(".")
return suffix[dot_pos + 1 :] if dot_pos != -1 else suffix
@staticmethod
def split_doi_and_subpath(doi_with_path: str) -> tuple[str, str]:
first_slash = doi_with_path.find("/")
if first_slash != -1:
after_suffix_start = first_slash + 1
after_first = doi_with_path[after_suffix_start:]
second_slash = after_first.find("/")
if second_slash != -1:
doi_end = after_suffix_start + second_slash
doi = doi_with_path[:doi_end]
subpath = doi_with_path[doi_end:]
if subpath == "/":
return (doi, "")
else:
return (doi, subpath)
else:
return (doi_with_path, "")
else:
return (doi_with_path, "")
@classmethod
def parse_remote_host(cls, path: str) -> str:
path_array = path.split(":")
remote_host = path_array[0]
if len(path_array) == 2 and path_array[1] != "" or len(path_array) > 2:
raise IllegalArgumentException("Invalid remote host")
return remote_host
@classmethod
def parse_remote_host_with_path(cls, path: str) -> tuple[str, str, str]:
path = re.sub(r"//+|/\./+|/\.$", "/", path)
if re.search(r"/\.\./|/\.\.$", path) is not None:
raise IllegalArgumentException("Path traversal found.")
path_array = path.split(":")
if len(path_array) != 2:
raise IllegalArgumentException("Invalid remote host.")
remote_host = path_array[0]
folder_array = path_array[1].split("/")
is_absolute_path = folder_array[0] == ""
if not is_absolute_path:
raise IllegalArgumentException("Must be absolute paths.")
del folder_array[0]
if len(folder_array) == 0:
laboratory = ""
folder = ""
else:
laboratory = folder_array.pop(0)
folder = "/" + "/".join(folder_array)
return (remote_host, laboratory, folder)
@classmethod
def parse_doi_remote_host(cls, path: str) -> tuple[str, str, str]:
parts = path.split(":", 1)
if len(parts) != 2:
raise IllegalArgumentException("remote_path must be in the form 'remote:10.xxxx/prefix.ID'")
remote, doi_with_path = parts
if not cls.is_doi(doi_with_path):
raise IllegalArgumentException(f"Path `{doi_with_path}` does not look like a DOI.")
doi, subpath = cls.split_doi_and_subpath(doi_with_path)
return (remote, doi, subpath)
def find_folder_by_doi(self, doi: str, password: str | None = None) -> tuple[Folder, Laboratory]:
doi_clean = doi.rstrip("/")
doi_id = self.doi_suffix_id(doi_clean)
doi_api = DoiApi(self.connection)
doi_resp = doi_api.retrieve(doi_id)
returned_doi = doi_resp.doi.rstrip("/")
if returned_doi.lower() != doi_clean.lower():
raise IllegalArgumentException(
f"DOI mismatch: requested `{doi_clean}` but server returned `{returned_doi}`."
)
folder_api = FoldersApi(self.connection)
# The folder is found by its id, so whether it is locked is known only from what
# retrieve returns: a full record, or the short one of a locked folder.
folder: Folder | None
try:
folder = folder_api.retrieve(doi_resp.folder_id)
except FolderLockedException:
folder = None
if folder is None or folder.lock:
if password is None:
raise FolderLockedException(f"Folder for DOI `{doi_clean}` is locked.")
folder_api.auth(doi_resp.folder_id, password)
if folder is None:
folder = folder_api.retrieve(doi_resp.folder_id)
lab_api = LaboratoriesApi(self.connection)
labs = lab_api.list()
lab = labs.find_by_id(folder.laboratory_id)
if lab is None:
raise UnexpectedException(f"Laboratory with id {folder.laboratory_id} not found.")
self.connection.laboratories = labs
return (folder, lab)
def resolve_folder(self, remote_path: str, password: str | None = None) -> tuple[Folder, Laboratory]:
path_component = remote_path.split(":", 1)[1] if ":" in remote_path else ""
if self.is_doi(path_component):
remote, doi, subpath = self.parse_doi_remote_host(remote_path)
doi_folder, laboratory = self.find_folder_by_doi(doi, password)
if not subpath:
return (doi_folder, laboratory)
else:
abs_path = doi_folder.path.rstrip("/") + subpath
folder = self.find_folder(laboratory, abs_path, password)
return (folder, laboratory)
else:
remote, laboratory_name, r_path = self.parse_remote_host_with_path(remote_path)
laboratory = self.find_laboratory(laboratory_name)
folder = self.find_folder(laboratory, r_path, password)
return (folder, laboratory)
def resolve_file(self, remote_path: str, password: str | None = None) -> tuple[Folder, Laboratory, str]:
path_component = remote_path.split(":", 1)[1] if ":" in remote_path else ""
if self.is_doi(path_component):
remote, doi, subpath = self.parse_doi_remote_host(remote_path)
doi_folder, laboratory = self.find_folder_by_doi(doi, password)
subpath_clean = subpath.rstrip("/")
if not subpath_clean:
raise IllegalArgumentException("DOI path must point to a file, not a folder.")
r_dirname = os.path.dirname(subpath_clean)
r_basename = os.path.basename(subpath_clean)
abs_path = doi_folder.path.rstrip("/") + r_dirname
parent_folder = self.find_folder(laboratory, abs_path, password)
return (parent_folder, laboratory, r_basename)
else:
remote, laboratory_name, r_path = self.parse_remote_host_with_path(remote_path)
r_path = r_path.rstrip("/")
r_dirname = os.path.dirname(r_path)
r_basename = os.path.basename(r_path)
laboratory = self.find_laboratory(laboratory_name)
parent_folder = self.find_folder(laboratory, r_dirname, password)
return (parent_folder, laboratory, r_basename)