The file /Users/agent/strukto/mirage/mirage/backends/object_storage/s3.py has been updated. All occurrences of '_guess_type' were successfully replaced with 'guess_type'.

import re
from datetime import timezone

from pydantic import BaseModel, ConfigDict

from mirage.backends.base import BaseBackend
from mirage.backends.filetype 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)

-                                 type=_guess_type(name)))

+                                 type=guess_type(name)))


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.filetype 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")


Now remove the old `_guess_type` from `memory.py` and clean up `local/__init__.py`.


Edit

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)


Write

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

__all__ = ["LocalBackend", "MemoryBackend"]


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

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)

-def _guess_type(path: str) -> str | None:

-    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""


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

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

__all__ = ["LocalBackend", "MemoryBackend"]

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

+from mirage.backends.local.memory import MemoryBackend

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

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

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

__all__ = ["LocalBackend", "MemoryBackend", "_guess_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


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

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


Now rename `backends/` → `backend/` directories and mass-replace all imports.


Bash

git mv mirage/backends mirage/backend && git mv tests/backends tests/backend

Rename backends/ to backend/ in both mirage/ and tests/


Now mass-replace `mirage.backends` → `mirage.backend` across all files. Let me find every file that needs updating.


Found 50 files
mirage/backend/local/__init__.py
mirage/backend/local/memory.py
mirage/backend/network/http.py
mirage/backend/object_storage/s3.py
mirage/backend/local/local.py
mirage/workspace/__init__.py
tests/backend/test_loader.py
mirage/__init__.py
mirage/backend/__init__.py
tests/observe/test_inspector.py
fuse/README.md
tests/fuse_ops/test_registry.py
mirage/cli/config.py
examples/claude-sdk.py
examples/claude-memory.py
fuse/tests/test_fs.py
tests/cli/test_config.py
tests/integration/test_remote_transfer.py
tests/workspace/test_lock.py
tests/workspace/test_init.py
tests/workspace/test_sync.py
tests/workspace/test_write_ops.py
tests/workspace/test_mount.py
tests/server/test_arg_resolver.py
tests/server/test_registry.py
tests/runtime/test_runtime.py
tests/runtime/test_accessors.py
tests/runtime/test_memory.py
tests/commands/test_init.py
tests/fuse_ops/test_format_ops.py
tests/fuse_ops/test_default.py
tests/fuse_ops/test_remote.py
tests/conftest.py
mirage/observe/inspector.py
mirage/cli/commands/workspace.py
tests/backend/test_local.py
tests/commands/test_read.py
mirage/runtime/local.py
tests/backend/test_proxy.py
mirage/runtime/remote.py
tests/backend/test_configs.py
examples/r2.py
mirage/backend/object_storage/r2.py
mirage/backend/network/__init__.py
mirage/cli/commands/command.py
mirage/server/service/local.py
mirage/backend/network/proxy.py
mirage/workspace/sync.py
mirage/workspace/mount.py
mirage/workspace/cache/policy.py


No files found


50 files to update. All need `mirage.backends` → `mirage.backend`. I'll use parallel agents to handle this efficiently.


Agent

Replace mirage.backends in src files

Prompt:
In EVERY file listed below, replace ALL occurrences of the string `mirage.backends` with `mirage.backend` (removing the trailing 's'). Use the Edit tool with replace_all=true on each file.

Files to update (source code):
1. mirage/backend/local/__init__.py
2. mirage/backend/local/memory.py
3. mirage/backend/local/local.py
4. mirage/backend/network/http.py
5. mirage/backend/network/proxy.py
6. mirage/backend/network/__init__.py
7. mirage/backend/object_storage/s3.py
8. mirage/backend/object_storage/r2.py
9. mirage/backend/__init__.py
10. mirage/backend/filetype.py (check if it has any, probably not)
11. mirage/__init__.py
12. mirage/workspace/__init__.py
13. mirage/workspace/sync.py
14. mirage/workspace/mount.py
15. mirage/workspace/cache/policy.py
16. mirage/cli/config.py
17. mirage/cli/commands/workspace.py
18. mirage/cli/commands/command.py
19. mirage/runtime/local.py
20. mirage/runtime/remote.py
21. mirage/observe/inspector.py
22. mirage/server/service/local.py
23. examples/r2.py
24. examples/claude-sdk.py
25. examples/claude-memory.py
26. fuse/README.md
27. fuse/tests/test_fs.py

For each file, read it first then use Edit with old_string="mirage.backends" new_string="mirage.backend" replace_all=true.

IMPORTANT: You MUST read each file before editing it. Do the edits in parallel batches where possible for efficiency.


Replace mirage.backends in test files

Prompt:
In EVERY file listed below, replace ALL occurrences of the string `mirage.backends` with `mirage.backend` (removing the trailing 's'). Use the Edit tool with replace_all=true on each file.

Files to update (test files):
1. tests/backend/test_loader.py
2. tests/backend/test_local.py
3. tests/backend/test_proxy.py
4. tests/backend/test_configs.py
5. tests/observe/test_inspector.py
6. tests/fuse_ops/test_registry.py
7. tests/fuse_ops/test_format_ops.py
8. tests/fuse_ops/test_default.py
9. tests/fuse_ops/test_remote.py
10. tests/cli/test_config.py
11. tests/integration/test_remote_transfer.py
12. tests/workspace/test_lock.py
13. tests/workspace/test_init.py
14. tests/workspace/test_sync.py
15. tests/workspace/test_write_ops.py
16. tests/workspace/test_mount.py
17. tests/server/test_arg_resolver.py
18. tests/server/test_registry.py
19. tests/runtime/test_runtime.py
20. tests/runtime/test_accessors.py
21. tests/runtime/test_memory.py
22. tests/commands/test_init.py
23. tests/commands/test_read.py
24. tests/conftest.py

For each file, read it first then use Edit with old_string="mirage.backends" new_string="mirage.backend" replace_all=true.

IMPORTANT: You MUST read each file before editing it. Do the edits in parallel batches where possible for efficiency.


All 24 files have been updated successfully. Here is a summary of every change made:

All occurrences of `mirage.backends` were replaced with `mirage.backend` (removing the trailing `s`) across all 24 files in a single parallel batch:

**Backend tests:**
- `/Users/agent/strukto/mirage/tests/backend/test_loader.py` - 5 occurrences (imports and string literals referencing `mirage.backends.loader`, `mirage.backends.local.memory`, etc.)
- `/Users/agent/strukto/mirage/tests/backend/test_local.py` - 1 occurrence (`mirage.backends.local.local`)
- `/Users/agent/strukto/mirage/tests/backend/test_proxy.py` - 1 occurrence (`mirage.backends.network.proxy`)
- `/Users/agent/strukto/mirage/tests/backend/test_configs.py` - 2 occurrences (`mirage.backends.object_storage.r2`, `mirage.backends.object_storage.s3`)

**Observe/fuse_ops/cli/integration tests:**
- `/Users/agent/strukto/mirage/tests/observe/test_inspector.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/fuse_ops/test_registry.py` - 5 occurrences (inline imports within test functions)
- `/Users/agent/strukto/mirage/tests/fuse_ops/test_format_ops.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/fuse_ops/test_default.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/fuse_ops/test_remote.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/cli/test_config.py` - 2 occurrences
- `/Users/agent/strukto/mirage/tests/integration/test_remote_transfer.py` - 1 occurrence

**Workspace tests:**
- `/Users/agent/strukto/mirage/tests/workspace/test_lock.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/workspace/test_init.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/workspace/test_sync.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/workspace/test_write_ops.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/workspace/test_mount.py` - 1 occurrence

**Server/runtime/commands tests:**
- `/Users/agent/strukto/mirage/tests/server/test_arg_resolver.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/server/test_registry.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/runtime/test_runtime.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/runtime/test_accessors.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/runtime/test_memory.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/commands/test_init.py` - 1 occurrence
- `/Users/agent/strukto/mirage/tests/commands/test_read.py` - 2 occurrences
- `/Users/agent/strukto/mirage/tests/conftest.py` - 1 occurrence

agentId: a5f7c2e8de2ec52ff (for resuming to continue this agent's work if needed)
<usage>total_tokens: 56990
tool_uses: 48
duration_ms: 52314</usage>

Prompt:
In EVERY file listed below, replace ALL occurrences of the string `mirage.backends` with `mirage.backend` (removing the trailing 's'). Use the Edit tool with replace_all=true on each file.

Files to update (test files):
1. tests/backend/test_loader.py
2. tests/backend/test_local.py
3. tests/backend/test_proxy.py
4. tests/backend/test_configs.py
5. tests/observe/test_inspector.py
6. tests/fuse_ops/test_registry.py
7. tests/fuse_ops/test_format_ops.py
8. tests/fuse_ops/test_default.py
9. tests/fuse_ops/test_remote.py
10. tests/cli/test_config.py
11. tests/integration/test_remote_transfer.py
12. tests/workspace/test_lock.py
13. tests/workspace/test_init.py
14. tests/workspace/test_sync.py
15. tests/workspace/test_write_ops.py
16. tests/workspace/test_mount.py
17. tests/server/test_arg_resolver.py
18. tests/server/test_registry.py
19. tests/runtime/test_runtime.py
20. tests/runtime/test_accessors.py
21. tests/runtime/test_memory.py
22. tests/commands/test_init.py
23. tests/commands/test_read.py
24. tests/conftest.py

For each file, read it first then use Edit with old_string="mirage.backends" new_string="mirage.backend" replace_all=true.

IMPORTANT: You MUST read each file before editing it. Do the edits in parallel batches where possible for efficiency.


All 25 files with occurrences were updated successfully (plus `mirage/backend/filetype.py` had no occurrences, as expected). Here is a summary of what was done:

**All replacements applied** - `mirage.backends` -> `mirage.backend` (removing the trailing `s`) across 25 files in one parallel batch:

- `/Users/agent/strukto/mirage/mirage/backend/local/__init__.py` - 2 occurrences
- `/Users/agent/strukto/mirage/mirage/backend/local/memory.py` - 2 occurrences
- `/Users/agent/strukto/mirage/mirage/backend/local/local.py` - 2 occurrences
- `/Users/agent/strukto/mirage/mirage/backend/network/http.py` - 2 occurrences
- `/Users/agent/strukto/mirage/mirage/backend/network/proxy.py` - 1 occurrence
- `/Users/agent/strukto/mirage/mirage/backend/network/__init__.py` - 1 occurrence
- `/Users/agent/strukto/mirage/mirage/backend/object_storage/s3.py` - 2 occurrences
- `/Users/agent/strukto/mirage/mirage/backend/object_storage/r2.py` - 1 occurrence
- `/Users/agent/strukto/mirage/mirage/backend/__init__.py` - 3 occurrences
- `/Users/agent/strukto/mirage/mirage/__init__.py` - 1 occurrence
- `/Users/agent/strukto/mirage/mirage/workspace/__init__.py` - 2 occurrences
- `/Users/agent/strukto/mirage/mirage/workspace/sync.py` - 1 occurrence
- `/Users/agent/strukto/mirage/mirage/workspace/mount.py` - 2 occurrences
- `/Users/agent/strukto/mirage/mirage/workspace/cache/policy.py` - 2 occurrences
- `/Users/agent/strukto/mirage/mirage/cli/config.py` - 6 occurrences
- `/Users/agent/strukto/mirage/mirage/cli/commands/workspace.py` - 2 occurrences
- `/Users/agent/strukto/mirage/mirage/cli/commands/command.py` - 3 occurrences (inside function body imports)
- `/Users/agent/strukto/mirage/mirage/runtime/local.py` - 1 occurrence
- `/Users/agent/strukto/mirage/mirage/runtime/remote.py` - 1 occurrence
- `/Users/agent/strukto/mirage/mirage/observe/inspector.py` - 1 occurrence
- `/Users/agent/strukto/mirage/mirage/server/service/local.py` - 1 occurrence
- `/Users/agent/strukto/mirage/examples/r2.py` - 1 occurrence
- `/Users/agent/strukto/mirage/examples/claude-sdk.py` - 1 occurrence
- `/Users/agent/strukto/mirage/examples/claude-memory.py` - 1 occurrence
- `/Users/agent/strukto/mirage/fuse/README.md` - 1 occurrence
- `/Users/agent/strukto/mirage/fuse/tests/test_fs.py` - 1 occurrence

`/Users/agent/strukto/mirage/mirage/backend/filetype.py` had no occurrences of `mirage.backends`, as anticipated.

agentId: a3bf8ea5cb437d6f5 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 68463
tool_uses: 53
duration_ms: 61675</usage>

Prompt:
In EVERY file listed below, replace ALL occurrences of the string `mirage.backends` with `mirage.backend` (removing the trailing 's'). Use the Edit tool with replace_all=true on each file.

Files to update (source code):
1. mirage/backend/local/__init__.py
2. mirage/backend/local/memory.py
3. mirage/backend/local/local.py
4. mirage/backend/network/http.py
5. mirage/backend/network/proxy.py
6. mirage/backend/network/__init__.py
7. mirage/backend/object_storage/s3.py
8. mirage/backend/object_storage/r2.py
9. mirage/backend/__init__.py
10. mirage/backend/filetype.py (check if it has any, probably not)
11. mirage/__init__.py
12. mirage/workspace/__init__.py
13. mirage/workspace/sync.py
14. mirage/workspace/mount.py
15. mirage/workspace/cache/policy.py
16. mirage/cli/config.py
17. mirage/cli/commands/workspace.py
18. mirage/cli/commands/command.py
19. mirage/runtime/local.py
20. mirage/runtime/remote.py
21. mirage/observe/inspector.py
22. mirage/server/service/local.py
23. examples/r2.py
24. examples/claude-sdk.py
25. examples/claude-memory.py
26. fuse/README.md
27. fuse/tests/test_fs.py

For each file, read it first then use Edit with old_string="mirage.backends" new_string="mirage.backend" replace_all=true.

IMPORTANT: You MUST read each file before editing it. Do the edits in parallel batches where possible for efficiency.


Let me verify no stale references remain and run tests.


No matches found


Clean. No stale `mirage.backends` or `_guess_type` references remain.


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

Rename backends/ to backend/ everywhere

Run tests


Bash

Run full test suite


mirage/workspace/__init__.py                              241     16    93%   88, 150-151, 162, 170, 172, 176, 219-220, 223-224, 272-275, 333
mirage/workspace/cache/__init__.py                          3      0   100%
mirage/workspace/cache/entry.py                             7      0   100%
mirage/workspace/cache/policy.py                           26      3    88%   24-25, 37
mirage/workspace/cache/store.py                            45      1    98%   14
mirage/workspace/mount.py                                  34      1    97%   63
mirage/workspace/sync.py                                   56      0   100%
-------------------------------------------------------------------------------------
TOTAL                                                    4417   1103    75%
609 passed, 1 skipped in 2.03s


All 609 tests pass.


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


All done. Here's a summary of the refactoring:

**New directory structure:**
```
mirage/backend/
├── __init__.py
├── base.py
├── filetype.py          ← NEW: extensible EXTENSION_MAP + guess_type()
├── loader.py
├── local/
│   ├── __init__.py
│   ├── local.py         (LocalBackend)
│   └── memory.py        (MemoryBackend)
├── object_storage/
│   ├── __init__.py
│   ├── s3.py            (S3Backend)
│   └── r2.py            (R2Backend)
└── network/
    ├── __init__.py
    ├── http.py           (HTTPBackend)
    └── proxy.py          (ProxyBackend)
```

**Changes made:**
1. **Grouped backends** into `local/`, `object_storage/`, `network/` subfolders
2. **Renamed** `backends` → `backend` across the entire codebase
3. **Extracted** `_guess_type` → `filetype.py` as public `guess_type()` with an extensible `EXTENSION_MAP` dict and `DEFAULT_TYPE = "binary"` fallback for unknown extensions
4. Updated all 50+ files with new import paths — 609 tests passing


In EVERY file listed below, replace ALL occurrences of the string `mirage.backends` with `mirage.backend` (removing the trailing 's'). Use the Edit tool with replace_all=true on each file.

Files to update (source code):
1. mirage/backend/local/__init__.py
2. mirage/backend/local/memory.py
3. mirage/backend/local/local.py
4. mirage/backend/network/http.py
5. mirage/backend/network/proxy.py
6. mirage/backend/network/__init__.py
7. mirage/backend/object_storage/s3.py
8. mirage/backend/object_storage/r2.py
9. mirage/backend/__init__.py
10. mirage/backend/filetype.py (check if it has any, probably not)
11. mirage/__init__.py
12. mirage/workspace/__init__.py
13. mirage/workspace/sync.py
14. mirage/workspace/mount.py
15. mirage/workspace/cache/policy.py
16. mirage/cli/config.py
17. mirage/cli/commands/workspace.py
18. mirage/cli/commands/command.py
19. mirage/runtime/local.py
20. mirage/runtime/remote.py
21. mirage/observe/inspector.py
22. mirage/server/service/local.py
23. examples/r2.py
24. examples/claude-sdk.py
25. examples/claude-memory.py
26. fuse/README.md
27. fuse/tests/test_fs.py

For each file, read it first then use Edit with old_string="mirage.backends" new_string="mirage.backend" replace_all=true.

IMPORTANT: You MUST read each file before editing it. Do the edits in parallel batches where possible for efficiency.

Prompt:
In EVERY file listed below, replace ALL occurrences of the string `mirage.backends` with `mirage.backend` (removing the trailing 's'). Use the Edit tool with replace_all=true on each file.

Files to update (source code):
1. mirage/backend/local/__init__.py
2. mirage/backend/local/memory.py
3. mirage/backend/local/local.py
4. mirage/backend/network/http.py
5. mirage/backend/network/proxy.py
6. mirage/backend/network/__init__.py
7. mirage/backend/object_storage/s3.py
8. mirage/backend/object_storage/r2.py
9. mirage/backend/__init__.py
10. mirage/backend/filetype.py (check if it has any, probably not)
11. mirage/__init__.py
12. mirage/workspace/__init__.py
13. mirage/workspace/sync.py
14. mirage/workspace/mount.py
15. mirage/workspace/cache/policy.py
16. mirage/cli/config.py
17. mirage/cli/commands/workspace.py
18. mirage/cli/commands/command.py
19. mirage/runtime/local.py
20. mirage/runtime/remote.py
21. mirage/observe/inspector.py
22. mirage/server/service/local.py
23. examples/r2.py
24. examples/claude-sdk.py
25. examples/claude-memory.py
26. fuse/README.md
27. fuse/tests/test_fs.py

For each file, read it first then use Edit with old_string="mirage.backends" new_string="mirage.backend" replace_all=true.

IMPORTANT: You MUST read each file before editing it. Do the edits in parallel batches where possible for efficiency.


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


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


In EVERY file listed below, replace ALL occurrences of the string `mirage.backends` with `mirage.backend` (removing the trailing 's'). Use the Edit tool with replace_all=true on each file.

Files to update (test files):
1. tests/backend/test_loader.py
2. tests/backend/test_local.py
3. tests/backend/test_proxy.py
4. tests/backend/test_configs.py
5. tests/observe/test_inspector.py
6. tests/fuse_ops/test_registry.py
7. tests/fuse_ops/test_format_ops.py
8. tests/fuse_ops/test_default.py
9. tests/fuse_ops/test_remote.py
10. tests/cli/test_config.py
11. tests/integration/test_remote_transfer.py
12. tests/workspace/test_lock.py
13. tests/workspace/test_init.py
14. tests/workspace/test_sync.py
15. tests/workspace/test_write_ops.py
16. tests/workspace/test_mount.py
17. tests/server/test_arg_resolver.py
18. tests/server/test_registry.py
19. tests/runtime/test_runtime.py
20. tests/runtime/test_accessors.py
21. tests/runtime/test_memory.py
22. tests/commands/test_init.py
23. tests/commands/test_read.py
24. tests/conftest.py

For each file, read it first then use Edit with old_string="mirage.backends" new_string="mirage.backend" replace_all=true.

IMPORTANT: You MUST read each file before editing it. Do the edits in parallel batches where possible for efficiency.

Prompt:
In EVERY file listed below, replace ALL occurrences of the string `mirage.backends` with `mirage.backend` (removing the trailing 's'). Use the Edit tool with replace_all=true on each file.

Files to update (test files):
1. tests/backend/test_loader.py
2. tests/backend/test_local.py
3. tests/backend/test_proxy.py
4. tests/backend/test_configs.py
5. tests/observe/test_inspector.py
6. tests/fuse_ops/test_registry.py
7. tests/fuse_ops/test_format_ops.py
8. tests/fuse_ops/test_default.py
9. tests/fuse_ops/test_remote.py
10. tests/cli/test_config.py
11. tests/integration/test_remote_transfer.py
12. tests/workspace/test_lock.py
13. tests/workspace/test_init.py
14. tests/workspace/test_sync.py
15. tests/workspace/test_write_ops.py
16. tests/workspace/test_mount.py
17. tests/server/test_arg_resolver.py
18. tests/server/test_registry.py
19. tests/runtime/test_runtime.py
20. tests/runtime/test_accessors.py
21. tests/runtime/test_memory.py
22. tests/commands/test_init.py
23. tests/commands/test_read.py
24. tests/conftest.py

For each file, read it first then use Edit with old_string="mirage.backends" new_string="mirage.backend" replace_all=true.

IMPORTANT: You MUST read each file before editing it. Do the edits in parallel batches where possible for efficiency.


     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.filetype import guess_type
     7→from mirage.types import FileStat, FileType
     8→
     9→
    10→class LocalBackend(BaseBackend):
    11→    """
    12→    Backend backed by the local filesystem via pathlib/os.
    13→    No external dependencies.
    14→
    15→    All virtual paths are resolved relative to `root`.
    16→    Example: root="/data", path="/reports/q1.csv" → /data/reports/q1.csv
    17→    """
    18→
    19→    def __init__(self, root: str) -> None:
    20→        self.root = Path(root).resolve()
    21→
    22→    # ── helpers ─────────────────────────────────────────────────────────────
    23→
    24→    def _resolve(self, path: str) -> Path:
    25→        # strip leading slash so joinpath works correctly
    26→        relative = path.lstrip("/")
    27→        resolved = (self.root / relative).resolve()
    28→        # prevent path traversal outside root
    29→        resolved.relative_to(self.root)
    30→        return resolved
    31→
    32→    def _to_stat(self, p: Path) -> FileStat:
    33→        st = p.stat()
    34→        modified = datetime.fromtimestamp(st.st_mtime,
    35→                                          tz=timezone.utc).isoformat()
    36→        if p.is_dir():
    37→            return FileStat(name=p.name,
    38→                            size=None,
    39→                            modified=modified,
    40→                            type=FileType.DIRECTORY)
    41→        return FileStat(name=p.name,
    42→                        size=st.st_size,
    43→                        modified=modified,
    44→                        type=guess_type(p.name))
    45→
    46→    # ── ops ─────────────────────────────────────────────────────────────────
    47→
    48→    def read(self, path: str, offset: int, size: int) -> bytes:
    49→        p = self._resolve(path)
    50→        with p.open("rb") as f:
    51→            f.seek(offset)
    52→            return f.read(size)
    53→
    54→    def readdir(self,
    55→                path: str,
    56→                cursor: str | None = None) -> tuple[list[str], str | None]:
    57→        p = self._resolve(path)
    58→        if not p.is_dir():
    59→            raise NotADirectoryError(str(p))
    60→        base = "/" + path.strip("/")
    61→        entries = sorted(base.rstrip("/") + "/" + e.name for e in p.iterdir())
    62→        # cursor is an offset index for pagination
    63→        start = int(cursor) if cursor else 0
    64→        page = entries[start:]
    65→        return page, None
    66→
    67→    def stat(self, path: str) -> FileStat:
    68→        p = self._resolve(path)
    69→        if not p.exists():
    70→            raise FileNotFoundError(str(p))
    71→        return self._to_stat(p)
    72→
    73→    def open(self, path: str, flags: int) -> int:
    74→        raise NotImplementedError("open/release are FUSE-only")
    75→
    76→    def release(self, fh: int) -> None:
    77→        raise NotImplementedError("open/release are FUSE-only")
    78→
    79→    def write(self, path: str, offset: int, data: bytes) -> int:
    80→        p = self._resolve(path)
    81→        with p.open("r+b") as f:
    82→            f.seek(offset)
    83→            f.write(data)
    84→        return len(data)
    85→
    86→    def create(self, path: str) -> None:
    87→        p = self._resolve(path)
    88→        p.parent.mkdir(parents=True, exist_ok=True)
    89→        p.touch(exist_ok=True)
    90→
    91→    def unlink(self, path: str) -> None:
    92→        p = self._resolve(path)
    93→        p.unlink()
    94→
    95→    def rmdir(self, path: str) -> None:
    96→        p = self._resolve(path)
    97→        p.rmdir()  # raises OSError if non-empty — matches POSIX semantics
    98→
    99→    def mkdir(self, path: str) -> None:
   100→        p = self._resolve(path)
   101→        p.mkdir(parents=True, exist_ok=True)
   102→
   103→    def rename(self, src: str, dst: str) -> None:
   104→        s = self._resolve(src)
   105→        d = self._resolve(dst)
   106→        s.rename(d)
   107→
   108→    def truncate(self, path: str, size: int) -> None:
   109→        p = self._resolve(path)
   110→        os.truncate(p, size)
   111→


     1→from pydantic import BaseModel, ConfigDict, Field
     2→
     3→from mirage.backends.base import BaseBackend
     4→from mirage.backends.filetype 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→
    11→    base_url: str
    12→    headers: dict[str, str] = Field(default_factory=dict)
    13→    timeout: int = 30
    14→    readdir_fn: object = None
    15→
    16→
    17→class HTTPBackend(BaseBackend):
    18→    """
    19→    Read-only HTTP backend using httpx.
    20→
    21→    Maps virtual paths to URLs: base_url + path
    22→    e.g. base_url="https://data.example.com", path="/q1.json"
    23→         → GET https://data.example.com/q1.json
    24→
    25→    Requires: pip install mirage[http]
    26→
    27→    Write ops (write, create, unlink, etc.) raise NotImplementedError —
    28→    HTTP is read-only by design. Override to support writable HTTP APIs.
    29→
    30→    readdir() requires a user-supplied `readdir_fn` since HTTP has no
    31→    standard directory listing protocol.
    32→    """
    33→
    34→    def __init__(self, config: HTTPConfig) -> None:
    35→        self.config = config
    36→        self._client = self._make_client()
    37→
    38→    def _make_client(self):
    39→        try:
    40→            import httpx
    41→        except ImportError:
    42→            raise ImportError(
    43→                "httpx is required for HTTPBackend: pip install 'mirage[http]'"
    44→            )
    45→        return httpx.Client(
    46→            base_url=self.config.base_url,
    47→            headers=self.config.headers,
    48→            timeout=self.config.timeout,
    49→            follow_redirects=True,
    50→        )
    51→
    52→    def _url(self, path: str) -> str:
    53→        return self.config.base_url.rstrip("/") + "/" + path.lstrip("/")
    54→
    55→    # ── ops ─────────────────────────────────────────────────────────────────
    56→
    57→    def read(self, path: str, offset: int, size: int) -> bytes:
    58→        headers = {"Range": f"bytes={offset}-{offset + size - 1}"}
    59→        resp = self._client.get(self._url(path), headers=headers)
    60→        if resp.status_code == 416:
    61→            return b""
    62→        resp.raise_for_status()
    63→        return resp.content
    64→
    65→    def readdir(self,
    66→                path: str,
    67→                cursor: str | None = None) -> tuple[list[str], str | None]:
    68→        if self.config.readdir_fn is None:
    69→            raise NotImplementedError(
    70→                "HTTPBackend.readdir() requires a readdir_fn in HTTPConfig. "
    71→                "Provide a callable(path) -> list[str] for your API.")
    72→        names = self.config.readdir_fn(path)
    73→        return names, None
    74→
    75→    def stat(self, path: str) -> FileStat:
    76→        resp = self._client.head(self._url(path))
    77→        if resp.status_code == 404:
    78→            raise FileNotFoundError(path)
    79→        resp.raise_for_status()
    80→        size = int(resp.headers.get("content-length", 0)) or None
    81→        modified = resp.headers.get("last-modified")
    82→        etag = resp.headers.get("etag", "").strip('"') or None
    83→        extra = {"etag": etag} if etag else {}
    84→        return FileStat(
    85→            name=path.rstrip("/").rsplit("/", 1)[-1],
    86→            size=size,
    87→            modified=modified,
    88→            type=guess_type(path),
    89→            extra=extra,
    90→        )
    91→
    92→    def open(self, path: str, flags: int) -> int:
    93→        raise NotImplementedError("open/release are FUSE-only")
    94→
    95→    def release(self, fh: int) -> None:
    96→        raise NotImplementedError("open/release are FUSE-only")
    97→
    98→    # ── write ops — not supported ────────────────────────────────────────────
    99→
   100→    def write(self, path: str, offset: int, data: bytes) -> int:
   101→        raise NotImplementedError("HTTPBackend is read-only")
   102→
   103→    def create(self, path: str) -> None:
   104→        raise NotImplementedError("HTTPBackend is read-only")
   105→
   106→    def unlink(self, path: str) -> None:
   107→        raise NotImplementedError("HTTPBackend is read-only")
   108→
   109→    def rmdir(self, path: str) -> None:
   110→        raise NotImplementedError("HTTPBackend is read-only")
   111→
   112→    def mkdir(self, path: str) -> None:
   113→        raise NotImplementedError("HTTPBackend is read-only")
   114→
   115→    def rename(self, src: str, dst: str) -> None:
   116→        raise NotImplementedError("HTTPBackend is read-only")
   117→
   118→    def truncate(self, path: str, size: int) -> None:
   119→        raise NotImplementedError("HTTPBackend is read-only")
   120→


     1→import base64
     2→
     3→import httpx
     4→
     5→from mirage.backends.base import BaseBackend
     6→from mirage.types import FileStat
     7→
     8→
     9→class ProxyBackend(BaseBackend):
    10→
    11→    def __init__(self,
    12→                 base_url: str,
    13→                 ws_id: str,
    14→                 timeout: int = 30,
    15→                 http_client: httpx.Client | None = None) -> None:
    16→        self._base_url = base_url.rstrip("/")
    17→        self._ws_id = ws_id
    18→        self._http = http_client or httpx.Client(timeout=timeout)
    19→
    20→    def _post(self, op: str, payload: dict) -> dict:
    21→        url = f"{self._base_url}/workspaces/{self._ws_id}/io/{op}"
    22→        resp = self._http.post(url, json=payload)
    23→        if resp.status_code == 404:
    24→            detail = resp.json().get("detail", "")
    25→            if "workspace" in detail:
    26→                raise KeyError(detail)
    27→            raise FileNotFoundError(payload.get("path", ""))
    28→        resp.raise_for_status()
    29→        return resp.json()
    30→
    31→    def read(self, path: str, offset: int, size: int) -> bytes:
    32→        r = self._post("read", {"path": path, "offset": offset, "size": size})
    33→        return base64.b64decode(r["data"])
    34→
    35→    def readdir(self,
    36→                path: str,
    37→                cursor: str | None = None) -> tuple[list[str], str | None]:
    38→        r = self._post("readdir", {"path": path, "cursor": cursor})
    39→        return r["entries"], r.get("cursor")
    40→
    41→    def stat(self, path: str) -> FileStat:
    42→        r = self._post("stat", {"path": path})
    43→        return FileStat(**r)
    44→
    45→    def open(self, path: str, flags: int) -> int:
    46→        raise NotImplementedError("open not supported on ProxyBackend")
    47→
    48→    def release(self, fh: int) -> None:
    49→        raise NotImplementedError("release not supported on ProxyBackend")
    50→
    51→    def write(self, path: str, offset: int, data: bytes) -> int:
    52→        encoded = base64.b64encode(data).decode()
    53→        r = self._post("write", {
    54→            "path": path,
    55→            "offset": offset,
    56→            "data": encoded
    57→        })
    58→        return r["written"]
    59→
    60→    def create(self, path: str) -> None:
    61→        self._post("create", {"path": path})
    62→
    63→    def unlink(self, path: str) -> None:
    64→        self._post("unlink", {"path": path})
    65→
    66→    def rmdir(self, path: str) -> None:
    67→        self._post("rmdir", {"path": path})
    68→
    69→    def mkdir(self, path: str) -> None:
    70→        self._post("mkdir", {"path": path})
    71→
    72→    def rename(self, src: str, dst: str) -> None:
    73→        self._post("rename", {"src": src, "dst": dst})
    74→
    75→    def truncate(self, path: str, size: int) -> None:
    76→        self._post("truncate", {"path": path, "size": size})
    77→
    78→    def fuse_cat(self, path: str) -> bytes:
    79→        r = self._post("fuse_cat", {"path": path})
    80→        return base64.b64decode(r["data"])
    81→


     1→from mirage.backends.network.proxy import ProxyBackend
     2→
     3→__all__ = ["ProxyBackend"]
     4→


     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.filetype import guess_type
     8→from mirage.types import FileStat, FileType
     9→
    10→
    11→class S3Config(BaseModel):
    12→    model_config = ConfigDict(frozen=True)
    13→
    14→    bucket: str
    15→    region: str | None = None
    16→    endpoint_url: str | None = None
    17→    aws_access_key_id: str | None = None
    18→    aws_secret_access_key: str | None = None
    19→    aws_profile: str | None = None
    20→    timeout: int = 30
    21→    proxy: str | None = None
    22→    select_threshold_bytes: int | None = 1 * 1024 * 1024  # 1 MB
    23→
    24→
    25→class S3Backend(BaseBackend):
    26→    """
    27→    S3-compatible backend using boto3.
    28→
    29→    Requires: pip install mirage[s3]
    30→
    31→    Key design decisions:
    32→    - read()  → GetObject with Range header (partial reads)
    33→    - readdir() → list_objects_v2 with paginator (handles >1000 keys)
    34→    - write()  → full PutObject (S3 has no partial write)
    35→    - grep()   → S3 Select for large files, full fetch for small files
    36→    - ls_l()   → list_objects_v2 already returns size+mtime, skip per-file stat
    37→    """
    38→
    39→    def __init__(self, config: S3Config) -> None:
    40→        self.config = config
    41→        self._client = self._make_client()
    42→
    43→    def _make_client(self):
    44→        try:
    45→            import boto3
    46→            from botocore.config import Config
    47→        except ImportError:
    48→            raise ImportError(
    49→                "boto3 is required for S3Backend: pip install 'mirage[s3]'")
    50→
    51→        kwargs: dict = {"service_name": "s3"}
    52→        c = self.config
    53→        if c.region:
    54→            kwargs["region_name"] = c.region
    55→        if c.endpoint_url:
    56→            kwargs["endpoint_url"] = c.endpoint_url
    57→        if c.aws_access_key_id and c.aws_secret_access_key:
    58→            kwargs["aws_access_key_id"] = c.aws_access_key_id
    59→            kwargs["aws_secret_access_key"] = c.aws_secret_access_key
    60→
    61→        cfg_kwargs: dict = {
    62→            "connect_timeout": c.timeout,
    63→            "read_timeout": c.timeout
    64→        }
    65→        if c.proxy:
    66→            cfg_kwargs["proxies"] = {"https": c.proxy, "http": c.proxy}
    67→
    68→        kwargs["config"] = Config(**cfg_kwargs)
    69→
    70→        if c.aws_profile:
    71→            session = boto3.Session(profile_name=c.aws_profile)
    72→            return session.client(**kwargs)
    73→        return boto3.client(**kwargs)
    74→
    75→    # ── key helpers ─────────────────────────────────────────────────────────
    76→
    77→    def _key(self, path: str) -> str:
    78→        """Convert virtual path to S3 key (strip leading slash)."""
    79→        return path.lstrip("/")
    80→
    81→    def _prefix(self, path: str) -> str:
    82→        """Directory prefix — always ends with '/' unless root."""
    83→        key = self._key(path)
    84→        if key and not key.endswith("/"):
    85→            key += "/"
    86→        return key
    87→
    88→    # ── ops ─────────────────────────────────────────────────────────────────
    89→
    90→    def read(self, path: str, offset: int, size: int) -> bytes:
    91→        end = offset + size - 1
    92→        resp = self._client.get_object(
    93→            Bucket=self.config.bucket,
    94→            Key=self._key(path),
    95→            Range=f"bytes={offset}-{end}",
    96→        )
    97→        return resp["Body"].read()
    98→
    99→    def readdir(self,
   100→                path: str,
   101→                cursor: str | None = None) -> tuple[list[str], str | None]:
   102→        prefix = self._prefix(path)
   103→        kwargs: dict = {
   104→            "Bucket": self.config.bucket,
   105→            "Prefix": prefix,
   106→            "Delimiter": "/",
   107→        }
   108→        if cursor:
   109→            kwargs["ContinuationToken"] = cursor
   110→
   111→        resp = self._client.list_objects_v2(**kwargs)
   112→
   113→        names: list[str] = []
   114→        # common prefixes = subdirectories
   115→        for cp in resp.get("CommonPrefixes") or []:
   116→            p = cp["Prefix"]
   117→            child = p.rstrip("/")
   118→            if child:
   119→                names.append(child)
   120→        # object keys = files
   121→        for obj in resp.get("Contents") or []:
   122→            key = obj["Key"]
   123→            relative = key[len(prefix):]
   124→            if relative and "/" not in relative:  # direct children only
   125→                names.append(key)
   126→
   127→        next_cursor = resp.get("NextContinuationToken")
   128→        return sorted(names), next_cursor
   129→
   130→    def stat(self, path: str) -> FileStat:
   131→        try:
   132→            resp = self._client.head_object(Bucket=self.config.bucket,
   133→                                            Key=self._key(path))
   134→            modified = resp["LastModified"].astimezone(
   135→                timezone.utc).isoformat()
   136→            return FileStat(
   137→                name=path.rstrip("/").rsplit("/", 1)[-1],
   138→                size=resp["ContentLength"],
   139→                modified=modified,
   140→                type=guess_type(path),
   141→                extra={"etag": resp.get("ETag", "").strip('"')},
   142→            )
   143→        except self._client.exceptions.NoSuchKey:
   144→            pass
   145→        except Exception as exc:
   146→            if not (hasattr(exc, "response")
   147→                    and exc.response.get("Error", {}).get("Code") == "404"):
   148→                raise
   149→        # key not found — check if it's a directory prefix
   150→        prefix = self._prefix(path)
   151→        resp = self._client.list_objects_v2(Bucket=self.config.bucket,
   152→                                            Prefix=prefix,
   153→                                            MaxKeys=1)
   154→        if resp.get("Contents") or resp.get("CommonPrefixes"):
   155→            return FileStat(
   156→                name=path.rstrip("/").rsplit("/", 1)[-1] or "/",
   157→                size=None,
   158→                type=FileType.DIRECTORY,
   159→            )
   160→        raise FileNotFoundError(path)
   161→
   162→    def open(self, path: str, flags: int) -> int:
   163→        raise NotImplementedError("open/release are FUSE-only")
   164→
   165→    def release(self, fh: int) -> None:
   166→        raise NotImplementedError("open/release are FUSE-only")
   167→
   168→    def write(self, path: str, offset: int, data: bytes) -> int:
   169→        # S3 has no partial write — we must do full replace
   170→        if offset != 0:
   171→            # fetch existing, splice, re-upload
   172→            existing = self.cat(path)
   173→            data = existing[:offset] + data + existing[offset + len(data):]
   174→        self._client.put_object(Bucket=self.config.bucket,
   175→                                Key=self._key(path),
   176→                                Body=data)
   177→        return len(data)
   178→
   179→    def create(self, path: str) -> None:
   180→        self._client.put_object(Bucket=self.config.bucket,
   181→                                Key=self._key(path),
   182→                                Body=b"")
   183→
   184→    def unlink(self, path: str) -> None:
   185→        self._client.delete_object(Bucket=self.config.bucket,
   186→                                   Key=self._key(path))
   187→
   188→    def rmdir(self, path: str) -> None:
   189→        prefix = self._prefix(path)
   190→        paginator = self._client.get_paginator("list_objects_v2")
   191→        for page in paginator.paginate(Bucket=self.config.bucket,
   192→                                       Prefix=prefix):
   193→            keys = [{"Key": obj["Key"]} for obj in page.get("Contents") or []]
   194→            if keys:
   195→                self._client.delete_objects(Bucket=self.config.bucket,
   196→                                            Delete={"Objects": keys})
   197→
   198→    def mkdir(self, path: str) -> None:
   199→        # S3 has no real directories — key prefix convention
   200→        prefix = self._prefix(path)
   201→        if prefix:
   202→            self._client.put_object(Bucket=self.config.bucket,
   203→                                    Key=prefix,
   204→                                    Body=b"")
   205→
   206→    def rename(self, src: str, dst: str) -> None:
   207→        self._client.copy_object(
   208→            Bucket=self.config.bucket,
   209→            CopySource={
   210→                "Bucket": self.config.bucket,
   211→                "Key": self._key(src)
   212→            },
   213→            Key=self._key(dst),
   214→        )
   215→        self._client.delete_object(Bucket=self.config.bucket,
   216→                                   Key=self._key(src))
   217→
   218→    def truncate(self, path: str, size: int) -> None:
   219→        data = self.cat(path)
   220→        if size < len(data):
   221→            data = data[:size]
   222→        else:
   223→            data = data + b"\x00" * (size - len(data))
   224→        self._client.put_object(Bucket=self.config.bucket,
   225→                                Key=self._key(path),
   226→                                Body=data)
   227→
   228→    # ── native overrides ────────────────────────────────────────────────────
   229→
   230→    def ls_l(self, path: str) -> list[FileStat]:
   231→        """list_objects_v2 returns size+mtime — skip per-file stat."""
   232→        prefix = self._prefix(path)
   233→        stats: list[FileStat] = []
   234→        paginator = self._client.get_paginator("list_objects_v2")
   235→        for page in paginator.paginate(Bucket=self.config.bucket,
   236→                                       Prefix=prefix,
   237→                                       Delimiter="/"):
   238→            for cp in page.get("CommonPrefixes") or []:
   239→                name = cp["Prefix"][len(prefix):].rstrip("/")
   240→                stats.append(
   241→                    FileStat(name=name, size=None, type=FileType.DIRECTORY))
   242→            for obj in page.get("Contents") or []:
   243→                key = obj["Key"]
   244→                name = key[len(prefix):]
   245→                if name and "/" not in name:
   246→                    modified = obj["LastModified"].astimezone(
   247→                        timezone.utc).isoformat()
   248→                    stats.append(
   249→                        FileStat(name=name,
   250→                                 size=obj["Size"],
   251→                                 modified=modified,
   252→                                 type=guess_type(name)))
   253→        return stats
   254→
   255→    def head(self, path: str, lines: int = 10) -> bytes:
   256→        threshold = self.config.select_threshold_bytes
   257→        if threshold is not None:
   258→            try:
   259→                s = self.stat(path)
   260→                if s.size and s.size > threshold:
   261→                    return self._head_select(path, lines)
   262→            except FileNotFoundError:
   263→                pass
   264→        return super().head(path, lines)
   265→
   266→    def _head_select(self, path: str, lines: int) -> bytes:
   267→        sql = f"SELECT * FROM S3Object LIMIT {int(lines)}"
   268→        try:
   269→            resp = self._client.select_object_content(
   270→                Bucket=self.config.bucket,
   271→                Key=self._key(path),
   272→                ExpressionType="SQL",
   273→                Expression=sql,
   274→                InputSerialization={
   275→                    "CSV": {
   276→                        "FileHeaderInfo": "NONE"
   277→                    },
   278→                    "CompressionType": "NONE"
   279→                },
   280→                OutputSerialization={"CSV": {}},
   281→            )
   282→            parts: list[str] = []
   283→            for event in resp["Payload"]:
   284→                if "Records" in event:
   285→                    parts.extend(
   286→                        event["Records"]["Payload"].decode().splitlines())
   287→            return "\n".join(parts[:lines]).encode()
   288→        except Exception:
   289→            return super().head(path, lines)
   290→
   291→    def presign(self, path: str, expires: int = 3600) -> str:
   292→        return self._client.generate_presigned_url(
   293→            "get_object",
   294→            Params={
   295→                "Bucket": self.config.bucket,
   296→                "Key": self._key(path)
   297→            },
   298→            ExpiresIn=expires,
   299→        )
   300→
   301→    def grep(self, pattern: str, path: str, flags: int = 0) -> list[str]:
   302→        """S3 Select for large files; full fetch for small files."""
   303→        threshold = self.config.select_threshold_bytes
   304→        if threshold is not None:
   305→            try:
   306→                s = self.stat(path)
   307→                if s.size and s.size > threshold:
   308→                    return self._grep_select(pattern, path)
   309→            except FileNotFoundError:
   310→                pass
   311→        return super().grep(pattern, path, flags)
   312→
   313→    def _grep_select(self, pattern: str, path: str) -> list[str]:
   314→        """S3 Select LIKE filter — regex unsupported, re-filtered in Python."""
   315→        escaped = (pattern.replace("'",
   316→                                   "''").replace("%",
   317→                                                 "\\%").replace("_", "\\_"))
   318→        sql = f"SELECT * FROM S3Object WHERE _1 LIKE '%{escaped}%'"
   319→        try:
   320→            resp = self._client.select_object_content(
   321→                Bucket=self.config.bucket,
   322→                Key=self._key(path),
   323→                ExpressionType="SQL",
   324→                Expression=sql,
   325→                InputSerialization={
   326→                    "CSV": {
   327→                        "FileHeaderInfo": "NONE"
   328→                    },
   329→                    "CompressionType": "NONE"
   330→                },
   331→                OutputSerialization={"CSV": {}},
   332→            )
   333→            lines: list[str] = []
   334→            for event in resp["Payload"]:
   335→                if "Records" in event:
   336→                    lines.extend(
   337→                        event["Records"]["Payload"].decode().splitlines())
   338→            compiled = re.compile(pattern)
   339→            return [line for line in lines if compiled.search(line)]
   340→        except Exception:
   341→            return super().grep(pattern, path)
   342→


     1→from pydantic import BaseModel, ConfigDict
     2→
     3→from mirage.backends.object_storage.s3 import S3Backend, S3Config
     4→
     5→
     6→class R2Config(BaseModel):
     7→    model_config = ConfigDict(frozen=True)
     8→
     9→    account_id: str
    10→    bucket: str
    11→    access_key_id: str
    12→    secret_access_key: str
    13→    region: str = "auto"
    14→    timeout: int = 30
    15→
    16→
    17→class R2Backend(S3Backend):
    18→    """
    19→    Cloudflare R2 backend — extends S3Backend with auto-computed endpoint.
    20→
    21→    Differences from S3Backend:
    22→    - endpoint_url auto-computed from account_id
    23→    - S3 Select DISABLED (R2 does not support SelectObjectContent)
    24→    - region: R2 location hint (wnam, enam, weur, eeur, apac, oc, auto)
    25→
    26→    Requires: pip install mirage[r2]
    27→    """
    28→
    29→    def __init__(self, config: R2Config) -> None:
    30→        endpoint = f"https://{config.account_id}.r2.cloudflarestorage.com"
    31→        s3_config = S3Config(
    32→            bucket=config.bucket,
    33→            region=config.region,
    34→            endpoint_url=endpoint,
    35→            aws_access_key_id=config.access_key_id,
    36→            aws_secret_access_key=config.secret_access_key,
    37→            timeout=config.timeout,
    38→            select_threshold_bytes=None,  # R2 does not support S3 Select
    39→        )
    40→        super().__init__(s3_config)
    41→        self._endpoint = endpoint
    42→


     1→from mirage.backends.base import BaseBackend
     2→from mirage.backends.local import LocalBackend, MemoryBackend
     3→from mirage.backends.network import ProxyBackend
     4→
     5→__all__ = ["BaseBackend", "LocalBackend", "MemoryBackend", "ProxyBackend"]
     6→


     1→EXTENSION_MAP: dict[str, str] = {
     2→    "json": "json",
     3→    "jsonl": "json",
     4→    "csv": "csv",
     5→    "tsv": "csv",
     6→    "txt": "text",
     7→    "md": "text",
     8→    "py": "text",
     9→    "js": "text",
    10→    "ts": "text",
    11→    "yaml": "text",
    12→    "yml": "text",
    13→    "toml": "text",
    14→}
    15→
    16→DEFAULT_TYPE = "binary"
    17→
    18→
    19→def guess_type(path: str) -> str:
    20→    """Return the file type string for *path* based on its extension.
    21→
    22→    Args:
    23→        path (str): file path or name.
    24→
    25→    Returns:
    26→        str: matched type from EXTENSION_MAP, or DEFAULT_TYPE.
    27→    """
    28→    ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
    29→    return EXTENSION_MAP.get(ext, DEFAULT_TYPE)
    30→


     1→import pytest
     2→
     3→from mirage.backends.loader import load_backend_class
     4→
     5→
     6→def test_load_from_module():
     7→    cls = load_backend_class("mirage.backends.local.memory:MemoryBackend")
     8→    from mirage.backends.local.memory import MemoryBackend
     9→    assert cls is MemoryBackend
    10→
    11→
    12→def test_load_from_script_file(tmp_path):
    13→    script = tmp_path / "custom_backend.py"
    14→    script.write_text(
    15→        "from mirage.backends.local.memory import MemoryBackend\n"
    16→        "class CustomBackend(MemoryBackend):\n"
    17→        "    pass\n")
    18→    cls = load_backend_class(f"{script}:CustomBackend")
    19→    assert cls.__name__ == "CustomBackend"
    20→    instance = cls()
    21→    instance.put("/test.txt", b"hello")
    22→    assert instance.cat("/test.txt") == b"hello"
    23→
    24→
    25→def test_load_invalid_spec_no_colon():
    26→    with pytest.raises(ValueError, match="invalid backend spec"):
    27→        load_backend_class("mirage.backends.memory")
    28→
    29→
    30→def test_load_missing_file():
    31→    with pytest.raises(ValueError, match="cannot load script"):
    32→        load_backend_class("/nonexistent/path.py:Cls")
    33→
    34→
    35→def test_load_missing_class():
    36→    with pytest.raises(ValueError, match="not found"):
    37→        load_backend_class("mirage.backends.local.memory:DoesNotExist")
    38→


     1→from mirage.backends.local import LocalBackend, MemoryBackend
     2→from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
     3→                          SyncPolicy, SyncResult)
     4→from mirage.workspace import Workspace
     5→
     6→__all__ = [
     7→    "Workspace",
     8→    "MemoryBackend",
     9→    "LocalBackend",
    10→    "ConsistencyPolicy",
    11→    "FileStat",
    12→    "Inode",
    13→    "MountMode",
    14→    "SyncPolicy",
    15→    "SyncResult",
    16→]
    17→


     1→import pytest
     2→
     3→from mirage.backends.local.local import LocalBackend
     4→from mirage.types import FileType
     5→
     6→
     7→@pytest.fixture
     8→def local_backend(tmp_path):
     9→    return LocalBackend(str(tmp_path))
    10→
    11→
    12→def test_create_and_cat(local_backend):
    13→    local_backend.create("/hello.txt")
    14→    local_backend.write("/hello.txt", 0, b"hello")
    15→    assert local_backend.cat("/hello.txt") == b"hello"
    16→
    17→
    18→def test_put_and_cat(local_backend):
    19→    local_backend.put("/f.txt", b"data")
    20→    assert local_backend.cat("/f.txt") == b"data"
    21→
    22→
    23→def test_read_partial(local_backend):
    24→    local_backend.put("/f.txt", b"abcdef")
    25→    assert local_backend.read("/f.txt", 2, 3) == b"cde"
    26→
    27→
    28→def test_mkdir_and_readdir(local_backend):
    29→    local_backend.mkdir("/mydir")
    30→    entries, _ = local_backend.readdir("/mydir")
    31→    assert entries == []
    32→
    33→
    34→def test_rmdir_empty(local_backend):
    35→    local_backend.mkdir("/emptydir")
    36→    local_backend.rmdir("/emptydir")
    37→    with pytest.raises(FileNotFoundError):
    38→        local_backend.stat("/emptydir")
    39→
    40→
    41→def test_unlink(local_backend):
    42→    local_backend.put("/del.txt", b"x")
    43→    local_backend.unlink("/del.txt")
    44→    with pytest.raises(FileNotFoundError):
    45→        local_backend.stat("/del.txt")
    46→
    47→
    48→def test_rename(local_backend):
    49→    local_backend.put("/old.txt", b"content")
    50→    local_backend.rename("/old.txt", "/new.txt")
    51→    assert local_backend.cat("/new.txt") == b"content"
    52→    with pytest.raises(FileNotFoundError):
    53→        local_backend.stat("/old.txt")
    54→
    55→
    56→def test_stat_file(local_backend):
    57→    local_backend.put("/f.txt", b"hello")
    58→    s = local_backend.stat("/f.txt")
    59→    assert s.name == "f.txt"
    60→    assert s.size == 5
    61→    assert s.type == "text"
    62→
    63→
    64→def test_stat_directory(local_backend):
    65→    local_backend.mkdir("/mydir")
    66→    s = local_backend.stat("/mydir")
    67→    assert s.type == FileType.DIRECTORY
    68→    assert s.size is None
    69→
    70→
    71→def test_stat_missing_raises(local_backend):
    72→    with pytest.raises(FileNotFoundError):
    73→        local_backend.stat("/missing.txt")
    74→
    75→
    76→def test_write_at_offset(local_backend):
    77→    local_backend.put("/f.txt", b"hello world")
    78→    local_backend.write("/f.txt", 6, b"earth")
    79→    assert local_backend.cat("/f.txt") == b"hello earth"
    80→
    81→
    82→def test_truncate_shrink(local_backend):
    83→    local_backend.put("/f.txt", b"abcdef")
    84→    local_backend.truncate("/f.txt", 3)
    85→    assert local_backend.cat("/f.txt") == b"abc"
    86→
    87→
    88→def test_truncate_grow(local_backend):
    89→    local_backend.put("/f.txt", b"ab")
    90→    local_backend.truncate("/f.txt", 5)
    91→    assert len(local_backend.cat("/f.txt")) == 5
    92→
    93→
    94→def test_path_traversal_raises(local_backend):
    95→    with pytest.raises(ValueError):
    96→        local_backend.stat("/../etc/passwd")
    97→