" class="imageDropped">
+ for tag in list(soup.find_all(True)):
+ if tag.parent is None: # already removed with an earlier block
+ continue
+ hit = False
+ for name, value in tag.attrs.items():
+ for v in (value if isinstance(value, list) else [value]):
+ if isinstance(v, str) and (reference.search(v) or (name == "id" and image_id.match(v))):
+ hit = True
+ if not hit:
+ continue
+ block = tag.find_parent("div", class_="attachmentDiv") or tag
+ parent = block.parent
+ if parent is not None:
+ block.decompose()
+ changed = True
+ # an image sits in its own ; drop that paragraph if nothing else is left in it
+ if parent.name == "p" and not parent.get_text(strip=True) and not parent.find(True):
+ parent.decompose()
+ return str(soup) if changed else None
+
+
+class WorkspaceFilesystem(RSpaceFSBase):
+ """Target kinds: root | folder | document | field | file. A field's ``parent_gid`` is its
+ document; a file's ``parent_gid`` and ``field_gid`` are the document and field it was
+ reached through."""
+
+ protocol = ("rspace-workspace",)
+ FILE_KINDS = ("file",)
+ #: The only ELN field type that can hold files. See the module docstring: attachment
+ #: fields are legacy, are not offered by the form editor, and never carry linked media.
+ FILE_BEARING_FIELD_TYPES = ("text",)
+ CAN_LINK = True
+
+ def __init__(self, server: Optional[str] = None, api_key: Optional[str] = None, *,
+ eln_client: Optional[eln.ELNClient] = None, writable: bool = False,
+ allow_delete: bool = False, path_style: str = "name",
+ page_size: int = 100, **storage_options: Any) -> None:
+ super().__init__(writable=writable, allow_delete=allow_delete, path_style=path_style,
+ **storage_options)
+ self.eln_client = client_or_new(eln_client, eln.ELNClient, server, api_key, "WorkspaceFilesystem")
+ #: how many records to fetch per API request while streaming a folder listing.
+ self.page_size = max(1, int(page_size))
+
+ # ------------------------------------------------------------ path grammar
+
+ def _resolve(self, path: str) -> Target:
+ if paths.is_root(path):
+ return Target("root")
+ target = Target("root")
+ for _, gid in self._gids(paths.segments(path)):
+ prefix = gid[:2]
+ if target.kind in ("root", "folder"):
+ if prefix in CONTAINER_PREFIXES:
+ target = Target("folder", gid=gid)
+ elif prefix in DOCUMENT_PREFIXES:
+ target = Target("document", gid=gid)
+ else:
+ raise FileNotFoundError(errno.ENOENT, f"{path!r} not found", path)
+ elif target.kind == "document":
+ target = Target("field", gid=gid, parent_gid=target.gid, field_gid=gid)
+ elif target.kind == "field":
+ if prefix != FILE_PREFIX:
+ raise FileNotFoundError(errno.ENOENT, f"{path!r} not found", path)
+ target = Target("file", gid=gid, parent_gid=target.parent_gid, field_gid=target.field_gid)
+ else:
+ raise FileNotFoundError(errno.ENOENT, f"{path!r} not found", path)
+ return target
+
+ # ------------------------------------------------------------ RSpace plumbing
+
+ def _tree_items(self, folder_gid: Optional[str]) -> Iterator[dict]:
+ """Stream a folder's contents, fetching the next API page only when it is reached."""
+ first_page = self.eln_client.list_folder_tree(
+ None if folder_gid is None else folder_gid[2:], TREE_TYPES, page_size=self.page_size)
+ return stream_pages(self.eln_client, first_page, "records")
+
+ def _record(self, doc_gid: str) -> dict:
+ return self.eln_client.get_document(doc_gid[2:])
+
+ @staticmethod
+ def _require_unsigned(document: dict, path: str, extra: str = "") -> None:
+ """A signed document is locked; say so before touching the server."""
+ if document.get("signed"):
+ raise ReadOnlyError(
+ errno.EROFS, f"{path!r}: this document is signed, so its fields cannot be changed."
+ + (" " + extra if extra else ""), path)
+
+ def _update_field_content(self, document: dict, field: dict, content: str) -> None:
+ self._require_unsigned(document, document.get("globalId", ""))
+ # a refusal by the server (401/403) reaches the caller as PermissionError through
+ # the shared error translation; ReadOnlyError is for this filesystem's own posture
+ self.eln_client.update_document(
+ document["id"], form_id=document.get("form", {}).get("id"),
+ fields=[{"id": field["id"], "content": content}])
+
+ # ------------------------------------------------------------ entry builders
+
+ def _document_entry(self, document: dict, gid: str) -> dict:
+ raw = dict(document, hiddenFields=len(document.get("fields", [])) - len(self._file_fields(document)))
+ raw.pop("fields", None)
+ return self._entry(raw, True, gid, writable=not document.get("signed"))
+
+ def _read_only_segment(self, name, gid: str, signed: bool) -> str:
+ """The field's path segment, marked when its document is locked.
+
+ Only under the ``name`` style: the other two spell a segment so that a machine can
+ parse it, and appending to ``Results [FD421]`` or to ``FD421`` would break that.
+ """
+ segment = self._segment(name, gid)
+ return paths.mark_read_only(segment) if signed and self.path_style == "name" else segment
+
+ def _field_entry(self, field: dict, signed: bool = False) -> dict:
+ """A field of a signed document is marked in its own name.
+
+ This is the one place the lock can be surfaced for free: listing a document's fields
+ already fetches the document, so ``signed`` is in hand. The document's own entry in
+ its parent listing cannot say so, because the folder-tree endpoint the parent listing
+ comes from does not carry the flag and finding out would cost a request per document.
+ """
+ raw = dict(field)
+ raw.pop("files", None)
+ raw.pop("content", None)
+ raw["fieldType"] = field.get("type")
+ raw["signed"] = signed
+ return make_entry(self._read_only_segment(field.get("name"), field["globalId"], signed),
+ True, raw=raw, modified=field.get("lastModified"), writable=not signed)
+
+ # ------------------------------------------------------------ children
+
+ def _hidden_at_root(self, record: dict) -> bool:
+ """The Gallery root (a GF folder, duplicated by the gallery branch) and configured
+ system folders."""
+ if str(record.get("globalId", "")).startswith("GF"):
+ return True
+ return record.get("name") in HIDDEN_ROOT_FOLDERS and record.get("systemFolder", True) is not False
+
+ def _records_entries(self, records: Iterable[dict], folder_gid: Optional[str]) -> Iterator[dict]:
+ for record in records:
+ if folder_gid is None and self._hidden_at_root(record):
+ continue
+ yield self._entry(record, True)
+
+ def _children(self, target: Target, names_only: bool = False) -> Iterator[dict]:
+ if target.kind in ("root", "folder"):
+ return self._records_entries(self._tree_items(target.gid), target.gid)
+ if target.kind == "document":
+ document = self._record(target.gid)
+ signed = bool(document.get("signed"))
+ return (self._field_entry(field, signed=signed) for field in self._file_fields(document))
+ files = self._field(target.parent_gid, target.field_gid).get("files", [])
+ return (self._entry(file, False) for file in files)
+
+ # ------------------------------------------------------------ info
+
+ def _info_of(self, path: str, target: Target) -> dict:
+ if target.kind == "root":
+ return make_entry("", True, raw={"name": "Workspace"})
+ if target.kind == "folder":
+ return self._entry(self.eln_client.get_folder(target.gid[2:]), True, target.gid)
+ if target.kind == "document":
+ return self._document_entry(self._record(target.gid), target.gid)
+ if target.kind == "field":
+ document = self._record(target.parent_gid)
+ return self._field_entry(self._field(target.parent_gid, target.field_gid, document),
+ signed=bool(document.get("signed")))
+ return self._entry(self.eln_client.get_file_info(target.gid[2:]), False, target.gid)
+
+ # ------------------------------------------------------------ folders
+
+ @writes
+ def mkdir(self, path: str, create_parents: bool = True, exist_ok: bool = False, **kwargs) -> None:
+ """Create a Workspace folder. Notebooks and documents are not created here."""
+ path = self._strip_protocol(path)
+ parent_path, name = self._new_folder(path)
+ parent = self._resolve(parent_path)
+ if parent.kind not in ("root", "folder") or (parent.gid or "FL")[:2] in NOTEBOOK_PREFIXES:
+ raise NotImplementedError("folders can only be created in the Home folder or another folder")
+ parent_id = parent.gid[2:] if parent.gid else None
+ self._create_folder(path, name, self._tree_items(parent.gid),
+ lambda: self.eln_client.create_folder(name, parent_id, notebook=False),
+ FOLDER_PREFIXES, exist_ok)
+
+ @deletes
+ def rmdir(self, path: str) -> None:
+ path = self._strip_protocol(path)
+ target = self._resolve(path)
+ folder_gid = target.gid if target.kind == "folder" and target.gid[:2] in FOLDER_PREFIXES else None
+ self._remove_folder(path, folder_gid, self.eln_client.delete_folder,
+ NotImplementedError("only empty Workspace folders can be removed here"))
+
+ # ------------------------------------------------------------ files
+
+ def _download(self, file_id: str, file: BinaryIO, chunk_size: int) -> None:
+ self.eln_client.download_file(file_id, file, chunk_size)
+
+ @writes
+ def upload_fileobj(self, path: str, file: BinaryIO, **options: Any) -> dict:
+ """
+ Write ``file`` to ``path``: the last segment names the file to create and its parent is
+ the document field to link it into. The bytes go to the Gallery and the field gains a
+ link to them. Returns the Gallery file record.
+ """
+ path = self._strip_protocol(path)
+ container, name = self._upload_target(path)
+ target = self._resolve(container)
+ if target.kind != "field":
+ raise NotImplementedError(
+ f"{container!r}: files are uploaded into a document field folder (a text field)")
+ document = self._record(target.parent_gid)
+ field = self._field(target.parent_gid, target.field_gid, document)
+ # Checked before the upload: the field update afterwards would be refused and the
+ # file would be stranded in the Gallery, which the API cannot delete.
+ self._require_unsigned(document, container, "Nothing was uploaded.")
+ uploaded = self.eln_client.upload_file(file, **self._upload_kwargs(name))
+ self._update_field_content(document, field, (field.get("content") or "") + _file_token(uploaded["id"]))
+ self.invalidate_cache()
+ return uploaded
+
+ @writes
+ def link(self, path: str, media_file_gid: str) -> dict:
+ """Link a file already in the Gallery into the text field at ``path``, without
+ copying the bytes. This is the same append an upload ends with, minus the upload."""
+ path = self._strip_protocol(path)
+ target = self._resolve(path)
+ if target.kind != "field":
+ raise NotImplementedError(f"{path!r}: a Gallery file links into a document field (a text field)")
+ document = self._record(target.parent_gid)
+ field = self._field(target.parent_gid, target.field_gid, document)
+ numeric = _gallery_file_number(media_file_gid)
+ self._update_field_content(document, field, (field.get("content") or "") + _file_token(numeric))
+ self.invalidate_cache()
+ return self.eln_client.get_file_info(numeric)
+
+ @deletes
+ def rm_file(self, path: str) -> None:
+ """Unlink a file from its field. The Gallery file itself is left in place."""
+ path = self._strip_protocol(path)
+ target = self._resolve(path)
+ if target.kind != "file":
+ raise IsADirectoryError(errno.EISDIR, f"{path!r} is not a file", path)
+ document = self._record(target.parent_gid)
+ field = self._field(target.parent_gid, target.field_gid, document)
+ stripped = strip_file_references(field.get("content") or "", target.gid[2:])
+ if stripped is None:
+ raise FileNotFoundError(errno.ENOENT, f"{path!r} is not linked into this field", path)
+ self._update_field_content(document, field, stripped)
+ self.invalidate_cache()
diff --git a/rspace_client/inv/attachment_fs.py b/rspace_client/inv/attachment_fs.py
index 69be36e..91867fb 100644
--- a/rspace_client/inv/attachment_fs.py
+++ b/rspace_client/inv/attachment_fs.py
@@ -1,3 +1,4 @@
+import warnings
# This script implements a pyfilesystem for Inventory attachments.
# Inventory records -- containers, samples, subsamples, etc -- are modelled as
# directories, with each containing a set of attachments: the files.
@@ -21,6 +22,10 @@ def __init__(self, obj, *args, **kwargs) -> None:
class InventoryAttachmentFilesystem(FS):
def __init__(self, server: str, api_key: str) -> None:
+ warnings.warn(
+ "rspace_client.inv.attachment_fs.InventoryAttachmentFilesystem (PyFilesystem2) is deprecated "
+ "and will be removed in rspace-client 3.0; use rspace_client.fs.InventoryFilesystem or "
+ "RSpaceFilesystem (fsspec)", DeprecationWarning, stacklevel=2)
super(InventoryAttachmentFilesystem, self).__init__()
self.inv_client = inv.InventoryClient(server, api_key)
diff --git a/rspace_client/inv/inv.py b/rspace_client/inv/inv.py
index f4b9352..39cb9dc 100644
--- a/rspace_client/inv/inv.py
+++ b/rspace_client/inv/inv.py
@@ -2025,8 +2025,8 @@ def get_workbench_by_id(self, workbench_id: Union[str, int], include_content: bo
Include the bench's locations and their content (subsamples, containers).
"""
numeric_id = self._get_numeric_record_id(workbench_id)
- return self.retrieve_api_results(
- f"/workbenches/{numeric_id}?includeContent={'true' if include_content else 'false'}")
+ return self.retrieve_api_results(f"/workbenches/{numeric_id}",
+ params={"includeContent": "true" if include_content else "false"})
## ---------------------------------------------------------------------
## CSV import
@@ -2035,16 +2035,6 @@ def get_workbench_by_id(self, workbench_id: Union[str, int], include_content: bo
## containers (only simple LIST containers). Importing Instruments or
## Instrument Templates from CSV is NOT supported by the API.
- def _multipart_post(self, endpoint: str, files: dict, data: dict) -> dict:
- """
- Helper for multipart/form-data POSTs. ``requests`` sets the correct
- multipart Content-Type (with boundary) when ``files`` is supplied, and
- adds each entry of ``data`` as an additional form field.
- """
- # through the shared session, so timeout, retries, redirect refusal and error
- # handling are the same as for every other request
- return self._post_multipart(endpoint, files=files, data=data)
-
def parse_csv_import_file(
self, file: BinaryIO, record_type: Union[str, ImportRecordType]
) -> dict:
@@ -2071,7 +2061,7 @@ def parse_csv_import_file(
'radioOptionsForColumn' and 'quantityUnitForColumn'.
"""
record_type = ImportRecordType(record_type).value
- return self._multipart_post(
+ return self._post_multipart(
"/import/parseFile",
files={"file": file},
data={"recordType": record_type},
@@ -2133,7 +2123,7 @@ def import_csv_files(
"At least one of containers_file, samples_file or "
"subsamples_file must be supplied"
)
- return self._multipart_post(
+ return self._post_multipart(
"/import/importFiles",
files=files,
data={"importSettings": json.dumps(import_settings)},
diff --git a/rspace_client/tests/conftest.py b/rspace_client/tests/conftest.py
deleted file mode 100644
index 32ba096..0000000
--- a/rspace_client/tests/conftest.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""
-pytest hooks for the offline test suite.
-
-One mock RSpace server (``rspace_client/tests/mock_rspace``) serves the whole
-session. It is started here and handed to :mod:`rspace_client.tests.mock_server_case`,
-whose ``MockServerTestCase`` resets its fixtures before every test; the old
-one-server-per-class arrangement spent most of the suite's wall time in seven
-``serve_forever`` shutdowns.
-"""
-import pytest
-
-from rspace_client.tests import mock_server_case
-from rspace_client.tests.mock_rspace.server import run_in_thread
-
-
-@pytest.fixture(scope="session", autouse=True)
-def shared_mock_server():
- server, url = run_in_thread()
- mock_server_case.install_shared_server(server, url)
- try:
- yield server
- finally:
- mock_server_case.install_shared_server(None, None)
- server.shutdown()
- server.server_close()
diff --git a/rspace_client/tests/fs_base_test.py b/rspace_client/tests/fs_base_test.py
new file mode 100644
index 0000000..621338d
--- /dev/null
+++ b/rspace_client/tests/fs_base_test.py
@@ -0,0 +1,640 @@
+"""
+Unit tests for the shared fsspec base (rspace_client.fs.base) and the Gallery and
+Inventory branches built on it. No network: the RSpace clients are replaced with
+MagicMocks.
+
+Paths are fsspec paths: no leading slash, the root is "". Entries are plain dicts whose
+``name`` is the full path, so a child's own segment is ``paths.last_segment(entry["name"])``.
+"""
+import errno
+import traceback
+import unittest
+from datetime import datetime, timezone
+from io import BytesIO
+from unittest.mock import MagicMock
+
+from rspace_client.client_base import ClientBase
+from rspace_client.exceptions import AuthenticationError, RSpaceConnectionError
+from rspace_client.fs import (GalleryFilesystem, GallerySectionMismatch, InventoryFilesystem,
+ ReadOnlyError, RemoteApiError, WorkspaceFilesystem, make_entry)
+from rspace_client.fs.base import to_epoch
+from rspace_client.fs.gallery import classify_media_section
+from rspace_client.tests.mock_server_case import segments
+
+
+
+
+class MakeEntryTest(unittest.TestCase):
+
+ def test_iso_and_millis_timestamps_become_epoch_seconds(self):
+ self.assertAlmostEqual(to_epoch("2026-09-01T09:00:00.000Z"), 1788253200.0)
+ self.assertAlmostEqual(to_epoch(1788253200000), 1788253200.0)
+ self.assertIsNone(to_epoch(None))
+ self.assertIsNone(to_epoch("not a date"))
+
+ def test_details_and_rspace_keys(self):
+ entry = make_entry("GL5", False, raw={"globalId": "GL5", "name": "data.csv"}, size=27,
+ created="2026-09-01T09:00:00.000Z", modified="2026-09-02T09:00:00.000Z")
+ self.assertEqual("GL5", entry["name"])
+ self.assertEqual("file", entry["type"])
+ self.assertEqual(27, entry["size"])
+ created = datetime.fromtimestamp(entry["created"], tz=timezone.utc)
+ self.assertEqual(2026, created.year)
+ modified = datetime.fromtimestamp(entry["mtime"], tz=timezone.utc)
+ self.assertEqual(2, modified.day)
+ self.assertEqual("data.csv", entry["rspace"]["name"])
+ self.assertEqual("GL5", entry["globalId"])
+ self.assertNotIn("writable", entry) # unknown, not guessed
+
+ def test_mtime_falls_back_to_created(self):
+ entry = make_entry("GL5", False, created="2026-09-01T09:00:00.000Z")
+ self.assertEqual(entry["created"], entry["mtime"])
+
+ def test_directory_entry(self):
+ entry = make_entry("GF1", True)
+ self.assertEqual("directory", entry["type"])
+ self.assertIsNone(entry["size"])
+ self.assertIsNone(entry["created"])
+ self.assertIsNone(entry["mtime"])
+
+ def test_writable_is_only_set_when_known(self):
+ self.assertFalse(make_entry("SD1", True, writable=False)["writable"])
+ self.assertTrue(make_entry("SD1", True, writable=True)["writable"])
+
+
+def gallery_with_mock_client(**kwargs):
+ client = MagicMock()
+ client.list_folder_tree.side_effect = lambda folder_id=None, typesToInclude=[], **kw: {
+ None: {"records": [{"id": 2, "globalId": "FL2", "name": "Gallery"}]},
+ "2": {"records": [
+ {"id": 10, "globalId": "GF10", "name": "Images", "created": "2026-09-01T09:01:00.000Z"},
+ {"id": 100, "globalId": "GL100", "name": "a.png", "created": "2026-09-01T09:02:00.000Z", "size": 5},
+ ]},
+ 2: {"records": [
+ {"id": 10, "globalId": "GF10", "name": "Images", "created": "2026-09-01T09:01:00.000Z"},
+ {"id": 100, "globalId": "GL100", "name": "a.png", "created": "2026-09-01T09:02:00.000Z", "size": 5},
+ ]},
+ "10": {"records": []},
+ }[folder_id]
+ client.get_folder.return_value = {"id": 10, "globalId": "GF10", "name": "Images"}
+ client.get_file_info.return_value = {"id": 100, "globalId": "GL100", "name": "a.png", "size": 5}
+ client.create_folder.return_value = {"id": 77, "globalId": "GF77", "name": "new"}
+ kwargs.setdefault("path_style", "id")
+ return GalleryFilesystem(eln_client=client, **kwargs), client
+
+
+class GalleryFilesystemTest(unittest.TestCase):
+
+ def test_root_is_resolved_lazily(self):
+ fs, client = gallery_with_mock_client()
+ client.list_folder_tree.assert_not_called() # no network in the constructor
+ self.assertEqual(["GF10", "GL100"], fs.ls("", detail=False))
+ self.assertEqual(2, fs.gallery_id)
+
+ def test_missing_gallery_folder_is_a_clear_error(self):
+ client = MagicMock()
+ client.list_folder_tree.return_value = {"records": [{"id": 1, "name": "Something else"}]}
+ with self.assertRaises(FileNotFoundError):
+ GalleryFilesystem(eln_client=client).ls("")
+
+ def test_sizes_are_topped_up_when_the_listing_omits_them(self):
+ """Real folder-tree items have no size; consumers such as Galaxy require an int.
+ ``fetch_sizes`` decides whether a listing pays one request per file for it."""
+ listing = {"records": [{"id": 100, "globalId": "GL100", "name": "a.png",
+ "created": "2026-09-01T09:02:00.000Z"}]} # no 'size'
+ fs, client = gallery_with_mock_client(fetch_sizes=False)
+ client.list_folder_tree.side_effect = None
+ client.list_folder_tree.return_value = listing
+ fs._gallery_id = 2
+ self.assertIsNone(next(iter(fs.scandir("")))["size"]) # one call, no size
+ client.get_file_info.assert_not_called()
+
+ fs, client = gallery_with_mock_client() # fetch_sizes=True is the default
+ client.list_folder_tree.side_effect = None
+ client.list_folder_tree.return_value = listing
+ fs._gallery_id = 2
+ entry = next(iter(fs.scandir("")))
+ client.get_file_info.assert_called_once_with(100)
+ self.assertEqual(5, entry["size"])
+
+ def test_scandir_uses_one_listing_call_and_carries_details(self):
+ fs, client = gallery_with_mock_client()
+ entries = list(fs.scandir(""))
+ self.assertEqual(["GF10", "GL100"], [e["name"] for e in entries])
+ self.assertEqual(["directory", "file"], [e["type"] for e in entries])
+ self.assertEqual(5, entries[1]["size"])
+ self.assertIsNotNone(entries[1]["created"])
+ self.assertEqual("a.png", entries[1]["rspace"]["name"])
+ self.assertEqual("GL100", entries[1]["globalId"])
+ client.get_folder.assert_not_called() # no per-child info
+ client.get_file_info.assert_not_called() # the listing already carried a size
+
+ def test_ls_names_are_full_paths(self):
+ fs, _ = gallery_with_mock_client()
+ client_listing = fs.ls("GF10", detail=False)
+ self.assertEqual([], client_listing)
+ root = fs.ls("", detail=True)
+ self.assertEqual(["GF10", "GL100"], [e["name"] for e in root])
+ self.assertEqual(["GF10", "GL100"], segments(root))
+
+ def test_info_root_and_children(self):
+ fs, _ = gallery_with_mock_client()
+ self.assertEqual("directory", fs.info("")["type"])
+ self.assertEqual("GF10", fs.info("GF10")["name"])
+ entry = fs.info("GF10/GL100")
+ self.assertEqual("GF10/GL100", entry["name"]) # info names the path it was asked about
+ self.assertEqual("file", entry["type"])
+ self.assertEqual("GL100", entry["globalId"])
+ with self.assertRaises(FileNotFoundError):
+ fs.info("XX1")
+
+ def test_created_and_modified_are_datetimes(self):
+ fs, client = gallery_with_mock_client()
+ client.get_file_info.return_value = {"id": 100, "globalId": "GL100", "name": "a.png", "size": 5,
+ "created": "2026-09-01T09:02:00.000Z"}
+ created = fs.created("GF10/GL100")
+ self.assertEqual(2026, created.astimezone(timezone.utc).year)
+ self.assertEqual(created, fs.modified("GF10/GL100")) # no lastModified: created stands in
+ self.assertIsNone(fs.created(""))
+
+ def test_writable_allows_upload_and_mkdir(self):
+ fs, client = gallery_with_mock_client(writable=True)
+ self.assertFalse(fs.read_only)
+ payload = BytesIO(b"x")
+ fs.upload_fileobj("GF10/x.png", payload)
+ client.upload_file.assert_called_once_with(payload, "10", filename="x.png")
+ fs.upload_fileobj("x.png", payload) # directly under the root -> API default folder
+ client.upload_file.assert_called_with(payload, None, filename="x.png")
+ self.assertIsNone(fs.mkdir("GF10/new"))
+ client.create_folder.assert_called_once_with("new", "10")
+
+ def test_mkdir_at_root_targets_gallery_root(self):
+ fs, client = gallery_with_mock_client(writable=True)
+ fs.mkdir("new")
+ client.create_folder.assert_called_once_with("new", 2)
+
+ def test_mkdir_refuses_an_existing_folder_unless_exist_ok(self):
+ fs, client = gallery_with_mock_client(writable=True)
+ with self.assertRaises(FileExistsError):
+ fs.mkdir("Images")
+ client.create_folder.assert_not_called()
+ fs.mkdir("Images", exist_ok=True)
+ fs.makedirs("GF10", exist_ok=True)
+ client.create_folder.assert_not_called()
+
+ def test_rmdir_reaches_the_client_but_never_for_the_root(self):
+ fs, client = gallery_with_mock_client(allow_delete=True)
+ fs.rmdir("GF10")
+ client.delete_folder.assert_called_once_with("10")
+ with self.assertRaises(PermissionError):
+ fs.rmdir("")
+ client.delete_folder.assert_called_once()
+
+ def test_rmdir_refuses_a_folder_that_is_not_empty(self):
+ fs, client = gallery_with_mock_client(allow_delete=True)
+ client.list_folder_tree.side_effect = None
+ client.list_folder_tree.return_value = {"records": [{"id": 100, "globalId": "GL100", "name": "a.png", "size": 5}]}
+ fs._gallery_id = 2
+ with self.assertRaises(OSError) as ctx:
+ fs.rmdir("GF10")
+ self.assertEqual(errno.ENOTEMPTY, ctx.exception.errno)
+ client.delete_folder.assert_not_called()
+
+ def test_open_read_downloads(self):
+ fs, client = gallery_with_mock_client()
+ client.download_file.side_effect = lambda fid, fh, chunk_size=128: fh.write(b"bytes")
+ with fs.open("GF10/GL100", "rb") as fh:
+ self.assertEqual(b"bytes", fh.read())
+ client.download_file.assert_called_once()
+ self.assertEqual("100", client.download_file.call_args[0][0])
+ self.assertEqual(b"bytes", fs.cat_file("GF10/GL100"))
+ buffer = BytesIO()
+ fs.download_fileobj("GF10/GL100", buffer)
+ self.assertEqual(b"bytes", buffer.getvalue())
+
+ def test_opening_a_directory_for_reading_is_refused(self):
+ fs, _ = gallery_with_mock_client()
+ with self.assertRaises(IsADirectoryError):
+ fs.cat_file("GF10")
+
+ def test_open_write_uploads_on_close(self):
+ fs, client = gallery_with_mock_client(writable=True)
+ with fs.open("GF10/x.png", "wb") as fh:
+ fh.write(b"abc")
+ client.upload_file.assert_not_called() # buffered: one request per upload
+ client.upload_file.assert_called_once()
+ sent, folder_id = client.upload_file.call_args[0]
+ self.assertEqual(b"abc", sent.read())
+ self.assertEqual("10", folder_id)
+ self.assertEqual({"filename": "x.png"}, client.upload_file.call_args[1])
+
+ def test_a_transaction_defers_the_upload_and_discards_it_on_error(self):
+ fs, client = gallery_with_mock_client(writable=True)
+ with fs.transaction:
+ with fs.open("GF10/x.png", "wb") as fh:
+ fh.write(b"abc")
+ client.upload_file.assert_not_called() # the file was closed, nothing was sent yet
+ client.upload_file.assert_called_once()
+ self.assertEqual(b"abc", client.upload_file.call_args[0][0].read())
+
+ fs, client = gallery_with_mock_client(writable=True)
+ with self.assertRaises(RuntimeError):
+ with fs.transaction:
+ with fs.open("GF10/y.png", "wb") as fh:
+ fh.write(b"never")
+ raise RuntimeError("abort")
+ client.upload_file.assert_not_called()
+
+ def test_glob_finds_a_bracketed_segment_in_listing_order(self):
+ """Galaxy's search box escapes '[' as '\\[' and hands the pattern to glob; fsspec's own
+ glob would read '[GL112]' as a character class and find nothing."""
+ fs, client = gallery_with_mock_client(path_style="name")
+ client.list_folder_tree.side_effect = lambda folder_id=None, **kw: {
+ None: {"records": [{"id": 2, "globalId": "FL2", "name": "Gallery"}]},
+ 2: {"records": [{"id": 11, "globalId": "GF11", "name": "Documents"}]},
+ "11": {"records": [{"id": 110, "globalId": "GL110", "name": "protocol.docx", "size": 1},
+ {"id": 111, "globalId": "GL111", "name": "data.csv", "size": 1},
+ {"id": 112, "globalId": "GL112", "name": "data.csv", "size": 1}]},
+ }[folder_id]
+ self.assertEqual(["Documents/data [GL112].csv"], fs.glob("Documents/*\\[GL112\\]*"))
+ self.assertEqual(["Documents/data.csv", "Documents/data [GL112].csv"], fs.glob("Documents/*data*"))
+ self.assertEqual(["Documents/data.csv", "Documents/data [GL112].csv"], fs.glob("Documents/*DATA*"))
+ found = fs.glob("Documents/*.csv*", detail=True)
+ self.assertEqual("GL111", found["Documents/data.csv"]["globalId"])
+ self.assertEqual([], fs.glob("Documents/*nothing*"))
+
+
+def inventory_with_mock_client(**kwargs):
+ client = MagicMock()
+ attachment = {"id": 5, "globalId": "IF5", "name": "map.gb", "size": 9,
+ "created": "2026-09-01T09:00:00.000Z", "parentGlobalId": "IC1"}
+ client.get_container_by_id.return_value = {"id": 1, "globalId": "IC1", "name": "Freezer",
+ "created": "2026-09-01T08:00:00.000Z",
+ "attachments": [attachment]}
+ client.get_attachment_by_id.return_value = attachment
+ kwargs.setdefault("path_style", "id")
+ return InventoryFilesystem(inv_client=client, **kwargs), client
+
+
+class InventoryFilesystemTest(unittest.TestCase):
+
+ def test_entry_names_are_full_paths_and_the_segment_is_the_record(self):
+ fs, _ = inventory_with_mock_client()
+ self.assertEqual("IC1/IF5", fs.info("IC1/IF5")["name"]) # info names the path asked for
+ self.assertEqual("IF5", fs.info("IF5")["name"])
+ self.assertEqual("map.gb", fs.info("IC1/IF5")["rspace"]["name"])
+ entries = fs.ls("IC1")
+ self.assertEqual(["IC1/IF5"], [e["name"] for e in entries])
+ self.assertEqual(["IF5"], segments(entries))
+
+ def test_record_is_a_directory(self):
+ fs, client = inventory_with_mock_client()
+ entry = fs.info("IC1")
+ # info describes the record itself, so it does not pay for the container's contents
+ client.get_container_by_id.assert_called_with("1")
+ self.assertEqual("directory", entry["type"])
+ self.assertEqual("IC1", entry["name"])
+ self.assertEqual("Freezer", entry["rspace"]["name"])
+
+ def test_listing_a_container_asks_for_its_contents(self):
+ fs, client = inventory_with_mock_client()
+ list(fs.scandir("IC1"))
+ client.get_container_by_id.assert_called_with("1", include_content=True)
+
+ def test_scandir_builds_children_from_the_record(self):
+ fs, client = inventory_with_mock_client()
+ entries = list(fs.scandir("IC1"))
+ self.assertEqual(["IF5"], segments(entries)) # container with no content: attachments only
+ self.assertEqual(9, entries[0]["size"])
+ self.assertIsNotNone(entries[0]["created"])
+ self.assertEqual("file", entries[0]["type"])
+ client.get_attachment_by_id.assert_not_called()
+ self.assertEqual(["IC1/IF5"], fs.ls("IC1", detail=False))
+
+ def test_root_lists_benches_and_sections(self):
+ fs, client = inventory_with_mock_client()
+ client.get_workbenches.return_value = [{"id": 1, "globalId": "BE1", "name": "WB me", "cType": "WORKBENCH"}]
+ self.assertEqual(["BE1", "Containers", "Samples", "Templates"], fs.ls("", detail=False))
+ self.assertEqual("directory", fs.info("")["type"])
+ self.assertEqual("directory", fs.info("Containers")["type"])
+ client.get_container_by_id.assert_not_called()
+
+ def test_upload_and_remove_reach_the_client(self):
+ fs, client = inventory_with_mock_client(writable=True, allow_delete=True)
+ payload = BytesIO(b"x")
+ fs.upload_fileobj("IC1/x.txt", payload)
+ client.upload_attachment_by_global_id.assert_called_once_with("IC1", payload, filename="x.txt")
+ fs.rm_file("IC1/IF5")
+ client.delete_attachment_by_id.assert_called_once_with("5")
+
+ def test_rm_also_removes_a_file(self):
+ fs, client = inventory_with_mock_client(allow_delete=True)
+ fs.rm("IC1/IF5")
+ client.delete_attachment_by_id.assert_called_once_with("5")
+ with self.assertRaises(NotImplementedError):
+ fs.rm("IC1", recursive=True)
+
+
+def branch_with_mock_client(cls, **posture):
+ client = MagicMock()
+ fs = cls(inv_client=client, **posture) if cls is InventoryFilesystem else cls(eln_client=client, **posture)
+ return fs, client
+
+
+#: every way of changing the server that a branch filesystem offers
+MUTATIONS = {
+ "upload_fileobj": lambda f: f.upload_fileobj("GF10/x.txt", BytesIO(b"x")),
+ "mkdir": lambda f: f.mkdir("GF10/new"),
+ "rmdir": lambda f: f.rmdir("GF10"),
+ "rm_file": lambda f: f.rm_file("GF10/GL1"),
+ "link": lambda f: f.link("GF10", "GL1"),
+ "open 'wb'": lambda f: f.open("GF10/GL1", "wb"),
+ "pipe_file": lambda f: f.pipe_file("GF10/x.txt", b"x"),
+ "cp_file": lambda f: f.cp_file("GF10/GL1", "GF10/GL2"),
+ "mv": lambda f: f.mv("GF10/GL1", "GF10/GL2"),
+}
+DELETIONS = ("rm_file", "rmdir", "mv")
+
+#: what a branch does not offer at all is refused the same way however it was opened
+NOT_OFFERED = {
+ GalleryFilesystem: {"rm_file": NotImplementedError, # the RSpace API cannot delete Gallery files
+ "link": NotImplementedError}, # a Gallery file is already in the Gallery
+ InventoryFilesystem: {"mkdir": NotImplementedError, "rmdir": NotImplementedError},
+ WorkspaceFilesystem: {},
+}
+
+
+class WriteGuardTableTest(unittest.TestCase):
+ """The read-only guard is the same two flags on every branch, checked before any
+ request is made. One table so that no mutating method can slip past it."""
+
+ def check(self, fs, client, name, expected):
+ with self.assertRaises(expected, msg=f"{type(fs).__name__}.{name}"):
+ MUTATIONS[name](fs)
+ self.assertEqual([], client.method_calls, f"{type(fs).__name__}.{name} touched the server")
+
+ def test_read_only_by_default_refuses_every_mutation_without_a_request(self):
+ for cls, not_offered in NOT_OFFERED.items():
+ fs, client = branch_with_mock_client(cls, path_style="id")
+ self.assertTrue(fs.read_only)
+ for name in MUTATIONS:
+ self.check(fs, client, name, not_offered.get(name, ReadOnlyError))
+
+ def test_read_only_error_is_a_permission_error(self):
+ fs, _ = branch_with_mock_client(GalleryFilesystem, path_style="id")
+ with self.assertRaises(PermissionError) as ctx:
+ fs.mkdir("GF10/new")
+ self.assertEqual(errno.EROFS, ctx.exception.errno)
+
+ def test_writable_without_allow_delete_still_refuses_deletions(self):
+ for cls, not_offered in NOT_OFFERED.items():
+ fs, client = branch_with_mock_client(cls, path_style="id", writable=True)
+ for name in DELETIONS:
+ self.check(fs, client, name, not_offered.get(name, ReadOnlyError))
+ try:
+ MUTATIONS["upload_fileobj"](fs)
+ except ReadOnlyError:
+ self.fail(f"{cls.__name__}: writable=True must let upload past the guard")
+ except Exception:
+ pass # whatever the MagicMock client made of the request is not the point here
+
+ def test_allow_delete_alone_lets_deletions_past_the_guard_but_not_writes(self):
+ for cls, not_offered in NOT_OFFERED.items():
+ fs, client = branch_with_mock_client(cls, path_style="id", allow_delete=True)
+ self.check(fs, client, "upload_fileobj", ReadOnlyError)
+ self.check(fs, client, "mv", ReadOnlyError) # a move also writes
+ for name in ("rm_file", "rmdir"):
+ if name in not_offered:
+ continue
+ try:
+ MUTATIONS[name](fs)
+ except ReadOnlyError:
+ self.fail(f"{cls.__name__}.{name}: allow_delete=True must get past the guard")
+ except Exception:
+ pass
+
+
+class LabelledPathStyleTest(unittest.TestCase):
+ """The 'Name [GID]' path style: segments carry both, and resolve by the bracketed ID."""
+
+ def test_the_default_style_is_plain_names(self):
+ client = MagicMock()
+ self.assertEqual("name", GalleryFilesystem(eln_client=client).path_style)
+ self.assertEqual("labelled", GalleryFilesystem(eln_client=client, path_style="labelled").path_style)
+ self.assertEqual("id", GalleryFilesystem(eln_client=client, path_style="id").path_style)
+ with self.assertRaises(ValueError):
+ GalleryFilesystem(eln_client=client, path_style="pretty")
+
+ def test_gallery_listing_shows_names_and_resolves_by_id(self):
+ fs, client = gallery_with_mock_client(path_style="labelled")
+ self.assertEqual(["Images [GF10]", "a.png [GL100]"], fs.ls("", detail=False))
+ entries = list(fs.scandir(""))
+ self.assertEqual("Images [GF10]", entries[0]["name"])
+ # bare ids and labelled segments both resolve to the same record
+ self.assertEqual("GF10", fs.info("GF10")["globalId"])
+ entry = fs.info("Images [GF10]/a.png [GL100]")
+ self.assertEqual("GL100", entry["globalId"])
+ self.assertEqual("a.png", entry["rspace"]["name"])
+ client.get_file_info.assert_called_with("100")
+ # a stale label still resolves because only the id is used
+ self.assertEqual("GL100", fs.info("Images [GF10]/old name [GL100]")["globalId"])
+
+ def test_mkdir_uses_the_plain_name(self):
+ fs, client = gallery_with_mock_client(path_style="labelled", writable=True)
+ client.get_folder.return_value = {"id": 77, "globalId": "GF77", "name": "new"}
+ fs.mkdir("Images [GF10]/new")
+ client.create_folder.assert_called_once_with("new", "10")
+ with self.assertRaises(ValueError):
+ fs.mkdir("Images [GF10]/GF999")
+
+ def test_inventory_labelled_upload_passes_bare_global_id(self):
+ fs, client = inventory_with_mock_client(path_style="labelled", writable=True)
+ self.assertEqual(["Freezer [IC1]/map.gb [IF5]"], fs.ls("Freezer [IC1]", detail=False))
+ self.assertEqual("Freezer", fs.info("IC1")["rspace"]["name"])
+ payload = BytesIO(b"x")
+ fs.upload_fileobj("Freezer [IC1]/x.txt", payload)
+ client.upload_attachment_by_global_id.assert_called_once_with("IC1", payload, filename="x.txt")
+
+
+class ClassifyMediaSectionTest(unittest.TestCase):
+
+ def test_extension_picks_the_section_and_documents_is_not_a_catch_all(self):
+ self.assertEqual("Images", classify_media_section("photo.png"))
+ self.assertEqual("Images", classify_media_section("photo.JPG"))
+ self.assertEqual("Audios", classify_media_section("song.mp3"))
+ self.assertEqual("Videos", classify_media_section("clip.mp4"))
+ self.assertEqual("Documents", classify_media_section("report.pdf"))
+ self.assertEqual("Documents", classify_media_section("notes.md"))
+ self.assertEqual("Chemistry", classify_media_section("reaction.cdxml"))
+ # unlisted extensions and types with no specialised section fall through to Miscellaneous
+ for name in ("data.xyz", "archive.zip", "movie.mkv"):
+ self.assertEqual("Miscellaneous", classify_media_section(name), name)
+ self.assertIsNone(classify_media_section("noextension"))
+ self.assertIsNone(classify_media_section(None))
+
+
+class MismatchMessageTest(unittest.TestCase):
+ """
+ The section-mismatch message reaches end users verbatim (Galaxy shows it in the browser),
+ so it must lead with something a person can act on rather than a Python argument.
+ """
+
+ def _message(self, filename, folder_section, guessed_section_name):
+ client = MagicMock()
+ client.upload_file.side_effect = ClientBase.ApiError("File type not allowed", response_status_code=400)
+ client.get_folder.return_value = {"id": 8, "globalId": "GF8", "mediaType": folder_section}
+ fs = GalleryFilesystem(eln_client=client, writable=True, path_style="id")
+ payload = BytesIO(b"x")
+ payload.name = filename
+ with self.assertRaises(GallerySectionMismatch) as ctx:
+ fs.upload_fileobj("GF8/" + payload.name, payload)
+ self.assertEqual(folder_section, ctx.exception.folder_section)
+ self.assertEqual("GF8", ctx.exception.folder_global_id)
+ self.assertEqual(guessed_section_name, ctx.exception.file_media_type)
+ return str(ctx.exception)
+
+ def test_message_tells_a_person_what_to_do(self):
+ message = self._message("exported.csv", "Images", "Documents")
+ self.assertIn("Choose a folder in the 'Documents' section instead", message)
+ self.assertIn("upload without choosing a folder", message)
+ self.assertNotIn("construct the filesystem", message)
+ self.assertIn("exported.csv", message)
+ self.assertIn("Images", message)
+ self.assertIn("File type not allowed", message) # the server's own reason survives
+
+ def test_api_hint_is_kept_for_library_callers(self):
+ self.assertIn("on_mismatch='reroute'", self._message("exported.csv", "Images", "Documents"))
+
+ def test_the_exception_chain_is_suppressed(self):
+ """Galaxy prints the whole chain and clips it from the top, so three stacked
+ tracebacks would hide the explanation. One traceback, message last."""
+ client = MagicMock()
+ client.upload_file.side_effect = ClientBase.ApiError("File type not allowed", response_status_code=422)
+ client.get_folder.return_value = {"id": 8, "globalId": "GF8", "mediaType": "Images"}
+ fs = GalleryFilesystem(eln_client=client, writable=True, path_style="id")
+ payload = BytesIO(b"x")
+ payload.name = "notes.csv"
+ try:
+ fs.upload_fileobj("GF8/" + payload.name, payload)
+ except GallerySectionMismatch as err:
+ self.assertIsNone(err.__cause__)
+ self.assertTrue(err.__suppress_context__)
+ rendered = "".join(traceback.format_exception(type(err), err, err.__traceback__))
+ self.assertEqual(1, rendered.count("Traceback (most recent call last)"))
+ self.assertIn("File type not allowed", rendered) # server reason still reported
+ self.assertTrue(rendered.strip().endswith(str(err)))
+ else:
+ self.fail("expected GallerySectionMismatch")
+
+ def test_the_mismatch_is_not_turned_into_an_os_error(self):
+ """It is an ApiError subclass with fs_passthrough: the error translation leaves it alone."""
+ self.assertTrue(GallerySectionMismatch.fs_passthrough)
+ self.assertTrue(issubclass(GallerySectionMismatch, ClientBase.ApiError))
+ self.assertFalse(issubclass(GallerySectionMismatch, OSError))
+
+ def test_reroute_places_the_file_in_the_default_folder(self):
+ client = MagicMock()
+ client.upload_file.side_effect = [
+ ClientBase.ApiError("File type not allowed", response_status_code=400),
+ {"id": 999, "globalId": "GL999", "name": "notes.csv", "parentFolderId": 14},
+ ]
+ client.get_folder.side_effect = lambda fid: (
+ {"id": 8, "globalId": "GF8", "mediaType": "Images"} if str(fid) == "8"
+ else {"id": 14, "globalId": "GF14", "mediaType": "Documents", "name": "Api Inbox"})
+ fs = GalleryFilesystem(eln_client=client, writable=True, path_style="id", on_mismatch="reroute")
+ placement = fs.upload_fileobj("GF8/notes.csv", BytesIO(b"x"))
+ self.assertTrue(placement.rerouted)
+ self.assertEqual("GL999", placement.file_global_id)
+ self.assertEqual("GF14", placement.folder_global_id)
+ self.assertEqual("Documents", placement.section)
+ self.assertEqual("GF8", placement.requested_path)
+ self.assertEqual(2, client.upload_file.call_count)
+ self.assertIsNone(client.upload_file.call_args[0][1]) # the retry names no folder
+
+ def test_article_agrees_with_the_section_name(self):
+ self.assertIn("an 'Images' file", self._message("photo.jpg", "Documents", "Images"))
+ self.assertIn("a 'Documents' file", self._message("notes.csv", "Images", "Documents"))
+
+ def test_unclassifiable_file_points_at_miscellaneous(self):
+ self.assertIn("'Miscellaneous' section", self._message("archive.zip", "Images", "Miscellaneous"))
+
+
+class ApiErrorTranslationTest(unittest.TestCase):
+ """RSpace client exceptions surface as OSError subclasses at every public method."""
+
+ def test_404_is_file_not_found_and_exists_is_false(self):
+ fs, client = gallery_with_mock_client()
+ client.get_folder.side_effect = ClientBase.ApiError("gone", response_status_code=404)
+ with self.assertRaises(FileNotFoundError):
+ fs.info("GF10")
+ self.assertFalse(fs.exists("GF10"))
+ self.assertFalse(fs.isdir("GF10"))
+ self.assertFalse(fs.isfile("GF10"))
+
+ def test_403_and_401_are_permission_errors(self):
+ fs, client = gallery_with_mock_client()
+ client.get_folder.side_effect = ClientBase.ApiError("no", response_status_code=403)
+ with self.assertRaises(PermissionError):
+ fs.info("GF10")
+ client.get_folder.side_effect = ClientBase.ApiError("no", response_status_code=401)
+ with self.assertRaises(PermissionError):
+ fs.info("GF10")
+ client.get_folder.side_effect = AuthenticationError("bad key")
+ with self.assertRaises(PermissionError):
+ fs.info("GF10")
+
+ def test_5xx_and_connection_failures_are_connection_errors(self):
+ fs, client = gallery_with_mock_client()
+ client.get_folder.side_effect = ClientBase.ApiError("boom", response_status_code=503)
+ with self.assertRaises(ConnectionError):
+ fs.info("GF10")
+ client.get_folder.side_effect = RSpaceConnectionError("refused")
+ with self.assertRaises(ConnectionError):
+ fs.info("GF10")
+
+ def test_domain_errors_become_remote_api_errors_carrying_the_api_exception(self):
+ # 400/409/422 carry the server's own explanation (a refused link, a wrong section)
+ fs, client = gallery_with_mock_client()
+ for status in (400, 409, 422):
+ original = ClientBase.ApiError("nope", response_status_code=status)
+ client.get_folder.side_effect = original
+ with self.assertRaises(RemoteApiError) as ctx:
+ fs.info("GF10")
+ self.assertIsInstance(ctx.exception, OSError)
+ self.assertIs(original, ctx.exception.api_error)
+ self.assertEqual(status, ctx.exception.status)
+ self.assertIn("nope", str(ctx.exception))
+
+ def test_errors_during_a_lazy_listing_are_translated_too(self):
+ fs, client = gallery_with_mock_client()
+ client.list_folder_tree.side_effect = lambda *a, **kw: {
+ "records": [{"id": 2, "globalId": "FL2", "name": "Gallery"}],
+ "_links": [{"rel": "next", "link": "http://example.com/api/v1/folders/tree?pageNumber=1"}]}
+ client.get_link_contents.side_effect = ClientBase.ApiError("gone", response_status_code=404)
+ listing = fs.scandir("") # first page is fine; the second page fails while iterating
+ with self.assertRaises(FileNotFoundError):
+ list(listing)
+
+ def test_errors_in_ls_and_file_reads_are_translated(self):
+ fs, client = gallery_with_mock_client()
+ client.list_folder_tree.side_effect = ClientBase.ApiError("gone", response_status_code=404)
+ with self.assertRaises(FileNotFoundError):
+ fs.ls("")
+ fs, client = gallery_with_mock_client()
+ client.download_file.side_effect = ClientBase.ApiError("no", response_status_code=403)
+ with self.assertRaises(PermissionError):
+ fs.cat_file("GF10/GL100")
+ with self.assertRaises(PermissionError):
+ fs.open("GF10/GL100", "rb")
+
+ def test_the_original_exception_is_kept_as_the_cause(self):
+ fs, client = gallery_with_mock_client()
+ original = ClientBase.ApiError("gone", response_status_code=404)
+ client.get_folder.side_effect = original
+ with self.assertRaises(FileNotFoundError) as ctx:
+ fs.info("GF10")
+ self.assertIs(original, ctx.exception.__cause__)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/rspace_client/tests/fs_integration_test.py b/rspace_client/tests/fs_integration_test.py
new file mode 100644
index 0000000..066becd
--- /dev/null
+++ b/rspace_client/tests/fs_integration_test.py
@@ -0,0 +1,301 @@
+"""
+Live integration tests for the unified fsspec filesystem (rspace_client.fs) against a real
+RSpace server. Run with `pytest -m integration`; credentials come from RSPACE_URL and
+RSPACE_API_KEY (.env is loaded). Everything created is named fs-itest-* and removed
+again, except Gallery files (the API cannot delete those) and nothing is touched outside
+the test's own scratch folder, the Gallery Images section and one bench subsample.
+
+These address records by global ID rather than by the text of a path segment, so they hold
+whatever the path style is. The one exception is deliberate: `test_roots_are_browsable`
+resolves a name against a live server, which is the only place the default style's name
+lookup is exercised end to end.
+
+Paths carry no leading slash and the root is ``""``; an entry's ``name`` is its full path,
+so segments are compared through :func:`rspace_client.fs.paths.last_segment`.
+"""
+import time
+from io import BytesIO
+
+import pytest
+
+from rspace_client.fs import GalleryFilesystem, ReadOnlyError, RemoteApiError, RSpaceFilesystem
+from rspace_client.fs.gallery import GallerySectionMismatch
+from rspace_client.fs.paths import last_segment
+from rspace_client.inv.template_builder import TemplateBuilder
+from rspace_client.tests.base_test import BaseApiTest
+
+PNG_1x1 = bytes.fromhex(
+ "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da"
+ "6364601860fd7f0f0002870180eb47ba920000000049454e44ae426082")
+
+
+@pytest.mark.integration
+class UnifiedFilesystemIntegrationTest(BaseApiTest):
+
+ def setUp(self):
+ super().setUp()
+ self.assertClientCredentials() # skips when RSPACE_URL / RSPACE_API_KEY are unset
+ self.stamp = time.strftime("%Y%m%d-%H%M%S")
+ self.rs = RSpaceFilesystem(self.rspace_url, self.rspace_apikey, writable=True, allow_delete=True)
+ self.eln = self.rs.eln_client
+
+ # ---------------------------------------------------------------- helpers
+
+ def entries(self, path):
+ """(segment, global id) per child. Tests address by global id so they do not depend
+ on how the current path style spells a segment."""
+ return [(last_segment(e["name"]), (e.get("rspace") or {}).get("globalId") or "")
+ for e in self.rs.scandir(path)]
+
+ def names(self, path):
+ """The last segment of every child, what the old listdir returned."""
+ return [name for name, _gid in self.entries(path)]
+
+ def of_type(self, path, prefix):
+ """Children whose global ID has this two-letter prefix (BE bench, SS subsample, ...)."""
+ return [(name, gid) for name, gid in self.entries(path) if gid.startswith(prefix)]
+
+ # ---------------------------------------------------------------- tests
+
+ def test_roots_are_browsable(self):
+ self.assertEqual(["gallery", "inventory", "workspace"], self.rs.ls("", detail=False))
+
+ gallery = self.names("gallery")
+ self.assertIn("Images", gallery, gallery)
+ # the default style resolves a plain name against the live server
+ self.assertEqual("directory", self.rs.info("gallery/Images")["type"])
+
+ inventory = self.names("inventory")
+ self.assertTrue({"Containers", "Samples", "Templates"} <= set(inventory), inventory)
+ self.assertTrue(self.of_type("inventory", "BE"), "no bench listed")
+
+ workspace = self.entries("workspace")
+ self.assertFalse([n for n, gid in workspace if gid.startswith("GF")],
+ f"the Gallery root must be hidden in the Workspace: {workspace}")
+
+ def test_workspace_document_field_roundtrip(self):
+ folder_name = f"fs-itest-{self.stamp}"
+ self.rs.mkdir(f"workspace/{folder_name}")
+ (folder_gid,) = [gid for name, gid in self.entries("workspace") if name.startswith(folder_name)]
+ folder_path = f"workspace/{folder_gid}"
+ doc_id = None
+ try:
+ doc = self.eln.create_document(name=f"{folder_name} doc", parent_folder_id=int(folder_gid[2:]),
+ fields=[{"content": "
fs itest
"}])
+ doc_id = doc["id"]
+ doc_path = f"{folder_path}/{doc['globalId']}"
+ ((field_name, field_gid),) = self.entries(doc_path)
+ self.assertTrue(field_gid.startswith("FD"), field_gid)
+ field_path = f"{doc_path}/{field_gid}"
+ self.assertEqual([], self.rs.ls(field_path, detail=False))
+
+ for name, payload in ((f"{folder_name}.csv", b"a,b\n1,2\n"), (f"{folder_name}.png", PNG_1x1)):
+ self.rs.upload_fileobj(f"{field_path}/{name}", BytesIO(payload))
+ linked = self.entries(field_path)
+ self.assertEqual(2, len(linked), linked)
+
+ csv_gid = next(gid for name, gid in linked if name.endswith(".csv"))
+ self.assertEqual(b"a,b\n1,2\n", self.rs.cat_file(f"{field_path}/{csv_gid}"))
+ out = BytesIO()
+ self.rs.download_fileobj(f"{field_path}/{csv_gid}", out)
+ self.assertEqual(b"a,b\n1,2\n", out.getvalue())
+ content = self.eln.get_document(doc_id)["fields"][0]["content"]
+ self.assertNotIn(" folder
+ self.assertIn("plasmid_map.gb", entries) # the record's own attachment -> file
+ self.assertNotIn("Concentration", entries) # a number field cannot hold a file
+ info = fs.info(f"{sample}/Safety Data")
+ self.assertEqual("directory", info["type"])
+ self.assertEqual("attachment", info["rspace"]["fieldType"])
+ self.assertEqual(["msds.pdf"], names(fs, f"{sample}/Safety Data"))
+ out = BytesIO()
+ fs.download_fileobj(f"{sample}/Safety Data/msds.pdf", out)
+ self.assertTrue(out.getvalue())
+ # an empty attachment field is still visible, so a file can be put into it
+ self.assertEqual([], names(fs, "Samples/E. coli DH5a/Datasheet"))
+
+ def test_a_field_accepts_a_file_only_while_it_is_empty(self):
+ """RSpace replaces the file in an attachment field; a folder does not suggest that,
+ so an occupied field refuses the upload however the filesystem was opened."""
+ empty = "Samples/E. coli DH5a/Datasheet"
+ occupied = "Samples/Plasmid pUC19/Safety Data"
+ fs = self.fs(writable=True, allow_delete=True)
+ payload = BytesIO(b"sheet")
+ payload.name = "sheet.pdf"
+ fs.upload_fileobj(empty + "/sheet.pdf", payload) # empty field: accepted
+ self.assertEqual(1, len(names(fs, empty)))
+ for f in (self.fs(writable=True), fs): # even with allow_delete
+ with self.assertRaises(FileExistsError) as ctx:
+ f.upload_fileobj(occupied + "/y.txt", BytesIO(b"y"))
+ self.assertIn("msds.pdf", str(ctx.exception))
+ self.assertIn("Remove the existing file first", str(ctx.exception))
+ # the documented way round: clear it, then upload
+ (current,) = names(fs, occupied)
+ fs.rm_file(f"{occupied}/{current}")
+ self.assertEqual([], names(fs, occupied))
+ replacement = BytesIO(b"new")
+ replacement.name = "new.pdf"
+ fs.upload_fileobj(occupied + "/new.pdf", replacement)
+ self.assertEqual(["new.pdf"], names(fs, occupied))
+
+ def _media_gid(self, container, name):
+ return next((e["rspace"].get("mediaFileGlobalId")
+ for e in self.fs().scandir(container) if last_segment(e["name"]) == name),
+ "no such entry")
+
+ def test_an_attachment_becomes_a_gallery_item(self):
+ """An Inventory file can exist only inside Inventory, invisible in the Gallery and
+ reusable nowhere. Uploading through the Gallery and linking the result is what the
+ web interface does, and it is what an ELN upload already did."""
+ fs = self.fs(writable=True)
+ for container in ("Samples/Plasmid pUC19",
+ "Samples/Plasmid pUC19/Plasmid pUC19 #1",
+ "Samples/E. coli DH5a/Datasheet"):
+ attachment = fs.upload_fileobj(f"{container}/via-gallery.png", BytesIO(b"bytes"))
+ self.assertEqual("via-gallery.png", attachment["name"])
+ media = self._media_gid(container, "via-gallery.png")
+ self.assertTrue(str(media).startswith("GL"),
+ f"{container} attachment should point at a Gallery file, got {media!r}")
+
+ def test_the_inventory_only_upload_is_still_reachable(self):
+ """The API offers both, and the deprecated class depends on the older one."""
+ fs = self.fs(writable=True, via_gallery=False)
+ fs.upload_fileobj("Samples/Plasmid pUC19/inventory-only.png", BytesIO(b"bytes"))
+ self.assertIsNone(self._media_gid("Samples/Plasmid pUC19", "inventory-only.png"))
+
+ def test_via_gallery_needs_an_eln_client_and_says_so(self):
+ """It needs a second client. Left unset it turns itself on when one can be had;
+ asking for it explicitly when one cannot is an error, not a silent downgrade to
+ writing files that land somewhere other than the caller asked for."""
+ from unittest.mock import MagicMock
+ self.assertFalse(InventoryFilesystem(inv_client=MagicMock()).via_gallery)
+ self.assertTrue(self.fs().via_gallery) # built from a url and key
+ with self.assertRaises(ValueError):
+ InventoryFilesystem(inv_client=MagicMock(), via_gallery=True)
+
+ def test_a_bench_or_template_is_refused_before_anything_is_uploaded(self):
+ """Both present as ordinary record folders and the server refuses the attachment.
+ If the Gallery upload ran first its file would be stranded, and the RSpace API
+ cannot delete a Gallery file."""
+ fs = self.fs(writable=True)
+ gallery = GalleryFilesystem(self.url, "k")
+ gallery_before = sum(len(files) for _, _, files in gallery.walk(""))
+ for path, what in (("WB user1a/x.txt", "bench"), ("Templates/Plasmid/x.txt", "template")):
+ with self.assertRaises(NotImplementedError, msg=what) as ctx:
+ fs.upload_fileobj(path, BytesIO(b"x"))
+ self.assertIn("does not accept attachments", str(ctx.exception))
+ gallery.clear_name_cache()
+ self.assertEqual(gallery_before, sum(len(files) for _, _, files in gallery.walk("")),
+ "nothing may reach the Gallery")
+
+ def test_linking_an_existing_gallery_file_copies_no_bytes(self):
+ fs = self.fs(writable=True)
+ linked = fs.link("SA1000", "GL100")
+ self.assertEqual("GL100", linked["mediaFileGlobalId"])
+ self.assertIn(linked["name"], names(fs, "Samples/Plasmid pUC19"))
+
+ def test_the_server_refuses_a_link_to_a_bench_or_template(self):
+ fs = self.fs(writable=True)
+ for gid in ("BE1", "IT1"):
+ with self.assertRaises(RemoteApiError, msg=gid) as ctx:
+ fs.link(gid, "GL100")
+ self.assertIsInstance(ctx.exception, OSError)
+ self.assertIsNotNone(ctx.exception.api_error)
+ self.assertIn(ctx.exception.status, (400, 409, 422))
+
+ def test_whole_tree_walk_terminates(self):
+ fs = self.fs(page_size=50)
+ dirs = [p for p, _, _ in fs.walk("")]
+ self.assertEqual(len(dirs), len(set(dirs)))
+ self.assertIn("WB user1a/Bench box/E. coli DH5a #2/sample: E. coli DH5a", dirs)
+ self.assertTrue(len(dirs) > 40)
+ # find(withdirs=True) walks the same tree and reports the same directories
+ found = fs.find("WB user1a", withdirs=True)
+ self.assertIn("WB user1a/Bench box/E. coli DH5a #2/sample: E. coli DH5a", found)
+
+ def test_writes(self):
+ fs = self.fs(writable=True, allow_delete=True)
+ payload = BytesIO(b"x")
+ payload.name = "qc.txt"
+ fs.upload_fileobj("Containers/Freezer -80/Rack A/Plasmid pUC19 #1/qc.txt", payload)
+ self.assertIn("qc.txt", names(fs, "IC201/SS300"))
+ fs.upload_fileobj("IC201/SS300/sample: Plasmid pUC19/extra.txt", BytesIO(b"y")) # attaches to the sample
+ self.assertEqual(2, len([e for e in fs.scandir("SA1000") if e["type"] != "directory"]))
+ with self.assertRaises(NotImplementedError):
+ fs.upload_fileobj("Containers/z.txt", BytesIO(b"z"))
+ with self.assertRaises(NotImplementedError):
+ fs.mkdir("Containers/new")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/rspace_client/tests/fs_name_paths_test.py b/rspace_client/tests/fs_name_paths_test.py
new file mode 100644
index 0000000..88c4612
--- /dev/null
+++ b/rspace_client/tests/fs_name_paths_test.py
@@ -0,0 +1,243 @@
+"""
+The ``name`` path style: segments are the records' own names.
+
+Names, not labelled ``Name [GID]`` segments, are the default because fsspec consumers such
+as Galaxy derive both the label they display and the path they address from an entry's
+``name``, and a label with the ID appended would travel into downloaded file names and
+swallow the extension (``microscope.png [GL100]`` has no usable extension, and Galaxy picks
+a dataset's type from one).
+
+Under the ``name`` style a segment is just the name, a segment carrying a global ID is
+still accepted, and a name is resolved against its parent's listing.
+"""
+import mimetypes
+import os
+import tempfile
+import unittest
+from unittest.mock import MagicMock
+
+from rspace_client.fs import GalleryFilesystem, InventoryFilesystem, RSpaceFilesystem, paths
+from .mock_server_case import MockServerTestCase, segments
+
+
+
+
+class SegmentGrammarTest(unittest.TestCase):
+
+ def test_a_disambiguated_name_keeps_its_extension(self):
+ self.assertEqual("data [GL112].csv", paths.disambiguated_segment("data.csv", "GL112"))
+ self.assertEqual("notes [GL112]", paths.disambiguated_segment("notes", "GL112"))
+ self.assertEqual("GL112", paths.disambiguated_segment("", "GL112"))
+
+ def test_every_spelling_of_a_segment_parses_back_to_the_same_record(self):
+ for segment in ("GL112", "data.csv [GL112]", "data [GL112].csv"):
+ self.assertEqual("GL112", paths.parse_segment(segment)[0], segment)
+ self.assertEqual("data.csv", paths.parse_segment("data [GL112].csv")[1])
+ self.assertEqual((None, "data.csv"), paths.parse_segment("data.csv"))
+
+ def test_a_name_is_never_mistaken_for_a_labelled_segment(self):
+ self.assertEqual((None, "notes [draft]"), paths.parse_segment("notes [draft]"))
+ self.assertEqual((None, "report [v2].pdf"), paths.parse_segment("report [v2].pdf"))
+
+ def test_a_name_reserved_by_the_path_grammar_falls_back_to_the_global_id(self):
+ """'.' and '..' address a directory rather than a child, so a record named either
+ would be unreachable (and would send a walk round in circles). Use its ID instead."""
+ for name in (".", "..", " .. ", "\t..\n"):
+ self.assertEqual("GL1", paths.segment_for("name", name, "GL1"), name)
+ self.assertEqual("GL1", paths.segment_for("labelled", name, "GL1"), name)
+ self.assertEqual("GL1", paths.disambiguated_segment(name, "GL1"), name)
+ # anything else that merely contains dots is an ordinary name
+ for name, segment in (("...", "..."), (".hidden", ".hidden"), ("./", ".-"), ("../x", "..-x")):
+ self.assertEqual(segment, paths.segment_for("name", name, "GL1"), name)
+
+ def test_labelled_duplicate_names_stay_distinct(self):
+ a, b = paths.segment_for("labelled", "data.csv", "GL111"), paths.segment_for("labelled", "data.csv", "GL112")
+ self.assertNotEqual(a, b)
+ self.assertEqual(("GL111", "data.csv"), paths.parse_segment(a))
+
+ def test_labels_are_sanitised_and_brackets_in_names_survive(self):
+ self.assertEqual("A-B C [GF1]", paths.segment_for("labelled", "A/B C", "GF1"))
+ seg = paths.segment_for("labelled", "Run [rep 2]", "SD9")
+ self.assertEqual("Run [rep 2] [SD9]", seg)
+ self.assertEqual(("SD9", "Run [rep 2]"), paths.parse_segment(seg))
+ self.assertEqual("GF1", paths.segment_for("labelled", "", "GF1"))
+ self.assertEqual((None, "New folder"), paths.parse_segment("New folder"))
+
+ def test_both_separators_are_replaced(self):
+ """A backslash is a separator on Windows, so it cannot survive into a segment that a
+ consumer may write to disk."""
+ self.assertEqual("a-b", paths.segment_for("name", "a/b", "GL1"))
+ self.assertEqual("a-b", paths.segment_for("name", "a\\b", "GL1"))
+ self.assertEqual("C:-Users-x.png", paths.segment_for("name", "C:\\Users\\x.png", "GL1"))
+ self.assertNotIn("\0", paths.segment_for("name", "a\0b", "GL1"))
+
+
+class NamePathStyleTest(MockServerTestCase):
+
+ def fs(self, **kw):
+ kw.setdefault("path_style", "name")
+ return RSpaceFilesystem(self.url, "k", **kw)
+
+ def test_listings_show_plain_names(self):
+ rfs = self.fs()
+ self.assertEqual(["gallery/Images", "gallery/Documents", "gallery/Chemistry", "gallery/Api Imports"],
+ rfs.ls("gallery", detail=False))
+ self.assertIn("gallery/Images/microscope.png", rfs.ls("gallery/Images", detail=False))
+ self.assertIn("inventory/Containers", rfs.ls("inventory", detail=False))
+ self.assertIn("workspace/Project Alpha/Alpha protocol/Results",
+ rfs.ls("workspace/Project Alpha/Alpha protocol", detail=False))
+ # a signed document's fields carry the read-only marker under this style, and the
+ # unmarked spelling still resolves
+ signed = rfs.ls("workspace/Project Alpha/Signed protocol", detail=False)
+ self.assertEqual(["Data (signed)"], segments(signed))
+ self.assertFalse(rfs.info("workspace/Project Alpha/Signed protocol/Data")["writable"])
+
+ def test_siblings_sharing_a_name_stay_addressable(self):
+ rfs = self.fs()
+ entries = segments(rfs.ls("gallery/Documents", detail=False)) # two files called data.csv
+ self.assertEqual(len(entries), len(set(entries)))
+ self.assertIn("data.csv", entries)
+ self.assertIn("data [GL112].csv", entries)
+
+ def test_a_record_resolves_under_every_spelling(self):
+ rfs = self.fs()
+ for segment in ("data [GL112].csv", "data.csv [GL112]", "GL112"):
+ entry = rfs.info("gallery/Documents/" + segment)
+ self.assertEqual("GL112", entry["rspace"]["globalId"], segment)
+ self.assertEqual("gallery/Documents/" + segment, entry["name"], segment)
+ self.assertEqual("GL111", rfs.info("gallery/Documents/data.csv")["rspace"]["globalId"])
+
+ def test_a_name_that_is_not_there_is_not_found(self):
+ rfs = self.fs()
+ with self.assertRaises(FileNotFoundError):
+ rfs.info("gallery/Images/nothing-like-this.png")
+ self.assertFalse(rfs.exists("gallery/Images/nothing-like-this.png"))
+
+ def test_downloaded_files_keep_a_usable_extension(self):
+ rfs = self.fs()
+ out = tempfile.mkdtemp()
+ rfs.get("gallery/Documents/", out + "/", recursive=True)
+ for name in os.listdir(out):
+ self.assertIsNotNone(mimetypes.guess_type(name)[0], name)
+ self.assertEqual({"data.csv", "data [GL112].csv", "protocol.docx"}, set(os.listdir(out)))
+
+ def test_a_folder_can_be_created_and_then_copied_into_by_name(self):
+ """Under the labelled style the new folder's real segment carried an ID the caller
+ had no way to know, so copying into what it had just created failed."""
+ rfs = self.fs(writable=True)
+ rfs.mkdir("gallery/Documents/Round trip")
+ source = tempfile.mkdtemp()
+ with open(os.path.join(source, "notes.txt"), "wb") as handle:
+ handle.write(b"hello")
+ rfs.put(source + "/", "gallery/Documents/Round trip/", recursive=True)
+ self.assertEqual(["gallery/Documents/Round trip/notes.txt"],
+ rfs.ls("gallery/Documents/Round trip", detail=False))
+ self.assertEqual(b"hello", rfs.cat_file("gallery/Documents/Round trip/notes.txt"))
+
+ def test_resolving_a_name_is_remembered(self):
+ gallery = GalleryFilesystem(self.url, "k", path_style="name", fetch_sizes=False)
+ gallery.ls("Images/2026-09 run")
+ before = self.calls()
+ gallery.ls("Images/2026-09 run")
+ self.assertEqual(1, self.calls() - before) # the listing itself; the parents are cached
+ before = self.calls()
+ gallery.info("Images/2026-09 run")
+ self.assertEqual(1, self.calls() - before) # the folder record; the lookup costs nothing
+
+ def test_fetching_sizes_costs_one_call_per_file(self):
+ """The folder-tree listing carries no size; by default every file is fetched for it."""
+ plain = GalleryFilesystem(self.url, "k", path_style="name", fetch_sizes=False)
+ plain.ls("Images/2026-09 run") # resolve the parents first
+ before = self.calls()
+ entries = plain.ls("Images/2026-09 run")
+ self.assertEqual(1, self.calls() - before)
+ self.assertEqual([None], [e["size"] for e in entries])
+
+ sized = GalleryFilesystem(self.url, "k", path_style="name")
+ sized.ls("Images/2026-09 run")
+ before = self.calls()
+ entries = sized.ls("Images/2026-09 run")
+ self.assertEqual(1 + len(entries), self.calls() - before)
+ self.assertTrue(all(isinstance(e["size"], int) for e in entries))
+
+ def test_a_change_forgets_what_was_resolved(self):
+ gallery = GalleryFilesystem(self.url, "k", path_style="name", writable=True)
+ gallery.ls("Documents")
+ self.assertTrue(gallery._name_cache)
+ gallery.mkdir("Documents/New folder")
+ self.assertFalse(gallery._name_cache)
+
+ def test_a_name_that_looks_like_an_address_is_listed_with_its_real_id(self):
+ """Review F4: a record named 'GL9' or 'report [GL111].csv' would otherwise be listed
+ under a segment that resolves to the *other* record."""
+ self.assertEqual("GL9 [GL6]", paths.segment_for("name", "GL9", "GL6"))
+ self.assertEqual("report [GL111] [GL5].csv", paths.segment_for("name", "report [GL111].csv", "GL5"))
+ self.assertEqual("notes [draft]", paths.segment_for("name", "notes [draft]", "GL7")) # not an address
+ for listed, gid in (("GL9 [GL6]", "GL6"), ("report [GL111] [GL5].csv", "GL5")):
+ self.assertEqual(gid, paths.parse_segment(listed)[0])
+
+ files = [{"id": 5, "globalId": "GL5", "name": "report [GL111].csv"},
+ {"id": 6, "globalId": "GL6", "name": "GL9"},
+ {"id": 111, "globalId": "GL111", "name": "other.csv"},
+ {"id": 9, "globalId": "GL9", "name": "nine.csv"}]
+ client = MagicMock()
+ client.list_folder_tree.side_effect = lambda folder_id=None, typesToInclude=[], **kw: (
+ {"records": [{"id": 2, "globalId": "FL2", "name": "Gallery"}]} if folder_id is None
+ else {"records": files})
+ client.get_file_info.side_effect = lambda fid: next(f for f in files if str(f["id"]) == str(fid))
+ fs = GalleryFilesystem(eln_client=client)
+ self.assertEqual(["report [GL111] [GL5].csv", "GL9 [GL6]", "other.csv", "nine.csv"], fs.ls("", detail=False))
+ for listed in fs.ls("", detail=False):
+ entry = fs.info(listed)
+ self.assertEqual(listed, entry["name"])
+ # every listed name opens the record it shows
+ self.assertEqual(paths.parse_segment(listed)[0] or entry["globalId"], entry["globalId"])
+ self.assertEqual(next(f["name"] for f in files if f["globalId"] == entry["globalId"]),
+ entry["rspace"]["name"])
+
+ def test_a_hostile_record_name_stays_addressable(self):
+ """A name is chosen by an RSpace user, so on a shared instance it crosses a trust
+ boundary. One that would break the path grammar is listed by its global ID."""
+ client = MagicMock()
+ hostile = {"id": 5, "globalId": "IF5", "name": ".."}
+ client.get_container_by_id.return_value = {
+ "id": 1, "globalId": "IC1", "name": "Box", "locations": [],
+ "attachments": [hostile, {"id": 6, "globalId": "IF6", "name": "a/b.txt"}]}
+ client.get_attachment_by_id.return_value = hostile
+ fs = InventoryFilesystem(inv_client=client)
+ self.assertEqual(["IC1/IF5", "IC1/a-b.txt"], fs.ls("IC1", detail=False))
+ self.assertEqual("IC1/IF5", fs.info("IC1/IF5")["name"])
+ self.assertEqual("..", fs.info("IC1/IF5")["rspace"]["name"]) # the real name is kept
+
+ def test_a_filesystem_that_can_delete_does_not_claim_to_be_read_only(self):
+ """writable and allow_delete are deliberately separate, so a filesystem can
+ delete while refusing uploads. Reporting that as read-only would be a lie."""
+ for kw, read_only in (({}, True),
+ ({"writable": True}, False),
+ ({"allow_delete": True}, False),
+ ({"writable": True, "allow_delete": True}, False)):
+ self.assertEqual(read_only, self.fs(**kw).read_only, kw)
+ self.assertEqual(read_only, GalleryFilesystem(self.url, "k", **kw).read_only, kw)
+
+ def test_the_other_styles_still_work(self):
+ self.assertIn("gallery/Images [GF10]", self.fs(path_style="labelled").ls("gallery", detail=False))
+ self.assertIn("gallery/GF10", self.fs(path_style="id").ls("gallery", detail=False))
+ with self.assertRaises(ValueError):
+ self.fs(path_style="nonsense")
+
+ def test_glob_finds_a_bracketed_segment_in_listing_order(self):
+ """Galaxy's search escapes '[' as '\\[' and hands the pattern to glob; fsspec's own glob
+ would read '[GL112]' as a character class and find nothing."""
+ rfs = self.fs()
+ self.assertEqual(["gallery/Documents/data [GL112].csv"], rfs.glob("gallery/Documents/*\\[GL112\\]*"))
+ listed = [n for n in rfs.ls("gallery/Documents", detail=False) if "data" in paths.last_segment(n)]
+ self.assertEqual(listed, rfs.glob("gallery/Documents/*data*")) # listing order, not sorted
+ self.assertEqual(["gallery/Documents/data.csv", "gallery/Documents/data [GL112].csv"], listed)
+ gallery = GalleryFilesystem(self.url, "k", path_style="name")
+ self.assertEqual(["Documents/data [GL112].csv"], gallery.glob("Documents/*\\[GL112\\]*"))
+ found = gallery.glob("Documents/*CSV*", detail=True) # case-insensitive, entries carried
+ self.assertEqual(["GL111", "GL112"], [e["globalId"] for e in found.values()])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/rspace_client/tests/fs_review_test.py b/rspace_client/tests/fs_review_test.py
new file mode 100644
index 0000000..e1ef7ef
--- /dev/null
+++ b/rspace_client/tests/fs_review_test.py
@@ -0,0 +1,107 @@
+"""
+Regression tests from the 2026-10-07 review pass over rspace_client.fs: each test pins a
+behaviour that the review found wrong against the mock RSpace server.
+"""
+import os
+from unittest import mock
+
+import fsspec
+
+from rspace_client.client_base import ClientBase
+from rspace_client.fs import GalleryFilesystem, RemoteApiError, RSpaceFilesystem
+from rspace_client.fs.paths import last_segment
+from rspace_client.tests.mock_server_case import MockServerTestCase, names
+
+
+
+
+class ReviewRegressionTest(MockServerTestCase):
+
+ def test_resolving_a_name_does_not_fetch_sizes(self):
+ """A lookup lists the parent to find a name; the per-file size fetch that a listing
+ pays for is wasted there. Before: 6 calls for info() of a file by name."""
+ fs = GalleryFilesystem(self.url, "k") # fetch_sizes=True by default
+ before = self.calls()
+ entry = fs.info("Images/gel.jpg")
+ # gallery root id, root listing (to find Images), Images listing, the file's record
+ self.assertEqual(4, self.calls() - before)
+ self.assertEqual(868, entry["size"])
+ before = self.calls()
+ sizes = [e["size"] for e in fs.ls("Images") if e["type"] == "file"]
+ self.assertEqual([1096, 868], sizes) # a real listing still tops the sizes up
+ self.assertEqual(1 + 2, self.calls() - before)
+
+ def test_double_star_glob_is_recursive_not_a_search(self):
+ fs = GalleryFilesystem(self.url, "k")
+ everything = fs.glob("**")
+ self.assertIn("Images/gel.jpg", everything)
+ self.assertIn("Documents/data.csv", everything)
+ self.assertEqual(["Documents/data.csv", "Documents/data [GL112].csv"], fs.glob("Documents/*data*"))
+
+ def test_a_write_drops_the_listings_cache(self):
+ fs = GalleryFilesystem(self.url, "k", writable=True, use_listings_cache=True)
+ self.assertNotIn("new.txt", names(fs, "Documents"))
+ with fs.open("Documents/new.txt", "wb") as handle:
+ handle.write(b"x")
+ self.assertIn("new.txt", names(fs, "Documents"))
+
+ def test_a_move_out_of_the_gallery_is_refused_before_anything_is_copied(self):
+ fs = GalleryFilesystem(self.url, "k", writable=True, allow_delete=True)
+ with self.assertRaises(NotImplementedError):
+ fs.mv("Documents/data.csv", "Documents/moved.csv")
+ self.assertNotIn("moved.csv", names(fs, "Documents"))
+ mount = RSpaceFilesystem(self.url, "k", writable=True, allow_delete=True)
+ with self.assertRaises(NotImplementedError):
+ mount.mv("gallery/Documents/data.csv", "gallery/Documents/moved.csv")
+ self.assertNotIn("moved.csv", names(mount, "gallery/Documents"))
+ # where the source can be removed a move is an unlink plus a link
+ mount.mv("workspace/Project Alpha/Alpha protocol/Results/data.csv", "inventory/Containers/Freezer -80/data.csv")
+ self.assertIn("data.csv", names(mount, "inventory/Containers/Freezer -80"))
+ self.assertNotIn("data.csv", names(mount, "workspace/Project Alpha/Alpha protocol/Results"))
+
+ def test_list_paths_are_accepted_where_fsspec_passes_them(self):
+ fs = GalleryFilesystem(self.url, "k")
+ self.assertEqual(["Images", "Documents/data.csv"],
+ fs._strip_protocol(["rspace-gallery://Images/", "Documents/data.csv"]))
+ self.assertEqual(2, len(fs.cat(["Documents/data.csv", "Images/gel.jpg"])))
+ mount = RSpaceFilesystem(self.url, "k")
+ self.assertEqual(["gallery/Images"], mount._strip_protocol(["gallery/Images/"]))
+
+ def test_recursive_put_creates_the_folders_it_copies(self):
+ """fsspec's put() hands directories to put_file and expects the backend to make them."""
+ import os
+ import tempfile
+ fs = RSpaceFilesystem(self.url, "k", writable=True)
+ tmp = tempfile.mkdtemp()
+ os.makedirs(os.path.join(tmp, "img", "sub"))
+ with open(os.path.join(tmp, "img", "a.txt"), "wb") as handle:
+ handle.write(b"a")
+ with open(os.path.join(tmp, "img", "sub", "b.txt"), "wb") as handle:
+ handle.write(b"b")
+ fs.put(os.path.join(tmp, "img") + "/", "gallery/Documents/", recursive=True)
+ self.assertIn("gallery/Documents/sub/b.txt", fs.find("gallery/Documents"))
+ self.assertEqual(b"a", fs.cat_file("gallery/Documents/a.txt"))
+
+ def test_url_paths_come_back_without_host_or_query(self):
+ host = self.url.split("//")[1]
+ with mock.patch.dict(os.environ, {"RSPACE_API_KEY": "k"}):
+ fs, path = fsspec.core.url_to_fs(f"rspace://{host}/gallery/Images?scheme=http")
+ self.assertEqual("gallery/Images", path)
+ self.assertEqual(["2026-09 run", "gel.jpg", "microscope.png"],
+ sorted(last_segment(n) for n in fs.ls(path, detail=False)))
+ mapper = fsspec.get_mapper(f"rspace://{host}/gallery/Documents?scheme=http")
+ self.assertIn("data.csv", list(mapper))
+ self.assertEqual("", RSpaceFilesystem._strip_protocol(f"rspace://{host}?scheme=http"))
+ # a record name with a dot or colon below the top level is left alone
+ self.assertEqual("gallery/v1.2:final/x", RSpaceFilesystem._strip_protocol("gallery/v1.2:final/x"))
+
+ def test_a_500_with_a_server_message_is_a_remote_api_error_not_a_connection_error(self):
+ fs = GalleryFilesystem(self.url, "k", writable=True)
+ fs.eln_client.get_folder = mock.Mock(side_effect=ClientBase.ApiError("Not a Valid PNG File", response_status_code=500))
+ with self.assertRaises(RemoteApiError) as caught:
+ fs.info("GF10")
+ self.assertEqual(500, caught.exception.status)
+ self.assertIn("Not a Valid PNG File", str(caught.exception))
+ fs.eln_client.get_folder = mock.Mock(side_effect=ClientBase.ApiError("gateway", response_status_code=503))
+ with self.assertRaises(ConnectionError):
+ fs.info("GF10")
diff --git a/rspace_client/tests/fs_rspace_test.py b/rspace_client/tests/fs_rspace_test.py
new file mode 100644
index 0000000..0378a9f
--- /dev/null
+++ b/rspace_client/tests/fs_rspace_test.py
@@ -0,0 +1,497 @@
+"""The unified RSpaceFilesystem mount, its copy/link semantics, the tree helper and the
+rspace:// URL handling, exercised offline against the embedded mock server.
+
+Ported from the PyFilesystem2 suite. Paths carry no leading slash and the root is ``""``;
+an entry's ``name`` is its full path, so tests compare segments with
+:func:`rspace_client.fs.paths.last_segment`. The PyFilesystem error classes map to the
+builtin ``OSError`` family (``FileNotFoundError``, :class:`ReadOnlyError`,
+``NotImplementedError``, ``IsADirectoryError``, ``FileExistsError``).
+"""
+import gc
+import os
+import sys
+import tempfile
+import unittest
+from io import BytesIO
+from unittest.mock import patch
+
+import fsspec
+import fsspec.core
+from upath import UPath
+
+from rspace_client.fs import GalleryFilesystem, ReadOnlyError, RSpaceFilesystem, format_tree
+from rspace_client.fs.paths import last_segment
+from .mock_server_case import MockServerTestCase, segments
+
+MOUNTS = ["gallery", "inventory", "workspace"]
+
+
+
+
+class RSpaceFilesystemTest(MockServerTestCase):
+
+ def test_root_lists_the_worlds_and_shares_clients(self):
+ rs = RSpaceFilesystem(self.url, "k")
+ self.assertEqual(MOUNTS, rs.ls("", detail=False))
+ self.assertEqual("directory", rs.info("gallery")["type"])
+ self.assertEqual("directory", rs.info("")["type"])
+ self.assertIs(rs.gallery.eln_client, rs.eln_client)
+ self.assertIs(rs.inventory.inv_client, rs.inv_client)
+ self.assertTrue(rs.read_only)
+
+ def test_a_missing_record_behind_a_global_id_does_not_exist(self):
+ # a server 404 must be a FileNotFoundError, so exists() is False
+ rs = RSpaceFilesystem(self.url, "k")
+ for path in ("gallery/GF99999", "gallery/GL99999", "inventory/IC99999",
+ "inventory/Samples/SA99999", "workspace/SD99999", "workspace/FL99999"):
+ self.assertFalse(rs.exists(path), path)
+ with self.assertRaises(FileNotFoundError, msg=path):
+ rs.info(path)
+ self.assertFalse(rs.exists("gallery/GF99999/anything"))
+ with self.assertRaises(FileNotFoundError):
+ rs.ls("nope") # not a mount
+
+ def test_browse_through_the_mount(self):
+ rs = RSpaceFilesystem(self.url, "k")
+ self.assertEqual(["gallery/Images", "gallery/Documents", "gallery/Chemistry", "gallery/Api Imports"],
+ rs.ls("gallery", detail=False))
+ rows = rs.ls("gallery/Documents", detail=True)
+ # two files are called data.csv; the second carries its id, and keeps its extension
+ self.assertEqual(["protocol.docx", "data.csv", "data [GL112].csv"], segments(rows))
+ self.assertTrue(all(r["name"].startswith("gallery/Documents/") for r in rows))
+ self.assertEqual(["Rack A", "Rack B", "freezer_manual.pdf"], segments(rs.ls("inventory/IC200", detail=False)))
+ walked = {p for p, _, _ in rs.walk("")}
+ self.assertIn("gallery/Images/2026-09 run", walked)
+ self.assertIn("inventory", walked) # walking the whole mount works
+ self.assertIn("inventory/WB user1a/Bench box", walked)
+ self.assertEqual(["WB user1a", "Containers", "Samples", "Templates"],
+ segments(rs.ls("inventory", detail=False)))
+ out = BytesIO()
+ rs.download_fileobj("gallery/Documents/data.csv", out)
+ self.assertTrue(out.getvalue().startswith(b"well,od600"))
+ self.assertEqual(out.getvalue(), rs.cat_file("gallery/Documents/data.csv"))
+ with rs.open("inventory/IC200/IF500", "rb") as fh:
+ self.assertEqual(455, len(fh.read()))
+ self.assertEqual(455, rs.info("inventory/IC200/IF500")["size"])
+
+ def test_a_partial_mount_needs_only_the_clients_it_uses(self):
+ # mounts= narrows what is needed, so an ELN client alone can mount gallery
+ from rspace_client.eln.eln import ELNClient
+ rs = RSpaceFilesystem(eln_client=ELNClient(self.url, "k"), mounts=["gallery", "workspace"])
+ self.assertEqual(["gallery", "workspace"], rs.ls("", detail=False))
+ self.assertIsNone(rs.inv_client)
+ with self.assertRaises(ValueError):
+ RSpaceFilesystem(eln_client=ELNClient(self.url, "k"), mounts=["inventory"])
+ with self.assertRaises(ValueError):
+ RSpaceFilesystem(mounts=["gallery"])
+
+ def test_only_the_chosen_branches_are_mounted(self):
+ """A caller can expose part of RSpace. The names and the paths below them do not
+ change, so narrowing the set later does not invalidate paths to what remains."""
+ rs = RSpaceFilesystem(self.url, "k", mounts=["gallery"])
+ self.assertEqual(["gallery"], rs.ls("", detail=False))
+ self.assertEqual(("gallery",), rs.mount_names)
+ self.assertIn("gallery/Images", rs.ls("gallery", detail=False))
+ self.assertIsNone(rs.inventory)
+ self.assertIsNone(rs.workspace)
+ with self.assertRaises(FileNotFoundError):
+ rs.ls("workspace")
+ self.assertFalse(rs.exists("workspace"))
+
+ def test_mounts_are_listed_in_a_stable_order_however_they_are_given(self):
+ rs = RSpaceFilesystem(self.url, "k", mounts=("workspace", "gallery", "workspace"))
+ self.assertEqual(["gallery", "workspace"], rs.ls("", detail=False))
+
+ def test_an_unusable_mount_set_is_refused(self):
+ for bad in (["galery"], [], ["gallery", "eln"]):
+ with self.assertRaises(ValueError, msg=bad):
+ RSpaceFilesystem(self.url, "k", mounts=bad)
+
+ def test_id_style_and_bare_id_aliases(self):
+ rs = RSpaceFilesystem(self.url, "k", path_style="id")
+ rs.gallery.gallery_id # resolve the root outside the measurement
+ before = self.calls()
+ rows = rs.ls("gallery", detail=True)
+ self.assertEqual(1, self.calls() - before) # one call per directory, no per-child info
+ self.assertEqual(["gallery/GF10", "gallery/GF11", "gallery/GF12", "gallery/GF14"], [r["name"] for r in rows])
+ self.assertTrue(all(r["type"] == "directory" and r["created"] is not None for r in rows))
+ self.assertEqual("Images", rows[0]["rspace"]["name"])
+ self.assertEqual("GF10", rows[0]["globalId"])
+ self.assertEqual(["IC201", "IC202", "IF500"], segments(rs.ls("inventory/IC200", detail=False))) # bare ids
+ self.assertEqual(["SA1000"], segments(rs.ls("inventory/SS301", detail=False))) # the sample shortcut is its id too
+ labelled = RSpaceFilesystem(self.url, "k", path_style="labelled")
+ # fsspec convention: info()["name"] echoes the path that was asked for; the style
+ # shows in the listing segment and the record is under rspace/globalId
+ info = labelled.info("gallery/GF10")
+ self.assertEqual("gallery/GF10", info["name"])
+ self.assertEqual(("GF10", "Images", "directory"), (info["globalId"], info["rspace"]["name"], info["type"]))
+ self.assertIn("gallery/Images [GF10]", labelled.ls("gallery", detail=False))
+ self.assertEqual(["protocol.docx [GL110]", "data.csv [GL111]", "data.csv [GL112]"],
+ segments(labelled.ls("gallery/Documents [GF11]", detail=False))) # duplicate names stay distinct
+ self.assertEqual(["Rack A [IC201]", "Rack B [IC202]", "freezer_manual.pdf [IF500]"],
+ segments(labelled.ls("inventory/Freezer -80 [IC200]", detail=False)))
+ # a bare id resolves under every style, and the listing names it in that style's way
+ plain = RSpaceFilesystem(self.url, "k")
+ self.assertEqual("Images", plain.info("gallery/GF10")["rspace"]["name"])
+ self.assertIn("gallery/Images", plain.ls("gallery", detail=False))
+ # children are prefixed with the path as it was asked for (fsspec convention)
+ self.assertEqual(["gallery/GF10/2026-09 run", "gallery/GF10/microscope.png", "gallery/GF10/gel.jpg"],
+ plain.ls("gallery/GF10", detail=False))
+
+ def test_write_posture_is_shared_and_root_is_fixed(self):
+ rs = RSpaceFilesystem(self.url, "k")
+ for branch in (rs.gallery, rs.inventory, rs.workspace):
+ self.assertEqual((False, False), (branch.writable, branch.allow_delete))
+ rs = RSpaceFilesystem(self.url, "k", writable=True, allow_delete=True)
+ self.assertFalse(rs.read_only)
+ for branch in (rs.gallery, rs.inventory, rs.workspace):
+ self.assertEqual((True, True), (branch.writable, branch.allow_delete))
+ payload = BytesIO(b"\x00\x01binary\r\n--x\r\n") # bytes a sloppy multipart encoder would mangle
+ payload.name = "blob.bin"
+ rs.upload_fileobj("gallery/Chemistry [GF12]/blob.bin", payload)
+ self.assertEqual(["gallery/Chemistry [GF12]/blob.bin"], rs.ls("gallery/Chemistry [GF12]", detail=False))
+ out = BytesIO()
+ rs.download_fileobj("gallery/Chemistry [GF12]/blob.bin", out)
+ self.assertEqual(payload.getvalue(), out.getvalue())
+ rs.mkdir("gallery/TopLevel") # the parent is the Gallery root folder
+ self.assertIn("gallery/TopLevel", rs.ls("gallery", detail=False))
+ rs.rmdir("gallery/TopLevel")
+ self.assertNotIn("gallery/TopLevel", rs.ls("gallery", detail=False))
+ payload = BytesIO(b"note")
+ payload.name = "n.txt"
+ rs.upload_fileobj("inventory/SS301/n.txt", payload)
+ self.assertIn("n.txt", segments(rs.ls("inventory/SS301", detail=False)))
+ rs.rm_file("inventory/SS301/n.txt")
+ self.assertEqual(["inventory/SS301/sample: Plasmid pUC19"], rs.ls("inventory/SS301", detail=False))
+ for op in (lambda: rs.mkdir("scratch"), lambda: rs.upload_fileobj("x.txt", BytesIO(b"x")),
+ lambda: rs.rmdir("gallery"), lambda: rs.rm_file("gallery"), lambda: rs.rm("gallery")):
+ with self.assertRaises(ReadOnlyError):
+ op()
+ self.assertEqual(MOUNTS, rs.ls("", detail=False))
+
+ def test_the_fixed_top_level_cannot_be_written_to(self):
+ """The top level is a fixed list of mounts; a write there must be refused rather
+ than vanishing into a filesystem that is never read back, whatever the posture."""
+ for rs in (RSpaceFilesystem(self.url, "k"),
+ RSpaceFilesystem(self.url, "k", writable=True, allow_delete=True)):
+ for write in (lambda f: f.pipe_file("a.txt", b"x"),
+ lambda f: f.open("c.txt", "wb"),
+ lambda f: f.touch("d.txt"),
+ lambda f: f.makedirs("e"),
+ lambda f: f.mkdir("e"),
+ lambda f: f.upload_fileobj("g.txt", BytesIO(b"x")),
+ lambda f: f.put_file(__file__, "h.py"),
+ lambda f: f.cp_file("gallery/Images/microscope.png", "i.png"),
+ lambda f: f.mv("gallery/Images/microscope.png", "j.png"),
+ lambda f: f.link("k", "GL100")):
+ with self.assertRaises(ReadOnlyError):
+ write(rs)
+ # below a name that is not a mount there is nothing to create either
+ with self.assertRaises(FileNotFoundError):
+ rs.makedirs("e/f")
+ with self.assertRaises(IsADirectoryError):
+ rs.open("gallery", "rb")
+ self.assertEqual(MOUNTS, rs.ls("", detail=False))
+
+ def test_copying_a_gallery_file_into_rspace_links_it(self):
+ """The web interface calls this "link from Gallery". Without it, a generic copy
+ downloads the bytes and uploads them again, leaving a second Gallery file that the
+ RSpace API cannot delete."""
+ rs = RSpaceFilesystem(self.url, "k", writable=True)
+ before = set(rs.ls("gallery/Images", detail=False))
+
+ rs.cp_file("gallery/Images/microscope.png", "inventory/Samples/Plasmid pUC19/microscope.png")
+ attached = next(i for i in rs.scandir("inventory/Samples/Plasmid pUC19")
+ if last_segment(i["name"]) == "microscope.png")
+ self.assertEqual("GL100", attached["rspace"]["mediaFileGlobalId"])
+
+ field = "workspace/Project Alpha/Alpha protocol/Objective"
+ rs.copy("gallery/Images/microscope.png", f"{field}/microscope.png") # fsspec's copy goes through cp_file
+ self.assertIn("GL100", [i["rspace"]["globalId"] for i in rs.scandir(field)])
+
+ self.assertEqual(before, set(rs.ls("gallery/Images", detail=False)),
+ "linking must not create a new Gallery file")
+
+ def test_any_gallery_backed_source_links_not_just_the_gallery_mount(self):
+ """A file in a document field *is* a Gallery file, so copying one into Inventory
+ should link it too. Keying on the source mount rather than on what it resolves to
+ silently duplicated exactly what this method exists to prevent."""
+ rs = RSpaceFilesystem(self.url, "k", writable=True)
+ before = set(rs.ls("gallery/Documents", detail=False))
+ rs.cp_file("workspace/Project Alpha/Alpha protocol/Results/data.csv",
+ "inventory/Samples/Plasmid pUC19/data.csv")
+ attached = next(i for i in rs.scandir("inventory/Samples/Plasmid pUC19")
+ if last_segment(i["name"]) == "data.csv")
+ self.assertEqual("GL111", attached["rspace"]["mediaFileGlobalId"])
+ self.assertEqual(before, set(rs.ls("gallery/Documents", detail=False)))
+
+ def test_a_copy_under_a_new_name_moves_the_bytes_rather_than_lying(self):
+ """A link carries the Gallery file's own name, all the API offers. Asking for a
+ different name has to fall through to a real copy, or the destination path would not
+ exist afterwards."""
+ rs = RSpaceFilesystem(self.url, "k", writable=True)
+ dst = "inventory/Samples/E. coli DH5a/renamed.png"
+ rs.cp_file("gallery/Images/microscope.png", dst)
+ self.assertTrue(rs.exists(dst))
+ self.assertEqual(rs.cat_file("gallery/Images/microscope.png"), rs.cat_file(dst))
+
+ # test_copy_can_preserve_time_without_failing_after_the_upload is not ported: fsspec's
+ # cp_file has no preserve_time option and there is no setinfo, so there is no timestamp
+ # write-back to survive. RSpace owns its timestamps in either framework.
+
+ def test_move_refuses_before_copying_when_the_source_cannot_be_removed(self):
+ # a move is copy + remove; without allow_delete the remove would fail
+ # after the copy and leave a duplicate behind
+ rs = RSpaceFilesystem(self.url, "k", writable=True)
+ before = self.calls()
+ with self.assertRaises(ReadOnlyError):
+ rs.mv("inventory/IC200/freezer_manual.pdf", "inventory/Samples/Buffer stock/freezer_manual.pdf")
+ self.assertEqual(before, self.calls(), "nothing may be uploaded before the refusal")
+ self.assertIn("freezer_manual.pdf", segments(rs.ls("inventory/IC200", detail=False)))
+ # a read-only destination is refused just as early
+ before = self.calls()
+ with self.assertRaises(ReadOnlyError):
+ RSpaceFilesystem(self.url, "k", allow_delete=True).mv(
+ "inventory/IC200/freezer_manual.pdf", "inventory/Samples/Buffer stock/freezer_manual.pdf")
+ self.assertEqual(before, self.calls())
+
+ def test_copy_honours_overwrite(self):
+ """fsspec's cp_file has no overwrite flag: copying onto a destination that already
+ exists is not an error, it is a second copy. For a Gallery source into Inventory
+ that is a second link to the same Gallery file, and still no new Gallery file."""
+ rs = RSpaceFilesystem(self.url, "k", writable=True)
+ gallery_before = set(rs.ls("gallery/Images", detail=False))
+ dst = "inventory/Samples/Buffer stock/microscope.png"
+ rs.cp_file("gallery/Images/microscope.png", dst)
+ self.assertTrue(rs.exists(dst))
+ rs.cp_file("gallery/Images/microscope.png", dst) # allowed, no FileExistsError
+ linked = [i for i in rs.scandir("inventory/Samples/Buffer stock")
+ if i["rspace"].get("mediaFileGlobalId") == "GL100"]
+ self.assertEqual(2, len(linked))
+ self.assertEqual(gallery_before, set(rs.ls("gallery/Images", detail=False)))
+
+ def test_clear_name_cache_reaches_every_branch(self):
+ rs = RSpaceFilesystem(self.url, "k")
+ rs.ls("gallery/Images")
+ self.assertTrue(rs.gallery._name_cache)
+ rs.clear_name_cache()
+ self.assertFalse(any(b._name_cache for b in (rs.gallery, rs.inventory, rs.workspace)))
+
+ def test_a_copy_that_is_not_from_the_gallery_still_copies(self):
+ """Only a Gallery source can be linked; everything else falls through to the generic
+ copy, which moves the bytes. IF500 is an Inventory-only file (no mediaFileGlobalId),
+ so copying it into a document field has to upload a new Gallery file."""
+ rs = RSpaceFilesystem(self.url, "k", writable=True)
+ source = "inventory/IC200/freezer_manual.pdf"
+ self.assertIsNone(rs.info(source)["rspace"]["mediaFileGlobalId"])
+ gallery_files_before = sum(len(files) for _, _, files in rs.walk("gallery"))
+ dst = "workspace/Project Alpha/Alpha protocol/Objective/freezer_manual.pdf"
+ rs.cp_file(source, dst)
+ self.assertTrue(rs.exists(dst))
+ self.assertTrue(rs.info(dst)["rspace"]["globalId"].startswith("GL"))
+ self.assertEqual(rs.cat_file(source), rs.cat_file(dst))
+ gallery_files_after = sum(len(files) for _, _, files in rs.walk("gallery"))
+ self.assertEqual(gallery_files_before + 1, gallery_files_after, "the bytes must have moved")
+
+ def test_gallery_section_policy_is_configurable_through_the_mount(self):
+ """The Gallery routing policy must be reachable from the unified mount."""
+ from rspace_client.fs.gallery import ON_MISMATCH_RAISE, ON_MISMATCH_REROUTE
+ rs = RSpaceFilesystem(self.url, "k", writable=True)
+ self.assertEqual(ON_MISMATCH_RAISE, rs.on_mismatch)
+ self.assertEqual(ON_MISMATCH_RAISE, rs.gallery.on_mismatch)
+ rerouting = RSpaceFilesystem(self.url, "k", writable=True, on_mismatch=ON_MISMATCH_REROUTE)
+ self.assertEqual(ON_MISMATCH_REROUTE, rerouting.gallery.on_mismatch)
+ with self.assertRaises(ValueError):
+ RSpaceFilesystem(self.url, "k", on_mismatch="bogus")
+
+ def test_upload_returns_the_branch_result(self):
+ rs = RSpaceFilesystem(self.url, "k", writable=True)
+ payload = BytesIO(b"x")
+ payload.name = "p.png"
+ placement = rs.upload_fileobj("gallery/Images [GF10]/p.png", payload)
+ self.assertFalse(placement.rerouted)
+ self.assertEqual("Images [GF10]", placement.requested_path.split("/")[-1])
+ self.assertTrue(placement.file_global_id.startswith("GL"))
+
+ def test_failed_construction_closes_cleanly(self):
+ """A constructor that raises must raise ValueError and nothing else: no
+ AttributeError from a half-built object when it is garbage-collected."""
+ with patch.object(sys, "unraisablehook") as hook:
+ for construct in (lambda: RSpaceFilesystem(self.url, "k", path_style="bogus"),
+ lambda: RSpaceFilesystem(self.url, "k", mounts=["galery"]),
+ lambda: RSpaceFilesystem(self.url, "k", mounts=[]),
+ lambda: RSpaceFilesystem(mounts=["gallery"])):
+ with self.assertRaises(ValueError):
+ construct()
+ gc.collect()
+ hook.assert_not_called()
+
+ def test_tree_helper_names_and_collisions(self):
+ rs = RSpaceFilesystem(self.url, "k")
+ text = format_tree(rs, "gallery", max_depth=2)
+ self.assertTrue(text.startswith("gallery/\n"))
+ self.assertIn("Documents/", text)
+ self.assertIn("data.csv (GL111)", text) # duplicate names get their id
+ self.assertIn("data.csv (GL112)", text)
+ self.assertNotIn("protocol.docx (GL110)", text) # unique names do not
+ self.assertIn("freezer_manual.pdf (455 B)", format_tree(rs, "inventory/IC200"))
+ always = format_tree(rs, "gallery", max_depth=1, show_ids="always", sizes=False)
+ self.assertIn("Images (GF10)/", always)
+ top = format_tree(rs, "", max_depth=1)
+ self.assertIn("gallery/", top)
+ self.assertIn("inventory/", top)
+
+ # ------------------------------------------------------------ rspace:// URLs
+
+ @property
+ def host(self):
+ return self.url[len("http://"):]
+
+ def test_url_reads_key_from_environment_only(self):
+ url = f"rspace://{self.host}?scheme=http"
+ self.assertEqual({"server": self.url}, RSpaceFilesystem._get_kwargs_from_urls(url))
+ with patch.dict(os.environ, {"RSPACE_API_KEY": "k"}):
+ rs, path = fsspec.core.url_to_fs(url)
+ self.assertIsInstance(rs, RSpaceFilesystem)
+ self.assertEqual(MOUNTS, rs.ls(path, detail=False))
+ self.assertEqual(MOUNTS, rs.ls("", detail=False))
+ self.assertTrue(rs.read_only)
+ self.assertFalse(fsspec.core.url_to_fs(f"{url}&writable=1")[0].read_only)
+ self.assertEqual("id", fsspec.core.url_to_fs(f"{url}&path_style=id")[0].path_style)
+ self.assertEqual(("gallery",), fsspec.core.url_to_fs(f"{url}&mounts=gallery")[0].mount_names)
+ with fsspec.open(f"rspace://{self.host}/gallery/Documents/data.csv?scheme=http") as fh:
+ self.assertTrue(fh.read().startswith(b"well,od600"))
+ self.assertEqual(MOUNTS, fsspec.filesystem("rspace", server=self.url).ls("", detail=False))
+ with self.assertRaises(ValueError):
+ fsspec.core.url_to_fs(f"rspace://user:secret@{self.host}?scheme=http")
+ with patch.dict(os.environ, {}, clear=True):
+ os.environ.pop("RSPACE_API_KEY", None)
+ with self.assertRaises(ValueError):
+ fsspec.core.url_to_fs(url)
+ with self.assertRaises(ValueError):
+ RSpaceFilesystem(server=self.url)
+ # an explicit key never consults the environment
+ self.assertEqual(MOUNTS, RSpaceFilesystem(self.url, "k").ls("", detail=False))
+
+ def test_url_cannot_be_told_which_secret_to_send_or_to_use_http_remotely(self):
+ # a URL often comes from someone else; it must not pick the variable that
+ # leaves the machine, nor send the real key in clear text to a remote host
+ with patch.dict(os.environ, {"RSPACE_API_KEY": "k", "OTHER_SECRET": "s"}):
+ for bad in ("api_key_env=OTHER_SECRET", "api_key=s"):
+ with self.assertRaises(ValueError, msg=bad) as ctx:
+ fsspec.core.url_to_fs(f"rspace://{self.host}?scheme=http&{bad}")
+ self.assertIn("RSPACE_API_KEY", str(ctx.exception))
+ with self.assertRaises(ValueError) as ctx:
+ fsspec.core.url_to_fs("rspace://rspace.example.org?scheme=http")
+ self.assertIn("clear text", str(ctx.exception))
+ # loopback development servers keep working over http
+ self.assertEqual(MOUNTS, fsspec.core.url_to_fs(f"rspace://{self.host}?scheme=http")[0].ls("", detail=False))
+ kwargs = RSpaceFilesystem._get_kwargs_from_urls
+ self.assertEqual("http://[::1]:8080", kwargs("rspace://[::1]:8080?scheme=http")["server"])
+ self.assertEqual("http://localhost:8080", kwargs("rspace://localhost:8080?scheme=http")["server"])
+ self.assertEqual("https://my.host", kwargs("rspace://my.host")["server"])
+ self.assertEqual("https://my.host", kwargs("rspace://my.host/gallery/Images")["server"])
+ with self.assertRaises(ValueError):
+ kwargs("rspace://my.host:8080?scheme=http")
+ self.assertEqual({}, kwargs("file:///tmp/x")) # not ours, nothing to say
+
+ def test_url_reports_bad_parameters_as_value_errors(self):
+ with patch.dict(os.environ, {"RSPACE_API_KEY": "k"}):
+ for bad in (f"rspace://{self.host}?scheme=http&path_style=fancy",
+ f"rspace://{self.host}?scheme=ftp",
+ f"rspace://{self.host}?scheme=http&mounts=eln",
+ "rspace:///gallery"):
+ with self.assertRaises(ValueError, msg=bad):
+ fsspec.core.url_to_fs(bad)
+
+ def test_upath_lists_a_folder(self):
+ with patch.dict(os.environ, {"RSPACE_API_KEY": "k"}):
+ folder = UPath(f"rspace://{self.host}/gallery/Images?scheme=http")
+ self.assertIsInstance(folder.fs, RSpaceFilesystem)
+ self.assertEqual(self.url, folder.fs.server)
+ self.assertTrue(folder.is_dir())
+ self.assertEqual(["2026-09 run", "microscope.png", "gel.jpg"], [c.name for c in folder.iterdir()])
+ # The generic UPath keeps a query string inside the path and joins children after
+ # it, so child operations need the connection option passed as a keyword instead;
+ # a real https URL has no query and does not meet this.
+ folder = UPath(f"rspace://{self.host}/gallery/Images", server=self.url)
+ children = list(folder.iterdir())
+ self.assertEqual(["2026-09 run", "microscope.png", "gel.jpg"], [c.name for c in children])
+ self.assertTrue(children[0].is_dir())
+ self.assertTrue(children[1].is_file())
+ self.assertTrue(children[1].read_bytes().startswith(b"\x89PNG"))
+ self.assertEqual(1096, children[1].stat().st_size)
+ self.assertTrue((folder / "microscope.png").exists())
+ self.assertFalse((folder / "nope.png").exists())
+ self.assertEqual(["microscope.png"], [p.name for p in folder.glob("*.png")])
+ self.assertEqual(MOUNTS, [p.name for p in UPath(f"rspace://{self.host}/", server=self.url).iterdir()])
+
+
+class GalleryWriteRegressionTest(MockServerTestCase):
+ """Findings from the code review of the Gallery branch, each pinned against the mock
+ server so it cannot come back."""
+
+ def test_standard_writes_reach_rspace(self):
+ """pipe_file, put_file and open('wb') name the file to create, while upload_fileobj
+ historically named a container; all must work."""
+ fs_ = GalleryFilesystem(self.url, "k", writable=True, path_style="id")
+ fs_.pipe_file("GF12/notes.txt", b"hello")
+ with tempfile.TemporaryDirectory() as tmp:
+ local = os.path.join(tmp, "report.csv")
+ with open(local, "w") as handle:
+ handle.write("a,b\n")
+ fs_.put_file(local, "GF12/report.csv")
+ explicit = BytesIO(b"x")
+ explicit.name = "explicit.png"
+ fs_.upload_fileobj("GF12/explicit.png", explicit)
+ with fs_.open("GF12/written.txt", "wb") as handle:
+ handle.write(b"from a handle")
+ names = sorted(fs_.info(n)["rspace"]["name"] for n in fs_.ls("GF12", detail=False))
+ self.assertEqual(["explicit.png", "notes.txt", "report.csv", "written.txt"], names)
+ written = next(n for n in fs_.ls("GF12", detail=False) if fs_.info(n)["rspace"]["name"] == "notes.txt")
+ self.assertEqual(b"hello", fs_.cat_file(written))
+ self.assertEqual("from a handle",
+ fs_.read_text(next(n for n in fs_.ls("GF12", detail=False)
+ if fs_.info(n)["rspace"]["name"] == "written.txt")))
+
+ def test_closing_a_write_handle_twice_is_harmless(self):
+ fs_ = GalleryFilesystem(self.url, "k", writable=True, path_style="id")
+ handle = fs_.open("GF12/x.txt", "wb")
+ handle.write(b"bye")
+ self.assertEqual([], fs_.ls("GF12")) # nothing is sent until the handle closes
+ handle.close()
+ handle.close()
+ self.assertEqual(1, len(fs_.ls("GF12"))) # not uploaded twice
+ self.assertEqual(b"bye", fs_.cat_file(fs_.ls("GF12", detail=False)[0]))
+
+ def test_a_transaction_defers_the_upload_to_the_end_of_the_block(self):
+ fs_ = GalleryFilesystem(self.url, "k", writable=True, path_style="id")
+ with fs_.transaction:
+ with fs_.open("GF12/t.txt", "wb") as handle:
+ handle.write(b"later")
+ self.assertEqual([], fs_.ls("GF12"), "closed inside the transaction, not yet uploaded")
+ self.assertEqual(1, len(fs_.ls("GF12")))
+ self.assertEqual(b"later", fs_.cat_file(fs_.ls("GF12", detail=False)[0]))
+
+ def test_mkdir_refuses_a_duplicate_name(self):
+ fs_ = GalleryFilesystem(self.url, "k", writable=True, path_style="id")
+ fs_.mkdir("Dup")
+ with self.assertRaises(FileExistsError):
+ fs_.mkdir("Dup")
+ fs_.mkdir("Dup", exist_ok=True) # still fine
+ fs_.makedirs("Dup", exist_ok=True)
+ self.assertEqual(1, sum(1 for e in fs_.ls("") if e["rspace"]["name"] == "Dup"))
+
+ def test_scandir_validates_before_iteration(self):
+ fs_ = GalleryFilesystem(self.url, "k", path_style="id")
+ with self.assertRaises(NotADirectoryError):
+ fs_.scandir("GF11/GL110") # a file, refused at the call and not on first next()
+ rs = RSpaceFilesystem(self.url, "k")
+ with self.assertRaises(FileNotFoundError):
+ rs.scandir("gallery/nope")
+ with self.assertRaises(FileNotFoundError):
+ rs.scandir("nope")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/rspace_client/tests/fs_workspace_test.py b/rspace_client/tests/fs_workspace_test.py
new file mode 100644
index 0000000..f0ee17e
--- /dev/null
+++ b/rspace_client/tests/fs_workspace_test.py
@@ -0,0 +1,270 @@
+"""The ELN Workspace as folders, notebooks, documents and text fields holding files, and the
+signed-document lock, against the embedded mock server."""
+import unittest
+from io import BytesIO
+from itertools import islice
+from unittest.mock import patch
+
+from rspace_client.eln.eln import ELNClient
+from rspace_client.fs import GalleryFilesystem, ReadOnlyError, RSpaceFilesystem, WorkspaceFilesystem, format_tree
+from rspace_client.fs import workspace
+from rspace_client.fs.paths import last_segment
+from rspace_client.fs.workspace import strip_file_references
+from .mock_server_case import MockServerTestCase, names
+
+
+ATTACHMENT_HTML = (
+ 'fs test
\n\n![image i.png]()
\n '
+) # verbatim shape recorded from a real RSpace 2.27 server
+
+
+
+
+class StripFileReferencesTest(unittest.TestCase):
+ """Unlinking must remove the markup the server renders, not just the token."""
+
+ def test_removes_attachment_block(self):
+ out = strip_file_references(ATTACHMENT_HTML, 216)
+ self.assertNotIn("216", out)
+ self.assertIn("sourceId=227", out) # the image stays
+ self.assertIn("fs test
", out)
+
+ def test_removes_image_and_its_empty_paragraph(self):
+ out = strip_file_references(ATTACHMENT_HTML, 227)
+ self.assertNotIn("227", out)
+ self.assertNotIn("", out)
+ self.assertIn("attachOnText_216", out) # the csv block stays
+
+ def test_field_id_is_not_mistaken_for_a_file_id(self):
+ self.assertIsNone(strip_file_references(ATTACHMENT_HTML, 247)) # sourceParentId=247 is the field
+ self.assertIsNone(strip_file_references(ATTACHMENT_HTML, 21)) # prefix of 216, not a match
+ self.assertIsNone(strip_file_references(ATTACHMENT_HTML, 999))
+
+ def test_literal_token_is_removed_too(self):
+ self.assertEqual("x
", strip_file_references("x
", 5))
+
+
+class WorkspaceTest(MockServerTestCase):
+
+ def fs(self, **kw):
+ return WorkspaceFilesystem(self.url, "k", **kw)
+
+ def test_root_hides_gallery_and_templates(self):
+ fs = self.fs()
+ before = self.calls()
+ listed = fs.ls("", detail=False)
+ self.assertEqual(1, self.calls() - before)
+ self.assertEqual(["Shared", "Api Inbox", "Imports", "Project Alpha", "Project Beta"], listed)
+ with patch.object(workspace, "HIDDEN_ROOT_FOLDERS", ()):
+ unhidden = self.fs(page_size=50).ls("", detail=False)
+ self.assertEqual(["Templates"], [n for n in unhidden if n not in listed])
+ self.assertFalse(any("[GF" in n for n in unhidden)) # the Gallery root is always hidden here
+ shared = next(e for e in self.fs().scandir("") if last_segment(e["name"]) == "Shared")
+ self.assertTrue(shared["rspace"]["systemFolder"]) # flag comes with the tree listing
+
+ def test_folders_notebooks_documents_fields_files(self):
+ fs = self.fs()
+ self.assertEqual(["Lab notebook 2026", "Alpha protocol", "Signed protocol"],
+ names(fs, "Project Alpha"))
+ self.assertEqual(["2026-09-15 entry", "2026-09-16 entry"],
+ names(fs, "Project Alpha/Lab notebook 2026"))
+ before = self.calls()
+ fields = list(fs.scandir("Project Alpha [FL20]/Alpha protocol [SD42]"))
+ self.assertEqual(1, self.calls() - before)
+ self.assertEqual(["Objective", "Results"], [last_segment(f["name"]) for f in fields]) # text fields only
+ self.assertTrue(all(f["type"] == "directory" for f in fields))
+ doc = fs.info("Project Alpha/Alpha protocol")
+ self.assertEqual(5, doc["rspace"]["hiddenFields"]) # attachment, number, date, string, choice
+ files = fs.ls("FL20/SD42/Results", detail=True)
+ self.assertEqual(["data.csv", "plate1.png"], [last_segment(f["name"]) for f in files])
+ self.assertTrue(all(f["type"] == "file" for f in files))
+ self.assertEqual(27, files[0]["size"])
+ self.assertIsNotNone(files[0]["created"])
+ out = BytesIO()
+ fs.download_fileobj("FL20/SD42/FD421/data.csv", out)
+ self.assertTrue(out.getvalue().startswith(b"well,od600"))
+ self.assertEqual(out.getvalue(), fs.cat_file("FL20/SD42/FD421/data.csv"))
+ with fs.open("FL20/SD42/FD421/data.csv", "rb") as handle:
+ self.assertEqual(out.getvalue(), handle.read())
+ with self.assertRaises(FileNotFoundError):
+ fs.ls("FL20/SD42/FD423") # a NUMBER field is not browsable
+ with self.assertRaises(IsADirectoryError):
+ fs.cat_file("FL20/SD42/FD421") # a field is a folder, not a file
+
+ def test_attachment_fields_are_not_browsable(self):
+ """Text is the only ELN field type that holds files.
+
+ rspace-web links a file to a document through a ```` token in a text
+ field's HTML: every caller of ``Field.addMediaFileLink`` passes a text field. The
+ ``Attachment`` type still exists in ``FieldType`` and the forms API still accepts
+ it, but the form editor offers only Number, String, Text, Radio, Choice, Date and
+ Time (``RSFormController.FIELD_KEYS``), so an attachment field is legacy and always
+ empty. Showing it as a folder would promise something it cannot do, so it is hidden
+ and counted like any other field that cannot hold a file.
+ """
+ fs = self.fs()
+ self.assertNotIn("Raw data", names(fs, "FL20/SD42")) # FD422 is an ATTACHMENT field
+ self.assertEqual(5, fs.info("FL20/SD42")["rspace"]["hiddenFields"])
+ with self.assertRaises(FileNotFoundError):
+ fs.ls("FL20/SD42/FD422")
+
+ def test_upload_links_into_the_field_and_remove_unlinks(self):
+ fs = self.fs(writable=True, allow_delete=True)
+ field = "Project Alpha [FL20]/Lab notebook 2026 [NB30]/2026-09-16 entry [SD41]/Data [FD410]"
+ self.assertEqual([], fs.ls(field, detail=False))
+ payload = BytesIO(b"a,b\n1,2\n")
+ payload.name = "run.csv"
+ uploaded = fs.upload_fileobj(field + "/run.csv", payload)
+ self.assertEqual("run.csv", uploaded["name"])
+ self.assertTrue(str(uploaded["globalId"]).startswith("GL"))
+ self.assertEqual(["run.csv"], names(fs, field))
+ (linked,) = names(fs, field)
+ gid = next(iter(fs.scandir(field)))["rspace"]["globalId"]
+ self.assertEqual(uploaded["globalId"], gid)
+ content = ELNClient(self.url, "k").get_document(41)["fields"][0]["content"]
+ self.assertIn(f'attachOnText_{gid[2:]}"', content) # the mock renders the token like the server
+ self.assertNotIn("", "GF10", "SD42", "", "GL"):
+ with self.assertRaises(ValueError, msg=bad):
+ fs.link(field, bad)
+ self.assertEqual([], fs.ls(field, detail=False))
+ linked = fs.link(field, "102") # a bare number is fine, as is 'GL102'
+ self.assertEqual("GL102", linked["globalId"])
+ self.assertEqual(["GL102"], [e["rspace"]["globalId"] for e in fs.scandir(field)])
+ with self.assertRaises(NotImplementedError):
+ fs.link("Project Alpha/Alpha protocol", "GL102") # a document is not a link target
+
+ def test_link_and_remove_refuse_a_signed_document_too(self):
+ # the same lock check for every field write, not only upload
+ fs = self.fs(writable=True, allow_delete=True)
+ field = "Project Alpha/Signed protocol/Data"
+ with self.assertRaises(ReadOnlyError):
+ fs.link(field, "GL100")
+ (linked,) = fs.ls(field, detail=False)
+ with self.assertRaises(ReadOnlyError):
+ fs.rm_file(linked)
+ self.assertEqual([linked], fs.ls(field, detail=False))
+
+ def test_makedir_creates_folders_only(self):
+ fs = self.fs(writable=True, allow_delete=True)
+ self.assertIsNone(fs.mkdir("Project Alpha [FL20]/Analysis"))
+ self.assertIn("Analysis", names(fs, "FL20"))
+ fs.mkdir("TopLevel")
+ self.assertIn("TopLevel", names(fs, ""))
+ with self.assertRaises(NotImplementedError):
+ fs.mkdir("FL20/NB30/entry") # not inside a notebook
+ with self.assertRaises(NotImplementedError):
+ fs.mkdir("FL20/SD42/field") # not inside a document
+ with self.assertRaises(NotImplementedError):
+ fs.rmdir("FL20/NB30") # a notebook is not a folder to remove here
+ fs.rmdir("FL20/Analysis")
+ self.assertNotIn("Analysis", names(fs, "FL20"))
+
+ def test_listings_stream_without_exposing_pages(self):
+ fs = self.fs(page_size=3)
+ listed = fs.ls("", detail=False) # fetched 3 at a time, but no page-N folders
+ self.assertEqual(5, len(listed))
+ self.assertFalse(any(n.startswith("page-") for n in listed))
+ # the first visible record is on the first page: stopping there costs one request
+ before = self.calls()
+ first = list(islice(fs.scandir(""), 1))
+ self.assertEqual(["Shared"], [last_segment(e["name"]) for e in first])
+ self.assertEqual(1, self.calls() - before)
+ before = self.calls()
+ self.assertEqual(5, len(list(fs.scandir("")))) # 7 records in pages of 3
+ self.assertEqual(3, self.calls() - before)
+ dirs = [p for p, _, _ in self.fs().walk("", detail=True)]
+ self.assertEqual(len(dirs), len(set(dirs)))
+ self.assertIn("Project Alpha/Alpha protocol/Results", dirs)
+ text = format_tree(self.fs(), "Project Alpha [FL20]")
+ self.assertIn("Alpha protocol/", text)
+ self.assertIn("Results/", text)
+ self.assertIn("plate1.png", text)
+
+ def test_mounted_in_rspace_filesystem(self):
+ rs = RSpaceFilesystem(self.url, "k")
+ self.assertEqual(["gallery", "inventory", "workspace"], rs.ls("", detail=False))
+ self.assertIn("workspace/Project Alpha", rs.ls("workspace", detail=False))
+ self.assertIs(rs.workspace.eln_client, rs.gallery.eln_client)
+ self.assertIsInstance(rs.workspace, WorkspaceFilesystem)
+ out = BytesIO()
+ rs.download_fileobj("workspace/FL20/SD42/FD421/GL111", out)
+ self.assertEqual(27, len(out.getvalue()))
+ self.assertEqual(out.getvalue(), rs.cat_file("workspace/FL20/SD42/FD421/GL111"))
+ with self.assertRaises(ReadOnlyError):
+ rs.rmdir("workspace")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/rspace_client/tests/galaxy_plugin_contract_test.py b/rspace_client/tests/galaxy_plugin_contract_test.py
new file mode 100644
index 0000000..e258fa5
--- /dev/null
+++ b/rspace_client/tests/galaxy_plugin_contract_test.py
@@ -0,0 +1,136 @@
+"""
+The contract Galaxy's shipped RSpace file source relies on.
+
+Galaxy has shipped ``galaxy/files/sources/rspace.py`` since 25.1. It is built on
+``rspace_client.eln.fs.GalleryFilesystem``, and it does not merely call it: it subclasses
+it, monkeypatches ``ELNClient.upload_file`` with a function of its own, and hands that
+function a ``FakedNameIO`` wrapper that forwards ``read`` through ``__getattr__``. Anything this
+library does to those call shapes reaches Galaxy users as a broken upload button, so the
+shapes Galaxy depends on are pinned here.
+
+The classes below are copied from Galaxy's plugin rather than imported, so that the test
+runs without Galaxy installed and keeps working when Galaxy is not on the path. Keep them
+in step with `galaxy/files/sources/rspace.py` when that file changes.
+"""
+import os
+import unittest
+import warnings
+from io import BytesIO
+from types import MethodType
+
+from .mock_server_case import MockServerTestCase
+
+
+class FakedNameIO:
+ """Galaxy wraps the dataset it uploads so the name carries a real extension: its own
+ datasets are all called *.dat, which the Gallery would reject outside Miscellaneous."""
+
+ def __init__(self, handle, name=None):
+ self._handle = handle
+ self._name = name
+
+ @property
+ def name(self):
+ return self._name or self._handle.name
+
+ def __getattr__(self, attribute):
+ return getattr(self._handle, attribute)
+
+
+class GalleryFilesystemContractTest(MockServerTestCase):
+
+ def galaxy_style_filesystem(self):
+ """Build the filesystem the way Galaxy's plugin builds it, patches and all."""
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore") # the shim's DeprecationWarning is expected
+ from rspace_client.eln.fs import GalleryFilesystem
+
+ test = self
+
+ class Patched(GalleryFilesystem):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ original = self.eln_client.upload_file
+
+ # Galaxy replaces upload_file with exactly this signature: no **kwargs, and
+ # no 'filename'. A new keyword argument here is a TypeError in production.
+ def upload_file(self_, file, folder_id=None, caption=None):
+ self._upload_response = original(file, folder_id=folder_id, caption=caption)
+ test.patched_call = {"name": getattr(file, "name", None), "folder_id": folder_id}
+
+ self.eln_client.upload_file = MethodType(upload_file, self.eln_client)
+
+ def upload(self, path, file, chunk_size=None, **options):
+ super().upload(path, file, chunk_size, **options)
+ self.upload_global_id = self._upload_response["globalId"]
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ fs = Patched(self.url, "k")
+ inner = fs.upload
+
+ def upload(self_, path, file, chunk_size=None, **options):
+ # Galaxy passes the *containing folder* as the path and names the file through
+ # the wrapper, which is the convention the deprecated shim still honours.
+ inner(os.path.dirname(path), FakedNameIO(file, name=os.path.basename(path)), chunk_size, **options)
+
+ fs.upload = MethodType(upload, fs)
+ return fs
+
+ def test_galaxy_can_still_upload(self):
+ fs = self.galaxy_style_filesystem()
+ fs.upload("/GF11/report.csv", BytesIO(b"a,b\n1,2\n"))
+ self.assertTrue(fs.upload_global_id.startswith("GL"))
+ # the name reached the API through the file object, not through a new keyword
+ self.assertEqual({"name": "report.csv", "folder_id": "11"}, self.patched_call)
+ self.assertIn(fs.upload_global_id, fs.listdir("/GF11"))
+
+ def test_galaxy_reads_names_dates_and_sizes_out_of_the_info(self):
+ """Galaxy's _resource_info_to_dict takes the segment from 'basic', the display name
+ and an ISO created date from 'rspace', and requires an integer size."""
+ import datetime
+ fs = self.galaxy_style_filesystem()
+ rows = list(fs.filterdir("/GF11", namespaces=["details"]))
+ self.assertTrue(rows)
+ for info in rows:
+ self.assertEqual(info.name, info.get("basic", "name"))
+ self.assertTrue(info.get("rspace", "name"))
+ if not info.is_dir:
+ self.assertIsInstance(info.size, int)
+ datetime.datetime.fromisoformat(info.get("rspace", "created"))
+
+ def test_created_uses_an_offset_older_pythons_can_parse(self):
+ """fromisoformat rejects a trailing 'Z' before Python 3.11, and Galaxy runs on 3.10;
+ pinned here so the check also bites on interpreters that would accept 'Z'."""
+ fs = self.galaxy_style_filesystem()
+ files = [i for i in fs.filterdir("/GF11", namespaces=["details"]) if not i.is_dir]
+ self.assertTrue(files)
+ for info in files:
+ created = info.get("rspace", "created")
+ self.assertFalse(created.endswith("Z"), created)
+ self.assertTrue(created.endswith("+00:00"), created)
+
+ def test_the_shim_still_uses_bare_global_id_segments(self):
+ """Galaxy builds its URIs from the segment, so those must stay stable identifiers."""
+ fs = self.galaxy_style_filesystem()
+ self.assertTrue(all(n[:2].isalpha() and n[2:].isdigit() for n in fs.listdir("/")))
+
+ def test_the_shim_keeps_its_released_names(self):
+ """Names 2.7.x exported from the shim; the code now lives in rspace_client.fs.gallery."""
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ from rspace_client.eln import fs as shim
+ for name in ("GalleryFilesystem", "GalleryInfo", "GallerySectionMismatch", "Placement",
+ "MISCELLANEOUS_SECTION", "ON_MISMATCH_RAISE", "ON_MISMATCH_REROUTE",
+ "classify_media_section", "is_folder"):
+ self.assertTrue(hasattr(shim, name), name)
+ self.assertEqual("Miscellaneous", shim.classify_media_section("data.xyz"))
+
+ def test_the_shim_warns_on_construction(self):
+ from rspace_client.eln.fs import GalleryFilesystem
+ with self.assertWarns(DeprecationWarning):
+ GalleryFilesystem(self.url, "k")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/rspace_client/tests/invapi_test.py b/rspace_client/tests/invapi_test.py
index 98364cb..6277051 100644
--- a/rspace_client/tests/invapi_test.py
+++ b/rspace_client/tests/invapi_test.py
@@ -1138,21 +1138,7 @@ def test_get_datacite_settings(self):
self.assertIn("serverUrl", result)
self.assertIn("repositoryPrefix", result)
- def _require_link_field_support(self):
- """
- Skips the calling test unless the target server is known to support
- Inventory Link fields (RSpace server PR #803 / RSDEV-1131). Released
- servers predate the feature, so the tests are opt-in via the
- RSPACE_SUPPORTS_LINK_FIELDS environment variable.
- """
- if not os.getenv("RSPACE_SUPPORTS_LINK_FIELDS"):
- pytest.skip(
- "Skipping Link-field test: set RSPACE_SUPPORTS_LINK_FIELDS=1 "
- "to run against a server that supports Inventory Link fields"
- )
-
def test_add_link_extra_field(self):
- self._require_link_field_support()
target = self.invapi.create_sample(base.random_string())
source = self.invapi.create_sample(base.random_string())
link_ef = inv.ExtraField.link(
@@ -1168,7 +1154,6 @@ def test_add_link_extra_field(self):
)
def test_create_sample_with_link_extra_field(self):
- self._require_link_field_support()
target = self.invapi.create_sample(base.random_string())
link_ef = inv.ExtraField.link(
"cites", inv.RelationType.CITES, target["globalId"]
@@ -1182,7 +1167,6 @@ def test_create_sample_with_link_extra_field(self):
self.assertEqual(1, len(link_fields))
def test_get_referencing_items(self):
- self._require_link_field_support()
target = self.invapi.create_sample(base.random_string())
source = self.invapi.create_sample(base.random_string())
link_ef = inv.ExtraField.link(
@@ -1193,13 +1177,11 @@ def test_get_referencing_items(self):
self.assertIsInstance(referencing, dict)
def test_get_link_target_summary(self):
- self._require_link_field_support()
target = self.invapi.create_sample(base.random_string())
summary = self.invapi.get_link_target_summary(target["globalId"])
self.assertEqual(target["globalId"], summary["globalId"])
def test_create_sample_template_with_link_field(self):
- self._require_link_field_support()
template_post = (
template_builder.TemplateBuilder(base.random_string(), "ml")
.link(
diff --git a/rspace_client/tests/mock_rspace/fixtures.py b/rspace_client/tests/mock_rspace/fixtures.py
index 208df60..4dc69c8 100644
--- a/rspace_client/tests/mock_rspace/fixtures.py
+++ b/rspace_client/tests/mock_rspace/fixtures.py
@@ -152,16 +152,7 @@ def _folder(self, fid: int, name: str, parent: Optional[int], prefix: str = "FL"
def _gfile(self, gid: int, name: str, parent: int, t: int = 0, text: Optional[str] = None,
caption: str = "") -> dict:
- blob = fake_blob(name, text)
- g = {
- "id": gid, "globalId": f"GL{gid}", "name": name, "caption": caption,
- "contentType": guess_type(name), "created": iso(t), "size": len(blob),
- "version": 1, "parentFolderId": parent,
- }
- self.gallery_files[gid] = g
- self.blobs[g["globalId"]] = blob
- self.children.setdefault(parent, []).append(g["globalId"])
- return g
+ return self.add_gallery_file(name, parent, fake_blob(name, text), None, caption, gid=gid, t=t)
def _doc(self, did: int, name: str, parent: int, form_id: int, fields: List[dict],
t: int = 0, signed: bool = False) -> dict:
@@ -248,24 +239,7 @@ def _instrument(self, iid: int, name: str, parent: Optional[str] = None, t: int
return ins
def _ifile(self, fid: int, name: str, parent_gid: str, t: int = 0, text: Optional[str] = None) -> dict:
- blob = fake_blob(name, text)
- a = {
- "id": fid, "globalId": f"IF{fid}", "name": name, "parentGlobalId": parent_gid,
- "mediaFileGlobalId": None, "type": "GENERAL", "contentMimeType": guess_type(name),
- "extension": ext_of(name), "size": len(blob), "created": iso(t),
- "createdBy": OWNER["username"], "deleted": False,
- }
- self.inv_files[fid] = a
- self.blobs[a["globalId"]] = blob
- parent = self.record_by_global_id(parent_gid)
- if parent_gid.startswith("SF"): # a field holds one file; a new one replaces it
- previous = parent.get("attachment_id")
- if previous is not None:
- self.inv_files[previous]["deleted"] = True
- parent["attachment_id"] = fid
- else:
- parent["attachment_ids"].append(fid)
- return a
+ return self.add_inv_file(parent_gid, name, fake_blob(name, text), None, fid=fid, t=t)
def _build(self) -> None:
# ---- ELN tree
@@ -408,14 +382,15 @@ def remove_folder(self, fid: int) -> None:
del self.children[fid]
def add_gallery_file(self, name: str, parent: Optional[int], content: bytes,
- content_type: Optional[str], caption: str = "") -> dict:
+ content_type: Optional[str], caption: str = "", gid: Optional[int] = None,
+ t: int = 200) -> dict:
parent = parent if parent is not None else 14 # "Api Imports"
if not self.is_gallery_folder(parent) or parent == self.gallery_root_id():
raise ValueError(f"folder {parent} is not a Gallery folder that can hold files")
- gid = self.new_id()
+ gid = self.new_id() if gid is None else gid
g = {
"id": gid, "globalId": f"GL{gid}", "name": name, "caption": caption,
- "contentType": content_type or guess_type(name), "created": iso(200), "size": len(content),
+ "contentType": content_type or guess_type(name), "created": iso(t), "size": len(content),
"version": 1, "parentFolderId": parent,
}
self.gallery_files[gid] = g
@@ -462,15 +437,16 @@ def link_gallery_file(self, parent_gid: str, media_gid: str) -> dict:
linked["mediaFileGlobalId"] = media_gid
return linked
- def add_inv_file(self, parent_gid: str, name: str, content: bytes, content_type: Optional[str]) -> dict:
+ def add_inv_file(self, parent_gid: str, name: str, content: bytes, content_type: Optional[str],
+ fid: Optional[int] = None, t: int = 202) -> dict:
if parent_gid[:2] not in ("IC", "SA", "SS", "IT", "IN", "SF"):
raise ValueError(f"{parent_gid} cannot hold attachments")
parent = self.record_by_global_id(parent_gid)
- fid = self.new_id()
+ fid = self.new_id() if fid is None else fid
a = {
"id": fid, "globalId": f"IF{fid}", "name": name, "parentGlobalId": parent_gid,
"mediaFileGlobalId": None, "type": "GENERAL", "contentMimeType": content_type or guess_type(name),
- "extension": ext_of(name), "size": len(content), "created": iso(202),
+ "extension": ext_of(name), "size": len(content), "created": iso(t),
"createdBy": OWNER["username"], "deleted": False,
}
self.inv_files[fid] = a
diff --git a/rspace_client/tests/mock_server_case.py b/rspace_client/tests/mock_server_case.py
index 69e133f..ced0c39 100644
--- a/rspace_client/tests/mock_server_case.py
+++ b/rspace_client/tests/mock_server_case.py
@@ -1,11 +1,11 @@
"""
Shared fixture for the offline filesystem tests.
-The embedded mock RSpace (``rspace_client/tests/mock_rspace``) is started once per
-session by ``conftest.py`` and installed here; every ``MockServerTestCase`` subclass
-talks to that one server and begins each test from the stock fixtures
-(``server.reset()``). Under plain ``python -m unittest`` no conftest runs, so the
-first class to need a server starts one lazily and keeps it for the process.
+The embedded mock RSpace (``rspace_client/tests/mock_rspace``) is started once per process,
+by the first test class that needs it, and kept until exit; every ``MockServerTestCase``
+begins each test from the stock fixtures (``server.reset()``). One server rather than one
+per class, because the suite used to spend most of its wall time in ``serve_forever``
+shutdowns.
"""
import atexit
import unittest
@@ -13,34 +13,29 @@
import requests
+from rspace_client.fs.paths import last_segment
from rspace_client.tests.mock_rspace.server import MockRSpaceServer, run_in_thread
-_shared: Tuple[Optional[MockRSpaceServer], Optional[str]] = (None, None)
-_owned: Tuple[Optional[MockRSpaceServer], Optional[str]] = (None, None)
+_server: Optional[Tuple[MockRSpaceServer, str]] = None
-def install_shared_server(server: Optional[MockRSpaceServer], url: Optional[str]) -> None:
- """Called by the pytest session fixture with the session's server, then with None at teardown."""
- global _shared
- _shared = (server, url)
+def shared_server() -> Tuple[MockRSpaceServer, str]:
+ global _server
+ if _server is None:
+ _server = run_in_thread()
+ atexit.register(lambda: (_server[0].shutdown(), _server[0].server_close()))
+ return _server
-def _stop_owned() -> None:
- server, _ = _owned
- if server is not None:
- server.shutdown()
- server.server_close()
+def names(fs, path):
+ """The path segments of a directory's children, in listing order (fsspec's ``name`` is
+ the full path)."""
+ return [last_segment(n) for n in fs.ls(path, detail=False)]
-def shared_server() -> Tuple[MockRSpaceServer, str]:
- """The session server if pytest installed one, else a process-wide fallback started on demand."""
- global _owned
- if _shared[0] is not None:
- return _shared # type: ignore[return-value]
- if _owned[0] is None:
- _owned = run_in_thread()
- atexit.register(_stop_owned)
- return _owned # type: ignore[return-value]
+def segments(entries):
+ """The last path segment of each entry (dict) or path (str)."""
+ return [last_segment(e["name"] if isinstance(e, dict) else e) for e in entries]
class MockServerTestCase(unittest.TestCase):
diff --git a/tools/galaxy/README.md b/tools/galaxy/README.md
new file mode 100644
index 0000000..6f7f02f
--- /dev/null
+++ b/tools/galaxy/README.md
@@ -0,0 +1,37 @@
+# Checking Galaxy's RSpace file source
+
+Galaxy ships an `rspace` file source (`galaxy/files/sources/rspace.py`, since Galaxy 25.1)
+built on this library's deprecated `rspace_client.eln.fs.GalleryFilesystem`, so changes here
+can break Galaxy. `check_plugin.py` loads that real plugin through Galaxy's real
+`ConfiguredFileSources`, without a Galaxy server:
+
+```bash
+python3 -m venv .galaxy-venv
+.galaxy-venv/bin/pip install galaxy-files -e .
+
+.galaxy-venv/bin/python tools/galaxy/check_plugin.py --mock / # offline
+.galaxy-venv/bin/python tools/galaxy/check_plugin.py / /GF8 # live RSpace via .env
+```
+
+Check that files have an integer `size` and a `ctime`, that `name` is the RSpace name while
+`uri` uses the global ID, and that `source.list(path, query=...)` finds files by name. The
+same dependencies are pinned offline by `rspace_client/tests/galaxy_plugin_contract_test.py`,
+along with the upload path through Galaxy's `FakedNameIO` wrapper.
+
+## The fsspec file source
+
+`rspace_fsspec_source.py` is the reference Galaxy file source for `rspace_client.fs`
+(`RSpaceFilesystem`, type `rspace_unified`), built on Galaxy's `FsspecFilesSource`. It is the
+file proposed for Galaxy's own `galaxy/files/sources/rspace.py` once Galaxy drops the
+PyFilesystem2 plugins (galaxyproject/galaxy#21832). Galaxy discovers file sources only inside
+its own package, so to try it copy the file there, for example into a Galaxy container:
+
+```bash
+docker cp tools/galaxy/rspace_fsspec_source.py galaxy:/galaxy/lib/galaxy/files/sources/rspace_unified.py
+```
+
+and add a `type: rspace_unified` entry to `file_sources_conf.yml` (the module docstring shows
+one). The container's `rspace-client` must contain `rspace_client.fs`. Galaxy 25.1 ships the
+fsspec base class already; the plugin's hooks accept both the 25.1 and the 26.x signatures.
+Verified 2026-10-06 against Galaxy 25.1 and RSpace 2.26: listing, search, pagination, import
+and export through the web API and the upload picker.
diff --git a/tools/galaxy/check_plugin.py b/tools/galaxy/check_plugin.py
new file mode 100644
index 0000000..219a8a5
--- /dev/null
+++ b/tools/galaxy/check_plugin.py
@@ -0,0 +1,92 @@
+"""
+Drive Galaxy's own RSpace file source against this client, without running Galaxy.
+
+Galaxy ships a ``rspace`` FilesSource plugin (galaxy/files/sources/rspace.py) built on
+``rspace_client.eln.fs.GalleryFilesystem``. This script loads that real plugin through
+Galaxy's real ``ConfiguredFileSources`` machinery and lists paths through it, so changes
+to this library can be checked against the actual consumer in seconds.
+
+Setup (once):
+
+ python3 -m venv .galaxy-venv
+ .galaxy-venv/bin/pip install galaxy-files -e .
+
+Run against the bundled mock RSpace (offline):
+
+ .galaxy-venv/bin/python tools/galaxy/check_plugin.py --mock
+
+Run against a real RSpace (RSPACE_URL / RSPACE_API_KEY, or .env):
+
+ .galaxy-venv/bin/python tools/galaxy/check_plugin.py / /GF8
+
+What to look for: entries carry the RSpace *name* while the URI uses the global ID, and
+files must have an integer ``size`` (Galaxy's RemoteFile model rejects null).
+"""
+import argparse
+import os
+import sys
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+if REPO_ROOT not in sys.path:
+ sys.path.insert(0, REPO_ROOT)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("paths", nargs="*", default=["/"], help="paths to list (default: /)")
+ ap.add_argument("--mock", action="store_true", help="start the bundled mock RSpace server and use it")
+ ap.add_argument("--url", default=os.getenv("RSPACE_URL"))
+ ap.add_argument("--api-key", default=os.getenv("RSPACE_API_KEY"))
+ ap.add_argument("--limit", type=int, default=20)
+ ap.add_argument("--recursive", action="store_true")
+ args = ap.parse_args()
+
+ try:
+ from galaxy.files import ConfiguredFileSources, ConfiguredFileSourcesConf
+ from galaxy.files.models import FileSourcePluginsConfig
+ except ImportError:
+ sys.exit("galaxy-files is not installed in this interpreter: pip install galaxy-files")
+
+ if args.mock:
+ from rspace_client.tests.mock_rspace.server import run_in_thread
+ _, args.url = run_in_thread()
+ args.api_key = "mock-key"
+ print(f"(embedded mock RSpace at {args.url})")
+ if not args.url or not args.api_key:
+ try:
+ from dotenv import load_dotenv
+ load_dotenv(os.path.join(REPO_ROOT, ".env"))
+ args.url = args.url or os.getenv("RSPACE_URL")
+ args.api_key = args.api_key or os.getenv("RSPACE_API_KEY")
+ except ImportError:
+ pass
+ if not args.url or not args.api_key:
+ sys.exit("set RSPACE_URL and RSPACE_API_KEY (or pass --mock)")
+
+ conf = [{"type": "rspace", "id": "rspace_check", "label": "RSpace",
+ "endpoint": args.url, "api_key": args.api_key, "writable": True}]
+ sources = ConfiguredFileSources(FileSourcePluginsConfig(), ConfiguredFileSourcesConf(conf_dict=conf))
+ source = sources.get_file_source_path("gxfiles://rspace_check").file_source
+
+ import rspace_client
+ print(f"plugin : {type(source).__name__} (writable={source.get_writable()})")
+ print(f"client : {os.path.dirname(rspace_client.__file__)}")
+
+ failures = 0
+ for path in args.paths:
+ try:
+ entries, count = source.list(path, recursive=args.recursive, limit=args.limit)
+ except Exception as exc: # a broken plugin contract shows up here
+ failures += 1
+ print(f"\n{path}\n ERROR {type(exc).__name__}: {str(exc)[:400]}")
+ continue
+ print(f"\n{path} ({count} entries)")
+ print(f" {'class':<10} {'name (what Galaxy shows)':<38} {'size':>9} uri")
+ for entry in entries:
+ d = entry.model_dump()
+ print(f" {d.get('class_', '?'):<10} {str(d.get('name')):<38} {str(d.get('size', '-')):>9} {d.get('uri')}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/galaxy/rspace_fsspec_source.py b/tools/galaxy/rspace_fsspec_source.py
new file mode 100644
index 0000000..f7ce5be
--- /dev/null
+++ b/tools/galaxy/rspace_fsspec_source.py
@@ -0,0 +1,98 @@
+"""
+Reference Galaxy file source for the unified RSpace filesystem, built on Galaxy's
+``FsspecFilesSource``. This is the file proposed for ``lib/galaxy/files/sources/rspace.py``
+in the Galaxy repository (galaxyproject/galaxy#21832 tracks the move from ``fs`` to fsspec);
+it is kept here so it can be tested against this client before that pull request exists.
+
+To try it in a Galaxy checkout or container, copy this file into ``galaxy/files/sources/``
+(Galaxy discovers plugins by walking that package and reading ``__all__``) and configure a
+source in ``file_sources_conf.yml``:
+
+ - type: rspace_unified
+ id: rspace
+ label: RSpace
+ doc: Gallery, Inventory and Workspace of your RSpace account
+ endpoint: https://my.rspace.host
+ api_key: ${user.preferences['rspace|api_key']}
+ writable: true
+ # mounts: gallery,inventory,workspace (default: all three)
+ # path_style: name (name | labelled | id)
+ # fetch_sizes: true (one extra request per Gallery file, Galaxy needs sizes)
+
+Galaxy 25.1 and 26.x differ in how they call the path hooks (with or without a ``config``
+argument) and ``_open_fs`` (with or without ``cache_options``); the signatures below accept
+both.
+"""
+from typing import Optional, Union
+
+from galaxy.files.models import FilesSourceRuntimeContext
+from galaxy.util.config_templates import TemplateExpansion
+
+from ._fsspec import (
+ CacheOptionsDictType,
+ FsspecBaseFileSourceConfiguration,
+ FsspecBaseFileSourceTemplateConfiguration,
+ FsspecFilesSource,
+)
+
+try:
+ from rspace_client.fs import RSpaceFilesystem
+except ImportError:
+ RSpaceFilesystem = None
+
+
+class RSpaceUnifiedFileSourceTemplateConfiguration(FsspecBaseFileSourceTemplateConfiguration):
+ endpoint: Union[str, TemplateExpansion]
+ api_key: Union[str, TemplateExpansion]
+ mounts: Optional[Union[str, TemplateExpansion]] = None
+ path_style: Optional[Union[str, TemplateExpansion]] = None
+ fetch_sizes: Union[bool, TemplateExpansion] = True
+
+
+class RSpaceUnifiedFileSourceConfiguration(FsspecBaseFileSourceConfiguration):
+ endpoint: str
+ api_key: str
+ mounts: Optional[str] = None
+ path_style: Optional[str] = None
+ fetch_sizes: bool = True
+
+
+class RSpaceUnifiedFilesSource(
+ FsspecFilesSource[RSpaceUnifiedFileSourceTemplateConfiguration, RSpaceUnifiedFileSourceConfiguration]
+):
+ plugin_type = "rspace_unified"
+ required_module = RSpaceFilesystem
+ required_package = "rspace-client"
+
+ template_config_class = RSpaceUnifiedFileSourceTemplateConfiguration
+ resolved_config_class = RSpaceUnifiedFileSourceConfiguration
+
+ def _open_fs(
+ self,
+ context: FilesSourceRuntimeContext[RSpaceUnifiedFileSourceConfiguration],
+ cache_options: Optional[CacheOptionsDictType] = None,
+ ):
+ if RSpaceFilesystem is None:
+ raise self.required_package_exception
+ config = context.config
+ options = dict(cache_options or {})
+ if config.mounts:
+ options["mounts"] = tuple(m.strip() for m in config.mounts.split(",") if m.strip())
+ if config.path_style:
+ options["path_style"] = config.path_style
+ return RSpaceFilesystem(
+ config.endpoint,
+ config.api_key,
+ writable=bool(getattr(config, "writable", False)),
+ fetch_sizes=config.fetch_sizes,
+ **options,
+ )
+
+ def _to_filesystem_path(self, path: str, config=None) -> str:
+ return "" if path in ("", "/") else path.lstrip("/")
+
+ def _adapt_entry_path(self, filesystem_path: str, config=None) -> str:
+ return "/" if not filesystem_path else "/" + filesystem_path.lstrip("/")
+
+
+__all__ = ("RSpaceUnifiedFilesSource",)