Skip to content

Unified PyFilesystem across RSpace (Gallery, ELN/Workspace, Inventory) #58

Open

Description

@tilorspace

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 FS classes (Gallery, Workspace, Inventory) composed with MountFS behind one RSpaceFilesystem(...) 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=True enables upload/makedir; destructive ops gated separately behind allow_delete=True (R14).
  • Workspace document fields: modelled as subfolders, typed (R1). remove of a file in a field unlinks the reference only, never deletes the underlying Gallery file (R5).
  • makedir in Workspace: folders only.
  • Names: global-ID paths (default path_style="id") plus a labelled-tree helper; opt-in path_style="labelled" for Name [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.py is 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); optional page-N pseudo-dir under a large section; then record global IDs nesting per IC -> IC|SA|SS + IF, SA -> SS + IF, SS -> IF; leaf attachments IF.
  • Workspace /workspace[/page-N]/<FL|NB|SD>.../<SD>/<fieldGID>/<GL> - root resolves to the Home tree (system folders filtered, R11); FL/NB are directories; SD is a directory of fields; a field global ID is a directory of its linked files GL.

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/ with base.py and paths.py.
  • Move GalleryFilesystem to fs/gallery.py as a thin RSpaceFSBase subclass; make its Gallery-root lookup lazy (R4).
  • Move InventoryAttachmentFilesystem to fs/inventory.py as InventoryFilesystem, 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 the DeprecationWarning.
  • Acceptance: existing tests pass unchanged; old imports still work and warn; inv/fs.py import 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 a MountFS, constructs one ELNClient and one InventoryClient, mounts /gallery and /inventory (workspace in Phase 4).
  • Enforce read-only-by-default via the write-guard.
  • tree.py: print_tree(fs, path="/", style="labelled") reading rspace.name, appending the short global ID only on sibling-name collisions (R9).
  • opener.py: opener parsing rspace://<host>, key from RSPACE_API_KEY/keyring, rejecting a key@host userinfo component (R15); register the fs.opener entry point.
  • Tests: listdir('/') == ['gallery','inventory'] (then + workspace after Phase 4); a read-only upload raises ResourceReadOnly; 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) via get_workbenches().
  • Section listings use list_top_level_containers/list_samples/list_sample_templates with Pagination; expose page-N pseudo-dirs from totalHits/page_size, with flatten_pages=True following next links.
  • Record directories list child records and attachments, using get_container_by_id(..., include_content=True) and the sample/subsample getters (R3).
  • upload to a record attaches (writable-gated); remove of an IF deletes the attachment (delete-gated).
  • Tests: root sections + bench; a container listing mixing sub-records and attachments; page maths; flatten_pages; drilling IC -> 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 via list_folder_tree(typesToInclude=[...]), system folders filtered (R11). FL/NB are directories; SD is a directory of fields.
  • Fields from get_document(...). Requirement (R1): only file-bearing field types are exposed as browsable directories - TEXT (embeds <fileId> links) and ATTACHMENT (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 its globalId, labelled with name, writable only when the FS is writable. getinfo on a document reports how many fields were hidden so the omission is not silent.
  • listdir(field) = that field's files (no HTML parsing).
  • upload(field, file) (writable-gated): upload_file to the Gallery, then append a <fileId=..> link to that specific field via a read-modify-write; treat a locked/signed-document API error as fs.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).
  • makedir under /workspace: folders only via create_folder(notebook=False); a notebook/document path raises the usual FS error.
  • Large folders/notebooks paginate with the same page-N scheme.
  • Tests: Home listing filters system folders; document lists only TEXT/ATTACHMENT fields (others omitted, hidden count reported); upload appends a link to the right field; unlink-remove leaves the Gallery file; folder-only makedir; 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 broken inv import 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=True walk can be chatty; page_size and lazy per-directory fetching bound it, and flatten_pages stays opt-in.
  • If MountFS routing or walk misbehaves 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_fs imports. Suggest keep through 2.x, remove in 3.0.
  • "Shared"/group content in Workspace and Inventory is out of scope here (R13).
  • utils.py command-line key exposure noted in the review is not part of this work.

Activity

  1. moved this from Triage to Todo in 👷 RSpace developmenton Jul 27, 2026
  2. moved this from Todo to In Progress in 👷 RSpace developmenton Sep 21, 2026
  3. self-assigned this
    on Sep 21, 2026
  4. tilorspace commented on Oct 9, 2026

    @tilorspace
    ContributorAuthor

    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

    • PyFilesystem2 has had no release since May 2022. Its own tracker points users to fsspec, and it is the reason we pin setuptools<82.
    • Galaxy, the main consumer, is moving all its file sources from fs to fsspec (Migrate file source plugins from fs to fsspec galaxyproject/galaxy#21832), and rspace.py is on that list. A new Galaxy file source built on PyFilesystem would have to be ported again right away.

    Where it stands

    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 separate writable and allow_delete flags, Workspace documents as folders of fields, field remove unlinking only, folder-only makedir, 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):

    • Glue: fsspec has no MountFS, so RSpaceFilesystem is a small dispatcher. Errors are builtin OSError subclasses instead of fs.errors. The opener is an fsspec.specs entry point, so fsspec.open("rspace://host/...") and UPath work. The API key comes from RSPACE_API_KEY only; keyring support was dropped.
    • Paths: segments default to record names (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.
    • Pagination: the page-N pseudo-folders are gone. Listings stream lazily, and Galaxy paginates on its own side.
    • Fields: the Workspace shows only text fields. In practice no other field type holds files, including the legacy attachment type. Inventory attachment fields on samples, templates and instruments appear as folders holding at most one file.
    • Uploads: uploads into Inventory records and ELN fields go through the Gallery and are linked, which matches what the web UI does. Copying a Gallery-backed file links it instead of duplicating the bytes.

    Backwards compatibility: rspace_client.eln.fs.GalleryFilesystem and rspace_client.inv.attachment_fs.InventoryAttachmentFilesystem keep their behaviour, emit a DeprecationWarning, and will be removed in 3.0 together with fs. galaxy_plugin_contract_test.py pins 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 in tools/galaxy/rspace_fsspec_source.py.

    I'll update the title to "Unified fsspec filesystem across RSpace (Gallery, ELN/Workspace, Inventory)". Jira: RSDEV-1519.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions