Skip to content

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

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions