Compare commits
4
Commits
a2bc9bb642
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab34a4ca6
|
||
|
|
96bfceba7a
|
||
|
|
2c0f4a75c5
|
||
|
|
3081d7fbb3
|
+4
-1
@@ -2,16 +2,19 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.3.21] - 2026-11-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Built access level labels from their bits instead of a fixed table of combinations, so a level the server adds later is labelled too. A project level (bit 16 and up) is named after the project as the server reports it to its members, e.g. `Brain/MINDS 2.0 Open`, and otherwise `Project<n> Open`. A restrict network other than CBS is `Restrict<n> Open`; `0x0200`, formerly `5Kikan Open`, is now `Restrict2 Open`.
|
- Built access level labels from their bits instead of a fixed table of combinations, so a level the server adds later is labelled too. A project level (bit 16 and up) is named after the project as the server reports it to its members, e.g. `Brain/MINDS 2.0 Open`, and otherwise `Project<n> Open`. A restrict network other than CBS is `Restrict<n> Open`; `0x0200`, formerly `5Kikan Open`, is now `Restrict2 Open`.
|
||||||
- `chacl` takes keys built the same way: `cbs_or_pw_open`, `project0_open` and so on. `5kikan_open` is no longer accepted. Its help lists them, an invalid level lists the projects the user belongs to with their keys, and `public` and `storage`, which the server refuses, are refused before any request.
|
- `chacl` takes keys built the same way: `cbs_or_pw_open`, `project0_open` and so on. `5kikan_open` is no longer accepted. Its help lists them, an invalid level lists the projects the user belongs to with their keys, and `public` and `storage`, which the server refuses, are refused before any request.
|
||||||
- `ls` asks the server for the user's projects once per run when logged in.
|
- `ls` asks the server for the user's projects once per run when logged in.
|
||||||
|
- Required Python 3.11 or later, as `pydantic-settings` and `validators` do.
|
||||||
|
- Raised the minimum version of `pydantic` to 2.14.3, and of the development tools `black`, `flake8`, `isort` and `pyright` to their current releases.
|
||||||
- Removed `FolderAccessLevel.ACCESS_LEVELS` and `FolderAccessLevelItem`. `FolderSimple.access_level_label()` labels a level with the project names given to it.
|
- Removed `FolderAccessLevel.ACCESS_LEVELS` and `FolderAccessLevelItem`. `FolderSimple.access_level_label()` labels a level with the project names given to it.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Reported an access level that has no meaning as `Unknown (0x…)` instead of `Storage`. Storage is the absence of every bit, so it matched any level. A level made of known bits is no longer labelled by the first table entry it contained either: `0x10104` was `CBS or PW Open`. This changes the `access_level` value in `ls` output, including JSON.
|
- Reported an access level that has no meaning as `Unknown (0x…)` instead of `Storage`. Storage is the absence of every bit, so it matched any level. A level made of known bits is no longer labelled by the first table entry it contained either: `0x10104` was `CBS or PW Open`. This changes the `access_level` value in `ls` output, including JSON.
|
||||||
|
- Reported a locked folder that was fetched without its password as `FolderLockedException`, a subclass of `UnauthorizedException`, instead of a pydantic `ValidationError`. The server answers with a short record that has no sub-folders or path. A password-protected DOI folder fails this way before its password is sent, so it can now be opened again.
|
||||||
|
|
||||||
## [1.3.20] - 2026-11-04
|
## [1.3.20] - 2026-11-04
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pydantic.dataclasses import dataclass
|
|||||||
|
|
||||||
from mdrsclient.api.base import BaseApi
|
from mdrsclient.api.base import BaseApi
|
||||||
from mdrsclient.api.utils import retry_on_expired_token, token_check
|
from mdrsclient.api.utils import retry_on_expired_token, token_check
|
||||||
from mdrsclient.exceptions import UnauthorizedException
|
from mdrsclient.exceptions import FolderLockedException, UnauthorizedException
|
||||||
from mdrsclient.models import Folder, FolderSimple
|
from mdrsclient.models import Folder, FolderSimple
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +38,12 @@ class FoldersApi(BaseApi):
|
|||||||
token_check(self.connection)
|
token_check(self.connection)
|
||||||
response = self.connection.get(url)
|
response = self.connection.get(url)
|
||||||
self._raise_response_error(response)
|
self._raise_response_error(response)
|
||||||
ret = TypeAdapter(Folder).validate_python(response.json())
|
data = response.json()
|
||||||
|
# A locked folder not yet opened with its password comes back with 200 but only
|
||||||
|
# its listing fields, without `metadata`, `sub_folders` and `path`.
|
||||||
|
if data.get("lock") and "sub_folders" not in data:
|
||||||
|
raise FolderLockedException(f"Folder `{data.get('name', id)}` is locked.")
|
||||||
|
ret = TypeAdapter(Folder).validate_python(data)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@retry_on_expired_token
|
@retry_on_expired_token
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ class UnauthorizedException(MDRSException):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FolderLockedException(UnauthorizedException):
|
||||||
|
"""Thrown when a folder cannot be opened without its password"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TokenExpiredException(UnauthorizedException):
|
class TokenExpiredException(UnauthorizedException):
|
||||||
"""Thrown when the access token was still valid when sent but had expired on arrival"""
|
"""Thrown when the access token was still valid when sent but had expired on arrival"""
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -8,9 +8,9 @@ from mdrsclient.cache import CacheInterface
|
|||||||
from mdrsclient.config import ConfigFile, ConfigInterface
|
from mdrsclient.config import ConfigFile, ConfigInterface
|
||||||
from mdrsclient.connection import MDRSConnection
|
from mdrsclient.connection import MDRSConnection
|
||||||
from mdrsclient.exceptions import (
|
from mdrsclient.exceptions import (
|
||||||
|
FolderLockedException,
|
||||||
IllegalArgumentException,
|
IllegalArgumentException,
|
||||||
MissingConfigurationException,
|
MissingConfigurationException,
|
||||||
UnauthorizedException,
|
|
||||||
UnexpectedException,
|
UnexpectedException,
|
||||||
)
|
)
|
||||||
from mdrsclient.models import File, Folder, Laboratory, Token, User
|
from mdrsclient.models import File, Folder, Laboratory, Token, User
|
||||||
@@ -103,7 +103,7 @@ class MdrsService:
|
|||||||
raise UnexpectedException(f"Folder `{path}` not found.")
|
raise UnexpectedException(f"Folder `{path}` not found.")
|
||||||
if folders[0].lock:
|
if folders[0].lock:
|
||||||
if password is None:
|
if password is None:
|
||||||
raise UnauthorizedException(f"Folder `{path}` is locked.")
|
raise FolderLockedException(f"Folder `{path}` is locked.")
|
||||||
folder_api.auth(folders[0].id, password)
|
folder_api.auth(folders[0].id, password)
|
||||||
return folder_api.retrieve(folders[0].id)
|
return folder_api.retrieve(folders[0].id)
|
||||||
|
|
||||||
@@ -207,12 +207,20 @@ class MdrsService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
folder_api = FoldersApi(self.connection)
|
folder_api = FoldersApi(self.connection)
|
||||||
folder = folder_api.retrieve(doi_resp.folder_id)
|
# 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.lock:
|
if folder is None or folder.lock:
|
||||||
if password is None:
|
if password is None:
|
||||||
raise UnauthorizedException(f"Folder for DOI `{doi_clean}` is locked.")
|
raise FolderLockedException(f"Folder for DOI `{doi_clean}` is locked.")
|
||||||
folder_api.auth(doi_resp.folder_id, password)
|
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)
|
lab_api = LaboratoriesApi(self.connection)
|
||||||
labs = lab_api.list()
|
labs = lab_api.list()
|
||||||
|
|||||||
+8
-9
@@ -7,20 +7,19 @@ name = "mdrsclient"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mdrs-client-python"
|
name = "mdrs-client-python"
|
||||||
version = "1.3.20"
|
version = "1.3.21"
|
||||||
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 = [
|
authors = [
|
||||||
{ name = "Yoshihiro OKUMURA", email = "yoshihiro.okumura@riken.jp" }
|
{ name = "Yoshihiro OKUMURA", email = "yoshihiro.okumura@riken.jp" }
|
||||||
]
|
]
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"Intended Audience :: Science/Research",
|
"Intended Audience :: Science/Research",
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"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",
|
||||||
@@ -33,7 +32,7 @@ dependencies = [
|
|||||||
"requests>=2.34.2",
|
"requests>=2.34.2",
|
||||||
"requests-toolbelt>=1.0.0",
|
"requests-toolbelt>=1.0.0",
|
||||||
"python-dotenv>=1.2.4",
|
"python-dotenv>=1.2.4",
|
||||||
"pydantic>=2.14.2",
|
"pydantic>=2.14.3",
|
||||||
"pydantic-settings>=2.16.2",
|
"pydantic-settings>=2.16.2",
|
||||||
"PyJWT>=2.15.1",
|
"PyJWT>=2.15.1",
|
||||||
"validators>=0.36.0",
|
"validators>=0.36.0",
|
||||||
@@ -41,11 +40,11 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"black>=26.5.1",
|
"black>=26.10.1",
|
||||||
"flake8>=7.3.0",
|
"flake8>=7.5.0",
|
||||||
"Flake8-pyproject>=1.2.4",
|
"Flake8-pyproject>=1.2.4",
|
||||||
"isort>=9.0.1",
|
"isort>=9.0.2",
|
||||||
"pyright>=1.1.411",
|
"pyright>=1.1.414",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -53,7 +52,7 @@ mdrs = "mdrsclient.__main__:main"
|
|||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
target-version = ['py310']
|
target-version = ['py311']
|
||||||
include = '\.pyi?$'
|
include = '\.pyi?$'
|
||||||
exclude = '''
|
exclude = '''
|
||||||
/(
|
/(
|
||||||
|
|||||||
+55
-6
@@ -1,6 +1,8 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from mdrsclient.api import FoldersApi
|
||||||
|
from mdrsclient.exceptions import FolderLockedException
|
||||||
from mdrsclient.models import Doi, Folder, Laboratories, Laboratory
|
from mdrsclient.models import Doi, Folder, Laboratories, Laboratory
|
||||||
from mdrsclient.services import MdrsService
|
from mdrsclient.services import MdrsService
|
||||||
|
|
||||||
@@ -28,10 +30,43 @@ def make_locked_folder() -> Folder:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# What the server returns for a locked folder not yet opened with its password.
|
||||||
|
SHORT_RECORD = {
|
||||||
|
"id": "f1",
|
||||||
|
"pid": None,
|
||||||
|
"name": "root",
|
||||||
|
"access_level": 4,
|
||||||
|
"lock": True,
|
||||||
|
"size": 0,
|
||||||
|
"laboratory_id": 1,
|
||||||
|
"description": "",
|
||||||
|
"created_at": TIMESTAMP,
|
||||||
|
"updated_at": TIMESTAMP,
|
||||||
|
"restrict_opened_at": None,
|
||||||
|
"application": {"id": 0, "status": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetrieveLockedFolder(unittest.TestCase):
|
||||||
|
def retrieve(self, data: dict) -> Folder:
|
||||||
|
connection = MagicMock()
|
||||||
|
connection.get.return_value.json.return_value = data
|
||||||
|
with patch.object(FoldersApi, "_raise_response_error"):
|
||||||
|
return FoldersApi(connection).retrieve("f1")
|
||||||
|
|
||||||
|
def test_short_record_means_locked(self):
|
||||||
|
with self.assertRaisesRegex(FolderLockedException, "Folder `root` is locked."):
|
||||||
|
self.retrieve(SHORT_RECORD)
|
||||||
|
|
||||||
|
def test_full_record_of_a_locked_folder_is_read(self):
|
||||||
|
full = dict(SHORT_RECORD, metadata=[], sub_folders=[], path="/root/")
|
||||||
|
self.assertEqual(self.retrieve(full).path, "/root/")
|
||||||
|
|
||||||
|
|
||||||
class TestFindFolderByDoi(unittest.TestCase):
|
class TestFindFolderByDoi(unittest.TestCase):
|
||||||
"""The DOI response carries the folder id as a field, not as a nested object."""
|
"""The DOI response carries the folder id as a field, not as a nested object."""
|
||||||
|
|
||||||
def test_a_locked_doi_folder_is_unlocked_with_its_folder_id(self):
|
def find(self, retrieved: list, password: str | None) -> tuple[MagicMock, tuple]:
|
||||||
laboratories = Laboratories()
|
laboratories = Laboratories()
|
||||||
laboratories.append(LABORATORY)
|
laboratories.append(LABORATORY)
|
||||||
|
|
||||||
@@ -41,14 +76,28 @@ class TestFindFolderByDoi(unittest.TestCase):
|
|||||||
patch("mdrsclient.services.LaboratoriesApi") as laboratories_api,
|
patch("mdrsclient.services.LaboratoriesApi") as laboratories_api,
|
||||||
):
|
):
|
||||||
doi_api.return_value.retrieve.return_value = Doi(id="20260429-001", doi=DOI, folder_id="f1")
|
doi_api.return_value.retrieve.return_value = Doi(id="20260429-001", doi=DOI, folder_id="f1")
|
||||||
folders_api.return_value.retrieve.return_value = make_locked_folder()
|
folders_api.return_value.retrieve.side_effect = retrieved
|
||||||
laboratories_api.return_value.list.return_value = laboratories
|
laboratories_api.return_value.list.return_value = laboratories
|
||||||
|
return folders_api.return_value, MdrsService(MagicMock()).find_folder_by_doi(DOI, password)
|
||||||
|
|
||||||
folder, laboratory = MdrsService(MagicMock()).find_folder_by_doi(DOI, "secret")
|
def test_a_locked_doi_folder_is_unlocked_with_its_folder_id(self):
|
||||||
|
api, (folder, laboratory) = self.find([FolderLockedException("locked"), make_locked_folder()], "secret")
|
||||||
|
api.auth.assert_called_once_with("f1", "secret")
|
||||||
|
self.assertEqual(api.retrieve.call_count, 2)
|
||||||
|
self.assertEqual(folder.id, "f1")
|
||||||
|
self.assertEqual(laboratory.name, "mylab")
|
||||||
|
|
||||||
folders_api.return_value.auth.assert_called_once_with("f1", "secret")
|
def test_a_locked_doi_folder_without_a_password(self):
|
||||||
self.assertEqual(folder.id, "f1")
|
with self.assertRaisesRegex(FolderLockedException, f"Folder for DOI `{DOI}` is locked."):
|
||||||
self.assertEqual(laboratory.name, "mylab")
|
self.find([FolderLockedException("locked")], None)
|
||||||
|
|
||||||
|
def test_a_full_record_still_asks_for_the_password(self):
|
||||||
|
# A locked folder whose record is readable anyway, e.g. its owner's.
|
||||||
|
api, (folder, _) = self.find([make_locked_folder()], "secret")
|
||||||
|
api.auth.assert_called_once_with("f1", "secret")
|
||||||
|
self.assertEqual(api.retrieve.call_count, 1)
|
||||||
|
with self.assertRaises(FolderLockedException):
|
||||||
|
self.find([make_locked_folder()], None)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user