Repository navigation
Unified PyFilesystem across RSpace (Gallery, ELN/Workspace, Inventory) #58
Description
Tracking issue for implementing a unified PyFilesystem across RSpace (Gallery, ELN/Workspace, Inventory).
Jira: RSDEV-564 - PyFilesystem for RSpace ELN
Design & review: docs/unified-pyfilesystem-investigation.md (branch investigate/unified-pyfilesystem); review items R1 to R15 are referenced below by number.
Plan source: docs/unified-pyfilesystem-implementation-plan.md
Implementation plan: unified PyFilesystem across RSpace
Decisions this plan is built on
- Architecture: three focused
FSclasses (Gallery, Workspace, Inventory) composed withMountFSbehind oneRSpaceFilesystem(...)factory at/gallery,/workspace,/inventory, sharing one client set. - Inventory depth: full hierarchy - containers list child containers/samples/subsamples and attachments, drillable to the leaf (R3).
- Write posture: read-only by default.
writable=Trueenablesupload/makedir; destructive ops gated separately behindallow_delete=True(R14). - Workspace document fields: modelled as subfolders, typed (R1).
removeof a file in a field unlinks the reference only, never deletes the underlying Gallery file (R5). makedirin Workspace: folders only.- Names: global-ID paths (default
path_style="id") plus a labelled-tree helper; opt-inpath_style="labelled"forName [GID]segments (R9). - Opener: host-only URL, key from
RSPACE_API_KEY/keyring, never in the URL (R15). - Back-compat: old import paths kept as deprecating shims; the missing
inv/fs.pyis added (fixes docs: broken import in PyFilesystem usage guide (rspace_client.inv.fs does not exist) #57).
Target module layout
rspace_client/fs/
__init__.py # public exports: RSpaceFilesystem, the three FS classes, print_tree
base.py # RSpaceFSBase(FS): shared openbin shim, Mode validation, write-guard, Info helper
paths.py # per-branch path grammar / parser (replaces bare path_to_id) (R2)
gallery.py # GalleryFilesystem (moved from eln/fs.py, lazy root)
inventory.py # InventoryFilesystem (full hierarchy + browsable root + pagination)
workspace.py # WorkspaceFilesystem (new, ELN)
rspace.py # RSpaceFilesystem(...) factory -> MountFS, shared clients, posture flags
tree.py # print_tree(...) labelled view (R9 option 1)
opener.py # PyFilesystem opener + fs.opener entry point (R15 host-only)
rspace_client/eln/fs.py # back-compat shim -> fs.gallery (DeprecationWarning)
rspace_client/inv/attachment_fs.py # back-compat shim -> fs.inventory (DeprecationWarning)
rspace_client/inv/fs.py # NEW shim so the documented import works (#57)
Path grammar (R2)
Each segment below a mount point is classified by position, not by blindly stripping a prefix.
- Gallery
/gallery/<GF|GL>...- every segment is a global ID; root resolves to the Gallery top folder. - Inventory
/inventory/<section-or-bench>[/page-N]/<record-gid>[/<record-gid>...]/<IF>- level 1 is a fixed section (Containers,Samples,Templates) or a bench (segment is the bench global ID, labelled with its name); optionalpage-Npseudo-dir under a large section; then record global IDs nesting perIC -> IC|SA|SS + IF,SA -> SS + IF,SS -> IF; leaf attachmentsIF. - Workspace
/workspace[/page-N]/<FL|NB|SD>.../<SD>/<fieldGID>/<GL>- root resolves to the Home tree (system folders filtered, R11);FL/NBare directories;SDis a directory of fields; a field global ID is a directory of its linked filesGL.
paths.py exposes a resolver returning a typed target (kind + identifiers) that each FS method switches on. Section/bench/page tokens are recognised explicitly; anything else must be a valid global ID or the resolver raises fs.errors.ResourceNotFound.
Write-guard (base.py)
RSpaceFSBase.__init__(..., writable=False, allow_delete=False). Helpers: _require_writable() raises fs.errors.ResourceReadOnly(path) unless writable; _require_delete() raises unless allow_delete. upload/openbin(w)/makedir call _require_writable; remove/removedir call _require_delete. The factory passes both flags to every sub-FS so the posture is uniform.
Phases
Each phase is a self-contained, shippable PR that keeps existing behaviour working.
Phase 1 - Foundation and refactor (no user-visible behaviour change)
- Create
rspace_client/fs/withbase.pyandpaths.py. - Move
GalleryFilesystemtofs/gallery.pyas a thinRSpaceFSBasesubclass; make its Gallery-root lookup lazy (R4). - Move
InventoryAttachmentFilesystemtofs/inventory.pyasInventoryFilesystem, behaviour unchanged (hierarchy added in Phase 3). - Add deprecating shims at the old paths plus new
inv/fs.py(docs: broken import in PyFilesystem usage guide (rspace_client.inv.fs does not exist) #57). - Port
eln_fs_test.py/inv_attachment_fs_test.py; add shim-import tests asserting theDeprecationWarning. - Acceptance: existing tests pass unchanged; old imports still work and warn;
inv/fs.pyimport from the docs now succeeds.
Phase 2 - Unified mount, posture, names, opener
rspace.py:RSpaceFilesystem(server, api_key, *, writable=False, allow_delete=False, path_style="id", page_size=10, flatten_pages=False)builds aMountFS, constructs oneELNClientand oneInventoryClient, mounts/galleryand/inventory(workspace in Phase 4).- Enforce read-only-by-default via the write-guard.
tree.py:print_tree(fs, path="/", style="labelled")readingrspace.name, appending the short global ID only on sibling-name collisions (R9).opener.py: opener parsingrspace://<host>, key fromRSPACE_API_KEY/keyring, rejecting akey@hostuserinfo component (R15); register thefs.openerentry point.- Tests:
listdir('/') == ['gallery','inventory'](then+ workspaceafter Phase 4); a read-onlyuploadraisesResourceReadOnly; a writable one succeeds; opener with a key-in-URL is rejected; labelled tree disambiguates a duplicate name. - Acceptance: a user can construct one FS and browse Gallery and Inventory;
open_fs("rspace://host")works with the env var set.
Phase 3 - Inventory full hierarchy and pagination
- Browsable root:
listdir('/inventory')returns the fixed sections plus the user's bench(es) viaget_workbenches(). - Section listings use
list_top_level_containers/list_samples/list_sample_templateswithPagination; exposepage-Npseudo-dirs fromtotalHits/page_size, withflatten_pages=Truefollowingnextlinks. - Record directories list child records and attachments, using
get_container_by_id(..., include_content=True)and the sample/subsample getters (R3). uploadto a record attaches (writable-gated);removeof anIFdeletes the attachment (delete-gated).- Tests: root sections + bench; a container listing mixing sub-records and attachments; page maths;
flatten_pages; drillingIC -> SS -> IF. - Acceptance: Inventory navigable from the mount root down to any attachment without knowing a global ID in advance.
Phase 4 - Workspace (ELN) filesystem
listdir('/workspace')= Home tree vialist_folder_tree(typesToInclude=[...]), system folders filtered (R11).FL/NBare directories;SDis a directory of fields.- Fields from
get_document(...). Requirement (R1): only file-bearing field types are exposed as browsable directories -TEXT(embeds<fileId>links) andATTACHMENT(holds a file). The other ten types (STRING,NUMBER,DATE,TIME,CHOICE,RADIO,REFERENCE,URI,IDENTIFIER,LINK) are omitted from the listing, so a user can only browse into fields that can actually contain files. Each exposed field is a directory keyed by itsglobalId, labelled withname, writable only when the FS is writable.getinfoon a document reports how many fields were hidden so the omission is not silent. listdir(field)= that field'sfiles(no HTML parsing).upload(field, file)(writable-gated):upload_fileto the Gallery, then append a<fileId=..>link to that specific field via a read-modify-write; treat a locked/signed-document API error asfs.errors.ResourceReadOnly(R6).download(field/<GL>, out): download that file.remove(field/<GL>)(delete-gated): unlink only - strip the<fileId=..>from the field content; do not delete the Gallery file (R5).makedirunder/workspace: folders only viacreate_folder(notebook=False); a notebook/document path raises the usual FS error.- Large folders/notebooks paginate with the same
page-Nscheme. - Tests: Home listing filters system folders; document lists only
TEXT/ATTACHMENTfields (others omitted, hidden count reported); upload appends a link to the right field; unlink-remove leaves the Gallery file; folder-onlymakedir; read-only default blocks writes. - Acceptance: a user can browse to a document, pick a field, and upload/download files; destructive and write ops obey the posture flags.
Phase 5 - Docs, examples, finalisation
- Rewrite the PyFilesystem section of
usage-guide.md; fix the brokeninvimport example. - Update the README feature table.
- Add an
examples/script showing mount, labelled tree, and an ELN document upload. - Record the deprecation-window decision in
CHANGELOG.md.
Testing strategy
Follow the existing pattern: unittest + requests mocked via unittest.mock.patch, reusing tests/data/*.json fixtures and adding ELN document and container-with-content fixtures. Add a MountFS routing test and a fs.walk-from-root smoke test (R8). No live-server calls in CI.
Review-item coverage map
| Item | Where handled |
|---|---|
| R1 typed fields | Phase 4 |
| R2 path grammar | paths.py, Phase 1 |
| R3 inventory hierarchy | Phase 3 |
| R4 lazy roots | Phase 1 (Gallery), Phase 3/4 (others) |
| R5 field remove = unlink | Phase 4 |
| R6 read-modify-write / locked docs | Phase 4 |
| R7 makedir at root | Phase 1 base + per-branch tests |
R8 walk from / |
Phase 2 tests |
| R9 names / labelled tree | Phase 2 (tree.py, path_style) |
| R10 same file, many paths | documented, Phase 5 |
| R11 Home overlaps Gallery / system folders | Phase 4 |
| R12 page-N is a window | Phase 3, documented |
| R13 only own Home/benches | documented Phase 5; "Shared" is future work |
| R14 write posture | base write-guard, Phase 2 |
| R15 opener host-only | Phase 2 (opener.py) |
Risks and rollback
- Each phase is additive and back-compatible; the shims mean nothing breaks if later phases slip. Phases 3 and 4 are the largest and can ship independently.
- The Inventory
include_content=Truewalk can be chatty;page_sizeand lazy per-directory fetching bound it, andflatten_pagesstays opt-in. - If MountFS routing or
walkmisbehaves at the root (R8), Phase 2 is the gate that catches it before Workspace lands.
Still open (not blocking Phase 1)
- Deprecation window for the old
eln.fs/inv.attachment_fsimports. Suggest keep through 2.x, remove in 3.0. - "Shared"/group content in Workspace and Inventory is out of scope here (R13).
utils.pycommand-line key exposure noted in the review is not part of this work.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIn Progress
Activity
tilorspace commentedon Oct 9, 2026
Update: the unified filesystem is now built on fsspec instead of PyFilesystem2
The plan above was implemented on PyFilesystem2 and reviewed as a stack of PRs. We've since changed the framework, and the PyFilesystem PRs #62, #63 and #64 are closed. Their branches are kept as a reference for the browsing model.
Why we switched
setuptools<82.fsto fsspec (Migrate file source plugins fromfstofsspecgalaxyproject/galaxy#21832), andrspace.pyis on that list. A new Galaxy file source built on PyFilesystem would have to be ported again right away.Where it stands
rspace_client.fsas an fsspec filesystem, stacked on Add an offline mock of the RSpace ELN and Inventory APIs for the test suite #61. Merge in order: Client layer: rebase advertised links, refuse redirects, add calls the filesystems need #60, Add an offline mock of the RSpace ELN and Inventory APIs for the test suite #61, Add rspace_client.fs: the Gallery, Inventory and ELN Workspace as one fsspec filesystem #68.What carried over from the plan: the three branches under one mount (
gallery/,inventory/,workspace/) sharing one set of clients, read-only by default with separatewritableandallow_deleteflags, Workspace documents as folders of fields, fieldremoveunlinking only, folder-onlymakedir, and an opener that takes only the host from the URL.What changed during the build (the reasons are in
docs/fsspec-filesystem-design.md):MountFS, soRSpaceFilesystemis a small dispatcher. Errors are builtinOSErrorsubclasses instead offs.errors. The opener is anfsspec.specsentry point, sofsspec.open("rspace://host/...")andUPathwork. The API key comes fromRSPACE_API_KEYonly; keyring support was dropped.path_style="name") instead of global IDs, because Galaxy shows the path segment to users. Duplicate names, and names that read like an address, get their global ID appended (data [GL112].csv)."labelled"and"id"are still available when a path has to survive a rename.page-Npseudo-folders are gone. Listings stream lazily, and Galaxy paginates on its own side.Backwards compatibility:
rspace_client.eln.fs.GalleryFilesystemandrspace_client.inv.attachment_fs.InventoryAttachmentFilesystemkeep their behaviour, emit aDeprecationWarning, and will be removed in 3.0 together withfs.galaxy_plugin_contract_test.pypins what Galaxy's shipped plugin relies on.Galaxy: while testing, we found that Galaxy 26.1 (which pins requests 2.34.2) can't export datasets to RSpace with any released rspace-client, because requests no longer accepts the wrapper object Galaxy uses for uploads. #68 fixes this on the client side. Exposing Inventory and the Workspace in Galaxy will need a PR to galaxyproject/galaxy once a release contains
rspace_client.fs. The proposed file source is intools/galaxy/rspace_fsspec_source.py.I'll update the title to "Unified fsspec filesystem across RSpace (Gallery, ELN/Workspace, Inventory)". Jira: RSDEV-1519.
Client layer: rebase advertised links, refuse redirects, add calls th…
Add an offline mock of the RSpace ELN and Inventory APIs for the test…