Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,51 @@ for globalId in content:
print(rspace_gallery_fs.getinfo(globalId).raw)
```

#### Uploading into Gallery folders and the media-type section rule

The RSpace Gallery is split into media-type sections (Images, Documents,
Chemistry, ...) and a file can only be placed in a folder whose section matches
the file's media type. Uploading, say, a PDF into a folder that lives in the
Images section is rejected by the server.

By default `upload` turns that rejection into a clear `GallerySectionMismatch`
that names the folder's section, rather than an opaque API error:

```python
from rspace_client.eln.fs import GallerySectionMismatch

try:
rspace_gallery_fs.upload("/GF123", file_obj) # GF123 is in the Images section
except GallerySectionMismatch as e:
print(e) # explains the section clash
print(e.folder_section) # e.g. "Images"
```

Alternatively, opt in to automatic rerouting. On a mismatch the file is placed
in the correct section's inbox instead of failing. Set the policy once when
constructing the filesystem (it then applies to every write, including generic
PyFilesystem operations), or override it per call:

```python
# filesystem-wide default
rspace_gallery_fs = fs.GalleryFilesystem(url, api_key, on_mismatch="reroute")

# or per upload
placement = rspace_gallery_fs.upload("/GF123", file_obj, on_mismatch="reroute")
```

`upload` returns a `Placement` telling you where the file actually landed:

```python
placement.rerouted # True if it did not go in the requested folder
placement.section # e.g. "Documents"
placement.path # e.g. "Gallery/Documents/Api Inbox"
placement.file_global_id # e.g. "GL999"
```

Note that rerouting places the file in the section's inbox, not a subfolder
matching the one you requested.

To access Inventory attachments construct a `InventoryAttachmentFilesystem` object.
```python
from rspace_client.inv import fs
Expand Down
239 changes: 236 additions & 3 deletions rspace_client/eln/fs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import logging
from dataclasses import dataclass
from fs.base import FS
from rspace_client.eln import eln
from rspace_client.client_base import ClientBase
from typing import Optional, List, Text, BinaryIO, Mapping, Any
from fs.info import Info
from fs.permissions import Permissions
Expand All @@ -9,6 +12,95 @@
from io import BytesIO
from ..fs_utils import path_to_id

logger = logging.getLogger(__name__)

# Accepted values for the GalleryFilesystem ``on_mismatch`` policy.
ON_MISMATCH_RAISE = "raise"
ON_MISMATCH_REROUTE = "reroute"
_ON_MISMATCH_VALUES = (ON_MISMATCH_RAISE, ON_MISMATCH_REROUTE)

# Best-effort mapping of file extension to the RSpace Gallery section that
# RSpace would classify it into. This mirrors the server's own classification
# but is NOT authoritative: it is used only to phrase error messages, never to
# decide whether an upload is allowed. Anything not listed falls through to the
# "Documents" catch-all, matching the server default.
_SECTION_BY_EXTENSION = {
# Images
"png": "Images", "jpg": "Images", "jpeg": "Images", "gif": "Images",
"tif": "Images", "tiff": "Images", "bmp": "Images", "svg": "Images",
"webp": "Images", "heic": "Images",
# Audios
"mp3": "Audios", "wav": "Audios", "flac": "Audios", "ogg": "Audios",
"m4a": "Audios", "aac": "Audios", "wma": "Audios",
# Videos
"mp4": "Videos", "mov": "Videos", "avi": "Videos", "mkv": "Videos",
"wmv": "Videos", "webm": "Videos", "m4v": "Videos", "flv": "Videos",
# Chemistry
"mol": "Chemistry", "mol2": "Chemistry", "rxn": "Chemistry",
"cdx": "Chemistry", "cdxml": "Chemistry", "smi": "Chemistry",
"sdf": "Chemistry", "cml": "Chemistry",
}


def classify_media_section(filename: Optional[Text]) -> Optional[Text]:
"""
Best-effort guess of the Gallery section for a filename, mirroring RSpace's
server-side classification. Returns a section name (e.g. "Images"), or
"Documents" as the catch-all when the extension is unrecognised, or None
when no filename/extension is available to guess from.

The server remains the authority on placement; this is only used to make
error messages and log lines more helpful.
"""
if not filename or "." not in filename:
return None
ext = filename.rsplit(".", 1)[-1].lower()
return _SECTION_BY_EXTENSION.get(ext, "Documents")


def _filename_for(file: BinaryIO, options: Mapping[Text, Any]) -> Optional[Text]:
"""Best-effort filename for a file object: an explicit ``filename`` option
wins, otherwise the file object's own ``name`` if it is a string."""
name = options.get("filename")
if name:
return name
name = getattr(file, "name", None)
return name if isinstance(name, str) else None


class GallerySectionMismatch(ClientBase.ApiError):
"""
Raised when a file could not be uploaded into the requested Gallery folder
because that folder belongs to a media-type section that does not accept the
file. Carries the folder's section and, when it could be guessed, the file's
media type, so callers can react programmatically as well as read the message.
"""

def __init__(self, message, *, folder_section=None, folder_global_id=None,
file_media_type=None, response_status_code=None):
super().__init__(message, response_status_code=response_status_code)
self.folder_section = folder_section
self.folder_global_id = folder_global_id
self.file_media_type = file_media_type


@dataclass
class Placement:
"""Where an uploaded file actually ended up in the Gallery.

Returned by :meth:`GalleryFilesystem.upload`. When ``rerouted`` is True the
file did not land in the folder named by ``requested_path`` (its section did
not accept the file) and was placed in the correct section instead;
``path`` reports the human-readable location it ended up in.
"""
file_global_id: Optional[Text]
folder_global_id: Optional[Text]
section: Optional[Text]
path: Text
rerouted: bool
requested_path: Optional[Text] = None


def is_folder(path):
return path.split('/')[-1][:2] == "GF"

Expand All @@ -21,8 +113,24 @@ def __init__(self, obj, *args, **kwargs) -> None:

class GalleryFilesystem(FS):

def __init__(self, server: str, api_key: str) -> None:
def __init__(self, server: str, api_key: str, on_mismatch: str = ON_MISMATCH_RAISE) -> None:
"""
:param server: RSpace server URL
:param api_key: RSpace API key
:param on_mismatch: what to do when a file is uploaded to a folder whose
Gallery section does not accept it. ``"raise"`` (default) raises a
:class:`GallerySectionMismatch`; ``"reroute"`` instead places the
file in the correct section automatically and reports where it
landed. This is the filesystem-wide default and applies to every
write (including generic PyFilesystem operations); individual
:meth:`upload` calls may override it.
"""
super(GalleryFilesystem, self).__init__()
if on_mismatch not in _ON_MISMATCH_VALUES:
raise ValueError(
"on_mismatch must be one of {}".format(_ON_MISMATCH_VALUES)
)
self.on_mismatch = on_mismatch
self.eln_client = eln.ELNClient(server, api_key)
self.gallery_id = next(file['id'] for file in self.eln_client.list_folder_tree()['records'] if file['name'] == 'Gallery')

Expand Down Expand Up @@ -111,11 +219,136 @@ def download(self, path: Text, file: BinaryIO, chunk_size: Optional[int] = None,
else:
self.eln_client.download_file(path_to_id(path), file)

def upload(self, path: Text, file: BinaryIO, chunk_size: Optional[int] = None, **options: Any) -> None:
def _folder_section(self, folder_id: Text) -> Optional[Text]:
"""The Gallery section (mediaType) a folder belongs to, or None if it
cannot be determined. Best-effort: never raises, so it cannot mask the
real outcome of an upload."""
try:
return self.eln_client.get_folder(folder_id).get("mediaType")
except Exception:
return None

def _human_path(self, folder: Mapping[Text, Any]) -> Text:
"""Best-effort readable path for a folder, e.g. 'Gallery/Documents/Api
Inbox'. Uses the API's pathToRootFolder when present, otherwise falls
back to the section and folder name."""
trail = folder.get("pathToRootFolder")
if isinstance(trail, list) and trail:
names = [f.get("name") for f in trail if f.get("name")]
if names:
return "/".join(names)
parts = ["Gallery"]
if folder.get("mediaType"):
parts.append(folder["mediaType"])
if folder.get("name"):
parts.append(folder["name"])
return "/".join(parts)

def _placement(self, response: Any, requested_path: Optional[Text],
rerouted: bool) -> Placement:
"""Build a Placement from an upload response, resolving the parent
folder for section/path feedback where possible.

Tolerates a response that is not a dict (e.g. None): some callers wrap
or replace ``eln_client.upload_file`` and discard its return value, so
Placement construction must never crash a successful upload.
"""
if not isinstance(response, dict):
response = {}
parent_id = response.get("parentFolderId")
section = None
folder_global_id = None
path = "Gallery"
if parent_id is not None:
try:
folder = self.eln_client.get_folder(parent_id)
section = folder.get("mediaType")
folder_global_id = folder.get("globalId")
path = self._human_path(folder)
except Exception:
pass
return Placement(
file_global_id=response.get("globalId"),
folder_global_id=folder_global_id,
section=section,
path=path,
rerouted=rerouted,
requested_path=requested_path,
)

def upload(self, path: Text, file: BinaryIO, chunk_size: Optional[int] = None,
on_mismatch: Optional[str] = None, **options: Any) -> Placement:
"""
:param path: Global Id of a folder in the appropriate gallery section or
else if empty then the upload will be placed in the Api
Imports folder of the relevant gallery section
:param file: a binary file object to be uploaded
:param on_mismatch: optional override of the filesystem-wide policy for
this call ("raise" or "reroute"); defaults to the value
passed to the constructor.
:return: a :class:`Placement` describing where the file ended up.

The RSpace Gallery is split into media-type sections (Images, Documents,
Chemistry, ...) and a file may only be placed in a folder whose section
matches the file's media type. If ``path`` names a folder in the wrong
section the upload is rejected. Depending on the effective policy this
either raises a :class:`GallerySectionMismatch` naming the folder's
section (``"raise"``) or places the file in the correct section's inbox
and returns a Placement with ``rerouted=True`` (``"reroute"``).
"""
self.eln_client.upload_file(file, path_to_id(path) if path else None)
policy = on_mismatch if on_mismatch is not None else self.on_mismatch
if policy not in _ON_MISMATCH_VALUES:
raise ValueError(
"on_mismatch must be one of {}".format(_ON_MISMATCH_VALUES)
)
folder_id = path_to_id(path) if path else None
try:
response = self.eln_client.upload_file(file, folder_id)
return self._placement(response, requested_path=path or None, rerouted=False)
except ClientBase.ApiError as err:
if folder_id is None:
raise
section = self._folder_section(folder_id)
if section is None:
raise
filename = _filename_for(file, options)
guessed = classify_media_section(filename)

if policy == ON_MISMATCH_REROUTE:
try:
file.seek(0)
except (AttributeError, OSError, ValueError):
pass
response = self.eln_client.upload_file(file, None)
placement = self._placement(response, requested_path=path, rerouted=True)
logger.info(
"RSpace Gallery: %s could not go in %s (section '%s'); "
"placed in %s instead",
"'{}'".format(filename) if filename else "file",
path, section, placement.path,
)
return placement

named = "'{}'".format(filename) if filename else "the file"
message = (
"Could not upload {named} to Gallery folder {path}. That folder "
"is in the '{section}' section, which only accepts {section} "
"files".format(named=named, path=path, section=section)
)
if guessed and guessed != section:
message += ", but {named} looks like a '{guessed}' file".format(
named=named, guessed=guessed
)
message += (
". Upload it to a folder in the matching section, omit the "
"folder path to let RSpace place it in the correct section "
"automatically, or construct the filesystem with "
"on_mismatch='reroute'. Original API error: {err}".format(err=err)
)
raise GallerySectionMismatch(
message,
folder_section=section,
folder_global_id="GF" + str(folder_id),
file_media_type=guessed,
response_status_code=getattr(err, "response_status_code", None),
) from err
Loading
Loading