Files
strukto-ai--mirage/python/mirage/commands/builtin/generic/find.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:44 +08:00

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