Compare commits

...

3 Commits

Author SHA1 Message Date
sonhmai 66f649ea3f test(types): trim FileStat split test to the enforced invariant
Keep only the two tests that guard real logic: the validator rejecting
content on a non-FILE kind, and type being required. Drop the
change-detector tests that restated the enum definition and the ones
that only exercised pydantic field storage.
2026-08-22 14:44:02 +07:00
sonhmai 0727bd75a5 refactor(types): split FileType (node kind) from ContentType 2026-08-22 14:44:01 +07:00
Zecheng Zhang b425d0fcf5 Merge pull request #883 from strukto-ai/feat/ts-generic-resource
feat(resource): assemble a TypeScript backend from one CommandIO table
2026-08-22 00:05:18 -07:00
148 changed files with 981 additions and 575 deletions
+5 -2
View File
@@ -19,7 +19,7 @@ from mirage import MountMode, Workspace
from mirage.sdk import (NULL_INDEX, Accessor, CommandIO, CommandSpec, FileStat,
GenericResource, IndexCacheStore, IOResult, PathSpec,
command, register_resource, stream_from_bytes)
from mirage.types import FileType
from mirage.types import ContentType, FileType
# A whole custom backend in one script: three async core functions over
# your data source, one CommandIO table, one GenericResource. Every
@@ -84,7 +84,10 @@ async def stat(
name = path.virtual.rstrip("/").rsplit("/", 1)[-1] or "/"
if isinstance(node, dict):
return FileStat(name=name, size=None, type=FileType.DIRECTORY)
return FileStat(name=name, size=len(node.encode()), type=FileType.TEXT)
return FileStat(name=name,
size=len(node.encode()),
type=FileType.FILE,
content=ContentType.TEXT)
# Optional: a bespoke domain verb, registered alongside the generics.
+11 -11
View File
@@ -18,20 +18,20 @@ from typing import Any, cast
from agents import Runner, TResponseInputItem
from openai import AsyncOpenAI
from mirage.types import FileType
from mirage.types import ContentType
from mirage.workspace.workspace import Workspace
_VISION_TYPES = {
FileType.IMAGE_PNG,
FileType.IMAGE_JPEG,
FileType.IMAGE_GIF,
ContentType.IMAGE_PNG,
ContentType.IMAGE_JPEG,
ContentType.IMAGE_GIF,
}
_MIMETYPE_FOR = {
FileType.IMAGE_PNG: "image/png",
FileType.IMAGE_JPEG: "image/jpeg",
FileType.IMAGE_GIF: "image/gif",
FileType.PDF: "application/pdf",
ContentType.IMAGE_PNG: "image/png",
ContentType.IMAGE_JPEG: "image/jpeg",
ContentType.IMAGE_GIF: "image/gif",
ContentType.PDF: "application/pdf",
}
@@ -56,14 +56,14 @@ class MirageRunner:
async def _block_for_path(self, path: str) -> dict[str, Any]:
st = await self._ws.ops.stat(path)
data = await self._ws.ops.read(path)
if st.type in _VISION_TYPES:
mime = _MIMETYPE_FOR[st.type]
if st.content in _VISION_TYPES:
mime = _MIMETYPE_FOR[st.content]
b64 = base64.b64encode(data).decode("ascii")
return {
"type": "input_image",
"image_url": f"data:{mime};base64,{b64}",
}
if st.type == FileType.PDF:
if st.content == ContentType.PDF:
if self._client is None:
self._client = AsyncOpenAI()
filename = path.rsplit("/", 1)[-1]
+20 -18
View File
@@ -13,12 +13,12 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.commands.builtin.constants import FILE_MIME_MAP
from mirage.types import FileStat, FileType
from mirage.types import ContentType, FileStat, FileType
def format_file_result(
path: str,
result: FileType | str,
result: ContentType | FileType | str,
brief: bool,
mime: bool,
) -> str:
@@ -26,33 +26,35 @@ def format_file_result(
Args:
path (str): operand as typed, omitted under -b.
result (FileType | str): detected type, or a ready description.
result (ContentType | FileType | str): detected type, or a ready
description.
brief (bool): -b, drop the filename column.
mime (bool): -i, map the type to its MIME spelling.
"""
key = result.value if isinstance(result, FileType) else str(result)
key = (result.value if isinstance(result, (ContentType,
FileType)) else str(result))
desc = FILE_MIME_MAP.get(key, key) if mime else key
if brief:
return desc
return f"{path}: {desc}"
def _detect(path: str, header: bytes, s: FileStat) -> FileType | str:
if s.type and s.type != FileType.BINARY:
return s.type
magic: list[tuple[bytes, FileType]] = [
(b"\x89PNG", FileType.IMAGE_PNG),
(b"\xff\xd8\xff", FileType.IMAGE_JPEG),
(b"GIF8", FileType.IMAGE_GIF),
(b"PK\x03\x04", FileType.ZIP),
(b"\x1f\x8b", FileType.GZIP),
(b"%PDF", FileType.PDF),
(b"{\n", FileType.JSON),
(b"[{", FileType.JSON),
def _detect(path: str, header: bytes, s: FileStat) -> ContentType | str:
if s.content is not None and s.content != ContentType.BINARY:
return s.content
magic: list[tuple[bytes, ContentType]] = [
(b"\x89PNG", ContentType.IMAGE_PNG),
(b"\xff\xd8\xff", ContentType.IMAGE_JPEG),
(b"GIF8", ContentType.IMAGE_GIF),
(b"PK\x03\x04", ContentType.ZIP),
(b"\x1f\x8b", ContentType.GZIP),
(b"%PDF", ContentType.PDF),
(b"{\n", ContentType.JSON),
(b"[{", ContentType.JSON),
]
for sig, ftype in magic:
if header.startswith(sig):
return ftype
if all(b < 128 for b in header[:256] if b != 0):
return FileType.TEXT
return FileType.BINARY
return ContentType.TEXT
return ContentType.BINARY
@@ -22,10 +22,7 @@ _ASCII_DIGITS = frozenset("0123456789")
_TYPE_LABELS = {
FileType.DIRECTORY: "directory",
FileType.SYMLINK: "symbolic link",
FileType.TEXT: "regular file",
FileType.BINARY: "regular file",
FileType.JSON: "regular file",
FileType.CSV: "regular file",
FileType.FILE: "regular file",
}
_DEFAULT_OWNER = "user"
@@ -352,8 +349,12 @@ def _render_stat(s: FileStat) -> str:
Args:
s (FileStat): the stat to render.
"""
# The record's type= shows a regular file's content shape and a
# non-regular node's kind, so one field reads the way it always has.
shown = (s.content.value if s.type is FileType.FILE
and s.content is not None else s.type.value)
return (f"name={s.name} size={s.size} modified={s.modified}"
f" type={s.type.value if s.type else None}")
f" type={shown}")
async def stat(
+4 -2
View File
@@ -49,7 +49,8 @@ def _stat_from_item(item: dict[str, Any]) -> FileStat:
return FileStat(
name=vfs_name,
size=item.get("size"),
type=guess_type(vfs_name),
type=FileType.FILE,
content=guess_type(vfs_name),
modified=remote_time,
fingerprint=sha1 or remote_time or None,
extra={
@@ -118,7 +119,8 @@ async def stat(
return FileStat(
name=result.entry.vfs_name or result.entry.name,
size=result.entry.size,
type=guess_type(result.entry.vfs_name),
type=FileType.FILE,
content=guess_type(result.entry.vfs_name),
modified=result.entry.remote_time,
fingerprint=sha1 or result.entry.remote_time or None,
extra={
+3 -2
View File
@@ -1,7 +1,7 @@
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.chroma.path import resolve_path
from mirage.core.chroma.sizes import ensure_dir_sizes
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.path import parent
@@ -34,7 +34,8 @@ async def stat(accessor,
entry = refreshed.entry
return FileStat(
name=entry.name,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
size=entry.size,
modified=entry.extra.get("updated_at"),
fingerprint=None,
+4 -2
View File
@@ -85,7 +85,8 @@ async def stat(
return FileStat(name=entry.name,
size=entry.size,
modified=entry.remote_time or None,
type=guess_type(entry.name))
type=FileType.FILE,
content=guess_type(entry.name))
parent = virtual_key.rsplit("/", 1)[0] or "/"
parent_listing = await index.list_dir(parent)
if parent_listing.entries is not None:
@@ -106,4 +107,5 @@ async def stat(
return FileStat(name=name,
size=size,
modified=modified,
type=guess_type(name))
type=FileType.FILE,
content=guess_type(name))
+5 -3
View File
@@ -5,7 +5,7 @@ from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.dify.client import get_document_detail
from mirage.core.dify.path import resolve_path
from mirage.core.dify.tree import extract_document_size
from mirage.types import FileStat, FileType, JsonValue, PathSpec
from mirage.types import ContentType, FileStat, FileType, JsonValue, PathSpec
async def stat_light(accessor: DifyAccessor,
@@ -27,7 +27,8 @@ async def stat_light(accessor: DifyAccessor,
extra["source_size"] = resolved.entry.size
return FileStat(
name=resolved.entry.name,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
size=None,
modified=timestamp_to_zulu(resolved.entry.remote_time),
fingerprint=None,
@@ -64,7 +65,8 @@ async def stat(accessor: DifyAccessor,
extra["indexing_status"] = detail.get("indexing_status")
return FileStat(
name=resolved.entry.name,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
size=None,
modified=timestamp_to_zulu(detail.get("updated_at")),
fingerprint=None,
+3 -3
View File
@@ -14,7 +14,7 @@
from mirage.core.hierarchy.codec import DATE, JSON_NAME
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
_GUILD = (Slot("guild", id_key="guild_id"), )
_CHANNEL = _GUILD + ("channels", Slot("channel", id_key="channel_id"))
@@ -34,12 +34,12 @@ SCOPES = (
segments=_GUILD +
("members", Slot("member", JSON_NAME, id_key="user_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="day", segments=_DAY),
Scope(kind="messages",
segments=_DAY + ("chat.jsonl", ),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
Scope(kind="files", segments=_DAY + ("files", )),
Scope(kind="file_blob", segments=_DAY + ("files", Slot("blob")),
leaf=True),
+12 -5
View File
@@ -20,7 +20,7 @@ from mirage.core.discord.scope import detect_scope
from mirage.core.hierarchy.probe import resolve_entry
from mirage.core.hierarchy.scope import ScopeMatch
from mirage.core.hierarchy.stat import entry_stat, make_stat
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
from mirage.utils.filetype import filetype_from_mimetype
from mirage.utils.key_prefix import mount_key, mount_prefix_of
@@ -56,7 +56,8 @@ def _file_blob_stat(match: ScopeMatch, path: PathSpec,
return FileStat(
name=entry.vfs_name or entry.name,
size=entry.size,
type=filetype_from_mimetype(mimetype),
type=FileType.FILE,
content=filetype_from_mimetype(mimetype),
extra={
"content_type": mimetype,
"attachment_id": entry.id,
@@ -123,9 +124,15 @@ async def _stat_chat(accessor: DiscordAccessor, match: ScopeMatch,
"""
entry = await resolve_entry(readdir, accessor, path, index)
if entry is not None:
return FileStat(name="chat.jsonl", type=FileType.TEXT, size=entry.size)
return FileStat(name="chat.jsonl",
type=FileType.FILE,
content=ContentType.TEXT,
size=entry.size)
await _channel_proven(accessor, path, index, up=2)
return FileStat(name="chat.jsonl", type=FileType.TEXT, size=None)
return FileStat(name="chat.jsonl",
type=FileType.FILE,
content=ContentType.TEXT,
size=None)
stat = make_stat(
@@ -136,7 +143,7 @@ stat = make_stat(
"channels_dir": _dir_stat,
"members_dir": _dir_stat,
"channel": _channel_stat,
"member": entry_stat("user_id", FileType.JSON),
"member": entry_stat("user_id", ContentType.JSON),
"files": _dir_stat,
"file_blob": _file_blob_stat,
},
+2 -1
View File
@@ -58,6 +58,7 @@ async def stat(accessor: DiskAccessor,
size=st.st_size,
modified=modified,
fingerprint=modified,
type=guess_type(p.name),
type=FileType.FILE,
content=guess_type(p.name),
mode=st.st_mode & 0o7777,
atime=epoch_to_iso(st.st_atime))
+4 -2
View File
@@ -45,7 +45,8 @@ def _stat_from_entry(entry: dict[str, Any]) -> FileStat:
return FileStat(
name=name,
size=size if isinstance(size, int) else None,
type=guess_type(name),
type=FileType.FILE,
content=guess_type(name),
modified=modified,
fingerprint=modified or None,
extra={
@@ -111,7 +112,8 @@ async def stat(
return FileStat(
name=result.entry.vfs_name or result.entry.name,
size=result.entry.size,
type=guess_type(result.entry.vfs_name),
type=FileType.FILE,
content=guess_type(result.entry.vfs_name),
modified=result.entry.remote_time,
fingerprint=result.entry.remote_time or None,
extra={
+2 -2
View File
@@ -14,7 +14,7 @@
from mirage.core.hierarchy.codec import DATE, Codec
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
EMAIL_JSON = Codec(suffix=".email.json")
@@ -32,7 +32,7 @@ SCOPES = (
Scope(kind="message",
segments=_DAY + (Slot("message", EMAIL_JSON, id_key="uid"), ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="attachment_dir",
segments=_DAY + (Slot("attachment_dir", id_key="uid"), )),
Scope(kind="attachment",
+5 -3
View File
@@ -17,7 +17,7 @@ from mirage.core.email.readdir import readdir
from mirage.core.email.scope import detect_scope
from mirage.core.hierarchy.scope import ScopeMatch
from mirage.core.hierarchy.stat import make_stat
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.filetype import guess_type
@@ -30,7 +30,8 @@ def _message_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
size=entry.size,
extra={"uid": entry.id},
)
@@ -49,7 +50,8 @@ def _attachment_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=guess_type(entry.vfs_name),
type=FileType.FILE,
content=guess_type(entry.vfs_name),
size=entry.size,
extra={"attachment_id": entry.id},
)
+3 -3
View File
@@ -16,7 +16,7 @@ from mirage.core.gcal.day import valid_day
from mirage.core.hierarchy.codec import Codec
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.resource.gcal.event_entry import EVENT_SUFFIX
from mirage.types import FileType
from mirage.types import ContentType
def is_event_name(text: str) -> bool:
@@ -50,12 +50,12 @@ SCOPES = (
Scope(kind="calendar_json",
segments=_CAL + ("calendar.json", ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="day", segments=_DAY),
Scope(kind="event",
segments=_DAY + (Slot("event", EVENT_NAME), ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
)
detect_scope = make_detect_scope(SCOPES)
+3 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.gcal.scope import detect_scope
from mirage.core.hierarchy.probe import resolve_entry
from mirage.core.hierarchy.scope import ScopeMatch
from mirage.core.hierarchy.stat import make_stat
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
@@ -32,7 +32,8 @@ def _file_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
modified=entry.remote_time,
size=entry.size,
extra={
+2 -2
View File
@@ -15,7 +15,7 @@
from mirage.core.gdocs.constants import FILE_NAME
from mirage.core.google.constants import CORPUS
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
# One description of the tree: readdir, stat, read and unlink all classify
# through it, so the file surface and the write surface cannot disagree
@@ -26,7 +26,7 @@ SCOPES = (
segments=(Slot("corpus",
CORPUS), Slot("name", FILE_NAME, id_key="file_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
)
detect_scope = make_detect_scope(SCOPES)
+3 -2
View File
@@ -17,14 +17,15 @@ from mirage.core.gdocs.readdir import readdir
from mirage.core.gdocs.scope import detect_scope
from mirage.core.hierarchy.scope import ScopeMatch
from mirage.core.hierarchy.stat import make_stat
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _file_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
modified=entry.remote_time,
size=entry.size,
extra={
+4 -2
View File
@@ -62,7 +62,8 @@ async def stat_from_api(accessor: GDriveAccessor, key: str,
return FileStat(
name=vfs_name,
size=size,
type=guess_type(vfs_name),
type=FileType.FILE,
content=guess_type(vfs_name),
modified=modified,
fingerprint=modified or None,
extra={
@@ -107,7 +108,8 @@ async def stat(
return FileStat(
name=entry.vfs_name or entry.name,
size=entry.size,
type=guess_type(entry.vfs_name),
type=FileType.FILE,
content=guess_type(entry.vfs_name),
modified=entry.remote_time,
fingerprint=entry.remote_time or None,
extra={
+2 -1
View File
@@ -59,7 +59,8 @@ async def stat(
return FileStat(
name=result.entry.name,
size=result.entry.size,
type=guess_type(result.entry.name),
type=FileType.FILE,
content=guess_type(result.entry.name),
fingerprint=result.entry.id,
extra={"sha": result.entry.id},
)
+2 -2
View File
@@ -14,7 +14,7 @@
from mirage.core.hierarchy.codec import DATE, Codec
from mirage.core.hierarchy.scope import ROOT, Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
GMAIL_JSON = Codec(suffix=".gmail.json")
@@ -32,7 +32,7 @@ SCOPES = (
Scope(kind="message",
segments=_DAY + (Slot("message", GMAIL_JSON, id_key="message_id"), ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="attachment_dir",
segments=_DAY + (Slot("attachment_dir", id_key="message_id"), )),
Scope(kind="attachment",
+5 -3
View File
@@ -19,7 +19,7 @@ from mirage.core.gmail.scope import detect_scope
from mirage.core.hierarchy.probe import resolve_entry
from mirage.core.hierarchy.scope import ScopeMatch
from mirage.core.hierarchy.stat import make_stat
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
from mirage.utils.filetype import guess_type
from mirage.utils.key_prefix import mount_key, mount_prefix_of
@@ -38,7 +38,8 @@ def _message_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
size=entry.size,
extra={
"message_id": entry.id,
@@ -60,7 +61,8 @@ def _attachment_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=guess_type(entry.vfs_name),
type=FileType.FILE,
content=guess_type(entry.vfs_name),
size=entry.size,
extra={"attachment_id": entry.id},
)
+2 -2
View File
@@ -15,7 +15,7 @@
from mirage.core.google.constants import CORPUS
from mirage.core.gsheets.constants import FILE_NAME
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
# One description of the tree: readdir, stat, read and unlink all classify
# through it, so the file surface and the write surface cannot disagree
@@ -26,7 +26,7 @@ SCOPES = (
segments=(Slot("corpus",
CORPUS), Slot("name", FILE_NAME, id_key="file_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
)
detect_scope = make_detect_scope(SCOPES)
+3 -2
View File
@@ -17,14 +17,15 @@ from mirage.core.gsheets.readdir import readdir
from mirage.core.gsheets.scope import detect_scope
from mirage.core.hierarchy.scope import ScopeMatch
from mirage.core.hierarchy.stat import make_stat
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _file_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
modified=entry.remote_time,
size=entry.size,
extra={
+2 -2
View File
@@ -15,7 +15,7 @@
from mirage.core.google.constants import CORPUS
from mirage.core.gslides.constants import FILE_NAME
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
# One description of the tree: readdir, stat, read and unlink all classify
# through it, so the file surface and the write surface cannot disagree
@@ -26,7 +26,7 @@ SCOPES = (
segments=(Slot("corpus",
CORPUS), Slot("name", FILE_NAME, id_key="file_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
)
detect_scope = make_detect_scope(SCOPES)
+3 -2
View File
@@ -17,14 +17,15 @@ from mirage.core.gslides.readdir import readdir
from mirage.core.gslides.scope import detect_scope
from mirage.core.hierarchy.scope import ScopeMatch
from mirage.core.hierarchy.stat import make_stat
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _file_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
modified=entry.remote_time,
size=entry.size,
extra={
+3 -3
View File
@@ -16,7 +16,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from mirage.core.hierarchy.codec import RAW, Codec
from mirage.types import FileType, PathSpec
from mirage.types import ContentType, PathSpec
ROOT = "root"
INVALID = "invalid"
@@ -63,7 +63,7 @@ class Scope:
slots.
leaf (bool): whether the position is a file rather than a
directory.
filetype (FileType | None): rendered type of a leaf; None on
filetype (ContentType | None): rendered type of a leaf; None on
directories.
probed (bool): whether stat must prove existence (parent listing
by default); False for positions that exist by construction,
@@ -72,7 +72,7 @@ class Scope:
kind: str
segments: tuple[Segment, ...]
leaf: bool = False
filetype: FileType | None = None
filetype: ContentType | None = None
probed: bool = True
+20 -6
View File
@@ -20,7 +20,7 @@ from mirage.core.hierarchy.probe import (A, ReaddirFn, assert_listed,
listed_size, resolve_entry)
from mirage.core.hierarchy.readdir import Guard
from mirage.core.hierarchy.scope import ROOT, DetectFn, ScopeMatch
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
ExtraFn = Callable[[ScopeMatch], dict[str, str]]
@@ -29,23 +29,36 @@ StatHook = Callable[[A, ScopeMatch, PathSpec, IndexCacheStore],
EntryStatFn = Callable[[ScopeMatch, PathSpec, IndexEntry], FileStat]
def entry_stat(id_field: str, filetype: FileType) -> EntryStatFn:
def entry_stat(id_field: str, filetype: ContentType | FileType) -> EntryStatFn:
"""The shape most id-addressed nodes share, keyed by an id field.
Name from the entry's ``vfs_name``, size and modified straight off
the listing, and the entry's id under ``id_field`` in ``extra``. A
kind whose shape differs writes its own ``EntryStatFn`` instead.
``filetype`` is the node's kind: a ``FileType`` for a non-regular
node (a directory entry, e.g. a linear team or trello board) or a
``ContentType`` for a regular file, whose node kind is then FILE.
Args:
id_field (str): the ``extra`` key the entry's id rides under.
filetype (FileType): the node's rendered type.
filetype (ContentType | FileType): the node's kind.
"""
def build(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
if isinstance(filetype, FileType):
return FileStat(
name=entry.vfs_name,
type=filetype,
size=entry.size,
modified=entry.remote_time or None,
extra={id_field: entry.id},
)
return FileStat(
name=entry.vfs_name,
type=filetype,
type=FileType.FILE,
content=filetype,
size=entry.size,
modified=entry.remote_time or None,
extra={id_field: entry.id},
@@ -129,8 +142,9 @@ def make_stat(
return FileStat(name=name, type=FileType.DIRECTORY, extra=extra)
return FileStat(
name=name,
type=scope.filetype
if scope.filetype is not None else FileType.JSON,
type=FileType.FILE,
content=scope.filetype
if scope.filetype is not None else ContentType.JSON,
size=await listed_size(index, path),
extra=extra,
)
+3 -2
View File
@@ -15,7 +15,7 @@
from mirage.accessor.history import HistoryAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.history.read import read
from mirage.types import FileStat, PathSpec
from mirage.types import FileStat, FileType, PathSpec
from mirage.utils.filetype import guess_type
@@ -37,5 +37,6 @@ async def stat(accessor: HistoryAccessor,
name=".bash_history",
size=len(data),
modified=None,
type=guess_type(".bash_history"),
type=FileType.FILE,
content=guess_type(".bash_history"),
)
+3 -3
View File
@@ -15,7 +15,7 @@
from mirage.core.hierarchy.codec import Codec
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.core.jaeger.client import is_trace_id
from mirage.types import FileType
from mirage.types import ContentType
OPERATIONS_FILE = "operations.json"
TOP_LEVEL_DIRS = ["services"]
@@ -33,13 +33,13 @@ SCOPES = (
Scope(kind="operations",
segments=("services", Slot("service"), OPERATIONS_FILE),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="traces", segments=("services", Slot("service"), "traces")),
Scope(kind="trace",
segments=("services", Slot("service"), "traces",
Slot("trace_id", TRACE_FILE)),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
)
detect_scope = make_detect_scope(SCOPES)
+2 -2
View File
@@ -18,7 +18,7 @@ from mirage.core.hierarchy.codec import Codec
from mirage.core.hierarchy.scope import (DetectFn, Scope, ScopeMatch, Segment,
Slot, make_detect_scope)
from mirage.resource.lancedb.config import LanceDBConfig
from mirage.types import FileType
from mirage.types import ContentType
from mirage.utils.filetype import image_type_for_extension
CARD = Codec(suffix=".md")
@@ -51,7 +51,7 @@ def scopes_for(config: LanceDBConfig) -> tuple[Scope, ...]:
Scope(kind="row_card",
segments=full + (Slot("row_id", CARD), ),
leaf=True,
filetype=FileType.TEXT))
filetype=ContentType.TEXT))
if config.blob_column:
blob = Codec(suffix="." + config.blob_ext)
scopes.append(
+8 -4
View File
@@ -24,7 +24,7 @@ from mirage.core.lancedb.query import table_exists
from mirage.core.lancedb.read import read
from mirage.core.lancedb.readdir import readdir_for
from mirage.core.lancedb.scope import detect_for, table_of
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
from mirage.utils.filetype import image_type_for_extension
@@ -48,16 +48,20 @@ async def _stat_row(accessor: LanceDBAccessor, match: ScopeMatch,
if match.kind == "row_blob":
file_type = image_type_for_extension(config.blob_ext)
else:
file_type = FileType.TEXT
file_type = ContentType.TEXT
# The row-dir readdir seeds exact card sizes; blob entries and a cold
# index fall back to rendering the row, so the size is exact either way.
lookup = await index.get(path.virtual.rstrip("/"))
if lookup.entry is not None and lookup.entry.size is not None:
return FileStat(name=_name_of(path),
size=lookup.entry.size,
type=file_type)
type=FileType.FILE,
content=file_type)
data = await read(accessor, path, index)
return FileStat(name=_name_of(path), size=len(data), type=file_type)
return FileStat(name=_name_of(path),
size=len(data),
type=FileType.FILE,
content=file_type)
GUARDS: dict[str, Guard[LanceDBAccessor]] = {"group": _table_guard}
+6 -6
View File
@@ -14,7 +14,7 @@
from mirage.core.hierarchy.codec import INT_JSON, JSON_NAME, JSONL_NAME
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
TOP_LEVEL_DIRS = ["traces", "sessions", "prompts", "datasets"]
@@ -27,14 +27,14 @@ SCOPES = (
Scope(kind="trace",
segments=("traces", Slot("trace_id", JSON_NAME)),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="sessions", segments=("sessions", ), probed=False),
Scope(kind="session", segments=("sessions", Slot("session_id"))),
Scope(kind="session_trace",
segments=("sessions", Slot("session_id"),
Slot("trace_id", JSON_NAME)),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="prompts", segments=("prompts", ), probed=False),
Scope(kind="prompt", segments=("prompts", Slot("prompt_name"))),
# A version that is not a plain ASCII integer cannot name a prompt
@@ -43,19 +43,19 @@ SCOPES = (
Scope(kind="prompt_version",
segments=("prompts", Slot("prompt_name"), Slot("version", INT_JSON)),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="datasets", segments=("datasets", ), probed=False),
Scope(kind="dataset", segments=("datasets", Slot("dataset_name"))),
Scope(kind="dataset_items",
segments=("datasets", Slot("dataset_name"), "items.jsonl"),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
Scope(kind="runs", segments=("datasets", Slot("dataset_name"), "runs")),
Scope(kind="dataset_run",
segments=("datasets", Slot("dataset_name"), "runs",
Slot("run_name", JSONL_NAME)),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
)
detect_scope = make_detect_scope(SCOPES)
+8 -8
View File
@@ -14,7 +14,7 @@
from mirage.core.hierarchy.codec import JSON_NAME
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
_TEAM = ("teams", Slot("team", id_key="team_id"))
_ISSUE = _TEAM + ("issues", Slot("issue", id_key="issue_id"))
@@ -30,41 +30,41 @@ SCOPES = (
Scope(kind="team_json",
segments=_TEAM + ("team.json", ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="members", segments=_TEAM + ("members", )),
Scope(kind="member",
segments=_TEAM +
("members", Slot("member", JSON_NAME, id_key="member_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="issues", segments=_TEAM + ("issues", )),
Scope(kind="issue", segments=_ISSUE),
Scope(kind="issue_json",
segments=_ISSUE + ("issue.json", ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="comments_jsonl",
segments=_ISSUE + ("comments.jsonl", ),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
Scope(kind="projects", segments=_TEAM + ("projects", )),
Scope(kind="project",
segments=_TEAM +
("projects", Slot("project", JSON_NAME, id_key="project_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="cycles", segments=_TEAM + ("cycles", )),
Scope(kind="cycle",
segments=_TEAM +
("cycles", Slot("cycle", JSON_NAME, id_key="cycle_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="documents", segments=_TEAM + ("documents", )),
Scope(kind="document",
segments=_TEAM +
("documents", Slot("document", JSON_NAME, id_key="document_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
)
detect_scope = make_detect_scope(SCOPES)
+8 -8
View File
@@ -15,20 +15,20 @@
from mirage.core.hierarchy.stat import entry_stat, make_stat
from mirage.core.linear.readdir import readdir
from mirage.core.linear.scope import detect_scope
from mirage.types import FileType
from mirage.types import ContentType, FileType
stat = make_stat(
detect_scope,
readdir,
entry_stats={
"team": entry_stat("team_id", FileType.DIRECTORY),
"team_json": entry_stat("team_id", FileType.JSON),
"member": entry_stat("user_id", FileType.JSON),
"team_json": entry_stat("team_id", ContentType.JSON),
"member": entry_stat("user_id", ContentType.JSON),
"issue": entry_stat("issue_id", FileType.DIRECTORY),
"issue_json": entry_stat("issue_id", FileType.JSON),
"comments_jsonl": entry_stat("issue_id", FileType.TEXT),
"project": entry_stat("project_id", FileType.JSON),
"cycle": entry_stat("cycle_id", FileType.JSON),
"document": entry_stat("document_id", FileType.JSON),
"issue_json": entry_stat("issue_id", ContentType.JSON),
"comments_jsonl": entry_stat("issue_id", ContentType.TEXT),
"project": entry_stat("project_id", ContentType.JSON),
"cycle": entry_stat("cycle_id", ContentType.JSON),
"document": entry_stat("document_id", ContentType.JSON),
},
)
+2 -2
View File
@@ -14,13 +14,13 @@
from mirage.core.hierarchy.codec import JSON_NAME
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
# The mount is one flat directory of memory files; which memories exist
# is a function of the configured scope filter, not of the path.
SCOPES = (Scope(kind="memory",
segments=(Slot("memory_id", JSON_NAME), ),
leaf=True,
filetype=FileType.JSON), )
filetype=ContentType.JSON), )
detect_scope = make_detect_scope(SCOPES)
+3 -2
View File
@@ -22,14 +22,15 @@ from mirage.core.mem0.client import get_memory
from mirage.core.mem0.readdir import readdir
from mirage.core.mem0.scope import detect_scope
from mirage.core.render.json import json_bytes
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _file_stat(memory: dict[str, Any]) -> FileStat:
body = json_bytes(memory)
return FileStat(
name=f"{memory['id']}.json",
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
size=len(body),
modified=memory.get("updated_at") or memory.get("created_at"),
extra={
+4 -4
View File
@@ -16,7 +16,7 @@ from mirage.core.hierarchy.codec import Codec
from mirage.core.hierarchy.scope import (Scope, ScopeMatch, Slot,
make_detect_scope)
from mirage.core.mongodb.types import KIND_DIR_NAMES, EntityKind
from mirage.types import FileType
from mirage.types import ContentType
def is_kind_dir(text: str) -> bool:
@@ -38,7 +38,7 @@ SCOPES = (
Scope(kind="database_json",
segments=(Slot("database"), "database.json"),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
Scope(kind="kind_dir", segments=(Slot("database"), Slot("kind", KIND))),
Scope(kind="entity",
segments=(Slot("database"), Slot("kind", KIND), Slot("name"))),
@@ -46,12 +46,12 @@ SCOPES = (
segments=(Slot("database"), Slot("kind",
KIND), Slot("name"), "schema.json"),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
Scope(kind="documents",
segments=(Slot("database"), Slot("kind", KIND), Slot("name"),
"documents.jsonl"),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
)
detect_scope = make_detect_scope(SCOPES)
+3 -2
View File
@@ -22,7 +22,7 @@ from mirage.core.mongodb.client import count_documents, get_indexes, is_view
from mirage.core.mongodb.readdir import database_guard, entity_guard, readdir
from mirage.core.mongodb.scope import detect_scope, entity_kind
from mirage.core.mongodb.types import EntityKind
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _database_extra(match: ScopeMatch) -> dict[str, str]:
@@ -80,7 +80,8 @@ async def _documents_stat(accessor: MongoDBAccessor, match: ScopeMatch,
} for idx in indexes]
return FileStat(
name="documents.jsonl",
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
extra={
"database": database,
"name": name,
+4 -2
View File
@@ -278,7 +278,8 @@ def entry_stat(item: dict[str, Any]) -> FileStat:
name=name,
size=item.get("size"),
modified=item.get("lastModifiedDateTime"),
type=guess_type(name),
type=FileType.FILE,
content=guess_type(name),
fingerprint=item.get("cTag"),
extra={
"id": item.get("id"),
@@ -645,7 +646,8 @@ async def stat_item(config: MsGraphConfig, loc: DriveLoc, virtual: str,
return FileStat(name=entry.name,
size=entry.size,
modified=entry.remote_time or None,
type=guess_type(entry.name),
type=FileType.FILE,
content=guess_type(entry.name),
extra=dict(entry.extra))
parent = virtual_key.rsplit("/", 1)[0] or "/"
parent_listing = await index.list_dir(parent)
+4 -2
View File
@@ -31,7 +31,8 @@ async def stat(accessor: NextcloudAccessor,
return FileStat(name=entry.name,
size=entry.size,
modified=entry.remote_time or None,
type=guess_type(entry.name))
type=FileType.FILE,
content=guess_type(entry.name))
parent = virtual_key.rsplit("/", 1)[0] or "/"
parent_listing = await index.list_dir(parent)
if parent_listing.entries is not None:
@@ -48,7 +49,8 @@ async def stat(accessor: NextcloudAccessor,
name=stripped.rsplit("/", 1)[-1],
size=md.content_length,
modified=modified,
type=guess_type(raw),
type=FileType.FILE,
content=guess_type(raw),
fingerprint=md.etag,
extra={"etag": md.etag} if md.etag else {},
)
+5 -5
View File
@@ -13,7 +13,7 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
# A page tree nests arbitrarily, so the page level is one VARIADIC slot:
# `pages/a__1/b__2` is a page at any depth, and the slots hold the DEEPEST
@@ -34,22 +34,22 @@ SCOPES = (
Scope(kind="page_json",
segments=("pages", _PAGE, "page.json"),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="page", segments=("pages", _PAGE)),
Scope(kind="database_json",
segments=_DB + ("database.json", ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="database", segments=_DB),
Scope(kind="data_source_json",
segments=_DS + ("data_source.json", ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="data_source", segments=_DS),
Scope(kind="page_json",
segments=_DS + (_PAGE, "page.json"),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="page", segments=_DS + (_PAGE, )),
)
+9 -4
View File
@@ -17,7 +17,7 @@ from mirage.core.hierarchy.scope import ScopeMatch
from mirage.core.hierarchy.stat import make_stat
from mirage.core.notion.readdir import readdir
from mirage.core.notion.scope import detect_scope
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _page_stat(match: ScopeMatch, path: PathSpec,
@@ -32,7 +32,10 @@ def _page_stat(match: ScopeMatch, path: PathSpec,
def _page_json_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(name=entry.vfs_name, type=FileType.JSON, size=entry.size)
return FileStat(name=entry.vfs_name,
type=FileType.FILE,
content=ContentType.JSON,
size=entry.size)
def _database_stat(match: ScopeMatch, path: PathSpec,
@@ -49,7 +52,8 @@ def _database_json_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
size=entry.size,
extra={"database_id": match.slots["database_id"]},
)
@@ -69,7 +73,8 @@ def _data_source_json_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name,
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
size=entry.size,
extra={"data_source_id": match.slots["data_source_id"]},
)
+4 -2
View File
@@ -68,7 +68,8 @@ def make_stat(driver: ObjectStoreDriver[A, C]) -> StatFn[A]:
name=entry.name,
size=entry.size,
modified=entry.remote_time or None,
type=guess_type(entry.name),
type=FileType.FILE,
content=guess_type(entry.name),
)
# If the parent directory was already listed by readdir() but
# this path is not among its children, it does not exist.
@@ -94,7 +95,8 @@ def make_stat(driver: ObjectStoreDriver[A, C]) -> StatFn[A]:
name=path.rstrip("/").rsplit("/", 1)[-1],
size=meta.size,
modified=meta.modified,
type=guess_type(path),
type=FileType.FILE,
content=guess_type(path),
fingerprint=meta.fingerprint,
revision=meta.revision,
extra=dict(meta.extra),
+5 -5
View File
@@ -14,7 +14,7 @@
from mirage.core.hierarchy.codec import Codec
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
ENTITY_FILES = ("schema.json", "semantic.json", "rows.jsonl")
@@ -39,7 +39,7 @@ SCOPES = (
Scope(kind="database_json",
segments=("database.json", ),
leaf=True,
filetype=FileType.JSON,
filetype=ContentType.JSON,
probed=False),
Scope(kind="schema", segments=(Slot("schema"), )),
Scope(kind="kind", segments=(Slot("schema"), Slot("kind", KIND))),
@@ -49,17 +49,17 @@ SCOPES = (
segments=(Slot("schema"), Slot("kind",
KIND), Slot("entity"), "schema.json"),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="entity_semantic",
segments=(Slot("schema"), Slot("kind", KIND), Slot("entity"),
"semantic.json"),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="entity_rows",
segments=(Slot("schema"), Slot("kind",
KIND), Slot("entity"), "rows.jsonl"),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
)
detect_scope = make_detect_scope(SCOPES)
+3 -2
View File
@@ -23,7 +23,7 @@ from mirage.core.hierarchy.stat import make_stat
from mirage.core.postgres import client
from mirage.core.postgres.readdir import readdir
from mirage.core.postgres.scope import detect_scope
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
@@ -89,7 +89,8 @@ async def _rows_stat(accessor: PostgresAccessor, match: ScopeMatch,
# see the CLAUDE.md FUSE rules). The storage size remains in extra.
return FileStat(
name="rows.jsonl",
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
size=None,
fingerprint=fingerprint,
extra={
+3 -3
View File
@@ -18,7 +18,7 @@ from mirage.core.hierarchy.codec import JSON_NAME, Codec
from mirage.core.hierarchy.scope import (DetectFn, Scope, ScopeMatch, Segment,
Slot, make_detect_scope)
from mirage.resource.qdrant.config import QdrantConfig
from mirage.types import FileType
from mirage.types import ContentType
from mirage.utils.filetype import image_type_for_extension
TXT = Codec(suffix=".txt")
@@ -53,13 +53,13 @@ def scopes_for(config: QdrantConfig) -> tuple[Scope, ...]:
Scope(kind="row_json",
segments=full + (Slot("row_id", JSON_NAME), ),
leaf=True,
filetype=FileType.TEXT))
filetype=ContentType.TEXT))
if config.text_field:
scopes.append(
Scope(kind="row_text",
segments=full + (Slot("row_id", TXT), ),
leaf=True,
filetype=FileType.TEXT))
filetype=ContentType.TEXT))
if config.blob_field:
blob = Codec(suffix="." + config.blob_ext)
scopes.append(
+8 -4
View File
@@ -24,7 +24,7 @@ from mirage.core.qdrant.query import table_exists
from mirage.core.qdrant.read import read
from mirage.core.qdrant.readdir import readdir_for
from mirage.core.qdrant.scope import detect_for, table_of
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
from mirage.utils.filetype import image_type_for_extension
@@ -48,16 +48,20 @@ async def _stat_row(accessor: QdrantAccessor, match: ScopeMatch,
if match.kind == "row_blob":
file_type = image_type_for_extension(config.blob_ext)
else:
file_type = FileType.TEXT
file_type = ContentType.TEXT
# The row-dir readdir seeds exact rendered sizes; a cold index falls
# back to rendering the row, so the size is exact either way.
lookup = await index.get(path.virtual.rstrip("/"))
if lookup.entry is not None and lookup.entry.size is not None:
return FileStat(name=_name_of(path),
size=lookup.entry.size,
type=file_type)
type=FileType.FILE,
content=file_type)
data = await read(accessor, path, index)
return FileStat(name=_name_of(path), size=len(data), type=file_type)
return FileStat(name=_name_of(path),
size=len(data),
type=FileType.FILE,
content=file_type)
GUARDS: dict[str, Guard[QdrantAccessor]] = {"group": _table_guard}
+2 -1
View File
@@ -45,7 +45,8 @@ async def stat(accessor: RAMAccessor,
name=p.rsplit("/", 1)[-1],
size=len(data),
modified=store.modified.get(p),
type=guess_type(p),
type=FileType.FILE,
content=guess_type(p),
mode=attrs.get("mode"),
uid=attrs.get("uid"),
gid=attrs.get("gid"),
+2 -1
View File
@@ -63,7 +63,8 @@ async def stat(
name=p.rsplit("/", 1)[-1],
size=size,
modified=await store.get_modified(p),
type=guess_type(p),
type=FileType.FILE,
content=guess_type(p),
mode=attrs.get("mode"),
uid=attrs.get("uid"),
gid=attrs.get("gid"),
+3 -3
View File
@@ -17,7 +17,7 @@ from dataclasses import dataclass
from mirage.core.hierarchy.codec import DATE, JSON_NAME, Codec
from mirage.core.hierarchy.scope import (ROOT, Scope, ScopeMatch, Slot,
make_detect_scope)
from mirage.types import FileType
from mirage.types import ContentType
def is_container(text: str) -> bool:
@@ -47,13 +47,13 @@ SCOPES = (
Scope(kind="user",
segments=("users", Slot("user", JSON_NAME, id_key="user_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="channel", segments=_CHANNEL),
Scope(kind="day", segments=_DAY),
Scope(kind="messages",
segments=_DAY + ("chat.jsonl", ),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
Scope(kind="files", segments=_DAY + ("files", )),
Scope(kind="file_blob", segments=_DAY + ("files", Slot("blob")),
leaf=True),
+9 -4
View File
@@ -20,7 +20,7 @@ from mirage.core.hierarchy.stat import make_stat
from mirage.core.slack.readdir import readdir
from mirage.core.slack.scope import detect_scope
from mirage.core.timeutil import epoch_to_iso
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
from mirage.utils.filetype import filetype_from_mimetype
from mirage.utils.key_prefix import mount_key, mount_prefix_of
@@ -52,7 +52,8 @@ def _user_stat(match: ScopeMatch, path: PathSpec,
entry: IndexEntry) -> FileStat:
return FileStat(
name=entry.vfs_name or entry.name,
type=FileType.JSON,
type=FileType.FILE,
content=ContentType.JSON,
size=entry.size,
extra={"user_id": entry.id},
)
@@ -68,7 +69,8 @@ def _file_blob_stat(match: ScopeMatch, path: PathSpec,
mimetype = entry.extra.get("mimetype", "")
return FileStat(
name=entry.vfs_name or entry.name,
type=filetype_from_mimetype(mimetype),
type=FileType.FILE,
content=filetype_from_mimetype(mimetype),
size=entry.size,
modified=_slack_modified(entry.remote_time),
extra={"file_id": entry.id},
@@ -125,7 +127,10 @@ def _chat_stat(match: ScopeMatch, path: PathSpec,
# A denied or empty day lists no chat.jsonl, and the kit reports the
# absent entry as ENOENT: slack does not fabricate a sizeless file
# for a sealed day (discord deliberately does; see its override).
return FileStat(name="chat.jsonl", type=FileType.TEXT, size=entry.size)
return FileStat(name="chat.jsonl",
type=FileType.FILE,
content=ContentType.TEXT,
size=entry.size)
stat = make_stat(
+2 -1
View File
@@ -49,7 +49,8 @@ async def stat(accessor: SSHAccessor,
size=attrs.size or 0,
modified=mod_str,
fingerprint=mod_str or None,
type=FileType.DIRECTORY if is_dir else guess_type(path),
type=FileType.DIRECTORY if is_dir else FileType.FILE,
content=None if is_dir else guess_type(path),
mode=(attrs.permissions
& 0o7777 if attrs.permissions is not None else None),
atime=(epoch_to_iso(attrs.atime)
+8 -8
View File
@@ -14,7 +14,7 @@
from mirage.core.hierarchy.codec import JSON_NAME
from mirage.core.hierarchy.scope import Scope, Slot, make_detect_scope
from mirage.types import FileType
from mirage.types import ContentType
_WS = ("workspaces", Slot("workspace", id_key="workspace_id"))
_BOARD = _WS + ("boards", Slot("board", id_key="board_id"))
@@ -32,41 +32,41 @@ SCOPES = (
Scope(kind="workspace_json",
segments=_WS + ("workspace.json", ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="boards", segments=_WS + ("boards", )),
Scope(kind="board", segments=_BOARD),
Scope(kind="board_json",
segments=_BOARD + ("board.json", ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="members", segments=_BOARD + ("members", )),
Scope(kind="member",
segments=_BOARD +
("members", Slot("member", JSON_NAME, id_key="member_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="labels", segments=_BOARD + ("labels", )),
Scope(kind="label",
segments=_BOARD +
("labels", Slot("label", JSON_NAME, id_key="label_id")),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="lists", segments=_BOARD + ("lists", )),
Scope(kind="list", segments=_LIST),
Scope(kind="list_json",
segments=_LIST + ("list.json", ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="cards", segments=_LIST + ("cards", )),
Scope(kind="card", segments=_CARD),
Scope(kind="card_json",
segments=_CARD + ("card.json", ),
leaf=True,
filetype=FileType.JSON),
filetype=ContentType.JSON),
Scope(kind="comments_jsonl",
segments=_CARD + ("comments.jsonl", ),
leaf=True,
filetype=FileType.TEXT),
filetype=ContentType.TEXT),
)
detect_scope = make_detect_scope(SCOPES)
+8 -8
View File
@@ -15,22 +15,22 @@
from mirage.core.hierarchy.stat import entry_stat, make_stat
from mirage.core.trello.readdir import readdir
from mirage.core.trello.scope import detect_scope
from mirage.types import FileType
from mirage.types import ContentType, FileType
stat = make_stat(
detect_scope,
readdir,
entry_stats={
"workspace": entry_stat("workspace_id", FileType.DIRECTORY),
"workspace_json": entry_stat("workspace_id", FileType.JSON),
"workspace_json": entry_stat("workspace_id", ContentType.JSON),
"board": entry_stat("board_id", FileType.DIRECTORY),
"board_json": entry_stat("board_id", FileType.JSON),
"member": entry_stat("member_id", FileType.JSON),
"label": entry_stat("label_id", FileType.JSON),
"board_json": entry_stat("board_id", ContentType.JSON),
"member": entry_stat("member_id", ContentType.JSON),
"label": entry_stat("label_id", ContentType.JSON),
"list": entry_stat("list_id", FileType.DIRECTORY),
"list_json": entry_stat("list_id", FileType.JSON),
"list_json": entry_stat("list_id", ContentType.JSON),
"card": entry_stat("card_id", FileType.DIRECTORY),
"card_json": entry_stat("card_id", FileType.JSON),
"comments_jsonl": entry_stat("card_id", FileType.TEXT),
"card_json": entry_stat("card_id", ContentType.JSON),
"comments_jsonl": entry_stat("card_id", ContentType.TEXT),
},
)
+39 -2
View File
@@ -18,7 +18,8 @@ from datetime import datetime
from enum import Enum, StrEnum
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Protocol, TypeAlias
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt
from pydantic import (BaseModel, ConfigDict, Field, NonNegativeInt,
model_validator)
if TYPE_CHECKING:
import aiohttp
@@ -62,8 +63,34 @@ class LsSortBy(str, Enum):
class FileType(str, Enum):
"""POSIX file type (the `st_mode` kind), the switch behavior branches on.
One per entry, always present. Directory and symlink are their own
kinds; every regular file is FILE and carries its content shape on
FileStat.content. Distinct from ContentType, which is only a
rendering hint for a FILE.
The full POSIX set is enumerated so the model is comprehensive. Only
DIRECTORY, FILE and SYMLINK are produced today; CHAR_DEVICE,
BLOCK_DEVICE, FIFO and SOCKET are declared but not yet emitted, and
the render/derivation tables (find letter, st_mode bits, ls char)
grow a row for one the moment a backend starts producing it.
"""
DIRECTORY = "directory"
FILE = "file"
SYMLINK = "symlink"
CHAR_DEVICE = "char_device"
BLOCK_DEVICE = "block_device"
FIFO = "fifo"
SOCKET = "socket"
class ContentType(str, Enum):
"""A regular file's content shape: the rendering hint (file/ls color).
Only meaningful for a FILE; a directory or symlink carries none. Not
a node kind -- nothing branches control flow on it.
"""
TEXT = "text"
BINARY = "binary"
JSON = "json"
@@ -90,13 +117,23 @@ class FileStat(BaseModel):
modified: str | None = None
fingerprint: str | None = None
revision: str | None = None
type: FileType | None = None
type: FileType
content: ContentType | None = None
mode: int | None = None
uid: int | str | None = None
gid: int | str | None = None
atime: str | None = None
extra: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def _content_only_on_file(self) -> "FileStat":
# content is a FILE's rendering hint; a directory or symlink has
# none. None on a FILE means "unknown", which is allowed.
if self.type is not FileType.FILE and self.content is not None:
raise ValueError(f"content must be None for {self.type.value}, "
f"got {self.content.value}")
return self
# Any value that survives a JSON round trip: what a decoded payload
# holds, what jq evaluates over, what an API field hands back. Recursive
+50 -50
View File
@@ -12,33 +12,33 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.types import FileType
from mirage.types import ContentType
EXTENSION_MAP: dict[str, FileType] = {
"json": FileType.JSON,
"jsonl": FileType.JSON,
"csv": FileType.CSV,
"tsv": FileType.CSV,
"txt": FileType.TEXT,
"md": FileType.TEXT,
"log": FileType.TEXT,
"py": FileType.TEXT,
"js": FileType.TEXT,
"ts": FileType.TEXT,
"yaml": FileType.TEXT,
"yml": FileType.TEXT,
"toml": FileType.TEXT,
"png": FileType.IMAGE_PNG,
"jpg": FileType.IMAGE_JPEG,
"jpeg": FileType.IMAGE_JPEG,
"gif": FileType.IMAGE_GIF,
"zip": FileType.ZIP,
"gz": FileType.GZIP,
"gzip": FileType.GZIP,
"pdf": FileType.PDF,
EXTENSION_MAP: dict[str, ContentType] = {
"json": ContentType.JSON,
"jsonl": ContentType.JSON,
"csv": ContentType.CSV,
"tsv": ContentType.CSV,
"txt": ContentType.TEXT,
"md": ContentType.TEXT,
"log": ContentType.TEXT,
"py": ContentType.TEXT,
"js": ContentType.TEXT,
"ts": ContentType.TEXT,
"yaml": ContentType.TEXT,
"yml": ContentType.TEXT,
"toml": ContentType.TEXT,
"png": ContentType.IMAGE_PNG,
"jpg": ContentType.IMAGE_JPEG,
"jpeg": ContentType.IMAGE_JPEG,
"gif": ContentType.IMAGE_GIF,
"zip": ContentType.ZIP,
"gz": ContentType.GZIP,
"gzip": ContentType.GZIP,
"pdf": ContentType.PDF,
}
DEFAULT_TYPE = FileType.BINARY
DEFAULT_TYPE = ContentType.BINARY
# Extension-guessed like upstream mailers' mime_guess, as a deliberate
# fixed subset: the stdlib mimetypes module consults platform tables,
@@ -79,64 +79,64 @@ def mime_type_for(filename: str) -> str:
return MIME_BY_EXTENSION.get(extension.lower(), OCTET_STREAM)
_MIMETYPE_MAP: dict[str, FileType] = {
"application/pdf": FileType.PDF,
"application/zip": FileType.ZIP,
"application/gzip": FileType.GZIP,
"application/json": FileType.JSON,
"image/png": FileType.IMAGE_PNG,
"image/jpeg": FileType.IMAGE_JPEG,
"image/gif": FileType.IMAGE_GIF,
"text/csv": FileType.CSV,
_MIMETYPE_MAP: dict[str, ContentType] = {
"application/pdf": ContentType.PDF,
"application/zip": ContentType.ZIP,
"application/gzip": ContentType.GZIP,
"application/json": ContentType.JSON,
"image/png": ContentType.IMAGE_PNG,
"image/jpeg": ContentType.IMAGE_JPEG,
"image/gif": ContentType.IMAGE_GIF,
"text/csv": ContentType.CSV,
}
def guess_type(path: str) -> FileType:
def guess_type(path: str) -> ContentType:
"""Return the file type for *path* based on its extension.
Args:
path (str): file path or name.
Returns:
FileType: matched type from EXTENSION_MAP, or DEFAULT_TYPE.
ContentType: 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)
IMAGE_TYPE_BY_EXTENSION: dict[str, FileType] = {
"png": FileType.IMAGE_PNG,
"jpg": FileType.IMAGE_JPEG,
"jpeg": FileType.IMAGE_JPEG,
"gif": FileType.IMAGE_GIF,
IMAGE_TYPE_BY_EXTENSION: dict[str, ContentType] = {
"png": ContentType.IMAGE_PNG,
"jpg": ContentType.IMAGE_JPEG,
"jpeg": ContentType.IMAGE_JPEG,
"gif": ContentType.IMAGE_GIF,
}
def image_type_for_extension(ext: str) -> FileType:
"""Return the FileType for a bare image extension.
def image_type_for_extension(ext: str) -> ContentType:
"""Return the ContentType for a bare image extension.
Args:
ext (str): extension without the dot (e.g. ``png``).
Returns:
FileType: matched image type, or BINARY for anything else.
ContentType: matched image type, or BINARY for anything else.
"""
return IMAGE_TYPE_BY_EXTENSION.get(ext.lower(), FileType.BINARY)
return IMAGE_TYPE_BY_EXTENSION.get(ext.lower(), ContentType.BINARY)
def filetype_from_mimetype(mime: str) -> FileType:
"""Map a standard mimetype string to a FileType.
def filetype_from_mimetype(mime: str) -> ContentType:
"""Map a standard mimetype string to a ContentType.
Args:
mime (str): mimetype string (e.g., "image/png", "application/pdf").
Returns:
FileType: matched type, TEXT for any text/*, or BINARY default.
ContentType: matched type, TEXT for any text/*, or BINARY default.
"""
if not mime:
return FileType.BINARY
return ContentType.BINARY
if mime in _MIMETYPE_MAP:
return _MIMETYPE_MAP[mime]
if mime.startswith("text/"):
return FileType.TEXT
return FileType.BINARY
return ContentType.TEXT
return ContentType.BINARY
@@ -18,11 +18,11 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from mirage.agents.openai_agents.runner import MirageRunner
from mirage.types import FileStat, FileType
from mirage.types import ContentType, FileStat, FileType
def _stat(file_type: FileType) -> FileStat:
return FileStat(name="x", type=file_type)
def _stat(file_type: ContentType) -> FileStat:
return FileStat(name="x", type=FileType.FILE, content=file_type)
@pytest.fixture
@@ -36,7 +36,7 @@ def ws():
@pytest.mark.asyncio
async def test_build_blocks_image_inlines_base64(ws):
ws.ops.stat.return_value = _stat(FileType.IMAGE_PNG)
ws.ops.stat.return_value = _stat(ContentType.IMAGE_PNG)
ws.ops.read.return_value = b"\x89PNG\r\n\x1a\n"
runner = MirageRunner(ws)
blocks = await runner.build_blocks("hi", ["/img.png"])
@@ -48,7 +48,7 @@ async def test_build_blocks_image_inlines_base64(ws):
@pytest.mark.asyncio
async def test_build_blocks_pdf_uploads_to_files_api(ws):
ws.ops.stat.return_value = _stat(FileType.PDF)
ws.ops.stat.return_value = _stat(ContentType.PDF)
ws.ops.read.return_value = b"%PDF-1.4 ..."
fake_client = MagicMock()
fake_client.files = MagicMock()
@@ -66,7 +66,7 @@ async def test_build_blocks_pdf_uploads_to_files_api(ws):
@pytest.mark.asyncio
async def test_build_blocks_text_decoded_inline(ws):
ws.ops.stat.return_value = _stat(FileType.TEXT)
ws.ops.stat.return_value = _stat(ContentType.TEXT)
ws.ops.read.return_value = b"hello world"
runner = MirageRunner(ws)
blocks = await runner.build_blocks("look", ["/notes.txt"])
@@ -75,7 +75,7 @@ async def test_build_blocks_text_decoded_inline(ws):
@pytest.mark.asyncio
async def test_build_blocks_jpeg(ws):
ws.ops.stat.return_value = _stat(FileType.IMAGE_JPEG)
ws.ops.stat.return_value = _stat(ContentType.IMAGE_JPEG)
ws.ops.read.return_value = b"\xff\xd8\xff..."
runner = MirageRunner(ws)
blocks = await runner.build_blocks("see", ["/photo.jpg"])
@@ -85,7 +85,7 @@ async def test_build_blocks_jpeg(ws):
@pytest.mark.asyncio
async def test_build_blocks_multiple_paths_in_order(ws):
types = [FileType.TEXT, FileType.IMAGE_PNG]
types = [ContentType.TEXT, ContentType.IMAGE_PNG]
bytes_seq = [b"first", b"\x89PNG\r\n\x1a\n"]
async def fake_stat(p):
@@ -21,12 +21,14 @@ from mirage.cache.index.ram import RAMIndexCacheStore
from mirage.commands.builtin.box.pushdown import narrow_scope
from mirage.core.box.client import BoxTokenManager
from mirage.core.box.config import BoxConfig
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
_NGLOBALS = narrow_scope.__globals__
DIR_STAT = FileStat(name="data", type=FileType.DIRECTORY)
FILE_STAT = FileStat(name="x.txt", type=FileType.TEXT)
FILE_STAT = FileStat(name="x.txt",
type=FileType.FILE,
content=ContentType.TEXT)
def make_accessor(content_search: bool = True) -> BoxAccessor:
@@ -21,7 +21,7 @@ from mirage.commands.builtin.discord.rg import rg
from mirage.commands.config import CommandOpts
from mirage.commands.errors import UsageError
from mirage.io.types import IOResult, materialize
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
@@ -232,7 +232,8 @@ async def test_discord_grep_falls_back_when_native_raises():
), patch(
"mirage.commands.builtin.discord.grep._stat",
new=AsyncMock(return_value=FileStat(name="2026-04-10.jsonl",
type=FileType.TEXT)),
type=FileType.FILE,
content=ContentType.TEXT)),
):
out, io = await grep(accessor, paths, ['hello'],
CommandOpts(flags={'w': True}))
@@ -297,7 +298,8 @@ async def test_discord_grep_multi_pattern_skips_native_search():
), patch(
"mirage.commands.builtin.discord.grep._stat",
new=AsyncMock(return_value=FileStat(name="2026-04-10.jsonl",
type=FileType.TEXT)),
type=FileType.FILE,
content=ContentType.TEXT)),
):
_, io = await grep(accessor, paths, [],
CommandOpts(flags={
@@ -370,7 +372,8 @@ async def test_discord_rg_multi_pattern_skips_native_search():
), patch(
"mirage.commands.builtin.discord.rg._stat",
new=AsyncMock(return_value=FileStat(name="2026-04-10.jsonl",
type=FileType.TEXT)),
type=FileType.FILE,
content=ContentType.TEXT)),
):
_, io = await rg(accessor, paths, [],
CommandOpts(flags={
@@ -424,8 +427,9 @@ async def test_discord_grep_file_blob_skips_native_search():
new=AsyncMock(return_value=b"quarter,amount\n"),
), patch(
"mirage.commands.builtin.discord.grep._stat",
new=AsyncMock(
return_value=FileStat(name="img__A1.png", type=FileType.TEXT)),
new=AsyncMock(return_value=FileStat(name="img__A1.png",
type=FileType.FILE,
content=ContentType.TEXT)),
):
out, io = await grep(accessor, paths, ['quarter'],
CommandOpts(flags={'w': True}))
@@ -21,12 +21,14 @@ from mirage.cache.index.ram import RAMIndexCacheStore
from mirage.commands.builtin.dropbox.pushdown import narrow_scope
from mirage.core.dropbox.client import DropboxTokenManager
from mirage.resource.dropbox.config import DropboxConfig
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
_NGLOBALS = narrow_scope.__globals__
DIR_STAT = FileStat(name="data", type=FileType.DIRECTORY)
FILE_STAT = FileStat(name="x.txt", type=FileType.TEXT)
FILE_STAT = FileStat(name="x.txt",
type=FileType.FILE,
content=ContentType.TEXT)
def make_accessor(content_search: bool = True) -> DropboxAccessor:
@@ -5,7 +5,8 @@ import pytest
from mirage.commands.builtin.generic.archive import walk as aw
from mirage.commands.builtin.generic.archive.types import Walked
from mirage.ops.types import LinkView, MountView
from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec
from mirage.types import (LINK_TARGET_KEY, ContentType, FileStat, FileType,
PathSpec)
from mirage.utils.key_prefix import mount_key
from mirage.utils.path import CycleError
@@ -29,7 +30,8 @@ class _Tree:
return FileStat(name=key, type=FileType.DIRECTORY)
if key in self.files:
return FileStat(name=key,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
size=len(self.files[key]))
raise FileNotFoundError(key)
@@ -17,7 +17,7 @@ import errno
from mirage.commands.builtin.generic.crossmount.relay.ls import run_ls
from mirage.ops.types import MountView, NamespaceView
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
# Two mounts, /a/ and /b/, as a plain virtual-path tree. The relayed
# primitives route by full virtual path, so one table stands for both,
@@ -62,7 +62,8 @@ def make_dispatch(calls: Calls, roots: frozenset[str] = frozenset()):
own = "/" if virtual in roots else virtual.rsplit("/", 1)[-1]
return FileStat(name=own,
size=0 if is_dir else 3,
type=FileType.DIRECTORY if is_dir else FileType.TEXT,
type=FileType.DIRECTORY if is_dir else FileType.FILE,
content=None if is_dir else ContentType.TEXT,
mode=0o755 if is_dir else 0o644), None
return dispatch
@@ -15,8 +15,8 @@
import pytest
from mirage.commands.builtin.generic.cp import CpFlags, cp
from mirage.types import (FileStat, FileType, NativeCopy, PathSpec,
PrimitiveCopy)
from mirage.types import (ContentType, FileStat, FileType, NativeCopy,
PathSpec, PrimitiveCopy)
from mirage.utils.errors import enotsup
@@ -41,7 +41,8 @@ def _make_backend(files: dict[str, bytes],
return FileStat(name=k.rsplit("/", 1)[-1], type=FileType.DIRECTORY)
if k in files:
return FileStat(name=k.rsplit("/", 1)[-1],
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
modified=stamps.get(k))
raise FileNotFoundError(k)
@@ -1,7 +1,7 @@
import pytest
from mirage.commands.builtin.generic.file import file_cmd
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _spec(path: str) -> PathSpec:
@@ -10,14 +10,17 @@ def _spec(path: str) -> PathSpec:
resource_path=path.strip("/"))
def _make_backend(files: dict[str, tuple[bytes, FileType]], dirs: set[str]):
def _make_backend(files: dict[str, tuple[bytes, ContentType]], dirs: set[str]):
async def stat_fn(p: PathSpec) -> FileStat:
if p.virtual in dirs:
return FileStat(name=p.virtual, type=FileType.DIRECTORY, size=0)
if p.virtual in files:
data, ftype = files[p.virtual]
return FileStat(name=p.virtual, type=ftype, size=len(data))
return FileStat(name=p.virtual,
type=FileType.FILE,
content=ftype,
size=len(data))
raise FileNotFoundError(p.virtual)
async def read_bytes(p: PathSpec) -> bytes:
@@ -29,7 +32,7 @@ def _make_backend(files: dict[str, tuple[bytes, FileType]], dirs: set[str]):
@pytest.mark.asyncio
async def test_file_single_text():
stat_fn, read_bytes = _make_backend(
{"/a.txt": (b"hello world\n", FileType.TEXT)}, set())
{"/a.txt": (b"hello world\n", ContentType.TEXT)}, set())
out, io = await file_cmd([_spec("/a.txt")],
read_bytes=read_bytes,
stat_fn=stat_fn)
@@ -41,8 +44,8 @@ async def test_file_single_text():
async def test_file_multiple_paths_one_line_each():
stat_fn, read_bytes = _make_backend(
{
"/a.txt": (b"hello\n", FileType.TEXT),
"/b.json": (b'{"k": 1}\n', FileType.JSON),
"/a.txt": (b"hello\n", ContentType.TEXT),
"/b.json": (b'{"k": 1}\n', ContentType.JSON),
}, set())
out, _io = await file_cmd(
[_spec("/a.txt"), _spec("/b.json")],
@@ -64,7 +67,7 @@ async def test_file_directory_reported_without_read():
@pytest.mark.asyncio
async def test_file_brief_drops_path_prefix():
stat_fn, read_bytes = _make_backend(
{"/a.txt": (b"hello\n", FileType.TEXT)}, set())
{"/a.txt": (b"hello\n", ContentType.TEXT)}, set())
out, _io = await file_cmd([_spec("/a.txt")],
read_bytes=read_bytes,
stat_fn=stat_fn,
@@ -12,7 +12,8 @@ from mirage.commands.builtin.generic.find import (FindArgs, apply_mount_prefix,
from mirage.commands.errors import FindParseError
from mirage.ops.types import LinkView
from mirage.resource.ram import RAMResource
from mirage.types import FileStat, FileType, FindType, MountMode, PathSpec
from mirage.types import (ContentType, FileStat, FileType, FindType, MountMode,
PathSpec)
from mirage.workspace import Workspace
@@ -124,7 +125,11 @@ async def test_apply_mtime_filter_keeps_within_window():
iso = now.isoformat()
async def stat(_spec: PathSpec) -> FileStat:
return FileStat(name="a.txt", size=1, modified=iso, type=FileType.TEXT)
return FileStat(name="a.txt",
size=1,
modified=iso,
type=FileType.FILE,
content=ContentType.TEXT)
out = await apply_mtime_filter(
["/a.txt"],
@@ -138,8 +143,11 @@ async def test_apply_mtime_filter_keeps_within_window():
@pytest.mark.asyncio
async def test_apply_mtime_filter_stats_the_mounted_virtual_path():
now = datetime.now(tz=timezone.utc)
stat = AsyncMock(return_value=FileStat(
name="a.txt", size=1, modified=now.isoformat(), type=FileType.TEXT))
stat = AsyncMock(return_value=FileStat(name="a.txt",
size=1,
modified=now.isoformat(),
type=FileType.FILE,
content=ContentType.TEXT))
out = await apply_mtime_filter(
["/a.txt"],
@@ -163,7 +171,8 @@ async def test_apply_mtime_filter_drops_outside_window():
return FileStat(name="a.txt",
size=1,
modified=old.isoformat(),
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
out = await apply_mtime_filter(
["/a.txt"],
@@ -181,7 +190,8 @@ async def test_apply_mtime_filter_drops_entries_with_no_modified_time():
return FileStat(name="a.txt",
size=1,
modified=None,
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
out = await apply_mtime_filter(
["/a.txt"],
@@ -205,7 +215,8 @@ async def test_apply_mtime_filter_honours_a_reported_utc_offset():
return FileStat(name="a.txt",
size=1,
modified=moment.isoformat(),
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
out = await apply_mtime_filter(
["/a.txt"],
@@ -224,7 +235,8 @@ async def test_apply_mtime_filter_drops_a_malformed_timestamp():
return FileStat(name="a.txt",
size=1,
modified="not-a-date",
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
out = await apply_mtime_filter(
["/a.txt"],
@@ -338,16 +350,27 @@ async def test_walk_find_empty_matches_empty_files_and_dirs():
async def stat(spec: PathSpec, _index):
stats = {
"/": FileStat(name="/", type=FileType.DIRECTORY),
"/empty.txt": FileStat(name="empty.txt",
size=0,
type=FileType.TEXT),
"/full.txt": FileStat(name="full.txt", size=1, type=FileType.TEXT),
"/empty-dir": FileStat(name="empty-dir", type=FileType.DIRECTORY),
"/full-dir": FileStat(name="full-dir", type=FileType.DIRECTORY),
"/full-dir/a.txt": FileStat(name="a.txt",
size=1,
type=FileType.TEXT),
"/":
FileStat(name="/", type=FileType.DIRECTORY),
"/empty.txt":
FileStat(name="empty.txt",
size=0,
type=FileType.FILE,
content=ContentType.TEXT),
"/full.txt":
FileStat(name="full.txt",
size=1,
type=FileType.FILE,
content=ContentType.TEXT),
"/empty-dir":
FileStat(name="empty-dir", type=FileType.DIRECTORY),
"/full-dir":
FileStat(name="full-dir", type=FileType.DIRECTORY),
"/full-dir/a.txt":
FileStat(name="a.txt",
size=1,
type=FileType.FILE,
content=ContentType.TEXT),
}
return stats[spec.virtual]
@@ -368,7 +391,8 @@ async def test_walk_find_not_negates_predicate():
return FileStat(name="/", type=FileType.DIRECTORY)
return FileStat(name=spec.virtual.rsplit("/", 1)[-1],
size=1,
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
results = await walk_find(_root_spec(),
readdir=readdir,
@@ -526,8 +550,11 @@ async def test_find_file_start_point_is_reported_not_walked():
[_file_spec()],
(),
find_core=_unreached_core,
stat_path=_stat_path(FileStat(name="a.txt", size=6,
type=FileType.TEXT)),
stat_path=_stat_path(
FileStat(name="a.txt",
size=6,
type=FileType.FILE,
content=ContentType.TEXT)),
)
assert io.exit_code == 0
assert stdout == b"/mnt/a.txt\n"
@@ -535,7 +562,10 @@ async def test_find_file_start_point_is_reported_not_walked():
@pytest.mark.asyncio
async def test_find_file_start_point_type_filters():
start = FileStat(name="a.txt", size=6, type=FileType.TEXT)
start = FileStat(name="a.txt",
size=6,
type=FileType.FILE,
content=ContentType.TEXT)
for ftype, expected in (("f", b"/mnt/a.txt\n"), ("d", b""), ("l", b"")):
stdout, io = await find([_file_spec()], ("-type", ftype),
find_core=_unreached_core,
@@ -546,7 +576,10 @@ async def test_find_file_start_point_type_filters():
@pytest.mark.asyncio
async def test_find_file_start_point_depth_and_size():
start = FileStat(name="a.txt", size=6, type=FileType.TEXT)
start = FileStat(name="a.txt",
size=6,
type=FileType.FILE,
content=ContentType.TEXT)
cases = [
({
"maxdepth": "0"
@@ -603,8 +636,11 @@ async def test_find_file_start_point_respells_the_operand():
[spec],
(),
find_core=_unreached_core,
stat_path=_stat_path(FileStat(name="a.txt", size=6,
type=FileType.TEXT)),
stat_path=_stat_path(
FileStat(name="a.txt",
size=6,
type=FileType.FILE,
content=ContentType.TEXT)),
)
assert stdout == b"/other/link.txt\n"
@@ -867,7 +903,10 @@ def _stat_map(stats: dict[str, FileStat | None]):
_DIR_STAT = FileStat(name="d", type=FileType.DIRECTORY)
_FILE_STAT = FileStat(name="f", size=6, type=FileType.TEXT)
_FILE_STAT = FileStat(name="f",
size=6,
type=FileType.FILE,
content=ContentType.TEXT)
# GNU findutils 4.10.0, pinned on debian:stable-slim:
# find A B -> A's rows, then B's rows (operand order, never
@@ -973,7 +1012,7 @@ async def test_walk_find_reports_a_directory_it_may_not_open():
"/": FileType.DIRECTORY,
"/open": FileType.DIRECTORY,
"/sealed": FileType.DIRECTORY,
"/open/o": FileType.TEXT,
"/open/o": FileType.FILE,
}
async def readdir(spec, index=None):
@@ -1,7 +1,7 @@
import pytest
from mirage.commands.builtin.generic.grep import grep
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
@@ -54,7 +54,8 @@ def _make_backend(files: dict[str, bytes], dirs: set[str] | None = None):
if p in files:
return FileStat(name=p.rsplit("/", 1)[-1] or p,
size=len(files[p]),
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
if p.rstrip("/") in inferred_dirs or p in inferred_dirs:
return FileStat(name=p.rsplit("/", 1)[-1] or "/",
type=FileType.DIRECTORY)
@@ -318,7 +319,8 @@ def _make_prefixed_backend(files: dict[str, bytes], mount_prefix: str):
if p in full_files:
return FileStat(name=p.rsplit("/", 1)[-1],
size=len(full_files[p]),
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
if p.rstrip("/") in inferred_dirs:
return FileStat(name=p.rsplit("/", 1)[-1] or "/",
type=FileType.DIRECTORY)
@@ -9,8 +9,8 @@ from mirage.commands.builtin.generic.ls import (LS_FAILURE, LS_MINOR_PROBLEM,
exit_status_for, format_simple,
ls, walk)
from mirage.ops.types import LinkView, MountView
from mirage.types import (LINK_TARGET_KEY, FileStat, FileType, LsSortBy,
PathSpec)
from mirage.types import (LINK_TARGET_KEY, ContentType, FileStat, FileType,
LsSortBy, PathSpec)
def _spec(path: str) -> PathSpec:
@@ -74,7 +74,8 @@ def _file(name: str, size: int = 0, modified: str | None = None) -> FileStat:
return FileStat(name=name,
size=size,
modified=modified,
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
def _dir(name: str) -> FileStat:
@@ -15,8 +15,8 @@
import pytest
from mirage.commands.builtin.generic.mv import MvFlags, mv
from mirage.types import (FileStat, FileType, NativeMove, PathSpec,
PrimitiveMove)
from mirage.types import (ContentType, FileStat, FileType, NativeMove,
PathSpec, PrimitiveMove)
from mirage.utils.errors import enoent, enotdir, enotsup
@@ -41,7 +41,8 @@ def _make_backend(files: dict[str, bytes],
return FileStat(name=k.rsplit("/", 1)[-1], type=FileType.DIRECTORY)
if k in files:
return FileStat(name=k.rsplit("/", 1)[-1],
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
modified=stamps.get(k))
raise FileNotFoundError(k)
@@ -6,7 +6,7 @@ from mirage.commands.builtin.generic.cmp import cmp_cmd
from mirage.commands.builtin.generic.md5 import md5
from mirage.commands.builtin.generic.sha256sum import sha256sum_generic
from mirage.commands.config import CommandOpts
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _spec(path: str) -> PathSpec:
@@ -39,7 +39,10 @@ def _make_stat(files: dict[str, bytes]):
key = path.virtual if isinstance(path, PathSpec) else path
if key not in files:
raise FileNotFoundError(key)
return FileStat(name=key, size=len(files[key]), type=FileType.TEXT)
return FileStat(name=key,
size=len(files[key]),
type=FileType.FILE,
content=ContentType.TEXT)
return stat
@@ -3,7 +3,7 @@ import pytest
from mirage.commands.builtin.generic.cut import cut
from mirage.commands.builtin.generic.file import file_cmd
from mirage.commands.builtin.generic.stat import stat as generic_stat
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _spec(path: str) -> PathSpec:
@@ -118,7 +118,8 @@ async def test_stat_default_format():
return FileStat(name=path.virtual,
size=42,
modified="2026-01-01",
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
out, _ = await generic_stat([_spec("a.txt")], stat_fn=stat_fn)
assert b"name=a.txt" in out
@@ -130,7 +131,10 @@ async def test_stat_default_format():
async def test_stat_custom_format():
async def stat_fn(path):
return FileStat(name="foo", size=10, type=FileType.TEXT)
return FileStat(name="foo",
size=10,
type=FileType.FILE,
content=ContentType.TEXT)
out, _ = await generic_stat([_spec("foo")], stat_fn=stat_fn, c="%n=%s")
assert out == b"foo=10\n"
@@ -150,7 +154,7 @@ async def test_stat_format_F_directory():
async def test_stat_format_F_regular():
async def stat_fn(path):
return FileStat(name="x", type=FileType.JSON)
return FileStat(name="x", type=FileType.FILE, content=ContentType.JSON)
out, _ = await generic_stat([_spec("x")], stat_fn=stat_fn, f="%F")
assert out == b"regular file\n"
@@ -160,7 +164,10 @@ async def test_stat_format_F_regular():
async def test_stat_multiple_paths():
async def stat_fn(path):
return FileStat(name=path.virtual, size=1, type=FileType.TEXT)
return FileStat(name=path.virtual,
size=1,
type=FileType.FILE,
content=ContentType.TEXT)
out, _ = await generic_stat([_spec("a"), _spec("b")],
stat_fn=stat_fn,
@@ -172,7 +179,7 @@ async def test_stat_multiple_paths():
async def test_stat_missing_operand():
async def stat_fn(path):
return FileStat(name="x")
return FileStat(type=FileType.FILE, name="x")
with pytest.raises(ValueError, match="missing operand"):
await generic_stat([], stat_fn=stat_fn)
@@ -182,7 +189,10 @@ async def test_stat_missing_operand():
async def test_file_text_default():
async def stat_fn(path):
return FileStat(name=path.virtual, size=5, type=FileType.TEXT)
return FileStat(name=path.virtual,
size=5,
type=FileType.FILE,
content=ContentType.TEXT)
async def read_bytes(path):
return b"hello"
@@ -198,7 +208,10 @@ async def test_file_text_default():
async def test_file_brief_mode():
async def stat_fn(path):
return FileStat(name="f", size=4, type=FileType.TEXT)
return FileStat(name="f",
size=4,
type=FileType.FILE,
content=ContentType.TEXT)
async def read_bytes(path):
return b"abcd"
@@ -214,7 +227,10 @@ async def test_file_brief_mode():
async def test_file_mime_mode():
async def stat_fn(path):
return FileStat(name="f.json", size=10, type=FileType.JSON)
return FileStat(name="f.json",
size=10,
type=FileType.FILE,
content=ContentType.JSON)
async def read_bytes(path):
return b'{"a": 1}'
@@ -245,7 +261,10 @@ async def test_file_directory():
async def test_file_multiple_paths():
async def stat_fn(path):
return FileStat(name=path.virtual, size=3, type=FileType.TEXT)
return FileStat(name=path.virtual,
size=3,
type=FileType.FILE,
content=ContentType.TEXT)
async def read_bytes(path):
return b"abc"
@@ -262,7 +281,10 @@ async def test_file_multiple_paths():
async def test_file_read_error_logs_and_falls_back():
async def stat_fn(path):
return FileStat(name="x", size=1, type=FileType.TEXT)
return FileStat(name="x",
size=1,
type=FileType.FILE,
content=ContentType.TEXT)
async def read_bytes(path):
raise OSError("denied")
@@ -277,7 +299,7 @@ async def test_file_read_error_logs_and_falls_back():
async def test_file_missing_operand():
async def stat_fn(path):
return FileStat(name="x")
return FileStat(type=FileType.FILE, name="x")
async def read_bytes(path):
return b""
@@ -5,7 +5,7 @@ from mirage.commands.builtin.generic.dirname import dirname
from mirage.commands.builtin.generic.mktemp import mktemp
from mirage.commands.builtin.generic.readlink import readlink
from mirage.commands.builtin.generic.realpath import realpath
from mirage.types import FileStat, PathSpec
from mirage.types import FileStat, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
@@ -56,7 +56,7 @@ async def test_dirname_multiple():
async def test_realpath_normalizes():
async def stat_fn(path):
return FileStat(name="x")
return FileStat(type=FileType.FILE, name="x")
out, _ = await realpath([_spec("/a/./b/../c")], stat_fn=stat_fn)
assert out == b"/a/c\n"
@@ -66,7 +66,7 @@ async def test_realpath_normalizes():
async def test_realpath_exists_check_passes():
async def stat_fn(path):
return FileStat(name="x")
return FileStat(type=FileType.FILE, name="x")
out, _ = await realpath([_spec("/a/b")], stat_fn=stat_fn, e=True)
assert out == b"/a/b\n"
@@ -89,7 +89,7 @@ async def test_realpath_exists_check_fails():
async def test_realpath_multiple():
async def stat_fn(path):
return FileStat(name="x")
return FileStat(type=FileType.FILE, name="x")
out, _ = await realpath([_spec("/a"), _spec("/b/../c")], stat_fn=stat_fn)
assert out == b"/a\n/c\n"
@@ -5,7 +5,7 @@ from mirage.commands.builtin.generic.jq import jq
from mirage.commands.builtin.generic.patch import patch
from mirage.commands.builtin.generic.tsort import tsort
from mirage.commands.builtin.generic.unzip import unzip
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
@@ -42,7 +42,9 @@ def _make_backend(files: dict[str, bytes]):
async def _stat_file(path) -> FileStat:
return FileStat(name=path.virtual, type=FileType.TEXT)
return FileStat(name=path.virtual,
type=FileType.FILE,
content=ContentType.TEXT)
@pytest.mark.asyncio
@@ -2,7 +2,7 @@ import pytest
from mirage.commands.builtin.generic.rg import parse_flags, rg
from mirage.commands.spec.types import FlagView
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
@@ -51,7 +51,8 @@ def _make_backend(files: dict[str, bytes], dirs: set[str] | None = None):
if p in files:
return FileStat(name=p.rsplit("/", 1)[-1] or p,
size=len(files[p]),
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
if p.rstrip("/") in inferred_dirs or p in inferred_dirs:
return FileStat(name=p.rsplit("/", 1)[-1] or "/",
type=FileType.DIRECTORY)
@@ -361,7 +362,8 @@ def _make_prefixed_backend(files: dict[str, bytes], mount_prefix: str):
if p in full_files:
return FileStat(name=p.rsplit("/", 1)[-1],
size=len(full_files[p]),
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
if p.rstrip("/") in inferred_dirs:
return FileStat(name=p.rsplit("/", 1)[-1] or "/",
type=FileType.DIRECTORY)
@@ -6,8 +6,8 @@ from mirage.commands.builtin.generic.stat import stat
from mirage.io.types import materialize
from mirage.ops.types import LinkView
from mirage.resource.ram import RAMResource
from mirage.types import (LINK_TARGET_KEY, FileStat, FileType, MountMode,
PathSpec)
from mirage.types import (LINK_TARGET_KEY, ContentType, FileStat, FileType,
MountMode, PathSpec)
from mirage.workspace import Workspace
_MTIME = "2026-01-02T15:30:45Z"
@@ -27,8 +27,11 @@ def _fs(**kw: object) -> FileStat:
base: dict[str, object] = dict(name="f.txt",
size=6,
modified=_MTIME,
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
base.update(kw)
if base.get("type") is not FileType.FILE and "content" not in kw:
base.pop("content", None)
return FileStat(**base)
@@ -8,7 +8,8 @@ from mirage.commands.builtin.generic.archive.types import Walked
from mirage.commands.builtin.generic.tar import (excluded, member_name, pruned,
strip_prefix, tar)
from mirage.ops.types import LinkView, MountView
from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec
from mirage.types import (LINK_TARGET_KEY, ContentType, FileStat, FileType,
PathSpec)
from mirage.utils.key_prefix import mount_key
from mirage.utils.path import CycleError
@@ -60,7 +61,8 @@ class _Tree:
return FileStat(name=key, type=FileType.DIRECTORY)
if key in self.files:
return FileStat(name=key,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
size=len(self.files[key]))
raise FileNotFoundError(key)
@@ -3,7 +3,7 @@ from types import SimpleNamespace
import pytest
from mirage.commands.builtin.generic.tree import tree
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _spec(path: str) -> PathSpec:
@@ -13,7 +13,10 @@ def _spec(path: str) -> PathSpec:
def _file(name: str, size: int = 0) -> FileStat:
return FileStat(name=name, size=size, type=FileType.TEXT)
return FileStat(name=name,
size=size,
type=FileType.FILE,
content=ContentType.TEXT)
def _dir(name: str) -> FileStat:
@@ -467,15 +470,24 @@ async def test_tree_marks_a_subdirectory_it_may_not_open_and_exits_2():
"/r":
FileStat(name="r", type=FileType.DIRECTORY),
"/r/a":
FileStat(name="a", type=FileType.TEXT, size=1),
FileStat(name="a",
type=FileType.FILE,
content=ContentType.TEXT,
size=1),
"/r/locked":
FileStat(name="locked", type=FileType.DIRECTORY),
"/r/locked/y":
FileStat(name="y", type=FileType.TEXT, size=1),
FileStat(name="y",
type=FileType.FILE,
content=ContentType.TEXT,
size=1),
"/r/sub":
FileStat(name="sub", type=FileType.DIRECTORY),
"/r/sub/y":
FileStat(name="y", type=FileType.TEXT, size=1),
FileStat(name="y",
type=FileType.FILE,
content=ContentType.TEXT,
size=1),
})
async def guarded(p: PathSpec, index=None) -> list[str]:
@@ -7,7 +7,8 @@ from mirage.commands.builtin.generic.archive.types import Walked
from mirage.commands.builtin.generic.zip_cmd import (excluded, member_name,
zip_cmd)
from mirage.ops.types import LinkView, MountView
from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec
from mirage.types import (LINK_TARGET_KEY, ContentType, FileStat, FileType,
PathSpec)
from mirage.utils.key_prefix import mount_key
@@ -53,7 +54,8 @@ class _Tree:
return FileStat(name=key, type=FileType.DIRECTORY)
if key in self.files:
return FileStat(name=key,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
size=len(self.files[key]))
raise FileNotFoundError(key)
@@ -40,7 +40,9 @@ def _ops(max_du_entries: int | None = None) -> CommandIO:
if virtual in TREE:
return FileStat(name=virtual, type=FileType.DIRECTORY)
if virtual in SIZES:
return FileStat(name=virtual, size=SIZES[virtual])
return FileStat(type=FileType.FILE,
name=virtual,
size=SIZES[virtual])
raise FileNotFoundError(virtual)
async def read_bytes(_accessor, _path, _index=None):
@@ -41,7 +41,8 @@ def _ops(stat_calls: list[str], find_op=None) -> CommandIO:
return FileStat(name=path.virtual,
type=FileType.DIRECTORY,
modified="2099-01-01T00:00:00+00:00")
return FileStat(name=path.virtual,
return FileStat(type=FileType.FILE,
name=path.virtual,
size=3,
modified="2099-01-01T00:00:00+00:00")
@@ -18,7 +18,7 @@ from mirage.accessor.base import NOOPAccessor
from mirage.commands.builtin.generic_bind.adapter import (CommandIO, Operation,
dir_aware_stat,
dir_aware_stream)
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.glob_walk import DEFAULT_MAX_GLOB_MATCHES
TREE = {
@@ -94,7 +94,7 @@ def _probe_ops(missing: set[str],
raise FileNotFoundError(path.virtual)
if path.virtual in typed:
return FileStat(name=path.virtual, type=FileType.DIRECTORY)
return FileStat(name=path.virtual, size=0)
return FileStat(type=FileType.FILE, name=path.virtual, size=0)
async def readdir(_accessor, path, _index):
target = path.virtual.rstrip("/") or "/"
@@ -241,7 +241,10 @@ async def test_rule_guard_asks_the_bound_gate_and_leaves_stat_alone():
async def stat(accessor, path, index=None):
calls.append(("stat", path.virtual))
return FileStat(name="k", type=FileType.TEXT, size=1)
return FileStat(name="k",
type=FileType.FILE,
content=ContentType.TEXT,
size=1)
async def readdir(accessor, path, index=None):
calls.append(("readdir", path.virtual))
@@ -26,7 +26,7 @@ from mirage.commands.config import CommandOpts
from mirage.commands.registry import command
from mirage.commands.spec import SPECS
from mirage.provision import Precision
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
SIZES = {
@@ -57,7 +57,8 @@ async def _stat(accessor, path, index=None) -> FileStat:
type=FileType.DIRECTORY)
return FileStat(name=virtual.rsplit("/", 1)[-1],
size=SIZES.get(virtual),
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
async def _readdir(accessor, path, index=None) -> list[str]:
@@ -309,7 +310,10 @@ async def test_index_hit_read_provision_without_paths_is_unknown():
async def _noop_stat(*args, **kwargs):
return FileStat(name="x", size=0, type=FileType.FILE)
return FileStat(name="x",
size=0,
type=FileType.FILE,
content=ContentType.FILE)
def _make_command(name: str, provision=None, filetype: str | None = None):
@@ -25,7 +25,7 @@ from mirage.commands.builtin.generic_bind.builders.shuf import \
from mirage.commands.builtin.utils.wrap import stream_from_bytes
from mirage.commands.config import CommandOpts
from mirage.io.types import materialize
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
_FILES = {
"/g/a.txt": b"alpha\n",
@@ -52,7 +52,8 @@ async def _stat(accessor, path, index=None):
if p in _FILES:
return FileStat(name=p.rsplit("/", 1)[-1],
size=len(_FILES[p]),
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
raise FileNotFoundError(p)
@@ -26,7 +26,7 @@ from mirage.commands.config import CommandOpts
from mirage.core.hierarchy.scope import ScopeMatch
from mirage.core.hierarchy.search import SearchQuery
from mirage.io.types import ByteSource
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
from tests.core.hierarchy.conftest import FakeAccessor, detect_scope, spec
@@ -42,7 +42,10 @@ async def _read_op(accessor: FakeAccessor,
async def _stat_op(accessor: FakeAccessor,
path: PathSpec,
index=NULL_INDEX) -> FileStat:
return FileStat(name="a.json", type=FileType.JSON, size=len(CONTENT))
return FileStat(name="a.json",
type=FileType.FILE,
content=ContentType.JSON,
size=len(CONTENT))
async def _readdir_op(accessor: FakeAccessor,
@@ -24,7 +24,7 @@ from mirage.commands.builtin.generic_bind import CommandIO
from mirage.commands.builtin.mongodb.cat import cat
from mirage.commands.config import CommandOpts
from mirage.resource.mongodb.config import MongoDBConfig
from mirage.types import FileStat, PathSpec
from mirage.types import FileStat, FileType, PathSpec
@pytest.fixture
@@ -38,7 +38,7 @@ def _path(s: str = "/db1/collections/coll1/documents.jsonl") -> PathSpec:
async def _fake_stat(_accessor, path, index=None):
return FileStat(name=path.virtual, size=None)
return FileStat(type=FileType.FILE, name=path.virtual, size=None)
async def _unused(*_args, **_kwargs):
@@ -23,7 +23,7 @@ from mirage.commands.builtin.object_store.stat import make_stat
from mirage.commands.config import CommandOpts
from mirage.io.types import materialize
from mirage.ops.types import NamespaceView, StatOverlay
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
_BACKEND_MTIME = "2020-05-05T05:05:05Z"
_OVERLAY_MTIME = "2024-01-01T00:00:00Z"
@@ -34,7 +34,8 @@ def _backend_stat() -> FileStat:
size=6,
modified=_BACKEND_MTIME,
mode=0o644,
type=FileType.TEXT)
type=FileType.FILE,
content=ContentType.TEXT)
async def _fake_stat_core(_accessor: Accessor,
@@ -21,7 +21,7 @@ from mirage.commands.builtin.slack.grep import grep
from mirage.commands.builtin.slack.rg import rg
from mirage.commands.config import CommandOpts
from mirage.io.types import IOResult
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
@@ -187,8 +187,10 @@ async def test_grep_falls_back_when_native_search_raises():
new=AsyncMock(return_value=b""),
), patch(
"mirage.commands.builtin.slack.grep._stat",
new=AsyncMock(return_value=FileStat(
name="chat.jsonl", type=FileType.TEXT, size=0)),
new=AsyncMock(return_value=FileStat(name="chat.jsonl",
type=FileType.FILE,
content=ContentType.TEXT,
size=0)),
):
out, io = await grep(
accessor, paths, ['hello'],
@@ -22,7 +22,7 @@ from mirage.commands.builtin.slack.rg import rg
from mirage.commands.config import CommandOpts
from mirage.core.slack.config import SlackConfig
from mirage.io.stream import materialize
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
@@ -157,7 +157,8 @@ async def test_grep_files_dir_redirects_to_per_file_scan(accessor, index):
patch("mirage.commands.builtin.slack.grep._stat",
new_callable=AsyncMock,
return_value=FileStat(name="report.txt",
type=FileType.TEXT)),
type=FileType.FILE,
content=ContentType.TEXT)),
):
from mirage.commands.builtin.slack.grep import grep
out, io = await grep(accessor, [
@@ -23,7 +23,7 @@ from mirage.commands.builtin.generic.rg import rg as generic_rg
from mirage.commands.builtin.generic.tail import tail_multi
from mirage.commands.builtin.generic.wc import format_multi
from mirage.io.types import materialize
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
_PAYLOAD = b"alpha\nbeta\n"
@@ -53,7 +53,10 @@ async def _warm_manager() -> CacheManager:
async def _stat(path) -> FileStat:
return FileStat(name="a.txt", type=FileType.TEXT, size=len(_PAYLOAD))
return FileStat(name="a.txt",
type=FileType.FILE,
content=ContentType.TEXT,
size=len(_PAYLOAD))
async def _readdir(path) -> list[str]:
@@ -1,7 +1,7 @@
from mirage.commands.builtin.find_helper import (expand_printf,
printf_needs_stat,
unrespell_raw)
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _spec(virtual: str, raw: str | None = None) -> PathSpec:
@@ -12,10 +12,17 @@ def _spec(virtual: str, raw: str | None = None) -> PathSpec:
raw_path=raw if raw is not None else virtual)
def _stat(size: int = 6, file_type: FileType = FileType.TEXT) -> FileStat:
def _stat(size: int = 6,
file_type: ContentType | FileType = ContentType.TEXT) -> FileStat:
if isinstance(file_type, FileType):
return FileStat(name="a",
size=size,
type=file_type,
modified="2026-08-16T13:45:30+00:00")
return FileStat(name="a",
size=size,
type=file_type,
type=FileType.FILE,
content=file_type,
modified="2026-08-16T13:45:30+00:00")
@@ -56,7 +63,11 @@ def test_expand_stat_directives():
def test_expand_reported_mode_over_default():
warnings: list[str] = []
search = _spec("/data")
st = FileStat(name="a", size=6, type=FileType.TEXT, mode=0o600)
st = FileStat(name="a",
size=6,
type=FileType.FILE,
content=ContentType.TEXT,
mode=0o600)
assert expand_printf("%m %M\n", "/data/a.txt", search, st,
warnings) == "600 -rw-------\n"
d = FileStat(name="sub", size=0, type=FileType.DIRECTORY, mode=0o700)
@@ -16,7 +16,7 @@ import pytest
from mirage.commands.builtin.utils.formatting import (_human_size,
format_ls_long)
from mirage.types import FileStat, FileType
from mirage.types import ContentType, FileStat, FileType
# Read off GNU coreutils 9.7 (`ls -lh` on a file of each size, debian
# stable-slim). The three rows that matter are the ones a plain
@@ -60,7 +60,8 @@ def test_human_size_matches_gnu(size: int, expected: str):
def test_format_ls_long_regular_file():
stat = FileStat(name="file.txt",
size=5,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
modified="2026-01-01T00:00:00Z")
[line] = format_ls_long([stat])
assert line == "-rw-r--r-- 1 user user 5 Jan 1 00:00 file.txt"
@@ -80,11 +81,13 @@ def test_format_ls_long_size_alignment():
stats = [
FileStat(name="a",
size=5,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
modified="2026-01-01T00:00:00Z"),
FileStat(name="b",
size=1234,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
modified="2026-01-01T00:00:00Z"),
]
lines = format_ls_long(stats)
@@ -95,7 +98,8 @@ def test_format_ls_long_size_alignment():
def test_format_ls_long_human_size():
stat = FileStat(name="big",
size=2048,
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
modified="2026-01-01T00:00:00Z")
[line] = format_ls_long([stat], human=True)
assert "2.0K" in line
@@ -103,6 +107,10 @@ def test_format_ls_long_human_size():
def test_format_ls_long_missing_modified():
stat = FileStat(name="x", size=0, type=FileType.TEXT, modified=None)
stat = FileStat(name="x",
size=0,
type=FileType.FILE,
content=ContentType.TEXT,
modified=None)
[line] = format_ls_long([stat])
assert "Jan 1 00:00" in line
@@ -28,7 +28,7 @@ from mirage.core.ram.read import read
from mirage.core.ram.readdir import readdir
from mirage.core.ram.stat import stat
from mirage.core.ram.write import write_bytes as _async_write_bytes
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
from mirage.commands.builtin.grep_helper import ( # isort: skip
NEVER_MATCH, classify_pattern, compile_pattern, extract_required_literal,
@@ -163,7 +163,9 @@ async def test_grep_files_only_recursive_scans_file_operands():
raise FileNotFoundError(path)
async def stat_fn(path):
return FileStat(name=path, type=FileType.TEXT)
return FileStat(name=path,
type=FileType.FILE,
content=ContentType.TEXT)
async def read_bytes_fn(path):
return b"alpha beta\n"
@@ -31,7 +31,9 @@ def _stat_over(files: dict[str, bytes], dirs: set[str] | None = None):
return FileStat(name=path.virtual, type=FileType.DIRECTORY)
if path.virtual not in files:
raise FileNotFoundError(path.virtual)
return FileStat(name=path.virtual, size=len(files[path.virtual]))
return FileStat(type=FileType.FILE,
name=path.virtual,
size=len(files[path.virtual]))
return stat
@@ -18,7 +18,7 @@ from mirage.commands.builtin.utils.slash_links import (is_slashed_link,
mkdir_link_refusal,
rm_link_refusal)
from mirage.ops.types import LinkView
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import ContentType, FileStat, FileType, PathSpec
def _spec(virtual: str, raw_path: str) -> PathSpec:
@@ -56,7 +56,7 @@ def _links(link_at: str | None, target: FileStat | None) -> LinkView:
DIR = FileStat(name="sub", type=FileType.DIRECTORY)
FILE = FileStat(name="reg", type=FileType.TEXT)
FILE = FileStat(name="reg", type=FileType.FILE, content=ContentType.TEXT)
def test_is_slashed_link_needs_both_the_slash_and_the_link():
@@ -22,7 +22,7 @@ from mirage.commands.cli.builtin.git.add import (EXECUTABLE, REGULAR, SYMLINK,
entry_mode, keep_addable,
staged_entry)
from mirage.commands.cli.builtin.git.ignore import IgnoreStack
from mirage.types import LINK_TARGET_KEY, FileStat, FileType
from mirage.types import LINK_TARGET_KEY, ContentType, FileStat, FileType
def stat(mode: int | None) -> FileStat:
@@ -31,7 +31,12 @@ def stat(mode: int | None) -> FileStat:
Args:
mode (int | None): permission bits, None when it has none.
"""
return FileStat(name="x", path="x", type=FileType.TEXT, size=4, mode=mode)
return FileStat(name="x",
path="x",
type=FileType.FILE,
content=ContentType.TEXT,
size=4,
mode=mode)
async def run(git_rw, line: str) -> tuple[int, bytes, bytes]:
@@ -26,7 +26,7 @@ from mirage.commands.cli.builtin.git.changes import (conflict_codes,
from mirage.commands.cli.builtin.git.index import read_index
from mirage.commands.cli.builtin.git.repo import open_repo
from mirage.commands.cli.builtin.git.types import RepoLocation, WorkTree
from mirage.types import FileStat, FileType
from mirage.types import ContentType, FileStat, FileType
REGULAR = 0o100644
EXECUTABLE = 0o100755
@@ -65,7 +65,8 @@ def stat(size: int | None = 4, mode: int | None = 0o644) -> FileStat:
"""
return FileStat(name="x",
path="x",
type=FileType.TEXT,
type=FileType.FILE,
content=ContentType.TEXT,
size=size,
mode=mode)
@@ -17,7 +17,7 @@ import pytest
from mirage.commands.cli.builtin.git.discover import discover
from mirage.commands.cli.builtin.git.errors import ( # yapf: disable
InvalidGitFileError, NotARepositoryError, NoWorkingDirectoryError)
from mirage.types import FileStat, FileType
from mirage.types import ContentType, FileStat, FileType
def _stat_over(present: set[str], files: set[str] = frozenset()):
@@ -30,7 +30,9 @@ def _stat_over(present: set[str], files: set[str] = frozenset()):
async def stat_path(path: str) -> FileStat | None:
if path in files:
return FileStat(name=path.rsplit("/", 1)[-1], type=FileType.TEXT)
return FileStat(name=path.rsplit("/", 1)[-1],
type=FileType.FILE,
content=ContentType.TEXT)
if path not in present:
return None
return FileStat(name=path.rsplit("/", 1)[-1], type=FileType.DIRECTORY)
+21 -5
View File
@@ -19,7 +19,7 @@ import pytest
from mirage.commands.builtin.grep_helper import compile_pattern, grep_recursive
from mirage.commands.builtin.rg_helper import rg_full
from mirage.resource.ram import RAMResource
from mirage.types import FileStat, FileType, MountMode, PathSpec
from mirage.types import ContentType, FileStat, FileType, MountMode, PathSpec
from mirage.workspace import Workspace
@@ -54,9 +54,17 @@ async def test_grep_helper_collects_warnings_on_unreadable_file():
readdir = _make_readdir({"/": ["/good.txt", "/bad.txt"]})
stat_fn = _make_stat({
"/good.txt":
FileStat(name="good.txt", size=12, modified=None, type=FileType.TEXT),
FileStat(name="good.txt",
size=12,
modified=None,
type=FileType.FILE,
content=ContentType.TEXT),
"/bad.txt":
FileStat(name="bad.txt", size=10, modified=None, type=FileType.TEXT),
FileStat(name="bad.txt",
size=10,
modified=None,
type=FileType.FILE,
content=ContentType.TEXT),
})
async def async_readdir(path):
@@ -132,9 +140,17 @@ async def test_rg_helper_collects_warnings_on_unreadable_file():
readdir = _make_readdir({"/": ["/good.py", "/bad.py"]})
stat_fn = _make_stat({
"/good.py":
FileStat(name="good.py", size=12, modified=None, type=FileType.TEXT),
FileStat(name="good.py",
size=12,
modified=None,
type=FileType.FILE,
content=ContentType.TEXT),
"/bad.py":
FileStat(name="bad.py", size=10, modified=None, type=FileType.TEXT),
FileStat(name="bad.py",
size=10,
modified=None,
type=FileType.FILE,
content=ContentType.TEXT),
})
async def async_readdir(path):
+2 -2
View File
@@ -20,7 +20,7 @@ from mirage.cache.index.config import IndexEntry
from mirage.core.box.read import read
from mirage.core.box.readdir import readdir
from mirage.core.box.stat import stat
from mirage.types import FileType, PathSpec
from mirage.types import ContentType, FileType, PathSpec
@pytest.mark.asyncio
@@ -56,7 +56,7 @@ async def test_stat_file_carries_box_metadata(accessor, index):
accessor,
PathSpec(resource_path="a.txt", virtual="/a.txt", directory="/"),
index)
assert info.type == FileType.TEXT
assert info.content == ContentType.TEXT
assert info.size == 5
assert info.modified == "2026-04-01T00:00:00+00:00"
assert info.fingerprint == "2026-04-01T00:00:00+00:00"
+2 -2
View File
@@ -15,14 +15,14 @@
import pytest
from mirage.core.chroma.stat import stat, stat_name
from mirage.types import FileType, PathSpec
from mirage.types import ContentType, FileType, PathSpec
from mirage.utils.key_prefix import mount_key
@pytest.mark.asyncio
async def test_stat_file(chroma_accessor, chroma_index, quickstart_path):
result = await stat(chroma_accessor, quickstart_path, chroma_index)
assert result.type == FileType.TEXT
assert result.content == ContentType.TEXT
assert result.name == "quickstart"
assert result.size == 12
assert result.modified == "2026-02-01T00:00:00Z"

Some files were not shown because too many files have changed in this diff Show More