Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Grep all import patterns for backends across codebase

Create subdirectories and move files with git mv

Creating subdirectories and moving files

Write sub-package __init__.py files

Update all imports across the codebase

Update top-level backends/__init__.py

Verify no stale imports remain

Run pre-commit and tests

Create filetype.py with extensible EXTENSION_MAP and guess_type()

Update all _guess_type imports to use filetype module

Remove _guess_type from memory.py and local/__init__.py

Rename backends/ to backend/ everywhere

Mass replace mirage.backends → mirage.backend in all files

Run tests


Write

EXTENSION_MAP: dict[str, str] = {
    "json": "json",
    "jsonl": "json",
    "csv": "csv",
    "tsv": "csv",
    "txt": "text",
    "md": "text",
    "py": "text",
    "js": "text",
    "ts": "text",
    "yaml": "text",
    "yml": "text",
    "toml": "text",
}

DEFAULT_TYPE = "binary"

def guess_type(path: str) -> str:
    """Return the file type string for *path* based on its extension.

    Args:
        path (str): file path or name.

    Returns:
        str: matched type from EXTENSION_MAP, or DEFAULT_TYPE.
    """
    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
    return EXTENSION_MAP.get(ext, DEFAULT_TYPE)


File created successfully at: /Users/agent/strukto/mirage/mirage/backends/filetype.py

EXTENSION_MAP: dict[str, str] = {
    "json": "json",
    "jsonl": "json",
    "csv": "csv",
    "tsv": "csv",
    "txt": "text",
    "md": "text",
    "py": "text",
    "js": "text",
    "ts": "text",
    "yaml": "text",
    "yml": "text",
    "toml": "text",
}

DEFAULT_TYPE = "binary"

def guess_type(path: str) -> str:
    """Return the file type string for *path* based on its extension.

    Args:
        path (str): file path or name.

    Returns:
        str: matched type from EXTENSION_MAP, or DEFAULT_TYPE.
    """
    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
    return EXTENSION_MAP.get(ext, DEFAULT_TYPE)


TodoWrite

Create filetype.py with extensible EXTENSION_MAP and guess_type()

Update all _guess_type imports and remove from memory.py

Rename backends/ to backend/ everywhere

Mass replace mirage.backends → mirage.backend in all files

Run tests


Rename backends/ to backend/ everywhere

Run tests

Update all _guess_type imports and remove from memory.py

Rename backends/ to backend/ everywhere

Run tests


Now update all files that use `_guess_type`. The files are:
- `mirage/backends/local/memory.py` (definition + usage)
- `mirage/backends/local/local.py`
- `mirage/backends/object_storage/s3.py`
- `mirage/backends/network/http.py`
- `mirage/workspace/__init__.py`
- `mirage/backends/local/__init__.py` (re-export)


     1→import hashlib
     2→from datetime import datetime, timezone
     3→
     4→from mirage.backends.base import BaseBackend
     5→from mirage.types import FileStat, FileType
     6→
     7→
     8→class MemoryBackend(BaseBackend):
     9→    """
    10→    In-memory backend backed by a plain dict. No external dependencies.
    11→    Useful for tests, ephemeral scratch space, and as a cache target.
    12→
    13→    Internal layout:
    14→        _files: dict[str, bytes]  — path → content
    15→        _dirs:  set[str]          — explicit directory paths
    16→    """
    17→
    18→    def __init__(self) -> None:
    19→        self._files: dict[str, bytes] = {}
    20→        self._dirs: set[str] = {"/"}
    21→        self._modified: dict[str, str] = {}  # path → ISO timestamp
    22→
    23→    # ── helpers ─────────────────────────────────────────────────────────────
    24→
    25→    def _norm(self, path: str) -> str:
    26→        """Normalise to absolute path without trailing slash."""
    27→        p = "/" + path.strip("/")
    28→        return p
    29→
    30→    def _now(self) -> str:
    31→        return datetime.now(timezone.utc).isoformat()
    32→
    33→    def _parent(self, path: str) -> str:
    34→        parts = path.rsplit("/", 1)
    35→        return parts[0] or "/"
    36→
    37→    # ── ops ─────────────────────────────────────────────────────────────────
    38→
    39→    def read(self, path: str, offset: int, size: int) -> bytes:
    40→        p = self._norm(path)
    41→        if p not in self._files:
    42→            raise FileNotFoundError(p)
    43→        return self._files[p][offset:offset + size]
    44→
    45→    def readdir(self,
    46→                path: str,
    47→                cursor: str | None = None) -> tuple[list[str], str | None]:
    48→        p = self._norm(path)
    49→        if p not in self._dirs:
    50→            raise FileNotFoundError(p)
    51→        prefix = p.rstrip("/") + "/"
    52→        # direct children only
    53→        seen: set[str] = set()
    54→        for key in list(self._files) + list(self._dirs):
    55→            if key == p:
    56→                continue
    57→            if key.startswith(prefix):
    58→                remainder = key[len(prefix):]
    59→                child = remainder.split("/")[0]
    60→                if child:
    61→                    seen.add(prefix + child)
    62→        names = sorted(seen)
    63→        # simple offset-based pagination via cursor
    64→        start = int(cursor) if cursor else 0
    65→        page = names[start:]
    66→        return page, None  # no pagination needed for in-memory
    67→
    68→    def stat(self, path: str) -> FileStat:
    69→        p = self._norm(path)
    70→        if p in self._dirs:
    71→            return FileStat(name=p.rsplit("/", 1)[-1] or "/",
    72→                            size=None,
    73→                            modified=self._modified.get(p),
    74→                            type=FileType.DIRECTORY)
    75→        if p in self._files:
    76→            data = self._files[p]
    77→            return FileStat(
    78→                name=p.rsplit("/", 1)[-1],
    79→                size=len(data),
    80→                modified=self._modified.get(p),
    81→                type=_guess_type(p),
    82→            )
    83→        raise FileNotFoundError(p)
    84→
    85→    def open(self, path: str, flags: int) -> int:
    86→        raise NotImplementedError("open/release are FUSE-only")
    87→
    88→    def release(self, fh: int) -> None:
    89→        raise NotImplementedError("open/release are FUSE-only")
    90→
    91→    def write(self, path: str, offset: int, data: bytes) -> int:
    92→        p = self._norm(path)
    93→        if p not in self._files:
    94→            raise FileNotFoundError(p)
    95→        existing = self._files[p]
    96→        # pad if offset beyond current size
    97→        if offset > len(existing):
    98→            existing = existing + b"\x00" * (offset - len(existing))
    99→        self._files[p] = existing[:offset] + data + existing[offset +
   100→                                                             len(data):]
   101→        self._modified[p] = self._now()
   102→        return len(data)
   103→
   104→    def create(self, path: str) -> None:
   105→        p = self._norm(path)
   106→        parent = self._parent(p)
   107→        if parent != "/" and parent not in self._dirs:
   108→            raise FileNotFoundError(
   109→                f"parent directory does not exist: {parent}")
   110→        self._files[p] = b""
   111→        self._modified[p] = self._now()
   112→
   113→    def unlink(self, path: str) -> None:
   114→        p = self._norm(path)
   115→        if p not in self._files:
   116→            raise FileNotFoundError(p)
   117→        del self._files[p]
   118→        self._modified.pop(p, None)
   119→
   120→    def rmdir(self, path: str) -> None:
   121→        p = self._norm(path)
   122→        if p not in self._dirs:
   123→            raise FileNotFoundError(p)
   124→        prefix = p.rstrip("/") + "/"
   125→        children = [
   126→            k for k in list(self._files) + list(self._dirs)
   127→            if k.startswith(prefix) and k != p
   128→        ]
   129→        if children:
   130→            raise OSError(f"directory not empty: {p}")
   131→        self._dirs.discard(p)
   132→
   133→    def mkdir(self, path: str) -> None:
   134→        p = self._norm(path)
   135→        parent = self._parent(p)
   136→        if parent != "/" and parent not in self._dirs:
   137→            raise FileNotFoundError(
   138→                f"parent directory does not exist: {parent}")
   139→        self._dirs.add(p)
   140→        self._modified[p] = self._now()
   141→
   142→    def rename(self, src: str, dst: str) -> None:
   143→        s, d = self._norm(src), self._norm(dst)
   144→        if s in self._files:
   145→            self._files[d] = self._files.pop(s)
   146→            self._modified[d] = self._modified.pop(s, self._now())
   147→        elif s in self._dirs:
   148→            self._dirs.discard(s)
   149→            self._dirs.add(d)
   150→            self._modified[d] = self._modified.pop(s, self._now())
   151→            # move all children
   152→            prefix = s.rstrip("/") + "/"
   153→            for key in list(self._files):
   154→                if key.startswith(prefix):
   155→                    new_key = d.rstrip("/") + "/" + key[len(prefix):]
   156→                    self._files[new_key] = self._files.pop(key)
   157→        else:
   158→            raise FileNotFoundError(s)
   159→
   160→    def truncate(self, path: str, size: int) -> None:
   161→        p = self._norm(path)
   162→        if p not in self._files:
   163→            raise FileNotFoundError(p)
   164→        data = self._files[p]
   165→        if size < len(data):
   166→            self._files[p] = data[:size]
   167→        else:
   168→            self._files[p] = data + b"\x00" * (size - len(data))
   169→        self._modified[p] = self._now()
   170→
   171→    # ── convenience ─────────────────────────────────────────────────────────
   172→
   173→    def checksum(self, path: str) -> str:
   174→        p = self._norm(path)
   175→        return hashlib.md5(self._files[p]).hexdigest()
   176→
   177→
   178→def _guess_type(path: str) -> str | None:
   179→    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
   180→    return {
   181→        "json": "json",
   182→        "jsonl": "json",
   183→        "csv": "csv",
   184→        "tsv": "csv",
   185→        "txt": "text",
   186→        "md": "text",
   187→        "py": "text",
   188→        "js": "text",
   189→        "ts": "text",
   190→        "yaml": "text",
   191→        "yml": "text",
   192→        "toml": "text",
   193→    }.get(ext)
   194→

import hashlib
from datetime import datetime, timezone

from mirage.backends.base import BaseBackend
from mirage.types import FileStat, FileType

class MemoryBackend(BaseBackend):
    """
    In-memory backend backed by a plain dict. No external dependencies.
    Useful for tests, ephemeral scratch space, and as a cache target.

    Internal layout:
        _files: dict[str, bytes]  — path → content
        _dirs:  set[str]          — explicit directory paths
    """

    def __init__(self) -> None:
        self._files: dict[str, bytes] = {}
        self._dirs: set[str] = {"/"}
        self._modified: dict[str, str] = {}  # path → ISO timestamp

    # ── helpers ─────────────────────────────────────────────────────────────

    def _norm(self, path: str) -> str:
        """Normalise to absolute path without trailing slash."""
        p = "/" + path.strip("/")
        return p

    def _now(self) -> str:
        return datetime.now(timezone.utc).isoformat()

    def _parent(self, path: str) -> str:
        parts = path.rsplit("/", 1)
        return parts[0] or "/"

    # ── ops ─────────────────────────────────────────────────────────────────

    def read(self, path: str, offset: int, size: int) -> bytes:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        return self._files[p][offset:offset + size]

    def readdir(self,
                path: str,
                cursor: str | None = None) -> tuple[list[str], str | None]:
        p = self._norm(path)
        if p not in self._dirs:
            raise FileNotFoundError(p)
        prefix = p.rstrip("/") + "/"
        # direct children only
        seen: set[str] = set()
        for key in list(self._files) + list(self._dirs):
            if key == p:
                continue
            if key.startswith(prefix):
                remainder = key[len(prefix):]
                child = remainder.split("/")[0]
                if child:
                    seen.add(prefix + child)
        names = sorted(seen)
        # simple offset-based pagination via cursor
        start = int(cursor) if cursor else 0
        page = names[start:]
        return page, None  # no pagination needed for in-memory

    def stat(self, path: str) -> FileStat:
        p = self._norm(path)
        if p in self._dirs:
            return FileStat(name=p.rsplit("/", 1)[-1] or "/",
                            size=None,
                            modified=self._modified.get(p),
                            type=FileType.DIRECTORY)
        if p in self._files:
            data = self._files[p]
            return FileStat(
                name=p.rsplit("/", 1)[-1],
                size=len(data),
                modified=self._modified.get(p),
                type=_guess_type(p),
            )
        raise FileNotFoundError(p)

    def open(self, path: str, flags: int) -> int:
        raise NotImplementedError("open/release are FUSE-only")

    def release(self, fh: int) -> None:
        raise NotImplementedError("open/release are FUSE-only")

    def write(self, path: str, offset: int, data: bytes) -> int:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        existing = self._files[p]
        # pad if offset beyond current size
        if offset > len(existing):
            existing = existing + b"\x00" * (offset - len(existing))
        self._files[p] = existing[:offset] + data + existing[offset +
                                                             len(data):]
        self._modified[p] = self._now()
        return len(data)

    def create(self, path: str) -> None:
        p = self._norm(path)
        parent = self._parent(p)
        if parent != "/" and parent not in self._dirs:
            raise FileNotFoundError(
                f"parent directory does not exist: {parent}")
        self._files[p] = b""
        self._modified[p] = self._now()

    def unlink(self, path: str) -> None:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        del self._files[p]
        self._modified.pop(p, None)

    def rmdir(self, path: str) -> None:
        p = self._norm(path)
        if p not in self._dirs:
            raise FileNotFoundError(p)
        prefix = p.rstrip("/") + "/"
        children = [
            k for k in list(self._files) + list(self._dirs)
            if k.startswith(prefix) and k != p
        ]
        if children:
            raise OSError(f"directory not empty: {p}")
        self._dirs.discard(p)

    def mkdir(self, path: str) -> None:
        p = self._norm(path)
        parent = self._parent(p)
        if parent != "/" and parent not in self._dirs:
            raise FileNotFoundError(
                f"parent directory does not exist: {parent}")
        self._dirs.add(p)
        self._modified[p] = self._now()

    def rename(self, src: str, dst: str) -> None:
        s, d = self._norm(src), self._norm(dst)
        if s in self._files:
            self._files[d] = self._files.pop(s)
            self._modified[d] = self._modified.pop(s, self._now())
        elif s in self._dirs:
            self._dirs.discard(s)
            self._dirs.add(d)
            self._modified[d] = self._modified.pop(s, self._now())
            # move all children
            prefix = s.rstrip("/") + "/"
            for key in list(self._files):
                if key.startswith(prefix):
                    new_key = d.rstrip("/") + "/" + key[len(prefix):]
                    self._files[new_key] = self._files.pop(key)
        else:
            raise FileNotFoundError(s)

    def truncate(self, path: str, size: int) -> None:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        data = self._files[p]
        if size < len(data):
            self._files[p] = data[:size]
        else:
            self._files[p] = data + b"\x00" * (size - len(data))
        self._modified[p] = self._now()

    # ── convenience ─────────────────────────────────────────────────────────

    def checksum(self, path: str) -> str:
        p = self._norm(path)
        return hashlib.md5(self._files[p]).hexdigest()

def _guess_type(path: str) -> str | None:
    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
    return {
        "json": "json",
        "jsonl": "json",
        "csv": "csv",
        "tsv": "csv",
        "txt": "text",
        "md": "text",
        "py": "text",
        "js": "text",
        "ts": "text",
        "yaml": "text",
        "yml": "text",
        "toml": "text",
    }.get(ext)


     1→import os
     2→from datetime import datetime, timezone
     3→from pathlib import Path
     4→
     5→from mirage.backends.base import BaseBackend
     6→from mirage.backends.local.memory import _guess_type
     7→from mirage.types import FileStat, FileType
     8→
     9→
    10→class LocalBackend(BaseBackend):

import os
from datetime import datetime, timezone
from pathlib import Path

from mirage.backends.base import BaseBackend
from mirage.backends.local.memory import _guess_type
from mirage.types import FileStat, FileType

class LocalBackend(BaseBackend):


     1→import re
     2→from datetime import timezone
     3→
     4→from pydantic import BaseModel, ConfigDict
     5→
     6→from mirage.backends.base import BaseBackend
     7→from mirage.backends.local.memory import _guess_type
     8→from mirage.types import FileStat, FileType
     9→
    10→

import re
from datetime import timezone

from pydantic import BaseModel, ConfigDict

from mirage.backends.base import BaseBackend
from mirage.backends.local.memory import _guess_type
from mirage.types import FileStat, FileType


     1→from pydantic import BaseModel, ConfigDict, Field
     2→
     3→from mirage.backends.base import BaseBackend
     4→from mirage.backends.local.memory import _guess_type
     5→from mirage.types import FileStat
     6→
     7→
     8→class HTTPConfig(BaseModel):
     9→    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
    10→

from pydantic import BaseModel, ConfigDict, Field

from mirage.backends.base import BaseBackend
from mirage.backends.local.memory import _guess_type
from mirage.types import FileStat

class HTTPConfig(BaseModel):
    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)


     1→from __future__ import annotations
     2→
     3→import time
     4→
     5→from mirage.backends.base import BaseBackend
     6→from mirage.backends.local.memory import _guess_type
     7→from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
     8→                          SyncPolicy, SyncResult, TransferStats)
     9→from mirage.workspace.cache import LRUCache
    10→from mirage.workspace.cache.policy import is_fresh

from __future__ import annotations

import time

from mirage.backends.base import BaseBackend
from mirage.backends.local.memory import _guess_type
from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
                          SyncPolicy, SyncResult, TransferStats)
from mirage.workspace.cache import LRUCache
from mirage.workspace.cache.policy import is_fresh


     1→from mirage.backends.local.local import LocalBackend
     2→from mirage.backends.local.memory import MemoryBackend, _guess_type
     3→
     4→__all__ = ["LocalBackend", "MemoryBackend", "_guess_type"]
     5→

from mirage.backends.local.local import LocalBackend
from mirage.backends.local.memory import MemoryBackend, _guess_type

__all__ = ["LocalBackend", "MemoryBackend", "_guess_type"]


Edit

from mirage.backends.base import BaseBackend
from mirage.types import FileStat, FileType

from mirage.backends.base import BaseBackend
from mirage.backends.filetype import guess_type
from mirage.types import FileStat, FileType


from mirage.backends.local.memory import _guess_type

from mirage.backends.filetype import guess_type


from mirage.backends.local.memory import _guess_type

from mirage.backends.filetype import guess_type


The file /Users/agent/strukto/mirage/mirage/backends/local/memory.py has been updated successfully.

from mirage.backends.base import BaseBackend
from mirage.backends.filetype import guess_type
from mirage.types import FileStat, FileType

import hashlib
from datetime import datetime, timezone

from mirage.backends.base import BaseBackend
from mirage.types import FileStat, FileType

class MemoryBackend(BaseBackend):
    """
    In-memory backend backed by a plain dict. No external dependencies.
    Useful for tests, ephemeral scratch space, and as a cache target.

    Internal layout:
        _files: dict[str, bytes]  — path → content
        _dirs:  set[str]          — explicit directory paths
    """

    def __init__(self) -> None:
        self._files: dict[str, bytes] = {}
        self._dirs: set[str] = {"/"}
        self._modified: dict[str, str] = {}  # path → ISO timestamp

    # ── helpers ─────────────────────────────────────────────────────────────

    def _norm(self, path: str) -> str:
        """Normalise to absolute path without trailing slash."""
        p = "/" + path.strip("/")
        return p

    def _now(self) -> str:
        return datetime.now(timezone.utc).isoformat()

    def _parent(self, path: str) -> str:
        parts = path.rsplit("/", 1)
        return parts[0] or "/"

    # ── ops ─────────────────────────────────────────────────────────────────

    def read(self, path: str, offset: int, size: int) -> bytes:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        return self._files[p][offset:offset + size]

    def readdir(self,
                path: str,
                cursor: str | None = None) -> tuple[list[str], str | None]:
        p = self._norm(path)
        if p not in self._dirs:
            raise FileNotFoundError(p)
        prefix = p.rstrip("/") + "/"
        # direct children only
        seen: set[str] = set()
        for key in list(self._files) + list(self._dirs):
            if key == p:
                continue
            if key.startswith(prefix):
                remainder = key[len(prefix):]
                child = remainder.split("/")[0]
                if child:
                    seen.add(prefix + child)
        names = sorted(seen)
        # simple offset-based pagination via cursor
        start = int(cursor) if cursor else 0
        page = names[start:]
        return page, None  # no pagination needed for in-memory

    def stat(self, path: str) -> FileStat:
        p = self._norm(path)
        if p in self._dirs:
            return FileStat(name=p.rsplit("/", 1)[-1] or "/",
                            size=None,
                            modified=self._modified.get(p),
                            type=FileType.DIRECTORY)
        if p in self._files:
            data = self._files[p]
            return FileStat(
                name=p.rsplit("/", 1)[-1],
                size=len(data),
                modified=self._modified.get(p),
                type=_guess_type(p),
            )
        raise FileNotFoundError(p)

    def open(self, path: str, flags: int) -> int:
        raise NotImplementedError("open/release are FUSE-only")

    def release(self, fh: int) -> None:
        raise NotImplementedError("open/release are FUSE-only")

    def write(self, path: str, offset: int, data: bytes) -> int:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        existing = self._files[p]
        # pad if offset beyond current size
        if offset > len(existing):
            existing = existing + b"\x00" * (offset - len(existing))
        self._files[p] = existing[:offset] + data + existing[offset +
                                                             len(data):]
        self._modified[p] = self._now()
        return len(data)

    def create(self, path: str) -> None:
        p = self._norm(path)
        parent = self._parent(p)
        if parent != "/" and parent not in self._dirs:
            raise FileNotFoundError(
                f"parent directory does not exist: {parent}")
        self._files[p] = b""
        self._modified[p] = self._now()

    def unlink(self, path: str) -> None:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        del self._files[p]
        self._modified.pop(p, None)

    def rmdir(self, path: str) -> None:
        p = self._norm(path)
        if p not in self._dirs:
            raise FileNotFoundError(p)
        prefix = p.rstrip("/") + "/"
        children = [
            k for k in list(self._files) + list(self._dirs)
            if k.startswith(prefix) and k != p
        ]
        if children:
            raise OSError(f"directory not empty: {p}")
        self._dirs.discard(p)

    def mkdir(self, path: str) -> None:
        p = self._norm(path)
        parent = self._parent(p)
        if parent != "/" and parent not in self._dirs:
            raise FileNotFoundError(
                f"parent directory does not exist: {parent}")
        self._dirs.add(p)
        self._modified[p] = self._now()

    def rename(self, src: str, dst: str) -> None:
        s, d = self._norm(src), self._norm(dst)
        if s in self._files:
            self._files[d] = self._files.pop(s)
            self._modified[d] = self._modified.pop(s, self._now())
        elif s in self._dirs:
            self._dirs.discard(s)
            self._dirs.add(d)
            self._modified[d] = self._modified.pop(s, self._now())
            # move all children
            prefix = s.rstrip("/") + "/"
            for key in list(self._files):
                if key.startswith(prefix):
                    new_key = d.rstrip("/") + "/" + key[len(prefix):]
                    self._files[new_key] = self._files.pop(key)
        else:
            raise FileNotFoundError(s)

    def truncate(self, path: str, size: int) -> None:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        data = self._files[p]
        if size < len(data):
            self._files[p] = data[:size]
        else:
            self._files[p] = data + b"\x00" * (size - len(data))
        self._modified[p] = self._now()

    # ── convenience ─────────────────────────────────────────────────────────

    def checksum(self, path: str) -> str:
        p = self._norm(path)
        return hashlib.md5(self._files[p]).hexdigest()

def _guess_type(path: str) -> str | None:
    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
    return {
        "json": "json",
        "jsonl": "json",
        "csv": "csv",
        "tsv": "csv",
        "txt": "text",
        "md": "text",
        "py": "text",
        "js": "text",
        "ts": "text",
        "yaml": "text",
        "yml": "text",
        "toml": "text",
    }.get(ext)

+from mirage.backends.filetype import guess_type


from mirage.backends.local.memory import _guess_type

from mirage.backends.filetype import guess_type


The file /Users/agent/strukto/mirage/mirage/backends/local/local.py has been updated successfully.

from mirage.backends.filetype import guess_type

import os
from datetime import datetime, timezone
from pathlib import Path

from mirage.backends.base import BaseBackend
from mirage.backends.local.memory import _guess_type
from mirage.types import FileStat, FileType

class LocalBackend(BaseBackend):
    """
    Backend backed by the local filesystem via pathlib/os.
    No external dependencies.

    All virtual paths are resolved relative to `root`.
    Example: root="/data", path="/reports/q1.csv" → /data/reports/q1.csv
    """

    def __init__(self, root: str) -> None:
        self.root = Path(root).resolve()

    # ── helpers ─────────────────────────────────────────────────────────────

    def _resolve(self, path: str) -> Path:
        # strip leading slash so joinpath works correctly
        relative = path.lstrip("/")
        resolved = (self.root / relative).resolve()
        # prevent path traversal outside root
        resolved.relative_to(self.root)
        return resolved

    def _to_stat(self, p: Path) -> FileStat:
        st = p.stat()
        modified = datetime.fromtimestamp(st.st_mtime,
                                          tz=timezone.utc).isoformat()
        if p.is_dir():
            return FileStat(name=p.name,
                            size=None,
                            modified=modified,
                            type=FileType.DIRECTORY)
        return FileStat(name=p.name,
                        size=st.st_size,
                        modified=modified,
                        type=_guess_type(p.name))

    # ── ops ─────────────────────────────────────────────────────────────────

    def read(self, path: str, offset: int, size: int) -> bytes:
        p = self._resolve(path)
        with p.open("rb") as f:
            f.seek(offset)
            return f.read(size)

    def readdir(self,
                path: str,
                cursor: str | None = None) -> tuple[list[str], str | None]:
        p = self._resolve(path)
        if not p.is_dir():
            raise NotADirectoryError(str(p))
        base = "/" + path.strip("/")
        entries = sorted(base.rstrip("/") + "/" + e.name for e in p.iterdir())
        # cursor is an offset index for pagination
        start = int(cursor) if cursor else 0
        page = entries[start:]
        return page, None

    def stat(self, path: str) -> FileStat:
        p = self._resolve(path)
        if not p.exists():
            raise FileNotFoundError(str(p))
        return self._to_stat(p)

    def open(self, path: str, flags: int) -> int:
        raise NotImplementedError("open/release are FUSE-only")

    def release(self, fh: int) -> None:
        raise NotImplementedError("open/release are FUSE-only")

    def write(self, path: str, offset: int, data: bytes) -> int:
        p = self._resolve(path)
        with p.open("r+b") as f:
            f.seek(offset)
            f.write(data)
        return len(data)

    def create(self, path: str) -> None:
        p = self._resolve(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.touch(exist_ok=True)

    def unlink(self, path: str) -> None:
        p = self._resolve(path)
        p.unlink()

    def rmdir(self, path: str) -> None:
        p = self._resolve(path)
        p.rmdir()  # raises OSError if non-empty — matches POSIX semantics

    def mkdir(self, path: str) -> None:
        p = self._resolve(path)
        p.mkdir(parents=True, exist_ok=True)

    def rename(self, src: str, dst: str) -> None:
        s = self._resolve(src)
        d = self._resolve(dst)
        s.rename(d)

    def truncate(self, path: str, size: int) -> None:
        p = self._resolve(path)
        os.truncate(p, size)

-from mirage.backends.local.memory import _guess_type


from mirage.backends.local.memory import _guess_type

from mirage.backends.filetype import guess_type


The file /Users/agent/strukto/mirage/mirage/backends/object_storage/s3.py has been updated successfully.

import re
from datetime import timezone

from pydantic import BaseModel, ConfigDict

from mirage.backends.base import BaseBackend
from mirage.backends.local.memory import _guess_type
from mirage.types import FileStat, FileType

class S3Config(BaseModel):
    model_config = ConfigDict(frozen=True)

    bucket: str
    region: str | None = None
    endpoint_url: str | None = None
    aws_access_key_id: str | None = None
    aws_secret_access_key: str | None = None
    aws_profile: str | None = None
    timeout: int = 30
    proxy: str | None = None
    select_threshold_bytes: int | None = 1 * 1024 * 1024  # 1 MB

class S3Backend(BaseBackend):
    """
    S3-compatible backend using boto3.

    Requires: pip install mirage[s3]

    Key design decisions:
    - read()  → GetObject with Range header (partial reads)
    - readdir() → list_objects_v2 with paginator (handles >1000 keys)
    - write()  → full PutObject (S3 has no partial write)
    - grep()   → S3 Select for large files, full fetch for small files
    - ls_l()   → list_objects_v2 already returns size+mtime, skip per-file stat
    """

    def __init__(self, config: S3Config) -> None:
        self.config = config
        self._client = self._make_client()

    def _make_client(self):
        try:
            import boto3
            from botocore.config import Config
        except ImportError:
            raise ImportError(
                "boto3 is required for S3Backend: pip install 'mirage[s3]'")

        kwargs: dict = {"service_name": "s3"}
        c = self.config
        if c.region:
            kwargs["region_name"] = c.region
        if c.endpoint_url:
            kwargs["endpoint_url"] = c.endpoint_url
        if c.aws_access_key_id and c.aws_secret_access_key:
            kwargs["aws_access_key_id"] = c.aws_access_key_id
            kwargs["aws_secret_access_key"] = c.aws_secret_access_key

        cfg_kwargs: dict = {
            "connect_timeout": c.timeout,
            "read_timeout": c.timeout
        }
        if c.proxy:
            cfg_kwargs["proxies"] = {"https": c.proxy, "http": c.proxy}

        kwargs["config"] = Config(**cfg_kwargs)

        if c.aws_profile:
            session = boto3.Session(profile_name=c.aws_profile)
            return session.client(**kwargs)
        return boto3.client(**kwargs)

    # ── key helpers ─────────────────────────────────────────────────────────

    def _key(self, path: str) -> str:
        """Convert virtual path to S3 key (strip leading slash)."""
        return path.lstrip("/")

    def _prefix(self, path: str) -> str:
        """Directory prefix — always ends with '/' unless root."""
        key = self._key(path)
        if key and not key.endswith("/"):
            key += "/"
        return key

    # ── ops ─────────────────────────────────────────────────────────────────

    def read(self, path: str, offset: int, size: int) -> bytes:
        end = offset + size - 1
        resp = self._client.get_object(
            Bucket=self.config.bucket,
            Key=self._key(path),
            Range=f"bytes={offset}-{end}",
        )
        return resp["Body"].read()

    def readdir(self,
                path: str,
                cursor: str | None = None) -> tuple[list[str], str | None]:
        prefix = self._prefix(path)
        kwargs: dict = {
            "Bucket": self.config.bucket,
            "Prefix": prefix,
            "Delimiter": "/",
        }
        if cursor:
            kwargs["ContinuationToken"] = cursor

        resp = self._client.list_objects_v2(**kwargs)

        names: list[str] = []
        # common prefixes = subdirectories
        for cp in resp.get("CommonPrefixes") or []:
            p = cp["Prefix"]
            child = p.rstrip("/")
            if child:
                names.append(child)
        # object keys = files
        for obj in resp.get("Contents") or []:
            key = obj["Key"]
            relative = key[len(prefix):]
            if relative and "/" not in relative:  # direct children only
                names.append(key)

        next_cursor = resp.get("NextContinuationToken")
        return sorted(names), next_cursor

    def stat(self, path: str) -> FileStat:
        try:
            resp = self._client.head_object(Bucket=self.config.bucket,
                                            Key=self._key(path))
            modified = resp["LastModified"].astimezone(
                timezone.utc).isoformat()
            return FileStat(
                name=path.rstrip("/").rsplit("/", 1)[-1],
                size=resp["ContentLength"],
                modified=modified,
                type=_guess_type(path),
                extra={"etag": resp.get("ETag", "").strip('"')},
            )
        except self._client.exceptions.NoSuchKey:
            pass
        except Exception as exc:
            if not (hasattr(exc, "response")
                    and exc.response.get("Error", {}).get("Code") == "404"):
                raise
        # key not found — check if it's a directory prefix
        prefix = self._prefix(path)
        resp = self._client.list_objects_v2(Bucket=self.config.bucket,
                                            Prefix=prefix,
                                            MaxKeys=1)
        if resp.get("Contents") or resp.get("CommonPrefixes"):
            return FileStat(
                name=path.rstrip("/").rsplit("/", 1)[-1] or "/",
                size=None,
                type=FileType.DIRECTORY,
            )
        raise FileNotFoundError(path)

    def open(self, path: str, flags: int) -> int:
        raise NotImplementedError("open/release are FUSE-only")

    def release(self, fh: int) -> None:
        raise NotImplementedError("open/release are FUSE-only")

    def write(self, path: str, offset: int, data: bytes) -> int:
        # S3 has no partial write — we must do full replace
        if offset != 0:
            # fetch existing, splice, re-upload
            existing = self.cat(path)
            data = existing[:offset] + data + existing[offset + len(data):]
        self._client.put_object(Bucket=self.config.bucket,
                                Key=self._key(path),
                                Body=data)
        return len(data)

    def create(self, path: str) -> None:
        self._client.put_object(Bucket=self.config.bucket,
                                Key=self._key(path),
                                Body=b"")

    def unlink(self, path: str) -> None:
        self._client.delete_object(Bucket=self.config.bucket,
                                   Key=self._key(path))

    def rmdir(self, path: str) -> None:
        prefix = self._prefix(path)
        paginator = self._client.get_paginator("list_objects_v2")
        for page in paginator.paginate(Bucket=self.config.bucket,
                                       Prefix=prefix):
            keys = [{"Key": obj["Key"]} for obj in page.get("Contents") or []]
            if keys:
                self._client.delete_objects(Bucket=self.config.bucket,
                                            Delete={"Objects": keys})

    def mkdir(self, path: str) -> None:
        # S3 has no real directories — key prefix convention
        prefix = self._prefix(path)
        if prefix:
            self._client.put_object(Bucket=self.config.bucket,
                                    Key=prefix,
                                    Body=b"")

    def rename(self, src: str, dst: str) -> None:
        self._client.copy_object(
            Bucket=self.config.bucket,
            CopySource={
                "Bucket": self.config.bucket,
                "Key": self._key(src)
            },
            Key=self._key(dst),
        )
        self._client.delete_object(Bucket=self.config.bucket,
                                   Key=self._key(src))

    def truncate(self, path: str, size: int) -> None:
        data = self.cat(path)
        if size < len(data):
            data = data[:size]
        else:
            data = data + b"\x00" * (size - len(data))
        self._client.put_object(Bucket=self.config.bucket,
                                Key=self._key(path),
                                Body=data)

    # ── native overrides ────────────────────────────────────────────────────

    def ls_l(self, path: str) -> list[FileStat]:
        """list_objects_v2 returns size+mtime — skip per-file stat."""
        prefix = self._prefix(path)
        stats: list[FileStat] = []
        paginator = self._client.get_paginator("list_objects_v2")
        for page in paginator.paginate(Bucket=self.config.bucket,
                                       Prefix=prefix,
                                       Delimiter="/"):
            for cp in page.get("CommonPrefixes") or []:
                name = cp["Prefix"][len(prefix):].rstrip("/")
                stats.append(
                    FileStat(name=name, size=None, type=FileType.DIRECTORY))
            for obj in page.get("Contents") or []:
                key = obj["Key"]
                name = key[len(prefix):]
                if name and "/" not in name:
                    modified = obj["LastModified"].astimezone(
                        timezone.utc).isoformat()
                    stats.append(
                        FileStat(name=name,
                                 size=obj["Size"],
                                 modified=modified,
                                 type=_guess_type(name)))
        return stats

    def head(self, path: str, lines: int = 10) -> bytes:
        threshold = self.config.select_threshold_bytes
        if threshold is not None:
            try:
                s = self.stat(path)
                if s.size and s.size > threshold:
                    return self._head_select(path, lines)
            except FileNotFoundError:
                pass
        return super().head(path, lines)

    def _head_select(self, path: str, lines: int) -> bytes:
        sql = f"SELECT * FROM S3Object LIMIT {int(lines)}"
        try:
            resp = self._client.select_object_content(
                Bucket=self.config.bucket,
                Key=self._key(path),
                ExpressionType="SQL",
                Expression=sql,
                InputSerialization={
                    "CSV": {
                        "FileHeaderInfo": "NONE"
                    },
                    "CompressionType": "NONE"
                },
                OutputSerialization={"CSV": {}},
            )
            parts: list[str] = []
            for event in resp["Payload"]:
                if "Records" in event:
                    parts.extend(
                        event["Records"]["Payload"].decode().splitlines())
            return "\n".join(parts[:lines]).encode()
        except Exception:
            return super().head(path, lines)

    def presign(self, path: str, expires: int = 3600) -> str:
        return self._client.generate_presigned_url(
            "get_object",
            Params={
                "Bucket": self.config.bucket,
                "Key": self._key(path)
            },
            ExpiresIn=expires,
        )

    def grep(self, pattern: str, path: str, flags: int = 0) -> list[str]:
        """S3 Select for large files; full fetch for small files."""
        threshold = self.config.select_threshold_bytes
        if threshold is not None:
            try:
                s = self.stat(path)
                if s.size and s.size > threshold:
                    return self._grep_select(pattern, path)
            except FileNotFoundError:
                pass
        return super().grep(pattern, path, flags)

    def _grep_select(self, pattern: str, path: str) -> list[str]:
        """S3 Select LIKE filter — regex unsupported, re-filtered in Python."""
        escaped = (pattern.replace("'",
                                   "''").replace("%",
                                                 "\\%").replace("_", "\\_"))
        sql = f"SELECT * FROM S3Object WHERE _1 LIKE '%{escaped}%'"
        try:
            resp = self._client.select_object_content(
                Bucket=self.config.bucket,
                Key=self._key(path),
                ExpressionType="SQL",
                Expression=sql,
                InputSerialization={
                    "CSV": {
                        "FileHeaderInfo": "NONE"
                    },
                    "CompressionType": "NONE"
                },
                OutputSerialization={"CSV": {}},
            )
            lines: list[str] = []
            for event in resp["Payload"]:
                if "Records" in event:
                    lines.extend(
                        event["Records"]["Payload"].decode().splitlines())
            compiled = re.compile(pattern)
            return [line for line in lines if compiled.search(line)]
        except Exception:
            return super().grep(pattern, path)


The file /Users/agent/strukto/mirage/mirage/backends/network/http.py has been updated successfully.

from pydantic import BaseModel, ConfigDict, Field

from mirage.backends.base import BaseBackend
from mirage.backends.local.memory import _guess_type
from mirage.types import FileStat

class HTTPConfig(BaseModel):
    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)

    base_url: str
    headers: dict[str, str] = Field(default_factory=dict)
    timeout: int = 30
    readdir_fn: object = None

class HTTPBackend(BaseBackend):
    """
    Read-only HTTP backend using httpx.

    Maps virtual paths to URLs: base_url + path
    e.g. base_url="https://data.example.com", path="/q1.json"
         → GET https://data.example.com/q1.json

    Requires: pip install mirage[http]

    Write ops (write, create, unlink, etc.) raise NotImplementedError —
    HTTP is read-only by design. Override to support writable HTTP APIs.

    readdir() requires a user-supplied `readdir_fn` since HTTP has no
    standard directory listing protocol.
    """

    def __init__(self, config: HTTPConfig) -> None:
        self.config = config
        self._client = self._make_client()

    def _make_client(self):
        try:
            import httpx
        except ImportError:
            raise ImportError(
                "httpx is required for HTTPBackend: pip install 'mirage[http]'"
            )
        return httpx.Client(
            base_url=self.config.base_url,
            headers=self.config.headers,
            timeout=self.config.timeout,
            follow_redirects=True,
        )

    def _url(self, path: str) -> str:
        return self.config.base_url.rstrip("/") + "/" + path.lstrip("/")

    # ── ops ─────────────────────────────────────────────────────────────────

    def read(self, path: str, offset: int, size: int) -> bytes:
        headers = {"Range": f"bytes={offset}-{offset + size - 1}"}
        resp = self._client.get(self._url(path), headers=headers)
        if resp.status_code == 416:
            return b""
        resp.raise_for_status()
        return resp.content

    def readdir(self,
                path: str,
                cursor: str | None = None) -> tuple[list[str], str | None]:
        if self.config.readdir_fn is None:
            raise NotImplementedError(
                "HTTPBackend.readdir() requires a readdir_fn in HTTPConfig. "
                "Provide a callable(path) -> list[str] for your API.")
        names = self.config.readdir_fn(path)
        return names, None

    def stat(self, path: str) -> FileStat:
        resp = self._client.head(self._url(path))
        if resp.status_code == 404:
            raise FileNotFoundError(path)
        resp.raise_for_status()
        size = int(resp.headers.get("content-length", 0)) or None
        modified = resp.headers.get("last-modified")
        etag = resp.headers.get("etag", "").strip('"') or None
        extra = {"etag": etag} if etag else {}
        return FileStat(
            name=path.rstrip("/").rsplit("/", 1)[-1],
            size=size,
            modified=modified,
            type=_guess_type(path),
            extra=extra,
        )

    def open(self, path: str, flags: int) -> int:
        raise NotImplementedError("open/release are FUSE-only")

    def release(self, fh: int) -> None:
        raise NotImplementedError("open/release are FUSE-only")

    # ── write ops — not supported ────────────────────────────────────────────

    def write(self, path: str, offset: int, data: bytes) -> int:
        raise NotImplementedError("HTTPBackend is read-only")

    def create(self, path: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def unlink(self, path: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def rmdir(self, path: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def mkdir(self, path: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def rename(self, src: str, dst: str) -> None:
        raise NotImplementedError("HTTPBackend is read-only")

    def truncate(self, path: str, size: int) -> None:
        raise NotImplementedError("HTTPBackend is read-only")


The file /Users/agent/strukto/mirage/mirage/workspace/__init__.py has been updated successfully.

from __future__ import annotations

import time

from mirage.backends.base import BaseBackend
from mirage.backends.local.memory import _guess_type
from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
                          SyncPolicy, SyncResult, TransferStats)
from mirage.workspace.cache import LRUCache
from mirage.workspace.cache.policy import is_fresh
from mirage.workspace.mount import MountRegistry
from mirage.workspace.sync import DirtyTracker

class _PathLock:

    def __init__(self, ws: Workspace, paths: list[str]) -> None:
        self._ws = ws
        self._paths = paths

    def __enter__(self) -> _PathLock:
        return self

    def __exit__(self, *_: object) -> None:
        self._ws.unlock_paths(self._paths)

class Workspace:
    """
    Unified virtual filesystem over heterogeneous backends.

    Mounts backends at virtual path prefixes and provides a command-level API
    (cat, ls, write, cp, mv, rm, ...) with transparent caching
    and write staging.

    Example::

        ws = Workspace(
            {"/data/": S3Backend(S3Config(bucket="my-bucket")),
             "/tmp/":  MemoryBackend()},
            cache_limit="256MB",
            sync_policy=SyncPolicy.STAGED,
        )
        content = ws.cat("/data/reports/q1.json")
        ws.write("/tmp/summary.txt", b"hello")
        ws.sync()
    """

    def __init__(
        self,
        backends: dict[str, BaseBackend | tuple],
        cache_limit: str | int = "512MB",
        sync_policy: SyncPolicy = SyncPolicy.STAGED,
        mode: MountMode = MountMode.READ,
        consistency: ConsistencyPolicy = ConsistencyPolicy.NONE,
        ttl_seconds: int = 300,
    ) -> None:
        self._registry = MountRegistry()
        self._cache = LRUCache(cache_limit)
        self._tracker = DirtyTracker()
        self._sync_policy = sync_policy
        self._consistency = consistency
        self._ttl_seconds = ttl_seconds
        self._locked_paths: set[str] = set()
        self._transfer_stats: dict[str, TransferStats] = {}
        self._created_at: float = time.monotonic()

        for prefix, value in backends.items():
            if isinstance(value, tuple) and len(value) == 3:
                backend, mount_mode, mount_consistency = value
            elif isinstance(value, tuple):
                backend, mount_mode = value
                mount_consistency = None
            else:
                backend = value
                mount_mode = mode
                mount_consistency = None
            self._registry.mount(prefix, backend, mount_mode,
                                 mount_consistency)

    # ── mount management ────────────────────────────────────────────────────

    def mount(self,
              prefix: str,
              backend: BaseBackend,
              mode: MountMode = MountMode.READ,
              consistency: ConsistencyPolicy | None = None) -> None:
        self._registry.mount(prefix, backend, mode, consistency)

    def backend(self, path: str) -> BaseBackend:
        backend, _, _ = self._registry.resolve(path)
        return backend

    # ── read ops ────────────────────────────────────────────────────────────

    def cat(self, path: str) -> bytes:
        inode = self._tracker.get(path)
        if inode and inode.dirty:
            entry = self._cache.get(path)
            if entry is not None:
                self._record_transfer(path,
                                      "read",
                                      len(entry.data),
                                      0,
                                      cache_hit=True)
                return entry.data

        entry = self._cache.get(path)
        remote_stat = None
        if entry is not None:
            consistency = self._effective_consistency(path)
            backend, bp, _ = self._registry.resolve(path)
            fresh, remote_stat = is_fresh(entry, consistency,
                                          self._ttl_seconds, backend, bp)
            if fresh:
                self._record_transfer(path,
                                      "read",
                                      len(entry.data),
                                      0,
                                      cache_hit=True)
                return entry.data
        else:
            backend, bp, _ = self._registry.resolve(path)

        t0 = time.monotonic()
        data = backend.cat(bp)
        duration_ms = int((time.monotonic() - t0) * 1000)
        self._record_transfer(path,
                              "read",
                              len(data),
                              duration_ms,
                              cache_hit=False)
        if remote_stat is None:
            remote_stat = backend.stat(bp)
        self._cache.put(path,
                        data,
                        etag=remote_stat.extra.get("etag"),
                        modified=remote_stat.modified,
                        size=remote_stat.size)
        self._tracker.on_read(path, data, self._mount_prefix(path))
        return data

    def ls(self, path: str) -> list[str]:
        backend, bp, _ = self._registry.resolve(path)
        prefix = self._mount_prefix(path).rstrip("/")
        backend_ok = False
        try:
            backend_entries = backend.ls(bp)
            backend_ok = True
        except FileNotFoundError:
            backend_entries = []
        result: set[str] = {
            prefix + "/" + e.lstrip("/")
            for e in backend_entries
        }
        norm_path = "/" + path.strip("/")
        dir_prefix = norm_path.rstrip("/") + "/"
        has_staged_children = False
        for inode in self._tracker.dirty():
            vp = inode.virtual_path
            if not vp.startswith(dir_prefix):
                continue
            if inode.checksum is None:
                result.discard(vp)
                continue
            has_staged_children = True
            remainder = vp[len(dir_prefix):]
            first = remainder.split("/")[0]
            if not first:
                continue
            if "/" in remainder:
                result.add(dir_prefix + first + "/")
            else:
                result.add(dir_prefix + first)
        if not backend_ok and not has_staged_children:
            raise FileNotFoundError(path)
        return sorted(result)

    def ls_l(self, path: str) -> list[FileStat]:
        backend, bp, _ = self._registry.resolve(path)
        return backend.ls_l(bp)

    def stat(self, path: str) -> FileStat:
        # For staged writes, build FileStat from the tracker rather than
        # hitting the backend (which hasn't received the write yet).
        inode = self._tracker.get(path)
        if inode and inode.dirty and inode.checksum is not None:
            entry = self._cache.get(path)
            size = len(entry.data) if entry is not None else inode.size
            return FileStat(
                name=path.rstrip("/").rsplit("/", 1)[-1],
                size=size,
                type=_guess_type(path),
            )
        backend, bp, _ = self._registry.resolve(path)
        return backend.stat(bp)

    def exists(self, path: str) -> bool:
        try:
            self.stat(path)
            return True
        except (FileNotFoundError, ValueError):
            return False

    def head(self, path: str, lines: int = 10) -> bytes:
        backend, bp, _ = self._registry.resolve(path)
        return backend.head(bp, lines)

    def tail(self, path: str, lines: int = 10) -> bytes:
        backend, bp, _ = self._registry.resolve(path)
        return backend.tail(bp, lines)

    def read(self, path: str, offset: int, size: int) -> bytes:
        inode = self._tracker.get(path)
        if inode and inode.dirty:
            entry = self._cache.get(path)
            if entry is not None:
                return entry.data[offset:offset + size]
        backend, bp, _ = self._registry.resolve(path)
        return backend.read(bp, offset, size)

    def grep(self, pattern: str, path: str) -> list[str]:
        backend, bp, _ = self._registry.resolve(path)
        return backend.grep(pattern, bp)

    # ── write ops ───────────────────────────────────────────────────────────

    def write(self, path: str, data: bytes) -> None:
        self._assert_not_locked(path)
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        self._cache.put(path, data)
        self._tracker.on_write(path, data, self._mount_prefix(path))
        self._record_transfer(path, "write", len(data), 0)
        if self._sync_policy == SyncPolicy.IMMEDIATE:
            backend.put(bp, data)
            self._tracker.on_read(path, data, self._mount_prefix(path))

    def mkdir(self, path: str) -> None:
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        backend.mkdir(bp)

    def rm(self, path: str) -> None:
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        inode = self._tracker.get(path)
        staged_only = inode and inode.dirty and inode.remote_checksum is None
        if not staged_only:
            backend.unlink(bp)
        self._cache.invalidate(path)
        self._tracker.on_delete(path)

    def mv(self, src: str, dst: str) -> None:
        self._assert_writable(src)
        sb, sbp, _ = self._registry.resolve(src)
        db, dbp, _ = self._registry.resolve(dst)
        src_inode = self._tracker.get(src)
        staged_only = (src_inode and src_inode.dirty
                       and src_inode.remote_checksum is None)
        if staged_only:
            entry = self._cache.get(src)
            data = entry.data if entry else b""
            self._cache.put(dst, data)
            self._tracker.on_write(dst, data, self._mount_prefix(dst))
            self._cache.invalidate(src)
            self._tracker.on_delete(src)
        elif sb is db:
            sb.rename(sbp, dbp)
            self._cache.invalidate(src)
        else:
            data = sb.cat(sbp)
            db.put(dbp, data)
            sb.unlink(sbp)
            self._cache.invalidate(src)

    def cp(self, src: str, dst: str) -> None:
        self._assert_writable(dst)
        data = self.cat(src)
        db, dbp, _ = self._registry.resolve(dst)
        db.put(dbp, data)

    # ── sync lifecycle ──────────────────────────────────────────────────────

    def sync(self, path: str | None = None) -> list[SyncResult]:
        results: list[SyncResult] = []
        targets = [
            i for i in self._tracker.dirty()
            if path is None or i.virtual_path == path
        ]
        for inode in targets:
            backend, bp, _ = self._registry.resolve(inode.virtual_path)
            entry = self._cache.get(inode.virtual_path)
            data = entry.data if entry else None
            result = self._tracker.on_sync(inode.virtual_path, bp, data,
                                           backend)
            results.append(result)
        return results

    def status(self) -> list[Inode]:
        return self._tracker.status()

    def reset(self) -> None:
        self._tracker.reset()
        self._cache.clear()

    # ── path locking ─────────────────────────────────────────────────────────

    def _lock_path(self, path: str) -> None:
        self._locked_paths.add(path)

    def unlock_paths(self, paths: list[str]) -> None:
        for p in paths:
            self._locked_paths.discard(p)

    def lock_paths(self, paths: list[str]) -> _PathLock:
        for p in paths:
            self._lock_path(p)
        return _PathLock(self, paths)

    def _assert_not_locked(self, path: str) -> None:
        if path in self._locked_paths:
            raise PermissionError(
                f"path {path!r} is locked by remote execution")

    # ── helpers ─────────────────────────────────────────────────────────────

    def _mount_prefix(self, path: str) -> str:
        norm = "/" + path.strip("/")
        for m in self._registry.mounts():
            if norm == m.prefix.rstrip("/") or norm.startswith(m.prefix):
                return m.prefix
        return "/"

    def _effective_consistency(self, path: str) -> ConsistencyPolicy:
        mount = self._registry.mount_for(path)
        if mount.consistency is not None:
            return mount.consistency
        return self._consistency

    def _assert_writable(self, path: str) -> None:
        _, _, mode = self._registry.resolve(path)
        if mode == MountMode.READ:
            raise PermissionError(f"mount at {path!r} is read-only")

    def _record_transfer(self,
                         path: str,
                         op: str,
                         nbytes: int,
                         duration_ms: int,
                         cache_hit: bool | None = None) -> None:
        prefix = self._mount_prefix(path)
        if prefix not in self._transfer_stats:
            self._transfer_stats[prefix] = TransferStats()
        stats = self._transfer_stats[prefix]
        if op == "read":
            stats.bytes_read += nbytes
            stats.read_ops += 1
            stats.total_read_ms += duration_ms
            if cache_hit is True:
                stats.cache_hits += 1
            elif cache_hit is False:
                stats.cache_misses += 1
        elif op == "write":
            stats.bytes_written += nbytes
            stats.write_ops += 1
            stats.total_write_ms += duration_ms


Need to update the usage site too — `_guess_type` → `guess_type`.


mirage/backends/network/http.py:88:            type=_guess_type(path),
mirage/backends/object_storage/s3.py:140:                type=_guess_type(path),
mirage/backends/object_storage/s3.py:252:                                 type=_guess_type(name)))
mirage/backends/local/local.py:44:                        type=_guess_type(p.name))
mirage/backends/local/memory.py:82:                type=_guess_type(p),
mirage/backends/local/memory.py:179:def _guess_type(path: str) -> str | None:
mirage/workspace/__init__.py:193:                type=_guess_type(path),


from __future__ import annotations

import time

from mirage.backends.base import BaseBackend
from mirage.backends.filetype import guess_type
from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
                          SyncPolicy, SyncResult, TransferStats)
from mirage.workspace.cache import LRUCache
from mirage.workspace.cache.policy import is_fresh
from mirage.workspace.mount import MountRegistry
from mirage.workspace.sync import DirtyTracker

class _PathLock:

    def __init__(self, ws: Workspace, paths: list[str]) -> None:
        self._ws = ws
        self._paths = paths

    def __enter__(self) -> _PathLock:
        return self

    def __exit__(self, *_: object) -> None:
        self._ws.unlock_paths(self._paths)

class Workspace:
    """
    Unified virtual filesystem over heterogeneous backends.

    Mounts backends at virtual path prefixes and provides a command-level API
    (cat, ls, write, cp, mv, rm, ...) with transparent caching
    and write staging.

    Example::

        ws = Workspace(
            {"/data/": S3Backend(S3Config(bucket="my-bucket")),
             "/tmp/":  MemoryBackend()},
            cache_limit="256MB",
            sync_policy=SyncPolicy.STAGED,
        )
        content = ws.cat("/data/reports/q1.json")
        ws.write("/tmp/summary.txt", b"hello")
        ws.sync()
    """

    def __init__(
        self,
        backends: dict[str, BaseBackend | tuple],
        cache_limit: str | int = "512MB",
        sync_policy: SyncPolicy = SyncPolicy.STAGED,
        mode: MountMode = MountMode.READ,
        consistency: ConsistencyPolicy = ConsistencyPolicy.NONE,
        ttl_seconds: int = 300,
    ) -> None:
        self._registry = MountRegistry()
        self._cache = LRUCache(cache_limit)
        self._tracker = DirtyTracker()
        self._sync_policy = sync_policy
        self._consistency = consistency
        self._ttl_seconds = ttl_seconds
        self._locked_paths: set[str] = set()
        self._transfer_stats: dict[str, TransferStats] = {}
        self._created_at: float = time.monotonic()

        for prefix, value in backends.items():
            if isinstance(value, tuple) and len(value) == 3:
                backend, mount_mode, mount_consistency = value
            elif isinstance(value, tuple):
                backend, mount_mode = value
                mount_consistency = None
            else:
                backend = value
                mount_mode = mode
                mount_consistency = None
            self._registry.mount(prefix, backend, mount_mode,
                                 mount_consistency)

    # ── mount management ────────────────────────────────────────────────────

    def mount(self,
              prefix: str,
              backend: BaseBackend,
              mode: MountMode = MountMode.READ,
              consistency: ConsistencyPolicy | None = None) -> None:
        self._registry.mount(prefix, backend, mode, consistency)

    def backend(self, path: str) -> BaseBackend:
        backend, _, _ = self._registry.resolve(path)
        return backend

    # ── read ops ────────────────────────────────────────────────────────────

    def cat(self, path: str) -> bytes:
        inode = self._tracker.get(path)
        if inode and inode.dirty:
            entry = self._cache.get(path)
            if entry is not None:
                self._record_transfer(path,
                                      "read",
                                      len(entry.data),
                                      0,
                                      cache_hit=True)
                return entry.data

        entry = self._cache.get(path)
        remote_stat = None
        if entry is not None:
            consistency = self._effective_consistency(path)
            backend, bp, _ = self._registry.resolve(path)
            fresh, remote_stat = is_fresh(entry, consistency,
                                          self._ttl_seconds, backend, bp)
            if fresh:
                self._record_transfer(path,
                                      "read",
                                      len(entry.data),
                                      0,
                                      cache_hit=True)
                return entry.data
        else:
            backend, bp, _ = self._registry.resolve(path)

        t0 = time.monotonic()
        data = backend.cat(bp)
        duration_ms = int((time.monotonic() - t0) * 1000)
        self._record_transfer(path,
                              "read",
                              len(data),
                              duration_ms,
                              cache_hit=False)
        if remote_stat is None:
            remote_stat = backend.stat(bp)
        self._cache.put(path,
                        data,
                        etag=remote_stat.extra.get("etag"),
                        modified=remote_stat.modified,
                        size=remote_stat.size)
        self._tracker.on_read(path, data, self._mount_prefix(path))
        return data

    def ls(self, path: str) -> list[str]:
        backend, bp, _ = self._registry.resolve(path)
        prefix = self._mount_prefix(path).rstrip("/")
        backend_ok = False
        try:
            backend_entries = backend.ls(bp)
            backend_ok = True
        except FileNotFoundError:
            backend_entries = []
        result: set[str] = {
            prefix + "/" + e.lstrip("/")
            for e in backend_entries
        }
        norm_path = "/" + path.strip("/")
        dir_prefix = norm_path.rstrip("/") + "/"
        has_staged_children = False
        for inode in self._tracker.dirty():
            vp = inode.virtual_path
            if not vp.startswith(dir_prefix):
                continue
            if inode.checksum is None:
                result.discard(vp)
                continue
            has_staged_children = True
            remainder = vp[len(dir_prefix):]
            first = remainder.split("/")[0]
            if not first:
                continue
            if "/" in remainder:
                result.add(dir_prefix + first + "/")
            else:
                result.add(dir_prefix + first)
        if not backend_ok and not has_staged_children:
            raise FileNotFoundError(path)
        return sorted(result)

    def ls_l(self, path: str) -> list[FileStat]:
        backend, bp, _ = self._registry.resolve(path)
        return backend.ls_l(bp)

    def stat(self, path: str) -> FileStat:
        # For staged writes, build FileStat from the tracker rather than
        # hitting the backend (which hasn't received the write yet).
        inode = self._tracker.get(path)
        if inode and inode.dirty and inode.checksum is not None:
            entry = self._cache.get(path)
            size = len(entry.data) if entry is not None else inode.size
            return FileStat(
                name=path.rstrip("/").rsplit("/", 1)[-1],
                size=size,
                type=_guess_type(path),
            )
        backend, bp, _ = self._registry.resolve(path)
        return backend.stat(bp)

    def exists(self, path: str) -> bool:
        try:
            self.stat(path)
            return True
        except (FileNotFoundError, ValueError):
            return False

    def head(self, path: str, lines: int = 10) -> bytes:
        backend, bp, _ = self._registry.resolve(path)
        return backend.head(bp, lines)

    def tail(self, path: str, lines: int = 10) -> bytes:
        backend, bp, _ = self._registry.resolve(path)
        return backend.tail(bp, lines)

    def read(self, path: str, offset: int, size: int) -> bytes:
        inode = self._tracker.get(path)
        if inode and inode.dirty:
            entry = self._cache.get(path)
            if entry is not None:
                return entry.data[offset:offset + size]
        backend, bp, _ = self._registry.resolve(path)
        return backend.read(bp, offset, size)

    def grep(self, pattern: str, path: str) -> list[str]:
        backend, bp, _ = self._registry.resolve(path)
        return backend.grep(pattern, bp)

    # ── write ops ───────────────────────────────────────────────────────────

    def write(self, path: str, data: bytes) -> None:
        self._assert_not_locked(path)
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        self._cache.put(path, data)
        self._tracker.on_write(path, data, self._mount_prefix(path))
        self._record_transfer(path, "write", len(data), 0)
        if self._sync_policy == SyncPolicy.IMMEDIATE:
            backend.put(bp, data)
            self._tracker.on_read(path, data, self._mount_prefix(path))

    def mkdir(self, path: str) -> None:
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        backend.mkdir(bp)

    def rm(self, path: str) -> None:
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        inode = self._tracker.get(path)
        staged_only = inode and inode.dirty and inode.remote_checksum is None
        if not staged_only:
            backend.unlink(bp)
        self._cache.invalidate(path)
        self._tracker.on_delete(path)

    def mv(self, src: str, dst: str) -> None:
        self._assert_writable(src)
        sb, sbp, _ = self._registry.resolve(src)
        db, dbp, _ = self._registry.resolve(dst)
        src_inode = self._tracker.get(src)
        staged_only = (src_inode and src_inode.dirty
                       and src_inode.remote_checksum is None)
        if staged_only:
            entry = self._cache.get(src)
            data = entry.data if entry else b""
            self._cache.put(dst, data)
            self._tracker.on_write(dst, data, self._mount_prefix(dst))
            self._cache.invalidate(src)
            self._tracker.on_delete(src)
        elif sb is db:
            sb.rename(sbp, dbp)
            self._cache.invalidate(src)
        else:
            data = sb.cat(sbp)
            db.put(dbp, data)
            sb.unlink(sbp)
            self._cache.invalidate(src)

    def cp(self, src: str, dst: str) -> None:
        self._assert_writable(dst)
        data = self.cat(src)
        db, dbp, _ = self._registry.resolve(dst)
        db.put(dbp, data)

    # ── sync lifecycle ──────────────────────────────────────────────────────

    def sync(self, path: str | None = None) -> list[SyncResult]:
        results: list[SyncResult] = []
        targets = [
            i for i in self._tracker.dirty()
            if path is None or i.virtual_path == path
        ]
        for inode in targets:
            backend, bp, _ = self._registry.resolve(inode.virtual_path)
            entry = self._cache.get(inode.virtual_path)
            data = entry.data if entry else None
            result = self._tracker.on_sync(inode.virtual_path, bp, data,
                                           backend)
            results.append(result)
        return results

    def status(self) -> list[Inode]:
        return self._tracker.status()

    def reset(self) -> None:
        self._tracker.reset()
        self._cache.clear()

    # ── path locking ─────────────────────────────────────────────────────────

    def _lock_path(self, path: str) -> None:
        self._locked_paths.add(path)

    def unlock_paths(self, paths: list[str]) -> None:
        for p in paths:
            self._locked_paths.discard(p)

    def lock_paths(self, paths: list[str]) -> _PathLock:
        for p in paths:
            self._lock_path(p)
        return _PathLock(self, paths)

    def _assert_not_locked(self, path: str) -> None:
        if path in self._locked_paths:
            raise PermissionError(
                f"path {path!r} is locked by remote execution")

    # ── helpers ─────────────────────────────────────────────────────────────

    def _mount_prefix(self, path: str) -> str:
        norm = "/" + path.strip("/")
        for m in self._registry.mounts():
            if norm == m.prefix.rstrip("/") or norm.startswith(m.prefix):
                return m.prefix
        return "/"

    def _effective_consistency(self, path: str) -> ConsistencyPolicy:
        mount = self._registry.mount_for(path)
        if mount.consistency is not None:
            return mount.consistency
        return self._consistency

    def _assert_writable(self, path: str) -> None:
        _, _, mode = self._registry.resolve(path)
        if mode == MountMode.READ:
            raise PermissionError(f"mount at {path!r} is read-only")

    def _record_transfer(self,
                         path: str,
                         op: str,
                         nbytes: int,
                         duration_ms: int,
                         cache_hit: bool | None = None) -> None:
        prefix = self._mount_prefix(path)
        if prefix not in self._transfer_stats:
            self._transfer_stats[prefix] = TransferStats()
        stats = self._transfer_stats[prefix]
        if op == "read":
            stats.bytes_read += nbytes
            stats.read_ops += 1
            stats.total_read_ms += duration_ms
            if cache_hit is True:
                stats.cache_hits += 1
            elif cache_hit is False:
                stats.cache_misses += 1
        elif op == "write":
            stats.bytes_written += nbytes
            stats.write_ops += 1
            stats.total_write_ms += duration_ms

-                type=_guess_type(path),


import hashlib
from datetime import datetime, timezone

from mirage.backends.base import BaseBackend
from mirage.backends.filetype import guess_type
from mirage.types import FileStat, FileType

class MemoryBackend(BaseBackend):
    """
    In-memory backend backed by a plain dict. No external dependencies.
    Useful for tests, ephemeral scratch space, and as a cache target.

    Internal layout:
        _files: dict[str, bytes]  — path → content
        _dirs:  set[str]          — explicit directory paths
    """

    def __init__(self) -> None:
        self._files: dict[str, bytes] = {}
        self._dirs: set[str] = {"/"}
        self._modified: dict[str, str] = {}  # path → ISO timestamp

    # ── helpers ─────────────────────────────────────────────────────────────

    def _norm(self, path: str) -> str:
        """Normalise to absolute path without trailing slash."""
        p = "/" + path.strip("/")
        return p

    def _now(self) -> str:
        return datetime.now(timezone.utc).isoformat()

    def _parent(self, path: str) -> str:
        parts = path.rsplit("/", 1)
        return parts[0] or "/"

    # ── ops ─────────────────────────────────────────────────────────────────

    def read(self, path: str, offset: int, size: int) -> bytes:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        return self._files[p][offset:offset + size]

    def readdir(self,
                path: str,
                cursor: str | None = None) -> tuple[list[str], str | None]:
        p = self._norm(path)
        if p not in self._dirs:
            raise FileNotFoundError(p)
        prefix = p.rstrip("/") + "/"
        # direct children only
        seen: set[str] = set()
        for key in list(self._files) + list(self._dirs):
            if key == p:
                continue
            if key.startswith(prefix):
                remainder = key[len(prefix):]
                child = remainder.split("/")[0]
                if child:
                    seen.add(prefix + child)
        names = sorted(seen)
        # simple offset-based pagination via cursor
        start = int(cursor) if cursor else 0
        page = names[start:]
        return page, None  # no pagination needed for in-memory

    def stat(self, path: str) -> FileStat:
        p = self._norm(path)
        if p in self._dirs:
            return FileStat(name=p.rsplit("/", 1)[-1] or "/",
                            size=None,
                            modified=self._modified.get(p),
                            type=FileType.DIRECTORY)
        if p in self._files:
            data = self._files[p]
            return FileStat(
                name=p.rsplit("/", 1)[-1],
                size=len(data),
                modified=self._modified.get(p),
                type=_guess_type(p),
            )
        raise FileNotFoundError(p)

    def open(self, path: str, flags: int) -> int:
        raise NotImplementedError("open/release are FUSE-only")

    def release(self, fh: int) -> None:
        raise NotImplementedError("open/release are FUSE-only")

    def write(self, path: str, offset: int, data: bytes) -> int:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        existing = self._files[p]
        # pad if offset beyond current size
        if offset > len(existing):
            existing = existing + b"\x00" * (offset - len(existing))
        self._files[p] = existing[:offset] + data + existing[offset +
                                                             len(data):]
        self._modified[p] = self._now()
        return len(data)

    def create(self, path: str) -> None:
        p = self._norm(path)
        parent = self._parent(p)
        if parent != "/" and parent not in self._dirs:
            raise FileNotFoundError(
                f"parent directory does not exist: {parent}")
        self._files[p] = b""
        self._modified[p] = self._now()

    def unlink(self, path: str) -> None:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        del self._files[p]
        self._modified.pop(p, None)

    def rmdir(self, path: str) -> None:
        p = self._norm(path)
        if p not in self._dirs:
            raise FileNotFoundError(p)
        prefix = p.rstrip("/") + "/"
        children = [
            k for k in list(self._files) + list(self._dirs)
            if k.startswith(prefix) and k != p
        ]
        if children:
            raise OSError(f"directory not empty: {p}")
        self._dirs.discard(p)

    def mkdir(self, path: str) -> None:
        p = self._norm(path)
        parent = self._parent(p)
        if parent != "/" and parent not in self._dirs:
            raise FileNotFoundError(
                f"parent directory does not exist: {parent}")
        self._dirs.add(p)
        self._modified[p] = self._now()

    def rename(self, src: str, dst: str) -> None:
        s, d = self._norm(src), self._norm(dst)
        if s in self._files:
            self._files[d] = self._files.pop(s)
            self._modified[d] = self._modified.pop(s, self._now())
        elif s in self._dirs:
            self._dirs.discard(s)
            self._dirs.add(d)
            self._modified[d] = self._modified.pop(s, self._now())
            # move all children
            prefix = s.rstrip("/") + "/"
            for key in list(self._files):
                if key.startswith(prefix):
                    new_key = d.rstrip("/") + "/" + key[len(prefix):]
                    self._files[new_key] = self._files.pop(key)
        else:
            raise FileNotFoundError(s)

    def truncate(self, path: str, size: int) -> None:
        p = self._norm(path)
        if p not in self._files:
            raise FileNotFoundError(p)
        data = self._files[p]
        if size < len(data):
            self._files[p] = data[:size]
        else:
            self._files[p] = data + b"\x00" * (size - len(data))
        self._modified[p] = self._now()

    # ── convenience ─────────────────────────────────────────────────────────

    def checksum(self, path: str) -> str:
        p = self._norm(path)
        return hashlib.md5(self._files[p]).hexdigest()

def _guess_type(path: str) -> str | None:
    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
    return {
        "json": "json",
        "jsonl": "json",
        "csv": "csv",
        "tsv": "csv",
        "txt": "text",
        "md": "text",
        "py": "text",
        "js": "text",
        "ts": "text",
        "yaml": "text",
        "yml": "text",
        "toml": "text",
    }.get(ext)


import os
from datetime import datetime, timezone
from pathlib import Path

from mirage.backends.base import BaseBackend
from mirage.backends.filetype import guess_type
from mirage.types import FileStat, FileType

class LocalBackend(BaseBackend):
    """
    Backend backed by the local filesystem via pathlib/os.
    No external dependencies.

    All virtual paths are resolved relative to `root`.
    Example: root="/data", path="/reports/q1.csv" → /data/reports/q1.csv
    """

    def __init__(self, root: str) -> None:
        self.root = Path(root).resolve()

    # ── helpers ─────────────────────────────────────────────────────────────

    def _resolve(self, path: str) -> Path:
        # strip leading slash so joinpath works correctly
        relative = path.lstrip("/")
        resolved = (self.root / relative).resolve()
        # prevent path traversal outside root
        resolved.relative_to(self.root)
        return resolved

    def _to_stat(self, p: Path) -> FileStat:
        st = p.stat()
        modified = datetime.fromtimestamp(st.st_mtime,
                                          tz=timezone.utc).isoformat()
        if p.is_dir():
            return FileStat(name=p.name,
                            size=None,
                            modified=modified,
                            type=FileType.DIRECTORY)
        return FileStat(name=p.name,
                        size=st.st_size,
                        modified=modified,
                        type=_guess_type(p.name))

    # ── ops ─────────────────────────────────────────────────────────────────

    def read(self, path: str, offset: int, size: int) -> bytes:
        p = self._resolve(path)
        with p.open("rb") as f:
            f.seek(offset)
            return f.read(size)

    def readdir(self,
                path: str,
                cursor: str | None = None) -> tuple[list[str], str | None]:
        p = self._resolve(path)
        if not p.is_dir():
            raise NotADirectoryError(str(p))
        base = "/" + path.strip("/")
        entries = sorted(base.rstrip("/") + "/" + e.name for e in p.iterdir())
        # cursor is an offset index for pagination
        start = int(cursor) if cursor else 0
        page = entries[start:]
        return page, None

    def stat(self, path: str) -> FileStat:
        p = self._resolve(path)
        if not p.exists():
            raise FileNotFoundError(str(p))
        return self._to_stat(p)

    def open(self, path: str, flags: int) -> int:
        raise NotImplementedError("open/release are FUSE-only")

    def release(self, fh: int) -> None:
        raise NotImplementedError("open/release are FUSE-only")

    def write(self, path: str, offset: int, data: bytes) -> int:
        p = self._resolve(path)
        with p.open("r+b") as f:
            f.seek(offset)
            f.write(data)
        return len(data)

    def create(self, path: str) -> None:
        p = self._resolve(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.touch(exist_ok=True)

    def unlink(self, path: str) -> None:
        p = self._resolve(path)
        p.unlink()

    def rmdir(self, path: str) -> None:
        p = self._resolve(path)
        p.rmdir()  # raises OSError if non-empty — matches POSIX semantics

    def mkdir(self, path: str) -> None:
        p = self._resolve(path)
        p.mkdir(parents=True, exist_ok=True)

    def rename(self, src: str, dst: str) -> None:
        s = self._resolve(src)
        d = self._resolve(dst)
        s.rename(d)

    def truncate(self, path: str, size: int) -> None:
        p = self._resolve(path)
        os.truncate(p, size)

-                        type=_guess_type(p.name))

+                        type=guess_type(p.name))