You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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).
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).
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.
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(branchinvestigate/unified-pyfilesystem); review items R1 to R15 are referenced below by number.Plan source:
docs/unified-pyfilesystem-implementation-plan.mdImplementation plan: unified PyFilesystem across RSpace
Decisions this plan is built on
FSclasses (Gallery, Workspace, Inventory) composed withMountFSbehind oneRSpaceFilesystem(...)factory at/gallery,/workspace,/inventory, sharing one client set.writable=Trueenablesupload/makedir; destructive ops gated separately behindallow_delete=True(R14).removeof a file in a field unlinks the reference only, never deletes the underlying Gallery file (R5).makedirin Workspace: folders only.path_style="id") plus a labelled-tree helper; opt-inpath_style="labelled"forName [GID]segments (R9).RSPACE_API_KEY/keyring, never in the URL (R15).inv/fs.pyis added (fixes docs: broken import in PyFilesystem usage guide (rspace_client.inv.fs does not exist) #57).Target module layout
Path grammar (R2)
Each segment below a mount point is classified by position, not by blindly stripping a prefix.
/gallery/<GF|GL>...- every segment is a global ID; root resolves to the Gallery top folder./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[/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.pyexposes 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 raisesfs.errors.ResourceNotFound.Write-guard (base.py)
RSpaceFSBase.__init__(..., writable=False, allow_delete=False). Helpers:_require_writable()raisesfs.errors.ResourceReadOnly(path)unlesswritable;_require_delete()raises unlessallow_delete.upload/openbin(w)/makedircall_require_writable;remove/removedircall_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)
rspace_client/fs/withbase.pyandpaths.py.GalleryFilesystemtofs/gallery.pyas a thinRSpaceFSBasesubclass; make its Gallery-root lookup lazy (R4).InventoryAttachmentFilesystemtofs/inventory.pyasInventoryFilesystem, behaviour unchanged (hierarchy added in Phase 3).inv/fs.py(docs: broken import in PyFilesystem usage guide (rspace_client.inv.fs does not exist) #57).eln_fs_test.py/inv_attachment_fs_test.py; add shim-import tests asserting theDeprecationWarning.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).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.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.open_fs("rspace://host")works with the env var set.Phase 3 - Inventory full hierarchy and pagination
listdir('/inventory')returns the fixed sections plus the user's bench(es) viaget_workbenches().list_top_level_containers/list_samples/list_sample_templateswithPagination; exposepage-Npseudo-dirs fromtotalHits/page_size, withflatten_pages=Truefollowingnextlinks.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).flatten_pages; drillingIC -> SS -> IF.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.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.page-Nscheme.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.Phase 5 - Docs, examples, finalisation
usage-guide.md; fix the brokeninvimport example.examples/script showing mount, labelled tree, and an ELN document upload.CHANGELOG.md.Testing strategy
Follow the existing pattern:
unittest+requestsmocked viaunittest.mock.patch, reusingtests/data/*.jsonfixtures and adding ELN document and container-with-content fixtures. Add aMountFSrouting test and afs.walk-from-root smoke test (R8). No live-server calls in CI.Review-item coverage map
paths.py, Phase 1/tree.py,path_style)opener.py)Risks and rollback
include_content=Truewalk can be chatty;page_sizeand lazy per-directory fetching bound it, andflatten_pagesstays opt-in.walkmisbehaves at the root (R8), Phase 2 is the gate that catches it before Workspace lands.Still open (not blocking Phase 1)
eln.fs/inv.attachment_fsimports. Suggest keep through 2.x, remove in 3.0.utils.pycommand-line key exposure noted in the review is not part of this work.