b448775596
Three codex comments on the guest link surface turned out to be one missing rule. The dispatcher already answered symlink, readlink and the unlink of a link; rename, a no-follow stat and the existence check were not there, so each one reached a backend that has never heard of the name. LINK_ENTRY_OPS plus one predicate says which verbs the node table answers, and ln, mv, git and FUSE read the rule instead of keeping their own copy of it. A rename of a link answered ENOENT and moved nothing, on pyodide, wasi, FUSE and os.rename. A no-follow stat answered ENOENT everywhere, so the wasi lstat rebuilt the row from the target string and reported epoch zero: a guest's own utime persisted and stayed invisible. FUSE was a third copy of that mistake, reporting the mount's construction time and the mounting user for every link. And symlink never looked at the name it was given, so ln -s over a live file exited 0 and left the bytes under a link nothing could read, and a guest could bury a mount root. Two more of the same family, found while probing rather than reported: a rename onto an existing link left the link shadowing the file that had just landed, and prepare_mv wrote the node table directly, which made a link rename the one write in the shell no admission policy could see. Both go through the door now, and ln -f is GNU's own algorithm (remove the destination, then link), so it replaces a regular file the way coreutils does. git checkout relied on symlink overwriting a link to retarget one, so restoreEntry removes the old name unconditionally rather than skipping that when the path is already linked.
203 lines
9.1 KiB
Plaintext
203 lines
9.1 KiB
Plaintext
---
|
|
title: Python filesystem
|
|
icon: file-binary
|
|
description: Use Python's open(), os.listdir, pathlib, PIL, pandas, directly against any Mirage mount.
|
|
---
|
|
|
|
Python code inside `ws.execute('python3 ...')` can `open()`, `os.listdir()`, `pathlib.Path()` etc. against any registered Mirage mount. Mirage mounts a filesystem of its own inside the interpreter, below the syscall boundary, so those calls reach Mirage's mount layer (RAM, S3, Linear, GDocs, Slack, anything you've registered).
|
|
|
|
```python
|
|
# inside python3
|
|
import json
|
|
team = json.load(open('/linear/teams/eng/team.json')) # reads through Linear's API
|
|
open('/ram/notes.txt', 'w').write('hello') # writes flush back to RAMResource
|
|
```
|
|
|
|
## What works
|
|
|
|
<Tabs>
|
|
<Tab title="Read a file">
|
|
```python
|
|
open('/s3/data.csv').read()
|
|
open('/linear/teams/eng/team.json').read()
|
|
open('/gdocs/owned/MyDoc.gdoc.json').read()
|
|
```
|
|
Every mounted prefix is collected before the run, so reads are sync and cost no round trip.
|
|
</Tab>
|
|
<Tab title="Write a file">
|
|
```python
|
|
open('/ram/out.txt', 'w').write('hello')
|
|
open('/s3/icon.png', 'wb').write(png_bytes)
|
|
```
|
|
`close()` records the write; when the script finishes, the bytes go back through the resource's `write` op, or its `append` op when the handle only extended the file.
|
|
</Tab>
|
|
<Tab title="List a directory">
|
|
```python
|
|
import os
|
|
for entry in os.listdir('/linear/teams/'):
|
|
print(entry)
|
|
|
|
# Synthesized paths work too, Gmail materializes date dirs on access:
|
|
for msg_dir in os.listdir('/gmail/INBOX/2026-05-03/'):
|
|
print(msg_dir)
|
|
```
|
|
A synthesized path is materialized when the prefix is collected, before the run.
|
|
</Tab>
|
|
<Tab title="pathlib & glob">
|
|
```python
|
|
from pathlib import Path
|
|
Path('/gdocs/owned/MyDoc.gdoc.json').read_text()
|
|
|
|
import glob
|
|
for f in glob.glob('/ram/*.txt'):
|
|
print(f)
|
|
```
|
|
</Tab>
|
|
<Tab title="Native libs">
|
|
```python
|
|
from PIL import Image
|
|
Image.new('RGB', (4, 4), color='red').save('/ram/icon.png')
|
|
|
|
import json
|
|
json.dump({'k': 'v'}, open('/ram/cfg.json', 'w'))
|
|
|
|
import numpy as np
|
|
np.save('/ram/arr.npy', np.arange(10))
|
|
```
|
|
Anything that goes through Python's `open()` works (PIL, numpy, pandas, json, pickle).
|
|
</Tab>
|
|
<Tab title="Symlinks & metadata">
|
|
```python
|
|
import os
|
|
|
|
os.symlink('report.json', '/ram/latest.json')
|
|
print(os.readlink('/ram/latest.json'), os.path.islink('/ram/latest.json'))
|
|
|
|
os.utime('/ram/report.json', (1767225600, 1767225600))
|
|
os.chmod('/ram/report.json', 0o600)
|
|
```
|
|
A link is namespace state, so no backend has to be able to store one:
|
|
the op reaches the name plane and a link lands on an S3 or Notion mount
|
|
the same way it lands on a RAM one. Links the shell made are collected
|
|
with the prefix, as links rather than as copies of their targets, so a
|
|
cyclic one is safe to walk past. A `chmod` or `utime` a backend cannot
|
|
keep is stored in the namespace overlay and reported back by `stat`, and
|
|
an `lstat` reads the link's own row rather than its target's, so a
|
|
`follow_symlinks=False` stamp is visible right after it is written.
|
|
`os.rename` moves a link by its own name instead of following it, and
|
|
`os.symlink` onto a name that is taken raises `FileExistsError`, both as
|
|
POSIX has them.
|
|
</Tab>
|
|
<Tab title="Cross-mount">
|
|
```python
|
|
# Read from one mount, write to another, same Python code:
|
|
import os, json
|
|
|
|
out = []
|
|
for t in os.listdir('/linear/teams/')[:5]:
|
|
team = json.load(open(f'/linear/teams/{t}/team.json'))
|
|
out.append({'name': team['name']})
|
|
|
|
json.dump(out, open('/ram/summary.json', 'w'), indent=2)
|
|
```
|
|
</Tab>
|
|
</Tabs>
|
|
|
|
## What doesn't work
|
|
|
|
| Case | Why | Workaround |
|
|
|---|---|---|
|
|
| **External edits show up mid-run**, someone else edits the GDoc while your script runs | The tree is collected before the run and served synchronously after that | Each run re-collects, so the next one sees the edit |
|
|
| **Huge mounts**, collecting a 100GB S3 bucket | Every byte is held in memory for the run | Mount a narrower prefix |
|
|
| **Concurrent writers**, two Python processes write the same path | Last `close()` wins, no conflict detection | Out of scope for v1 |
|
|
| **Browser-only resources from Node**, OPFS | OPFS isn't a thing in Node | Run in a browser-side Mirage |
|
|
|
|
## How it works (one paragraph)
|
|
|
|
Mirage registers its own Emscripten filesystem at each mount prefix, so every spelling of an operation (`open`, `os.open`, `pathlib`, a C extension's `fopen`, `shutil`'s fd-relative walk) arrives as the same callback. Emscripten's callbacks are synchronous, so nothing reaches the mount inline: before each run Mirage collects every mounted prefix into the filesystem's node table, and reads are served from there. Writes are recorded on a journal in the order the guest performed them, and replayed against the mounts after the script returns, where awaiting is free. A handle that only extended a file replays as an `append`, so an append loop ships its tail rather than the whole file each time. A failed replay lands on stderr with a nonzero exit, and stops the rest of the journal rather than applying entries whose prerequisite never landed.
|
|
|
|
```
|
|
Python open() / os.open() / C fopen()
|
|
│
|
|
▼
|
|
Mirage filesystem callbacks (sync)
|
|
│ │
|
|
reads writes
|
|
│ │
|
|
▼ ▼
|
|
node table mutation journal
|
|
(collected │
|
|
before the run) script ends
|
|
│
|
|
▼
|
|
replay against mounts
|
|
```
|
|
|
|
Nothing is patched inside the interpreter, and none of it needs JSPI.
|
|
|
|
## Quick start (TypeScript)
|
|
|
|
```ts
|
|
import { Workspace, RAMResource, S3Resource, MountMode } from '@struktoai/mirage-node'
|
|
|
|
const ws = new Workspace({}, { mode: MountMode.WRITE })
|
|
ws.addMount('/ram', new RAMResource(), MountMode.WRITE)
|
|
ws.addMount('/s3', new S3Resource({ bucket: 'my-bucket', region: 'us-east-1' }), MountMode.READ)
|
|
|
|
await ws.fs.writeFile('/ram/in.json', '{"hello":"world"}')
|
|
|
|
const r = await ws.execute(`python3 -c '
|
|
import json
|
|
print(json.load(open("/ram/in.json"))["hello"])
|
|
'`)
|
|
console.log(r.stdoutText) // "world"
|
|
```
|
|
|
|
See the full demo at [`examples/typescript/pyodide/vfs.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/pyodide/vfs.ts).
|
|
|
|
## Python packages (PIL, numpy, pandas, …)
|
|
|
|
Pyodide ships CPython, but third-party packages aren't loaded until you import them. Mirage scans the code you run for `import` statements and **auto-fetches matching packages on demand**, so `from PIL import Image`, `import numpy as np`, `import pandas as pd` all just work the first time. Subsequent calls hit Pyodide's package cache.
|
|
|
|
If you need to opt out (e.g., to keep workspace startup lean):
|
|
|
|
```ts
|
|
new Workspace({}, { python: { autoLoadFromImports: false } })
|
|
```
|
|
|
|
## Resource compatibility
|
|
|
|
| Resource | Status |
|
|
|---|---|
|
|
| RAM, OPFS (browser), Disk | ✅ |
|
|
| S3, R2, GCS, OCI, Supabase | ✅ (narrow the prefix to keep the working set small) |
|
|
| Linear, GDocs, GSheets, GSlides, GDrive | ✅ |
|
|
| GitHub, Redis | ✅ |
|
|
| Gmail (including synthesized date dirs) | ✅ (materialized when the prefix is collected) |
|
|
| Slack | ⚠️ preloads channel histories; large workspaces may be slow |
|
|
|
|
## Runtime requirements
|
|
|
|
None. Reads, mounts added between runs, and writes all work on any engine, with no V8 flag and no [JSPI](https://github.com/WebAssembly/js-promise-integration). That is the point of collecting the tree before the run and replaying writes after it: a filesystem callback never has to suspend, so nothing depends on stack switching, which no shipping Node enables by default and Safari does not implement.
|
|
|
|
To run the example:
|
|
|
|
```bash
|
|
node --import tsx/esm examples/typescript/pyodide/vfs.ts
|
|
```
|
|
|
|
## Errors you might see
|
|
|
|
| Symptom | What it means |
|
|
|---|---|
|
|
| `FileNotFoundError [Errno 44]` | The path was not in the tree collected before the run. A callback cannot go looking for more, so this is a genuine absence at collection time. |
|
|
| `python3: failed to <op> <path> on mount: ...` on stderr, exit 1 | The script finished but a recorded mutation could not be applied (auth, read-only mount, network). |
|
|
| `python3: skipped N later mutation(s) after that failure` | The journal is a sequence, not a set, so the replay stops at the first failure rather than applying entries whose prerequisite never landed. |
|
|
| `OSError [Errno 5]` on opening a file you can see | The mount listed the file but would not serve its bytes, so its real content is unknown and any handle is refused rather than guessing. |
|
|
| `console.warn: mirage preload: cannot read <path>` | One entry failed while the prefix was collected, others continued. The file is marked unreadable rather than left looking absent. |
|
|
|
|
## See also
|
|
|
|
- [`python`](/typescript/python): broader `python3` builtin behavior (env, argv, stdin, exit codes)
|
|
- [`examples/typescript/pyodide/vfs.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/pyodide/vfs.ts): runnable demo across RAM, S3, GDocs, Linear
|