bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
342 lines
12 KiB
Python
342 lines
12 KiB
Python
from collections.abc import Awaitable, Callable
|
|
from datetime import datetime, timezone
|
|
|
|
from mirage.cache.index import IndexCacheStore
|
|
from mirage.commands.builtin.find_eval import (FindArgs, FindEntry,
|
|
args_to_tree, keep,
|
|
prefix_path_nodes,
|
|
tree_has_empty)
|
|
from mirage.commands.builtin.find_helper import (_parse_depth, _parse_mtime,
|
|
_parse_size)
|
|
from mirage.commands.builtin.find_parse import parse_find_expression
|
|
from mirage.commands.builtin.utils.output import format_records
|
|
from mirage.io.types import ByteSource, IOResult
|
|
from mirage.types import FileStat, FileType, FindType, PathSpec
|
|
from mirage.utils.key_prefix import mount_key, mount_prefix_of
|
|
from mirage.utils.path import rebase_raw
|
|
|
|
|
|
def parse_find_args(
|
|
texts: tuple[str, ...],
|
|
*,
|
|
name: str | None = None,
|
|
type: str | None = None,
|
|
size: str | None = None,
|
|
mtime: str | None = None,
|
|
maxdepth: str | None = None,
|
|
iname: str | None = None,
|
|
path: str | None = None,
|
|
mindepth: str | None = None,
|
|
empty: bool = False,
|
|
) -> FindArgs:
|
|
if texts:
|
|
expr = parse_find_expression(list(texts))
|
|
return FindArgs(
|
|
min_size=expr.min_size,
|
|
max_size=expr.max_size,
|
|
mtime_min=expr.mtime_min,
|
|
mtime_max=expr.mtime_max,
|
|
maxdepth=expr.maxdepth,
|
|
mindepth=expr.mindepth,
|
|
empty=expr.uses_empty,
|
|
tree=expr.tree,
|
|
)
|
|
ftype: FindType | str | None = type
|
|
if type in (FindType.DIRECTORY.value, FindType.FILE.value):
|
|
ftype = FindType(type)
|
|
md = _parse_depth(maxdepth, "-maxdepth") if maxdepth is not None else None
|
|
md_min = (_parse_depth(mindepth, "-mindepth")
|
|
if mindepth is not None else None)
|
|
min_size, max_size = (None, None)
|
|
if size is not None:
|
|
min_size, max_size = _parse_size(size)
|
|
mtime_min, mtime_max = (None, None)
|
|
if mtime is not None:
|
|
mtime_min, mtime_max = _parse_mtime(mtime)
|
|
return FindArgs(
|
|
name=name,
|
|
iname=iname,
|
|
path_pattern=path,
|
|
type=ftype,
|
|
min_size=min_size,
|
|
max_size=max_size,
|
|
mtime_min=mtime_min,
|
|
mtime_max=mtime_max,
|
|
maxdepth=md,
|
|
mindepth=md_min,
|
|
empty=empty,
|
|
)
|
|
|
|
|
|
async def apply_mtime_filter(
|
|
results: list[str],
|
|
*,
|
|
mtime_min: float | None,
|
|
mtime_max: float | None,
|
|
stat: Callable[[PathSpec], Awaitable[FileStat]],
|
|
mount_prefix: str = "",
|
|
) -> list[str]:
|
|
if mtime_min is None and mtime_max is None:
|
|
return results
|
|
filtered: list[str] = []
|
|
for r in results:
|
|
try:
|
|
spec = PathSpec(virtual=r,
|
|
directory=r,
|
|
resolved=False,
|
|
resource_path=mount_key(r, mount_prefix))
|
|
s = await stat(spec)
|
|
except (FileNotFoundError, ValueError):
|
|
continue
|
|
if s.modified is None:
|
|
continue
|
|
mod_ts = datetime.fromisoformat(
|
|
s.modified).replace(tzinfo=timezone.utc).timestamp()
|
|
if mtime_min is not None and mod_ts < mtime_min:
|
|
continue
|
|
if mtime_max is not None and mod_ts > mtime_max:
|
|
continue
|
|
filtered.append(r)
|
|
return filtered
|
|
|
|
|
|
def apply_mount_prefix(results: list[str], mount_prefix: str) -> list[str]:
|
|
if not mount_prefix:
|
|
return results
|
|
out: list[str] = []
|
|
for r in results:
|
|
rel = r.lstrip("/")
|
|
# An empty relative path is the mount root itself (e.g. a
|
|
# single-file view mount); joining would add a bogus slash.
|
|
out.append(mount_prefix if not rel else mount_prefix + "/" + rel)
|
|
return out
|
|
|
|
|
|
async def find(
|
|
paths: list[PathSpec],
|
|
texts: tuple[str, ...],
|
|
*,
|
|
find_core: Callable[..., Awaitable[list[str]]],
|
|
stat: Callable[[PathSpec], Awaitable[FileStat]] | None = None,
|
|
name: str | None = None,
|
|
type: str | None = None,
|
|
size: str | None = None,
|
|
mtime: str | None = None,
|
|
maxdepth: str | None = None,
|
|
iname: str | None = None,
|
|
path: str | None = None,
|
|
mindepth: str | None = None,
|
|
empty: bool = False,
|
|
) -> tuple[ByteSource | None, IOResult]:
|
|
search_path = paths[0]
|
|
args = parse_find_args(texts,
|
|
name=name,
|
|
type=type,
|
|
size=size,
|
|
mtime=mtime,
|
|
maxdepth=maxdepth,
|
|
iname=iname,
|
|
path=path,
|
|
mindepth=mindepth,
|
|
empty=empty)
|
|
if stat is not None:
|
|
try:
|
|
await stat(search_path)
|
|
except (FileNotFoundError, ValueError) as exc:
|
|
stderr = f"find: '{search_path.raw_path}': {exc}".encode()
|
|
return b"", IOResult(stderr=stderr, exit_code=1)
|
|
root_prefix = mount_prefix_of(search_path.virtual,
|
|
search_path.resource_path)
|
|
# `-path` matches the display path as printed; stamp the mount
|
|
# prefix onto Path nodes before the backend walks mount-relative
|
|
# keys (#396).
|
|
args.tree = prefix_path_nodes(args_to_tree(args), root_prefix)
|
|
results = await find_core(
|
|
search_path,
|
|
name=args.name,
|
|
type=args.type,
|
|
min_size=args.min_size,
|
|
max_size=args.max_size,
|
|
maxdepth=args.maxdepth,
|
|
mindepth=args.mindepth,
|
|
name_exclude=args.name_exclude,
|
|
or_names=args.or_names,
|
|
iname=args.iname,
|
|
path_pattern=args.path_pattern,
|
|
empty=args.empty,
|
|
tree=args.tree,
|
|
)
|
|
if stat is not None:
|
|
results = await apply_mtime_filter(results,
|
|
mtime_min=args.mtime_min,
|
|
mtime_max=args.mtime_max,
|
|
stat=stat,
|
|
mount_prefix=root_prefix)
|
|
results = apply_mount_prefix(results, root_prefix)
|
|
results = rebase_raw(results, search_path.virtual, search_path.raw_path)
|
|
return format_records(results), IOResult()
|
|
|
|
|
|
def _modified_ts(modified: str | None) -> float | None:
|
|
# Missing or unparseable timestamps exclude the entry from -mtime
|
|
# matching, mirroring the TS implementation's NaN handling.
|
|
if not modified:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(modified)
|
|
except ValueError:
|
|
return None
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt.timestamp()
|
|
|
|
|
|
async def _stat_entry(
|
|
stat: Callable[[PathSpec, IndexCacheStore | None], Awaitable[FileStat]],
|
|
path: str,
|
|
prefix: str,
|
|
index: IndexCacheStore | None,
|
|
) -> FileStat | None:
|
|
spec = PathSpec(virtual=path,
|
|
directory=path,
|
|
resolved=False,
|
|
resource_path=mount_key(path, prefix))
|
|
try:
|
|
return await stat(spec, index)
|
|
except FileNotFoundError:
|
|
# Only missing entries resolve to None; API errors (rate limit, auth)
|
|
# propagate.
|
|
return None
|
|
|
|
|
|
async def _is_empty_entry(
|
|
readdir: Callable[[PathSpec, IndexCacheStore | None],
|
|
Awaitable[list[str]]],
|
|
stat: Callable[[PathSpec, IndexCacheStore | None], Awaitable[FileStat]],
|
|
path: str,
|
|
is_dir: bool,
|
|
prefix: str,
|
|
index: IndexCacheStore | None,
|
|
) -> bool:
|
|
if is_dir:
|
|
spec = PathSpec(virtual=path,
|
|
directory=path,
|
|
resolved=False,
|
|
resource_path=mount_key(path, prefix))
|
|
try:
|
|
return len(await readdir(spec, index)) == 0
|
|
except FileNotFoundError:
|
|
return False
|
|
st = await _stat_entry(stat, path, prefix, index)
|
|
return st is not None and (st.size or 0) == 0
|
|
|
|
|
|
async def _walk_collect(
|
|
readdir: Callable[[PathSpec, IndexCacheStore | None],
|
|
Awaitable[list[str]]],
|
|
stat: Callable[[PathSpec, IndexCacheStore | None], Awaitable[FileStat]],
|
|
is_dir_name: Callable[[str], bool | None],
|
|
spec: PathSpec,
|
|
index: IndexCacheStore | None,
|
|
maxdepth: int | None,
|
|
depth: int,
|
|
acc: list[tuple[str, bool]],
|
|
) -> None:
|
|
if maxdepth is not None and depth > maxdepth:
|
|
return
|
|
try:
|
|
children = await readdir(spec, index)
|
|
except FileNotFoundError:
|
|
# Only vanished dirs are skipped; API errors (rate limit, auth)
|
|
# propagate.
|
|
return
|
|
prefix = mount_prefix_of(spec.virtual, spec.resource_path)
|
|
for child in children:
|
|
hint = is_dir_name(child)
|
|
trimmed = child.rstrip("/") if child.endswith("/") else child
|
|
if hint is None:
|
|
st = await _stat_entry(stat, trimmed, prefix, index)
|
|
is_dir = st is not None and st.type == FileType.DIRECTORY
|
|
else:
|
|
is_dir = hint
|
|
acc.append((trimmed, is_dir))
|
|
if is_dir:
|
|
child_spec = PathSpec(virtual=trimmed,
|
|
directory=trimmed,
|
|
resolved=False,
|
|
resource_path=mount_key(trimmed, prefix))
|
|
await _walk_collect(readdir, stat, is_dir_name, child_spec, index,
|
|
maxdepth, depth + 1, acc)
|
|
|
|
|
|
async def walk_find(
|
|
search_path: PathSpec,
|
|
*,
|
|
readdir: Callable[[PathSpec, IndexCacheStore | None],
|
|
Awaitable[list[str]]],
|
|
stat: Callable[[PathSpec, IndexCacheStore | None], Awaitable[FileStat]],
|
|
is_dir_name: Callable[[str], bool | None],
|
|
index: IndexCacheStore | None,
|
|
args: FindArgs,
|
|
) -> list[str]:
|
|
collected: list[tuple[str, bool]] = []
|
|
prefix = mount_prefix_of(search_path.virtual, search_path.resource_path)
|
|
search_key = search_path.mount_path.strip("/")
|
|
root_path = (search_path.virtual.rstrip("/")
|
|
if search_path.virtual != "/" else "/")
|
|
root_stat = await _stat_entry(stat, root_path, prefix, index)
|
|
if root_stat is not None:
|
|
collected.append((root_path, root_stat.type == FileType.DIRECTORY))
|
|
# GNU depth convention: the search root is depth 0, its children are
|
|
# depth 1.
|
|
await _walk_collect(readdir, stat, is_dir_name, search_path, index,
|
|
args.maxdepth, 1, collected)
|
|
tree = prefix_path_nodes(args_to_tree(args), prefix)
|
|
need_empty = tree_has_empty(tree)
|
|
results: list[str] = []
|
|
for p, is_dir in sorted(collected):
|
|
entry_name = p.rsplit("/", 1)[-1]
|
|
key = p[len(prefix):] if prefix and p.startswith(prefix) else p
|
|
rel = key.strip("/")
|
|
if search_key:
|
|
depth = 0 if rel == search_key else rel.count(
|
|
"/") - search_key.count("/")
|
|
else:
|
|
depth = 0 if rel == "" else rel.count("/") + 1
|
|
is_empty = None
|
|
if need_empty:
|
|
is_empty = await _is_empty_entry(readdir, stat, p, is_dir, prefix,
|
|
index)
|
|
entry = FindEntry(key=key,
|
|
name=entry_name,
|
|
kind="d" if is_dir else "f",
|
|
depth=depth,
|
|
is_empty=is_empty)
|
|
if not keep(entry, tree, args.mindepth):
|
|
continue
|
|
need_size = (args.min_size is not None or args.max_size is not None)
|
|
need_mtime = args.mtime_min is not None or args.mtime_max is not None
|
|
st = None
|
|
if (need_size and not is_dir) or need_mtime:
|
|
st = await _stat_entry(stat, p, prefix, index)
|
|
if st is None:
|
|
continue
|
|
if need_size:
|
|
# Directories count as size 0 for -size: GNU compares the inode
|
|
# size (e.g. 4096 on ext4); see CLAUDE.md Rules.
|
|
size = 0 if is_dir else ((st.size if st is not None else 0) or 0)
|
|
if args.min_size is not None and size < args.min_size:
|
|
continue
|
|
if args.max_size is not None and size > args.max_size:
|
|
continue
|
|
if need_mtime and st is not None:
|
|
ts = _modified_ts(st.modified)
|
|
if ts is None:
|
|
continue
|
|
if args.mtime_min is not None and ts < args.mtime_min:
|
|
continue
|
|
if args.mtime_max is not None and ts > args.mtime_max:
|
|
continue
|
|
results.append(p)
|
|
return results
|