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
433 lines
14 KiB
Python
433 lines
14 KiB
Python
import asyncio
|
|
from dataclasses import asdict
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from mirage.commands.builtin.find_eval import Name, Not, Or
|
|
from mirage.commands.builtin.generic.find import (FindArgs, apply_mount_prefix,
|
|
apply_mtime_filter,
|
|
parse_find_args, walk_find)
|
|
from mirage.commands.errors import FindParseError
|
|
from mirage.resource.ram import RAMResource
|
|
from mirage.types import FileStat, FileType, FindType, MountMode, PathSpec
|
|
from mirage.workspace import Workspace
|
|
|
|
|
|
def _defaults() -> dict:
|
|
return asdict(FindArgs())
|
|
|
|
|
|
def test_parse_find_args_empty_returns_defaults():
|
|
args = parse_find_args(())
|
|
assert asdict(args) == _defaults()
|
|
|
|
|
|
def test_parse_find_args_name_passthrough():
|
|
args = parse_find_args((), name="*.txt")
|
|
assert args.name == "*.txt"
|
|
assert args.or_names is None
|
|
|
|
|
|
def test_parse_find_args_iname_and_path():
|
|
args = parse_find_args((), iname="HELLO.*", path="**/sub/*")
|
|
assert args.iname == "HELLO.*"
|
|
assert args.path_pattern == "**/sub/*"
|
|
|
|
|
|
def test_parse_find_args_maxdepth_mindepth_str_to_int():
|
|
args = parse_find_args((), maxdepth="3", mindepth="1")
|
|
assert args.maxdepth == 3
|
|
assert args.mindepth == 1
|
|
|
|
|
|
def test_parse_find_args_size_plus_lower_bound():
|
|
args = parse_find_args((), size="+500c")
|
|
assert args.min_size == 501
|
|
assert args.max_size is None
|
|
|
|
|
|
def test_parse_find_args_size_minus_upper_bound():
|
|
args = parse_find_args((), size="-1k")
|
|
assert args.min_size is None
|
|
assert args.max_size == 0
|
|
|
|
|
|
def test_parse_find_args_size_exact():
|
|
args = parse_find_args((), size="1k")
|
|
assert args.min_size == 1
|
|
assert args.max_size == 1024
|
|
|
|
|
|
def test_parse_find_args_mtime_minus_recent():
|
|
"""`-mtime -1` means modified within last 1 day."""
|
|
args = parse_find_args((), mtime="-1")
|
|
assert args.mtime_min is not None
|
|
assert args.mtime_max is None
|
|
|
|
|
|
def test_parse_find_args_mtime_plus_old():
|
|
args = parse_find_args((), mtime="+7")
|
|
assert args.mtime_min is None
|
|
assert args.mtime_max is not None
|
|
|
|
|
|
def test_parse_find_args_type_canonicalized_to_findtype_enum():
|
|
"""Known POSIX `-type` values become FindType members."""
|
|
assert parse_find_args((), type="d").type is FindType.DIRECTORY
|
|
assert parse_find_args((), type="f").type is FindType.FILE
|
|
|
|
|
|
def test_parse_find_args_unknown_type_left_as_string():
|
|
"""Non-POSIX types pass through verbatim (allows custom backend types)."""
|
|
assert parse_find_args((), type="symlink").type == "symlink"
|
|
|
|
|
|
def test_parse_find_args_unknown_predicate_raises():
|
|
with pytest.raises(FindParseError,
|
|
match="find: unknown predicate '-bogus'"):
|
|
parse_find_args(("-bogus", ))
|
|
|
|
|
|
def test_parse_find_args_negation_builds_not_tree():
|
|
args = parse_find_args(("-not", "-name", "*.pyc"))
|
|
assert args.tree == Not(Name("*.pyc"))
|
|
|
|
|
|
def test_parse_find_args_or_builds_or_tree():
|
|
args = parse_find_args(("-name", "*.txt", "-o", "-name", "*.py"))
|
|
assert args.tree == Or([Name("*.txt"), Name("*.py")])
|
|
|
|
|
|
def test_parse_find_args_or_names_none_when_only_one_name():
|
|
"""If only `name` is set with no `-or -name` clauses, or_names is None."""
|
|
args = parse_find_args((), name="*.txt")
|
|
assert args.or_names is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_mtime_filter_skips_when_no_window():
|
|
out = await apply_mtime_filter(
|
|
["/a.txt"],
|
|
mtime_min=None,
|
|
mtime_max=None,
|
|
stat=_unreached_stat,
|
|
)
|
|
assert out == ["/a.txt"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_mtime_filter_keeps_within_window():
|
|
now = datetime.now(tz=timezone.utc)
|
|
iso = now.isoformat()
|
|
|
|
async def stat(_spec: PathSpec) -> FileStat:
|
|
return FileStat(name="a.txt", size=1, modified=iso, type=FileType.TEXT)
|
|
|
|
out = await apply_mtime_filter(
|
|
["/a.txt"],
|
|
mtime_min=now.timestamp() - 60,
|
|
mtime_max=now.timestamp() + 60,
|
|
stat=stat,
|
|
)
|
|
assert out == ["/a.txt"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_mtime_filter_drops_outside_window():
|
|
old = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
|
|
|
async def stat(_spec: PathSpec) -> FileStat:
|
|
return FileStat(name="a.txt",
|
|
size=1,
|
|
modified=old.isoformat(),
|
|
type=FileType.TEXT)
|
|
|
|
out = await apply_mtime_filter(
|
|
["/a.txt"],
|
|
mtime_min=datetime(2025, 1, 1, tzinfo=timezone.utc).timestamp(),
|
|
mtime_max=None,
|
|
stat=stat,
|
|
)
|
|
assert out == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_mtime_filter_drops_entries_with_no_modified_time():
|
|
|
|
async def stat(_spec: PathSpec) -> FileStat:
|
|
return FileStat(name="a.txt",
|
|
size=1,
|
|
modified=None,
|
|
type=FileType.TEXT)
|
|
|
|
out = await apply_mtime_filter(
|
|
["/a.txt"],
|
|
mtime_min=1.0,
|
|
mtime_max=None,
|
|
stat=stat,
|
|
)
|
|
assert out == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_mtime_filter_silently_skips_stat_errors():
|
|
|
|
async def stat(_spec: PathSpec) -> FileStat:
|
|
raise FileNotFoundError("gone")
|
|
|
|
out = await apply_mtime_filter(
|
|
["/a.txt", "/b.txt"],
|
|
mtime_min=1.0,
|
|
mtime_max=None,
|
|
stat=stat,
|
|
)
|
|
assert out == []
|
|
|
|
|
|
def test_apply_mount_prefix_noop_when_empty():
|
|
assert apply_mount_prefix(["/a.txt"], "") == ["/a.txt"]
|
|
|
|
|
|
def test_apply_mount_prefix_prepends():
|
|
assert apply_mount_prefix(["/a.txt", "/dir/b.txt"],
|
|
"/mnt") == ["/mnt/a.txt", "/mnt/dir/b.txt"]
|
|
|
|
|
|
def test_apply_mount_prefix_strips_leading_slash_from_entries():
|
|
assert apply_mount_prefix(["a.txt"], "/mnt") == ["/mnt/a.txt"]
|
|
|
|
|
|
async def _unreached_stat(_spec: PathSpec) -> FileStat:
|
|
raise AssertionError("stat should not be called when no mtime window set")
|
|
|
|
|
|
def _root_spec() -> PathSpec:
|
|
return PathSpec(resource_path="",
|
|
virtual="/",
|
|
directory="/",
|
|
resolved=False)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_walk_find_tolerates_not_found_readdir():
|
|
readdir = AsyncMock(side_effect=FileNotFoundError("/"))
|
|
stat = AsyncMock(side_effect=FileNotFoundError("/"))
|
|
results = await walk_find(_root_spec(),
|
|
readdir=readdir,
|
|
stat=stat,
|
|
is_dir_name=lambda c: None,
|
|
index=None,
|
|
args=FindArgs())
|
|
assert results == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_walk_find_emits_start_path_at_depth_zero():
|
|
readdir = AsyncMock(return_value=["/child.txt"])
|
|
stat = AsyncMock(return_value=FileStat(name="/", type=FileType.DIRECTORY))
|
|
results = await walk_find(_root_spec(),
|
|
readdir=readdir,
|
|
stat=stat,
|
|
is_dir_name=lambda c: False,
|
|
index=None,
|
|
args=FindArgs(maxdepth=0))
|
|
assert results == ["/"]
|
|
readdir.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_walk_find_propagates_non_not_found_readdir_errors():
|
|
readdir = AsyncMock(side_effect=ValueError("bad page token"))
|
|
stat = AsyncMock()
|
|
with pytest.raises(ValueError, match="bad page token"):
|
|
await walk_find(_root_spec(),
|
|
readdir=readdir,
|
|
stat=stat,
|
|
is_dir_name=lambda c: None,
|
|
index=None,
|
|
args=FindArgs())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_walk_find_stat_fallback_treats_not_found_as_file():
|
|
readdir = AsyncMock(return_value=["/mystery"])
|
|
stat = AsyncMock(side_effect=FileNotFoundError("/mystery"))
|
|
results = await walk_find(_root_spec(),
|
|
readdir=readdir,
|
|
stat=stat,
|
|
is_dir_name=lambda c: None,
|
|
index=None,
|
|
args=FindArgs(type=FindType.FILE))
|
|
assert results == ["/mystery"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_walk_find_empty_matches_empty_files_and_dirs():
|
|
|
|
async def readdir(spec: PathSpec, _index):
|
|
table = {
|
|
"/": ["/empty.txt", "/full.txt", "/empty-dir", "/full-dir"],
|
|
"/empty-dir": [],
|
|
"/full-dir": ["/full-dir/a.txt"],
|
|
}
|
|
return table[spec.virtual]
|
|
|
|
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),
|
|
}
|
|
return stats[spec.virtual]
|
|
|
|
results = await walk_find(_root_spec(),
|
|
readdir=readdir,
|
|
stat=stat,
|
|
is_dir_name=lambda c: c.endswith("dir"),
|
|
index=None,
|
|
args=parse_find_args(("-empty", )))
|
|
assert results == ["/empty-dir", "/empty.txt"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_walk_find_not_negates_predicate():
|
|
readdir = AsyncMock(return_value=["/a.txt", "/b.md"])
|
|
|
|
async def stat(spec: PathSpec, _index):
|
|
if spec.virtual == "/":
|
|
return FileStat(name="/", type=FileType.DIRECTORY)
|
|
return FileStat(name=spec.virtual.rsplit("/", 1)[-1],
|
|
size=1,
|
|
type=FileType.TEXT)
|
|
|
|
results = await walk_find(_root_spec(),
|
|
readdir=readdir,
|
|
stat=stat,
|
|
is_dir_name=lambda c: False,
|
|
index=None,
|
|
args=parse_find_args(("-not", "-name", "*.txt")))
|
|
assert results == ["/", "/b.md"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_walk_find_stat_fallback_propagates_other_errors():
|
|
readdir = AsyncMock(return_value=["/mystery"])
|
|
stat = AsyncMock(side_effect=ValueError("rate limited"))
|
|
with pytest.raises(ValueError, match="rate limited"):
|
|
await walk_find(_root_spec(),
|
|
readdir=readdir,
|
|
stat=stat,
|
|
is_dir_name=lambda c: None,
|
|
index=None,
|
|
args=FindArgs())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_walk_find_size_filter_drops_not_found_entries():
|
|
readdir = AsyncMock(return_value=["/a.json"])
|
|
stat = AsyncMock(side_effect=FileNotFoundError("/a.json"))
|
|
results = await walk_find(_root_spec(),
|
|
readdir=readdir,
|
|
stat=stat,
|
|
is_dir_name=lambda c: False,
|
|
index=None,
|
|
args=FindArgs(min_size=1))
|
|
assert results == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_walk_find_size_filter_propagates_other_stat_errors():
|
|
readdir = AsyncMock(return_value=["/a.json"])
|
|
stat = AsyncMock(side_effect=ValueError("rate limited"))
|
|
with pytest.raises(ValueError, match="rate limited"):
|
|
await walk_find(_root_spec(),
|
|
readdir=readdir,
|
|
stat=stat,
|
|
is_dir_name=lambda c: False,
|
|
index=None,
|
|
args=FindArgs(min_size=1))
|
|
|
|
|
|
@pytest.mark.parametrize("kwargs,flag,value", [
|
|
({
|
|
"maxdepth": "abc"
|
|
}, "-maxdepth", "abc"),
|
|
({
|
|
"mindepth": "xx"
|
|
}, "-mindepth", "xx"),
|
|
({
|
|
"size": ""
|
|
}, "-size", ""),
|
|
({
|
|
"size": "abc"
|
|
}, "-size", "abc"),
|
|
({
|
|
"mtime": "abc"
|
|
}, "-mtime", "abc"),
|
|
])
|
|
def test_parse_find_args_invalid_numeric_raises_find_parse_error(
|
|
kwargs, flag, value):
|
|
with pytest.raises(FindParseError) as exc:
|
|
parse_find_args((), **kwargs)
|
|
assert str(exc.value) == f"find: invalid argument '{value}' to '{flag}'"
|
|
|
|
|
|
@pytest.mark.parametrize("expr", [
|
|
"-maxdepth abc",
|
|
"-mindepth xx",
|
|
"-size ''",
|
|
"-size abc",
|
|
"-mtime abc",
|
|
])
|
|
def test_find_invalid_numeric_arg_exits_one_with_clean_stderr(expr):
|
|
|
|
async def _go() -> tuple[int, str]:
|
|
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
|
ws.create_session("s")
|
|
r = await ws.execute(f"find / {expr}", session_id="s")
|
|
return r.exit_code, await r.stderr_str()
|
|
|
|
code, stderr = asyncio.run(_go())
|
|
assert code == 1
|
|
assert stderr.startswith("find: invalid argument ")
|
|
assert stderr.endswith("\n")
|
|
|
|
|
|
# ── Issue #312 parse-level regression tests ────────────────
|
|
|
|
|
|
def test_parse_find_args_start_path_included():
|
|
args = parse_find_args(())
|
|
assert args.maxdepth is None
|
|
|
|
|
|
def test_parse_find_args_maxdepth_zero():
|
|
args = parse_find_args(("-maxdepth", "0"))
|
|
assert args.maxdepth == 0
|
|
|
|
|
|
def test_parse_find_args_empty_predicate():
|
|
args = parse_find_args(("-empty", ))
|
|
assert args.empty is True
|
|
|
|
|
|
def test_parse_find_args_not_negation():
|
|
args = parse_find_args(("-not", "-name", "*.txt"))
|
|
assert isinstance(args.tree, Not)
|
|
assert isinstance(args.tree.kid, Name)
|
|
assert args.tree.kid.pattern == "*.txt"
|
|
|
|
|
|
def test_parse_find_args_bogus_predicate_raises():
|
|
with pytest.raises(FindParseError, match="unknown predicate"):
|
|
parse_find_args(("-boguspredicate", ))
|