chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,276 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from mirage.core.ram.mkdir import mkdir as mem_mkdir
|
||||
from mirage.core.ram.write import write_bytes as mem_write
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
LAST_MODIFIED = datetime(2026, 3, 31, tzinfo=timezone.utc)
|
||||
|
||||
_CORE_MODULES = [
|
||||
"mirage.core.s3.read",
|
||||
"mirage.core.s3.write",
|
||||
"mirage.core.s3.stat",
|
||||
"mirage.core.s3.readdir",
|
||||
"mirage.core.s3.find",
|
||||
"mirage.core.s3.du",
|
||||
"mirage.core.s3.stream",
|
||||
"mirage.core.s3.copy",
|
||||
"mirage.core.s3.rename",
|
||||
"mirage.core.s3.unlink",
|
||||
"mirage.core.s3.rmdir",
|
||||
"mirage.core.s3.rm",
|
||||
"mirage.core.s3.mkdir",
|
||||
"mirage.core.s3.create",
|
||||
"mirage.core.s3.truncate",
|
||||
]
|
||||
|
||||
|
||||
class AsyncMockBody:
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self._data = data
|
||||
|
||||
async def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
async def iter_chunks(self, chunk_size: int = 8192):
|
||||
for i in range(0, len(self._data), chunk_size):
|
||||
yield self._data[i:i + chunk_size]
|
||||
|
||||
|
||||
class AsyncMockPaginator:
|
||||
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self.objects = objects
|
||||
|
||||
async def paginate(self,
|
||||
Bucket: str,
|
||||
Prefix: str = "",
|
||||
Delimiter: str | None = None):
|
||||
del Bucket
|
||||
if Delimiter == "/":
|
||||
yield _paginate_directory(self.objects, Prefix)
|
||||
else:
|
||||
yield _paginate_flat(self.objects, Prefix)
|
||||
|
||||
|
||||
class AsyncMockS3Client:
|
||||
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self.objects = objects
|
||||
|
||||
async def get_object(self,
|
||||
Bucket: str,
|
||||
Key: str,
|
||||
Range: str | None = None) -> dict:
|
||||
del Bucket
|
||||
if Key not in self.objects:
|
||||
raise _mock_s3_error("NoSuchKey")
|
||||
data = self.objects[Key]
|
||||
if Range is not None:
|
||||
data = _slice_range(data, Range)
|
||||
return {"Body": AsyncMockBody(data), "ETag": f'"{Key}"'}
|
||||
|
||||
async def head_object(self, Bucket: str, Key: str) -> dict:
|
||||
del Bucket
|
||||
if Key not in self.objects:
|
||||
raise _mock_s3_error("NoSuchKey")
|
||||
return {
|
||||
"ContentLength": len(self.objects[Key]),
|
||||
"LastModified": LAST_MODIFIED,
|
||||
"ETag": f'"{Key}"',
|
||||
}
|
||||
|
||||
def get_paginator(self, name: str) -> AsyncMockPaginator:
|
||||
assert name == "list_objects_v2"
|
||||
return AsyncMockPaginator(self.objects)
|
||||
|
||||
async def put_object(self, Bucket: str, Key: str, Body: bytes) -> None:
|
||||
self.objects[Key] = Body
|
||||
|
||||
async def delete_object(self, Bucket: str, Key: str) -> None:
|
||||
self.objects.pop(Key, None)
|
||||
|
||||
async def copy_object(self, Bucket: str, CopySource: dict,
|
||||
Key: str) -> None:
|
||||
src_key = CopySource["Key"]
|
||||
if src_key in self.objects:
|
||||
self.objects[Key] = self.objects[src_key]
|
||||
|
||||
async def delete_objects(self, Bucket: str, Delete: dict) -> None:
|
||||
for obj in Delete.get("Objects", []):
|
||||
self.objects.pop(obj["Key"], None)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class MockAsyncSession:
|
||||
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self._client = AsyncMockS3Client(objects)
|
||||
|
||||
def client(self, **kwargs):
|
||||
return self._client
|
||||
|
||||
|
||||
def _mock_s3_error(code: str) -> Exception:
|
||||
exc = Exception(code)
|
||||
exc.response = {"Error": {"Code": code}}
|
||||
return exc
|
||||
|
||||
|
||||
def _paginate_directory(objects, prefix):
|
||||
common_prefixes: set[str] = set()
|
||||
contents: list[dict[str, object]] = []
|
||||
for key, data in sorted(objects.items()):
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
relative = key[len(prefix):]
|
||||
if not relative:
|
||||
continue
|
||||
if "/" in relative:
|
||||
child = relative.split("/", 1)[0]
|
||||
common_prefixes.add(prefix + child + "/")
|
||||
continue
|
||||
contents.append({"Key": key, "Size": len(data)})
|
||||
return {
|
||||
"CommonPrefixes": [{
|
||||
"Prefix": v
|
||||
} for v in sorted(common_prefixes)],
|
||||
"Contents": contents,
|
||||
}
|
||||
|
||||
|
||||
def _paginate_flat(objects, prefix):
|
||||
return {
|
||||
"Contents": [{
|
||||
"Key": k,
|
||||
"Size": len(v)
|
||||
} for k, v in sorted(objects.items()) if k.startswith(prefix)]
|
||||
}
|
||||
|
||||
|
||||
def _slice_range(data: bytes, range_spec: str) -> bytes:
|
||||
if not range_spec.startswith("bytes="):
|
||||
return data
|
||||
bounds = range_spec.removeprefix("bytes=").split("-", 1)
|
||||
start = int(bounds[0]) if bounds[0] else 0
|
||||
end = int(bounds[1]) if bounds[1] else len(data) - 1
|
||||
return data[start:end + 1]
|
||||
|
||||
|
||||
def patch_async_session(objects):
|
||||
mock_session = MockAsyncSession(objects)
|
||||
stack = ExitStack()
|
||||
for mod in _CORE_MODULES:
|
||||
stack.enter_context(
|
||||
patch(f"{mod}.async_session", return_value=mock_session))
|
||||
return stack
|
||||
|
||||
|
||||
def make_s3_ws(objects: dict[str, bytes]) -> Workspace:
|
||||
config = S3Config(
|
||||
bucket="test-bucket",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="fake",
|
||||
aws_secret_access_key="fake",
|
||||
)
|
||||
resource = S3Resource(config)
|
||||
return Workspace(
|
||||
{"/data": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
|
||||
|
||||
def make_memory_ws() -> tuple[Workspace, RAMResource]:
|
||||
resource = RAMResource()
|
||||
ws = Workspace(
|
||||
{"/data": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
return ws, resource
|
||||
|
||||
|
||||
def make_disk_ws(tmp_path) -> tuple[Workspace, object]:
|
||||
disk_root = tmp_path / "disk_root"
|
||||
disk_root.mkdir()
|
||||
resource = DiskResource(root=str(disk_root))
|
||||
ws = Workspace(
|
||||
{"/data": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
return ws, disk_root
|
||||
|
||||
|
||||
def memory_create_file(resource: RAMResource, path: str, content: bytes):
|
||||
accessor = resource.accessor
|
||||
parts = path.strip("/").split("/")
|
||||
for i in range(1, len(parts)):
|
||||
d = "/" + "/".join(parts[:i])
|
||||
if d not in accessor.store.dirs:
|
||||
try:
|
||||
asyncio.run(mem_mkdir(accessor, d))
|
||||
except (FileExistsError, ValueError):
|
||||
pass
|
||||
asyncio.run(mem_write(accessor, path, content))
|
||||
|
||||
|
||||
def run(ws: Workspace, cmd: str) -> str:
|
||||
|
||||
async def _run():
|
||||
io = await ws.execute(cmd)
|
||||
return await io.stdout_str()
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def run_exit(ws: Workspace, cmd: str) -> int:
|
||||
io = asyncio.run(ws.execute(cmd))
|
||||
return io.exit_code
|
||||
|
||||
|
||||
def make_resource_ws(request, tmp_path, files: dict[str, bytes]):
|
||||
if request.param == "s3":
|
||||
objects: dict[str, bytes] = {}
|
||||
for path, content in files.items():
|
||||
objects[path] = content
|
||||
ws = make_s3_ws(objects)
|
||||
with patch_async_session(objects):
|
||||
yield ws
|
||||
elif request.param == "ram":
|
||||
ws, resource = make_memory_ws()
|
||||
for path, content in files.items():
|
||||
memory_create_file(resource, "/" + path, content)
|
||||
yield ws
|
||||
else:
|
||||
ws, disk_root = make_disk_ws(tmp_path)
|
||||
for path, content in files.items():
|
||||
full = disk_root / path
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_bytes(content)
|
||||
yield ws
|
||||
@@ -0,0 +1,255 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import patch
|
||||
|
||||
_FAKE_TOKEN = "fake-gdrive-token"
|
||||
_FAKE_EXPIRES_IN = 9999999999
|
||||
|
||||
_FOLDER_MIME = "application/vnd.google-apps.folder"
|
||||
_FILE_MIME = "application/octet-stream"
|
||||
|
||||
_PATCH_TARGETS = {
|
||||
"list_files": [
|
||||
"mirage.core.gdrive.readdir.list_files",
|
||||
],
|
||||
"list_shared_drives": [
|
||||
"mirage.core.gdrive.readdir.list_shared_drives",
|
||||
],
|
||||
"list_all_files": [
|
||||
"mirage.core.gdocs.readdir.list_all_files",
|
||||
"mirage.core.gsheets.readdir.list_all_files",
|
||||
"mirage.core.gslides.readdir.list_all_files",
|
||||
],
|
||||
"download_file": [
|
||||
"mirage.core.gdrive.read.download_file",
|
||||
],
|
||||
"download_file_stream": [
|
||||
"mirage.core.gdrive.stream.download_file_stream",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class FakeGDrive:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._next_id: int = 1
|
||||
self._children: dict[str, list[dict]] = {"root": []}
|
||||
self._bytes: dict[str, bytes] = {}
|
||||
|
||||
def add_file(self, path: str, content: bytes) -> str:
|
||||
parts = [p for p in path.strip("/").split("/") if p]
|
||||
if not parts:
|
||||
raise ValueError(f"invalid file path: {path}")
|
||||
parent_id = self._ensure_dirs(parts[:-1])
|
||||
name = parts[-1]
|
||||
existing = self._find_child(parent_id, name)
|
||||
if existing is not None:
|
||||
self._bytes[existing["id"]] = content
|
||||
existing["size"] = str(len(content))
|
||||
existing["modifiedTime"] = self._next_modified_time()
|
||||
return existing["id"]
|
||||
file_id = self._mk_id("f")
|
||||
entry = {
|
||||
"id": file_id,
|
||||
"name": name,
|
||||
"mimeType": _FILE_MIME,
|
||||
"size": str(len(content)),
|
||||
"modifiedTime": "2026-04-16T00:00:00Z",
|
||||
"parents": [parent_id],
|
||||
}
|
||||
self._children[parent_id].append(entry)
|
||||
self._bytes[file_id] = content
|
||||
return file_id
|
||||
|
||||
def remove_file(self, path: str) -> None:
|
||||
parts = [p for p in path.strip("/").split("/") if p]
|
||||
if not parts:
|
||||
return
|
||||
parent_id = self._lookup_dirs(parts[:-1])
|
||||
if parent_id is None:
|
||||
return
|
||||
name = parts[-1]
|
||||
children = self._children.get(parent_id, [])
|
||||
for i, c in enumerate(list(children)):
|
||||
if c["name"] == name:
|
||||
self._bytes.pop(c["id"], None)
|
||||
children.pop(i)
|
||||
return
|
||||
|
||||
def list_children(self, folder_id: str) -> list[dict]:
|
||||
return list(self._children.get(folder_id, []))
|
||||
|
||||
def all_files(self) -> list[dict]:
|
||||
result: list[dict] = []
|
||||
for children in self._children.values():
|
||||
for c in children:
|
||||
if c["mimeType"] != _FOLDER_MIME:
|
||||
result.append(c)
|
||||
return result
|
||||
|
||||
def get_bytes(self, file_id: str) -> bytes:
|
||||
if file_id not in self._bytes:
|
||||
raise FileNotFoundError(file_id)
|
||||
return self._bytes[file_id]
|
||||
|
||||
def has_id(self, file_id: str) -> bool:
|
||||
return file_id in self._bytes
|
||||
|
||||
def _mk_id(self, kind: str) -> str:
|
||||
i = self._next_id
|
||||
self._next_id += 1
|
||||
return f"{kind}{i:04d}"
|
||||
|
||||
def _next_modified_time(self) -> str:
|
||||
# Bump the fake modifiedTime on every overwrite so fingerprint
|
||||
# comparisons can detect content mutation in tests.
|
||||
i = self._next_id
|
||||
return f"2026-04-16T00:00:{i:02d}Z"
|
||||
|
||||
def _ensure_dirs(self, parts: list[str]) -> str:
|
||||
parent_id = "root"
|
||||
for p in parts:
|
||||
existing = self._find_child(parent_id, p)
|
||||
if existing is not None and existing["mimeType"] == _FOLDER_MIME:
|
||||
parent_id = existing["id"]
|
||||
continue
|
||||
new_id = self._mk_id("d")
|
||||
entry = {
|
||||
"id": new_id,
|
||||
"name": p,
|
||||
"mimeType": _FOLDER_MIME,
|
||||
"modifiedTime": "2026-04-16T00:00:00Z",
|
||||
"parents": [parent_id],
|
||||
}
|
||||
self._children[parent_id].append(entry)
|
||||
self._children[new_id] = []
|
||||
parent_id = new_id
|
||||
return parent_id
|
||||
|
||||
def _lookup_dirs(self, parts: list[str]) -> str | None:
|
||||
parent_id = "root"
|
||||
for p in parts:
|
||||
existing = self._find_child(parent_id, p)
|
||||
if existing is None or existing["mimeType"] != _FOLDER_MIME:
|
||||
return None
|
||||
parent_id = existing["id"]
|
||||
return parent_id
|
||||
|
||||
def _find_child(self, parent_id: str, name: str) -> dict | None:
|
||||
for c in self._children.get(parent_id, []):
|
||||
if c["name"] == name:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_fake(token_manager, registry):
|
||||
if not registry:
|
||||
return None
|
||||
for tm, fake in registry:
|
||||
if tm is token_manager:
|
||||
return fake
|
||||
return registry[0][1]
|
||||
|
||||
|
||||
def _build_fakes(registry):
|
||||
|
||||
async def fake_refresh(_config):
|
||||
return _FAKE_TOKEN, _FAKE_EXPIRES_IN
|
||||
|
||||
async def fake_list_files(
|
||||
token_manager,
|
||||
folder_id: str = "root",
|
||||
drive_id: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
trashed: bool = False,
|
||||
page_size: int = 1000,
|
||||
) -> list[dict]:
|
||||
del drive_id, mime_type, trashed, page_size
|
||||
fake = _resolve_fake(token_manager, registry)
|
||||
if fake is None:
|
||||
return []
|
||||
return fake.list_children(folder_id)
|
||||
|
||||
async def fake_list_all_files(
|
||||
token_manager,
|
||||
mime_type: str | None = None,
|
||||
trashed: bool = False,
|
||||
page_size: int = 1000,
|
||||
) -> list[dict]:
|
||||
del mime_type, trashed, page_size
|
||||
fake = _resolve_fake(token_manager, registry)
|
||||
if fake is None:
|
||||
return []
|
||||
return fake.all_files()
|
||||
|
||||
async def fake_list_shared_drives(token_manager) -> list[dict]:
|
||||
return []
|
||||
|
||||
async def fake_download_file(token_manager, file_id: str) -> bytes:
|
||||
fake = _resolve_fake(token_manager, registry)
|
||||
if fake is None:
|
||||
raise FileNotFoundError(file_id)
|
||||
if fake.has_id(file_id):
|
||||
return fake.get_bytes(file_id)
|
||||
for _, other in registry:
|
||||
if other.has_id(file_id):
|
||||
return other.get_bytes(file_id)
|
||||
raise FileNotFoundError(file_id)
|
||||
|
||||
async def fake_download_file_stream(
|
||||
token_manager,
|
||||
file_id: str,
|
||||
chunk_size: int = 8192,
|
||||
) -> AsyncIterator[bytes]:
|
||||
data = await fake_download_file(token_manager, file_id)
|
||||
for i in range(0, len(data), chunk_size):
|
||||
yield data[i:i + chunk_size]
|
||||
|
||||
return {
|
||||
"refresh": fake_refresh,
|
||||
"list_files": fake_list_files,
|
||||
"list_shared_drives": fake_list_shared_drives,
|
||||
"list_all_files": fake_list_all_files,
|
||||
"download_file": fake_download_file,
|
||||
"download_file_stream": fake_download_file_stream,
|
||||
}
|
||||
|
||||
|
||||
def patch_gdrive(*pairs) -> ExitStack:
|
||||
"""Patch gdrive HTTP layer with (token_manager, FakeGDrive) pairs.
|
||||
|
||||
Args:
|
||||
*pairs: tuples of (token_manager, FakeGDrive). The right fake is
|
||||
selected by token_manager identity, so multiple gdrive resources
|
||||
can coexist with separate file trees.
|
||||
"""
|
||||
if len(pairs) == 1 and isinstance(pairs[0], FakeGDrive):
|
||||
registry = [(None, pairs[0])]
|
||||
else:
|
||||
registry = list(pairs)
|
||||
fakes = _build_fakes(registry)
|
||||
stack = ExitStack()
|
||||
stack.enter_context(
|
||||
patch("mirage.core.google._client.refresh_access_token",
|
||||
new=fakes["refresh"]))
|
||||
for name, targets in _PATCH_TARGETS.items():
|
||||
for target in targets:
|
||||
try:
|
||||
stack.enter_context(patch(target, new=fakes[name]))
|
||||
except (AttributeError, ModuleNotFoundError):
|
||||
pass
|
||||
return stack
|
||||
@@ -0,0 +1,272 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import hashlib
|
||||
from collections import Counter
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
LAST_MODIFIED = datetime(2026, 3, 31, tzinfo=timezone.utc)
|
||||
|
||||
_CORE_MODULES = [
|
||||
"mirage.core.s3.read",
|
||||
"mirage.core.s3.write",
|
||||
"mirage.core.s3.stat",
|
||||
"mirage.core.s3.readdir",
|
||||
"mirage.core.s3.find",
|
||||
"mirage.core.s3.du",
|
||||
"mirage.core.s3.stream",
|
||||
"mirage.core.s3.copy",
|
||||
"mirage.core.s3.rename",
|
||||
"mirage.core.s3.unlink",
|
||||
"mirage.core.s3.rmdir",
|
||||
"mirage.core.s3.rm",
|
||||
"mirage.core.s3.mkdir",
|
||||
"mirage.core.s3.create",
|
||||
"mirage.core.s3.truncate",
|
||||
]
|
||||
|
||||
|
||||
class _AsyncMockBody:
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self._data = data
|
||||
|
||||
async def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
async def iter_chunks(self, chunk_size: int = 8192):
|
||||
for i in range(0, len(self._data), chunk_size):
|
||||
yield self._data[i:i + chunk_size]
|
||||
|
||||
|
||||
def _mock_s3_error(code: str) -> Exception:
|
||||
exc = Exception(code)
|
||||
exc.response = {"Error": {"Code": code}}
|
||||
return exc
|
||||
|
||||
|
||||
def _paginate_directory(objects, prefix):
|
||||
common_prefixes: set[str] = set()
|
||||
contents: list[dict[str, object]] = []
|
||||
for key, data in sorted(objects.items()):
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
relative = key[len(prefix):]
|
||||
if not relative:
|
||||
contents.append({
|
||||
"Key": key,
|
||||
"Size": len(data),
|
||||
"LastModified": LAST_MODIFIED
|
||||
})
|
||||
continue
|
||||
if "/" in relative:
|
||||
child = relative.split("/", 1)[0]
|
||||
common_prefixes.add(prefix + child + "/")
|
||||
continue
|
||||
contents.append({
|
||||
"Key": key,
|
||||
"Size": len(data),
|
||||
"LastModified": LAST_MODIFIED
|
||||
})
|
||||
return {
|
||||
"CommonPrefixes": [{
|
||||
"Prefix": v
|
||||
} for v in sorted(common_prefixes)],
|
||||
"Contents": contents,
|
||||
}
|
||||
|
||||
|
||||
def _paginate_flat(objects, prefix):
|
||||
return {
|
||||
"Contents": [{
|
||||
"Key": k,
|
||||
"Size": len(v),
|
||||
"LastModified": LAST_MODIFIED
|
||||
} for k, v in sorted(objects.items()) if k.startswith(prefix)]
|
||||
}
|
||||
|
||||
|
||||
def _slice_range(data: bytes, range_spec: str) -> bytes:
|
||||
if not range_spec.startswith("bytes="):
|
||||
return data
|
||||
bounds = range_spec.removeprefix("bytes=").split("-", 1)
|
||||
start = int(bounds[0]) if bounds[0] else 0
|
||||
end = int(bounds[1]) if bounds[1] else len(data) - 1
|
||||
return data[start:end + 1]
|
||||
|
||||
|
||||
class _MultiBucketPaginator:
|
||||
|
||||
def __init__(self, buckets: dict[str, dict[str, bytes]]) -> None:
|
||||
self.buckets = buckets
|
||||
|
||||
async def paginate(self,
|
||||
Bucket: str,
|
||||
Prefix: str = "",
|
||||
Delimiter: str | None = None):
|
||||
objects = self.buckets.get(Bucket, {})
|
||||
if Delimiter == "/":
|
||||
yield _paginate_directory(objects, Prefix)
|
||||
else:
|
||||
yield _paginate_flat(objects, Prefix)
|
||||
|
||||
|
||||
class MultiBucketS3Client:
|
||||
|
||||
def __init__(self,
|
||||
buckets: dict[str, dict[str, bytes]],
|
||||
versioned: set[str] | None = None,
|
||||
etag_suffix: str = "") -> None:
|
||||
self.buckets = buckets
|
||||
self.versioned = versioned or set()
|
||||
self._versions: dict[tuple[str, str], list[tuple[str, bytes]]] = {}
|
||||
# Non-empty simulates multipart-upload ETags ("<md5>-2"), which are
|
||||
# NOT the MD5 of the content.
|
||||
self.etag_suffix = etag_suffix
|
||||
self.calls: Counter[str] = Counter()
|
||||
|
||||
def _etag(self, data: bytes) -> str:
|
||||
return hashlib.md5(data).hexdigest() + self.etag_suffix
|
||||
|
||||
def _objects(self, bucket: str) -> dict[str, bytes]:
|
||||
if bucket not in self.buckets:
|
||||
self.buckets[bucket] = {}
|
||||
return self.buckets[bucket]
|
||||
|
||||
def _track(self, bucket: str, key: str) -> str | None:
|
||||
if bucket not in self.versioned:
|
||||
return None
|
||||
current = self.buckets.get(bucket, {}).get(key)
|
||||
if current is None:
|
||||
return None
|
||||
history = self._versions.setdefault((bucket, key), [])
|
||||
if not history or history[-1][1] != current:
|
||||
vid = f"v{len(history) + 1}-{hashlib.md5(current).hexdigest()[:8]}"
|
||||
history.append((vid, current))
|
||||
return history[-1][0]
|
||||
|
||||
async def get_object(self,
|
||||
Bucket: str,
|
||||
Key: str,
|
||||
Range: str | None = None,
|
||||
VersionId: str | None = None) -> dict:
|
||||
self.calls["get_object"] += 1
|
||||
vid_for_resp = self._track(Bucket, Key)
|
||||
if VersionId is not None:
|
||||
history = self._versions.get((Bucket, Key), [])
|
||||
for vid, data in history:
|
||||
if vid == VersionId:
|
||||
vid_for_resp = vid
|
||||
break
|
||||
else:
|
||||
raise _mock_s3_error("NoSuchVersion")
|
||||
else:
|
||||
objects = self._objects(Bucket)
|
||||
if Key not in objects:
|
||||
raise _mock_s3_error("NoSuchKey")
|
||||
data = objects[Key]
|
||||
etag = self._etag(data)
|
||||
if Range is not None:
|
||||
data = _slice_range(data, Range)
|
||||
resp: dict = {"Body": _AsyncMockBody(data), "ETag": f'"{etag}"'}
|
||||
if vid_for_resp is not None:
|
||||
resp["VersionId"] = vid_for_resp
|
||||
return resp
|
||||
|
||||
async def head_object(self, Bucket: str, Key: str) -> dict:
|
||||
self.calls["head_object"] += 1
|
||||
objects = self._objects(Bucket)
|
||||
if Key not in objects:
|
||||
raise _mock_s3_error("NoSuchKey")
|
||||
data = objects[Key]
|
||||
etag = self._etag(data)
|
||||
vid = self._track(Bucket, Key)
|
||||
resp: dict = {
|
||||
"ContentLength": len(data),
|
||||
"LastModified": LAST_MODIFIED,
|
||||
"ETag": f'"{etag}"',
|
||||
}
|
||||
if vid is not None:
|
||||
resp["VersionId"] = vid
|
||||
return resp
|
||||
|
||||
def get_paginator(self, name: str):
|
||||
assert name == "list_objects_v2"
|
||||
return _MultiBucketPaginator(self.buckets)
|
||||
|
||||
async def put_object(self, Bucket: str, Key: str, Body: bytes) -> None:
|
||||
self._objects(Bucket)[Key] = Body
|
||||
|
||||
async def delete_object(self, Bucket: str, Key: str) -> None:
|
||||
self._objects(Bucket).pop(Key, None)
|
||||
|
||||
async def copy_object(self, Bucket: str, CopySource: dict,
|
||||
Key: str) -> None:
|
||||
src_bucket = CopySource.get("Bucket", Bucket)
|
||||
src_key = CopySource["Key"]
|
||||
src_objects = self._objects(src_bucket)
|
||||
if src_key in src_objects:
|
||||
self._objects(Bucket)[Key] = src_objects[src_key]
|
||||
|
||||
async def delete_objects(self, Bucket: str, Delete: dict) -> None:
|
||||
objects = self._objects(Bucket)
|
||||
for obj in Delete.get("Objects", []):
|
||||
objects.pop(obj["Key"], None)
|
||||
|
||||
async def list_objects_v2(self,
|
||||
Bucket: str,
|
||||
Prefix: str = "",
|
||||
Delimiter: str = "",
|
||||
MaxKeys: int = 1000,
|
||||
**kwargs) -> dict:
|
||||
del MaxKeys, kwargs
|
||||
objects = self._objects(Bucket)
|
||||
if Delimiter == "/":
|
||||
return _paginate_directory(objects, Prefix)
|
||||
return _paginate_flat(objects, Prefix)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class MultiBucketSession:
|
||||
|
||||
def __init__(self,
|
||||
buckets: dict[str, dict[str, bytes]],
|
||||
versioned: set[str] | None = None,
|
||||
etag_suffix: str = "") -> None:
|
||||
self._client = MultiBucketS3Client(buckets,
|
||||
versioned=versioned,
|
||||
etag_suffix=etag_suffix)
|
||||
|
||||
def client(self, **kwargs):
|
||||
return self._client
|
||||
|
||||
|
||||
def patch_s3_session(session: MultiBucketSession) -> ExitStack:
|
||||
stack = ExitStack()
|
||||
for mod in _CORE_MODULES:
|
||||
stack.enter_context(patch(f"{mod}.async_session",
|
||||
return_value=session))
|
||||
return stack
|
||||
|
||||
|
||||
def patch_s3_multi(buckets: dict[str, dict[str, bytes]],
|
||||
versioned: set[str] | None = None) -> ExitStack:
|
||||
return patch_s3_session(MultiBucketSession(buckets, versioned=versioned))
|
||||
@@ -0,0 +1,91 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws():
|
||||
mem = RAMResource()
|
||||
big_content = b"\n".join([f"line {i}".encode() for i in range(10000)])
|
||||
asyncio.run(mem.write("/big.txt", data=big_content))
|
||||
asyncio.run(
|
||||
mem.write("/small.txt", data=b"apple\nbanana\napricot\ncherry\n"))
|
||||
return Workspace(
|
||||
{"/data": (mem, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_cat_grep(ws):
|
||||
io = await ws.execute("cat /data/small.txt | grep ap")
|
||||
result = await io.stdout_str()
|
||||
assert "apple" in result
|
||||
assert "apricot" in result
|
||||
assert "banana" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_cat_head_early_termination(ws):
|
||||
io = await ws.execute("cat /data/big.txt | head -n 5")
|
||||
result = await io.stdout_str()
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 5
|
||||
assert lines[0] == "line 0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_cat_grep_sort(ws):
|
||||
io = await ws.execute("cat /data/small.txt | grep a | sort")
|
||||
result = await io.stdout_str()
|
||||
lines = result.strip().split("\n")
|
||||
assert lines == sorted(lines)
|
||||
|
||||
|
||||
def test_execute_via_asyncio_run(ws):
|
||||
|
||||
async def _run():
|
||||
io = await ws.execute("cat /data/small.txt")
|
||||
return await io.stdout_str()
|
||||
|
||||
assert "apple" in asyncio.run(_run())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_cat_wc(ws):
|
||||
io = await ws.execute("cat /data/small.txt | wc -l")
|
||||
assert (await io.stdout_str()).strip() == "4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_cat_tail(ws):
|
||||
io = await ws.execute("cat /data/big.txt | tail -n 3")
|
||||
result = await io.stdout_str()
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[-1] == "line 9999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_cat_sort_uniq(ws):
|
||||
io = await ws.execute("cat /data/small.txt | sort | uniq")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == len(set(lines))
|
||||
@@ -0,0 +1,33 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import make_s3_ws, patch_async_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_rm_after_cat_hits_backend_and_evicts_cache():
|
||||
objects = {"file.txt": b"hello\n"}
|
||||
with patch_async_session(objects):
|
||||
ws = make_s3_ws(objects)
|
||||
first = await ws.execute("cat /data/file.txt")
|
||||
assert (await first.stdout_str()) == "hello\n"
|
||||
|
||||
removed = await ws.execute("rm /data/file.txt")
|
||||
assert removed.exit_code == 0
|
||||
assert "file.txt" not in objects
|
||||
|
||||
reread = await ws.execute("cat /data/file.txt")
|
||||
assert reread.exit_code != 0
|
||||
@@ -0,0 +1,380 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import ExitStack
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.ram.mkdir import mkdir as mem_mkdir
|
||||
from mirage.core.ram.write import write_bytes as mem_write
|
||||
from mirage.core.redis.mkdir import mkdir as redis_mkdir
|
||||
from mirage.core.redis.write import write_bytes as redis_write
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.gdrive import GoogleDriveConfig, GoogleDriveResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.redis import RedisResource
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.types import DEFAULT_SESSION_ID, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from tests.integration.gdrive_mock import FakeGDrive, patch_gdrive
|
||||
from tests.integration.s3_mock import patch_s3_multi
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
|
||||
WRITABLE = {"ram", "disk", "redis", "s3"}
|
||||
|
||||
_PAIRS = [
|
||||
("ram", "s3"),
|
||||
("s3", "ram"),
|
||||
("disk", "s3"),
|
||||
("s3", "disk"),
|
||||
("redis", "s3"),
|
||||
("s3", "redis"),
|
||||
("s3", "s3"),
|
||||
("ram", "gdrive"),
|
||||
("gdrive", "ram"),
|
||||
("disk", "gdrive"),
|
||||
("gdrive", "disk"),
|
||||
("redis", "gdrive"),
|
||||
("gdrive", "redis"),
|
||||
("gdrive", "gdrive"),
|
||||
]
|
||||
|
||||
|
||||
def _supports_write(ptype: str) -> bool:
|
||||
return ptype in WRITABLE
|
||||
|
||||
|
||||
def _supports_delete(ptype: str) -> bool:
|
||||
return ptype in WRITABLE
|
||||
|
||||
|
||||
def _make_s3_resource(bucket: str) -> S3Resource:
|
||||
config = S3Config(bucket=bucket,
|
||||
region="us-east-1",
|
||||
aws_access_key_id="testing",
|
||||
aws_secret_access_key="testing")
|
||||
return S3Resource(config)
|
||||
|
||||
|
||||
def _make_redis_resource(prefix: str) -> RedisResource:
|
||||
return RedisResource(url=REDIS_URL, key_prefix=prefix)
|
||||
|
||||
|
||||
def _make_gdrive_resource() -> GoogleDriveResource:
|
||||
config = GoogleDriveConfig(
|
||||
client_id="fake-id",
|
||||
client_secret="fake-secret",
|
||||
refresh_token="fake-refresh",
|
||||
)
|
||||
return GoogleDriveResource(config)
|
||||
|
||||
|
||||
class _MountState:
|
||||
|
||||
def __init__(self, ptype: str, mount_path: str, idx: int) -> None:
|
||||
self.ptype = ptype
|
||||
self.mount_path = mount_path
|
||||
self.idx = idx
|
||||
self.disk_root = None
|
||||
self.s3_bucket: str | None = None
|
||||
self.gdrive: FakeGDrive | None = None
|
||||
self.redis_prefix: str | None = None
|
||||
self.resource = None
|
||||
self.accessor = None
|
||||
|
||||
|
||||
def _build_mount(ptype: str, mount_path: str, tmp_path,
|
||||
idx: int) -> _MountState:
|
||||
state = _MountState(ptype, mount_path, idx)
|
||||
if ptype == "ram":
|
||||
state.resource = RAMResource()
|
||||
state.accessor = state.resource.accessor
|
||||
elif ptype == "disk":
|
||||
root = tmp_path / f"disk{idx}"
|
||||
root.mkdir()
|
||||
state.disk_root = root
|
||||
state.resource = DiskResource(root=str(root))
|
||||
elif ptype == "redis":
|
||||
prefix = f"mirage:test:{uuid.uuid4().hex}:{idx}:"
|
||||
state.redis_prefix = prefix
|
||||
state.resource = _make_redis_resource(prefix)
|
||||
elif ptype == "s3":
|
||||
state.s3_bucket = f"test-bucket-{idx}"
|
||||
state.resource = _make_s3_resource(state.s3_bucket)
|
||||
elif ptype == "gdrive":
|
||||
state.gdrive = FakeGDrive()
|
||||
state.resource = _make_gdrive_resource()
|
||||
else:
|
||||
raise ValueError(f"unknown resource: {ptype}")
|
||||
return state
|
||||
|
||||
|
||||
async def _populate_file_async(state: _MountState, name: str,
|
||||
content: bytes) -> None:
|
||||
if state.ptype == "ram":
|
||||
parts = ("/" + name).strip("/").split("/")
|
||||
for i in range(1, len(parts)):
|
||||
d = "/" + "/".join(parts[:i])
|
||||
if d not in state.accessor.store.dirs:
|
||||
try:
|
||||
await mem_mkdir(state.accessor, d)
|
||||
except (FileExistsError, ValueError):
|
||||
pass
|
||||
await mem_write(state.accessor, "/" + name, content)
|
||||
elif state.ptype == "disk":
|
||||
full = state.disk_root / name
|
||||
full.parent.mkdir(parents=True, exist_ok=True)
|
||||
full.write_bytes(content)
|
||||
elif state.ptype == "redis":
|
||||
parts = ("/" + name).strip("/").split("/")
|
||||
for i in range(1, len(parts)):
|
||||
d = "/" + "/".join(parts[:i])
|
||||
try:
|
||||
await redis_mkdir(state.resource.accessor, d)
|
||||
except (FileExistsError, ValueError):
|
||||
pass
|
||||
await redis_write(state.resource.accessor, "/" + name, content)
|
||||
|
||||
|
||||
def _populate_file(state: _MountState, name: str, content: bytes,
|
||||
buckets: dict) -> None:
|
||||
if state.ptype in ("ram", "disk", "redis"):
|
||||
asyncio.run(_populate_file_async(state, name, content))
|
||||
elif state.ptype == "s3":
|
||||
buckets.setdefault(state.s3_bucket, {})[name] = content
|
||||
elif state.ptype == "gdrive":
|
||||
state.gdrive.add_file(name, content)
|
||||
|
||||
|
||||
async def _ls_for_index(ws: Workspace, state: "_MountState",
|
||||
name: str) -> None:
|
||||
mount_path = state.mount_path
|
||||
parts = name.strip("/").split("/")
|
||||
for i in range(len(parts)):
|
||||
sub = "/".join(parts[:i])
|
||||
path = f"{mount_path}/{sub}".rstrip("/") or mount_path
|
||||
# Files are seeded straight into the backend, bypassing the mirage
|
||||
# write path that would normally invalidate the parent listing, so a
|
||||
# previously warmed (now stale) index entry must be dropped before the
|
||||
# ls re-lists it.
|
||||
await state.resource.index.invalidate_dir(path)
|
||||
await ws.execute(f"ls {path}")
|
||||
|
||||
|
||||
class CrossMountEnv:
|
||||
|
||||
def __init__(self, ws: Workspace, m1: _MountState, m2: _MountState,
|
||||
buckets: dict) -> None:
|
||||
self.ws = ws
|
||||
self.m1 = m1
|
||||
self.m2 = m2
|
||||
self.buckets = buckets
|
||||
|
||||
@property
|
||||
def src_type(self) -> str:
|
||||
return self.m1.ptype
|
||||
|
||||
@property
|
||||
def dst_type(self) -> str:
|
||||
return self.m2.ptype
|
||||
|
||||
def create_file(self, mount_idx: int, name: str, content: bytes) -> None:
|
||||
state = self.m1 if mount_idx == 1 else self.m2
|
||||
_populate_file(state, name, content, self.buckets)
|
||||
if state.ptype == "gdrive":
|
||||
asyncio.run(_ls_for_index(self.ws, state, name))
|
||||
|
||||
def run(self, cmd: str) -> str:
|
||||
|
||||
async def _inner():
|
||||
io = await self.ws.execute(cmd)
|
||||
return await io.stdout_str()
|
||||
|
||||
return asyncio.run(_inner())
|
||||
|
||||
def exit(self, cmd: str) -> int:
|
||||
io = asyncio.run(self.ws.execute(cmd))
|
||||
return io.exit_code
|
||||
|
||||
def cleanup_redis(self) -> None:
|
||||
for state in (self.m1, self.m2):
|
||||
if state.ptype == "redis":
|
||||
asyncio.run(state.resource._store.clear())
|
||||
|
||||
|
||||
def _pair_id(pair: tuple[str, str]) -> str:
|
||||
return f"{pair[0]}->{pair[1]}"
|
||||
|
||||
|
||||
def _pair_marks(pair: tuple[str, str]):
|
||||
if "redis" in pair and not REDIS_URL:
|
||||
return [pytest.mark.skip(reason="REDIS_URL not set")]
|
||||
return []
|
||||
|
||||
|
||||
_PARAMS = [
|
||||
pytest.param(p, id=_pair_id(p), marks=_pair_marks(p)) for p in _PAIRS
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(params=_PARAMS)
|
||||
def cross(request, tmp_path):
|
||||
pair = request.param
|
||||
p1_type, p2_type = pair
|
||||
|
||||
m1 = _build_mount(p1_type, "/m1", tmp_path, 1)
|
||||
m2 = _build_mount(p2_type, "/m2", tmp_path, 2)
|
||||
|
||||
ws = Workspace(
|
||||
{
|
||||
"/m1": (m1.resource, MountMode.WRITE),
|
||||
"/m2": (m2.resource, MountMode.WRITE),
|
||||
},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
ws.get_session(DEFAULT_SESSION_ID).cwd = "/m1"
|
||||
|
||||
buckets: dict[str, dict[str, bytes]] = {}
|
||||
if m1.ptype == "s3":
|
||||
buckets[m1.s3_bucket] = {}
|
||||
if m2.ptype == "s3":
|
||||
buckets[m2.s3_bucket] = {}
|
||||
|
||||
env = CrossMountEnv(ws, m1, m2, buckets)
|
||||
|
||||
stack = ExitStack()
|
||||
if "s3" in pair:
|
||||
stack.enter_context(patch_s3_multi(buckets))
|
||||
if "gdrive" in pair:
|
||||
gd_pairs = []
|
||||
if m1.ptype == "gdrive":
|
||||
gd_pairs.append((m1.resource._token_manager, m1.gdrive))
|
||||
if m2.ptype == "gdrive":
|
||||
gd_pairs.append((m2.resource._token_manager, m2.gdrive))
|
||||
stack.enter_context(patch_gdrive(*gd_pairs))
|
||||
|
||||
with stack:
|
||||
try:
|
||||
yield env
|
||||
finally:
|
||||
env.cleanup_redis()
|
||||
|
||||
|
||||
def test_cat_cross(cross):
|
||||
cross.create_file(1, "a.txt", b"aaa\n")
|
||||
cross.create_file(2, "b.txt", b"bbb\n")
|
||||
out = cross.run("cat /m1/a.txt /m2/b.txt")
|
||||
assert "aaa" in out and "bbb" in out
|
||||
|
||||
|
||||
def test_grep_cross(cross):
|
||||
cross.create_file(1, "a.txt", b"hello world\n")
|
||||
cross.create_file(2, "b.txt", b"hello there\n")
|
||||
out = cross.run("grep hello /m1/a.txt /m2/b.txt")
|
||||
assert "/m1/a.txt:" in out
|
||||
assert "/m2/b.txt:" in out
|
||||
|
||||
|
||||
def test_head_cross(cross):
|
||||
cross.create_file(1, "a.txt", b"a1\na2\na3\n")
|
||||
cross.create_file(2, "b.txt", b"b1\nb2\nb3\n")
|
||||
out = cross.run("head -n 1 /m1/a.txt /m2/b.txt")
|
||||
assert "==> /m1/a.txt <==" in out
|
||||
assert "==> /m2/b.txt <==" in out
|
||||
|
||||
|
||||
def test_wc_cross(cross):
|
||||
cross.create_file(1, "a.txt", b"line1\nline2\n")
|
||||
cross.create_file(2, "b.txt", b"only\n")
|
||||
out = cross.run("wc -l /m1/a.txt /m2/b.txt")
|
||||
assert "/m1/a.txt" in out and "/m2/b.txt" in out
|
||||
|
||||
|
||||
def test_diff_identical_cross(cross):
|
||||
cross.create_file(1, "a.txt", b"same\n")
|
||||
cross.create_file(2, "b.txt", b"same\n")
|
||||
out = cross.run("diff /m1/a.txt /m2/b.txt")
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_diff_different_cross(cross):
|
||||
cross.create_file(1, "a.txt", b"hello\n")
|
||||
cross.create_file(2, "b.txt", b"world\n")
|
||||
out = cross.run("diff /m1/a.txt /m2/b.txt")
|
||||
assert "hello" in out or "world" in out
|
||||
|
||||
|
||||
def test_cmp_identical_cross(cross):
|
||||
cross.create_file(1, "a.txt", b"same\n")
|
||||
cross.create_file(2, "b.txt", b"same\n")
|
||||
code = cross.exit("cmp /m1/a.txt /m2/b.txt")
|
||||
assert code == 0
|
||||
|
||||
|
||||
def test_cp_cross(cross):
|
||||
cross.create_file(1, "src.txt", b"hello\n")
|
||||
code = cross.exit("cp /m1/src.txt /m2/dst.txt")
|
||||
if _supports_write(cross.dst_type):
|
||||
assert code == 0, f"cp failed for {cross.src_type}->{cross.dst_type}"
|
||||
assert cross.run("cat /m2/dst.txt") == "hello\n"
|
||||
else:
|
||||
assert code != 0, (
|
||||
f"cp into read-only {cross.dst_type} should have failed")
|
||||
|
||||
|
||||
def test_mv_cross(cross):
|
||||
cross.create_file(1, "src.txt", b"hello\n")
|
||||
code = cross.exit("mv /m1/src.txt /m2/moved.txt")
|
||||
if _supports_write(cross.dst_type) and _supports_delete(cross.src_type):
|
||||
assert code == 0, f"mv failed for {cross.src_type}->{cross.dst_type}"
|
||||
assert cross.run("cat /m2/moved.txt") == "hello\n"
|
||||
assert cross.exit("cat /m1/src.txt") != 0
|
||||
else:
|
||||
assert code != 0, (
|
||||
f"mv with read-only end ({cross.src_type}->{cross.dst_type}) "
|
||||
"should have failed")
|
||||
|
||||
|
||||
def test_cp_recursive_cross(cross):
|
||||
cross.create_file(1, "tree/a.txt", b"aaa\n")
|
||||
cross.create_file(1, "tree/sub/b.txt", b"bbb\n")
|
||||
code = cross.exit("cp -r /m1/tree /m2/copied")
|
||||
if _supports_write(cross.dst_type):
|
||||
assert code == 0, (
|
||||
f"cp -r failed for {cross.src_type}->{cross.dst_type}")
|
||||
assert cross.run("cat /m2/copied/a.txt") == "aaa\n"
|
||||
assert cross.run("cat /m2/copied/sub/b.txt") == "bbb\n"
|
||||
else:
|
||||
assert code != 0, (
|
||||
f"cp -r into read-only {cross.dst_type} should have failed")
|
||||
|
||||
|
||||
def test_mv_recursive_cross(cross):
|
||||
cross.create_file(1, "tree/a.txt", b"aaa\n")
|
||||
cross.create_file(1, "tree/sub/b.txt", b"bbb\n")
|
||||
code = cross.exit("mv /m1/tree /m2/moved")
|
||||
if _supports_write(cross.dst_type) and _supports_delete(cross.src_type):
|
||||
assert code == 0, (
|
||||
f"mv -r failed for {cross.src_type}->{cross.dst_type}")
|
||||
assert cross.run("cat /m2/moved/a.txt") == "aaa\n"
|
||||
assert cross.run("cat /m2/moved/sub/b.txt") == "bbb\n"
|
||||
assert cross.exit("cat /m1/tree/a.txt") != 0
|
||||
else:
|
||||
assert code != 0, (
|
||||
f"mv -r with read-only end ({cross.src_type}->{cross.dst_type}) "
|
||||
"should have failed")
|
||||
@@ -0,0 +1,107 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import make_resource_ws, run
|
||||
|
||||
FILES = {
|
||||
"docs/readme.txt": b"hello world\n",
|
||||
"docs/notes.txt": b"some notes\n",
|
||||
"src/main.py": b"print('hello')\n",
|
||||
"src/utils/helpers.py": b"def helper(): pass\n",
|
||||
"data.json": b'{"key": "value"}\n',
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(params=["ram", "s3", "disk"])
|
||||
def resource_ws(request, tmp_path):
|
||||
yield from make_resource_ws(request, tmp_path, FILES)
|
||||
|
||||
|
||||
def test_find_type_f(resource_ws):
|
||||
result = run(resource_ws, "find /data -type f | sort")
|
||||
lines = result.strip().splitlines()
|
||||
assert "/data/data.json" in lines
|
||||
assert "/data/docs/readme.txt" in lines
|
||||
assert "/data/src/main.py" in lines
|
||||
assert len(lines) == 5
|
||||
|
||||
|
||||
def test_find_maxdepth_0(resource_ws):
|
||||
result = run(resource_ws, "find /data -maxdepth 0 -type f | sort")
|
||||
lines = [line for line in result.strip().splitlines() if line]
|
||||
assert lines == []
|
||||
|
||||
|
||||
def test_find_maxdepth_1(resource_ws):
|
||||
result = run(resource_ws, "find /data -maxdepth 1 -type f | sort")
|
||||
lines = result.strip().splitlines()
|
||||
assert "/data/data.json" in lines
|
||||
assert "/data/docs/notes.txt" not in lines
|
||||
assert "/data/src/main.py" not in lines
|
||||
|
||||
|
||||
def test_find_maxdepth_2(resource_ws):
|
||||
result = run(resource_ws, "find /data -maxdepth 2 -type f | sort")
|
||||
lines = result.strip().splitlines()
|
||||
assert "/data/data.json" in lines
|
||||
assert "/data/docs/notes.txt" in lines
|
||||
assert "/data/docs/readme.txt" in lines
|
||||
assert "/data/src/main.py" in lines
|
||||
assert "/data/src/utils/helpers.py" not in lines
|
||||
|
||||
|
||||
def test_find_name_pattern(resource_ws):
|
||||
result = run(resource_ws, "find /data -name '*.txt' | sort")
|
||||
lines = result.strip().splitlines()
|
||||
assert lines == ["/data/docs/notes.txt", "/data/docs/readme.txt"]
|
||||
|
||||
|
||||
def test_find_pipe_sort_pipe_while_read_echo(resource_ws):
|
||||
cmd = ("find /data -maxdepth 2 -type f | sort | "
|
||||
"while read f; do echo \"=== $f ===\"; done")
|
||||
result = run(resource_ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
for line in lines:
|
||||
assert line.startswith("=== ") and line.endswith(" ===")
|
||||
paths = [line.removeprefix("=== ").removesuffix(" ===") for line in lines]
|
||||
assert paths == sorted(paths)
|
||||
assert "/data/data.json" in paths
|
||||
|
||||
|
||||
def test_find_pipe_sort_pipe_while_read_file(resource_ws):
|
||||
cmd = ("find /data -maxdepth 2 -type f -name '*.json' | sort | "
|
||||
"while read f; do echo \"=== $f ===\"; file $f; done")
|
||||
result = run(resource_ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
assert "=== /data/data.json ===" in lines
|
||||
assert any("json" in line for line in lines)
|
||||
|
||||
|
||||
def test_find_pipe_while_read_echo_content(resource_ws):
|
||||
cmd = ("find /data -name '*.txt' -type f | sort | "
|
||||
"while read f; do echo \"FILE: $f\"; done")
|
||||
result = run(resource_ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
file_paths = [line.removeprefix("FILE: ") for line in lines]
|
||||
assert "/data/docs/notes.txt" in file_paths
|
||||
assert "/data/docs/readme.txt" in file_paths
|
||||
|
||||
|
||||
def test_find_type_d_memory(resource_ws):
|
||||
result = run(resource_ws, "find /data -type d | sort")
|
||||
lines = result.strip().splitlines()
|
||||
if lines:
|
||||
assert all("/data" in line for line in lines)
|
||||
@@ -0,0 +1,82 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from contextlib import ExitStack
|
||||
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.types import ConsistencyPolicy, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from tests.integration.s3_mock import patch_s3_multi
|
||||
|
||||
|
||||
def _make_ws(consistency: ConsistencyPolicy) -> Workspace:
|
||||
config = S3Config(
|
||||
bucket="test-bucket",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="fake",
|
||||
aws_secret_access_key="fake",
|
||||
)
|
||||
resource = S3Resource(config)
|
||||
return Workspace(
|
||||
{"/data": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
consistency=consistency,
|
||||
)
|
||||
|
||||
|
||||
def test_s3_always_refetches_after_external_mutation():
|
||||
store = {"file.txt": b"v1"}
|
||||
stack = ExitStack()
|
||||
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
||||
try:
|
||||
ws = _make_ws(ConsistencyPolicy.ALWAYS)
|
||||
|
||||
async def run() -> tuple[bytes, bytes]:
|
||||
io1 = await ws.execute("cat /data/file.txt")
|
||||
first = await io1.materialize_stdout()
|
||||
store["file.txt"] = b"v2"
|
||||
io2 = await ws.execute("cat /data/file.txt")
|
||||
second = await io2.materialize_stdout()
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(run())
|
||||
assert first == b"v1"
|
||||
assert second == b"v2", (
|
||||
"S3 ALWAYS must refetch after external write to the mocked store")
|
||||
finally:
|
||||
stack.close()
|
||||
|
||||
|
||||
def test_s3_lazy_serves_cache():
|
||||
store = {"file.txt": b"v1"}
|
||||
stack = ExitStack()
|
||||
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
||||
try:
|
||||
ws = _make_ws(ConsistencyPolicy.LAZY)
|
||||
|
||||
async def run() -> tuple[bytes, bytes]:
|
||||
io1 = await ws.execute("cat /data/file.txt")
|
||||
first = await io1.materialize_stdout()
|
||||
store["file.txt"] = b"v2"
|
||||
io2 = await ws.execute("cat /data/file.txt")
|
||||
second = await io2.materialize_stdout()
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(run())
|
||||
assert first == b"v1"
|
||||
assert second == b"v1", (
|
||||
"S3 LAZY must serve cached bytes even after store mutation")
|
||||
finally:
|
||||
stack.close()
|
||||
@@ -0,0 +1,88 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from contextlib import ExitStack
|
||||
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.types import ConsistencyPolicy, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from tests.integration.s3_mock import patch_s3_multi
|
||||
|
||||
|
||||
def _make_ws(consistency: ConsistencyPolicy) -> Workspace:
|
||||
config = S3Config(
|
||||
bucket="shared-bucket",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="fake",
|
||||
aws_secret_access_key="fake",
|
||||
)
|
||||
resource = S3Resource(config)
|
||||
return Workspace(
|
||||
{"/data": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
consistency=consistency,
|
||||
)
|
||||
|
||||
|
||||
def test_two_workspaces_always_sees_other_writers_update():
|
||||
store = {"file.txt": b"v1"}
|
||||
stack = ExitStack()
|
||||
stack.enter_context(patch_s3_multi({"shared-bucket": store}))
|
||||
try:
|
||||
ws_a = _make_ws(ConsistencyPolicy.ALWAYS)
|
||||
ws_b = _make_ws(ConsistencyPolicy.ALWAYS)
|
||||
|
||||
async def run() -> tuple[bytes, bytes]:
|
||||
io_b1 = await ws_b.execute("cat /data/file.txt")
|
||||
b_first = await io_b1.materialize_stdout()
|
||||
|
||||
await ws_a.execute('echo -n "v2" > /data/file.txt')
|
||||
|
||||
io_b2 = await ws_b.execute("cat /data/file.txt")
|
||||
b_second = await io_b2.materialize_stdout()
|
||||
return b_first, b_second
|
||||
|
||||
b_first, b_second = asyncio.run(run())
|
||||
assert b_first == b"v1"
|
||||
assert b_second == b"v2", (
|
||||
"Workspace B under ALWAYS must see Workspace A's write "
|
||||
"via fingerprint mismatch; got stale cached bytes")
|
||||
finally:
|
||||
stack.close()
|
||||
|
||||
|
||||
def test_two_workspaces_lazy_may_serve_stale_after_other_writer():
|
||||
store = {"file.txt": b"v1"}
|
||||
stack = ExitStack()
|
||||
stack.enter_context(patch_s3_multi({"shared-bucket": store}))
|
||||
try:
|
||||
ws_a = _make_ws(ConsistencyPolicy.LAZY)
|
||||
ws_b = _make_ws(ConsistencyPolicy.LAZY)
|
||||
|
||||
async def run() -> bytes:
|
||||
io_b1 = await ws_b.execute("cat /data/file.txt")
|
||||
await io_b1.materialize_stdout()
|
||||
|
||||
await ws_a.execute('echo -n "v2" > /data/file.txt')
|
||||
|
||||
io_b2 = await ws_b.execute("cat /data/file.txt")
|
||||
return await io_b2.materialize_stdout()
|
||||
|
||||
b_second = asyncio.run(run())
|
||||
assert b_second in (b"v1", b"v2"), (
|
||||
"LAZY is allowed to serve cached bytes; this just documents "
|
||||
"the trade-off (cache was populated before A's write)")
|
||||
finally:
|
||||
stack.close()
|
||||
@@ -0,0 +1,76 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.gdrive import GoogleDriveConfig, GoogleDriveResource
|
||||
from mirage.types import ConsistencyPolicy, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from tests.integration.gdrive_mock import FakeGDrive, patch_gdrive
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gdrive_always_refetches_after_external_mutation():
|
||||
fake = FakeGDrive()
|
||||
fake.add_file("file.txt", b"v1")
|
||||
config = GoogleDriveConfig(
|
||||
client_id="fake-id",
|
||||
client_secret="fake-secret",
|
||||
refresh_token="fake-refresh",
|
||||
)
|
||||
resource = GoogleDriveResource(config)
|
||||
ws = Workspace(
|
||||
{"/gd": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
consistency=ConsistencyPolicy.ALWAYS,
|
||||
)
|
||||
with patch_gdrive(fake):
|
||||
await ws.execute("ls /gd")
|
||||
io1 = await ws.execute("cat /gd/file.txt")
|
||||
assert (await io1.materialize_stdout()) == b"v1"
|
||||
|
||||
fake.add_file("file.txt", b"v2-external")
|
||||
await ws.execute("ls /gd")
|
||||
|
||||
io2 = await ws.execute("cat /gd/file.txt")
|
||||
assert (await io2.materialize_stdout()) == b"v2-external", (
|
||||
"GDrive ALWAYS must refetch after modifiedTime changes")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gdrive_lazy_may_serve_stale():
|
||||
fake = FakeGDrive()
|
||||
fake.add_file("file.txt", b"v1")
|
||||
config = GoogleDriveConfig(
|
||||
client_id="fake-id",
|
||||
client_secret="fake-secret",
|
||||
refresh_token="fake-refresh",
|
||||
)
|
||||
resource = GoogleDriveResource(config)
|
||||
ws = Workspace(
|
||||
{"/gd": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
consistency=ConsistencyPolicy.LAZY,
|
||||
)
|
||||
with patch_gdrive(fake):
|
||||
await ws.execute("ls /gd")
|
||||
io1 = await ws.execute("cat /gd/file.txt")
|
||||
assert (await io1.materialize_stdout()) == b"v1"
|
||||
|
||||
fake.add_file("file.txt", b"v2-external")
|
||||
|
||||
io2 = await ws.execute("cat /gd/file.txt")
|
||||
got = await io2.materialize_stdout()
|
||||
assert got in (b"v1", b"v2-external"), (
|
||||
"LAZY allowed to serve cache; just confirming no crash")
|
||||
@@ -0,0 +1,64 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.gdrive import GoogleDriveConfig, GoogleDriveResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from tests.integration.gdrive_mock import FakeGDrive, patch_gdrive
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gdrive_ws():
|
||||
fake = FakeGDrive()
|
||||
fake.add_file("hello.txt", b"hello world\n")
|
||||
fake.add_file("data/numbers.txt", b"one\ntwo\nthree\n")
|
||||
|
||||
config = GoogleDriveConfig(
|
||||
client_id="fake-id",
|
||||
client_secret="fake-secret",
|
||||
refresh_token="fake-refresh",
|
||||
)
|
||||
resource = GoogleDriveResource(config)
|
||||
ws = Workspace({"/gd": resource}, mode=MountMode.READ)
|
||||
with patch_gdrive(fake):
|
||||
yield ws, fake
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gdrive_mock_cat(gdrive_ws):
|
||||
ws, _ = gdrive_ws
|
||||
await ws.execute("ls /gd")
|
||||
r = await ws.execute("cat /gd/hello.txt")
|
||||
assert (await r.stdout_str()) == "hello world\n", (
|
||||
f"exit={r.exit_code} stderr={await r.stderr_str()!r}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gdrive_mock_ls(gdrive_ws):
|
||||
ws, _ = gdrive_ws
|
||||
r = await ws.execute("ls /gd")
|
||||
out = await r.stdout_str()
|
||||
assert "hello.txt" in out
|
||||
assert "data" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gdrive_mock_grep(gdrive_ws):
|
||||
ws, _ = gdrive_ws
|
||||
await ws.execute("ls /gd")
|
||||
await ws.execute("ls /gd/data")
|
||||
r = await ws.execute("grep two /gd/data/numbers.txt")
|
||||
assert "two" in (await r.stdout_str())
|
||||
@@ -0,0 +1,156 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.gcs import GCSConfig, GCSResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
from .conftest import make_s3_ws, patch_async_session
|
||||
|
||||
S3_OBJECTS = {
|
||||
"data/report.txt": b"line1\nline2\nline3\n",
|
||||
"data/notes.txt": b"note1\nnote2\n",
|
||||
"data/config.json": b'{"key": "value"}\n',
|
||||
"data/metrics.csv": b"a,b\n1,2\n3,4\n",
|
||||
"archive/2026/deep.txt": b"deep\n",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_ws():
|
||||
return make_s3_ws(S3_OBJECTS)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gcs_ws():
|
||||
config = GCSConfig(
|
||||
bucket="test-bucket",
|
||||
access_key_id="GOOG_FAKE",
|
||||
secret_access_key="fake_secret",
|
||||
)
|
||||
resource = GCSResource(config)
|
||||
return Workspace(
|
||||
{"/gcs": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_ws():
|
||||
config = GCSConfig(
|
||||
bucket="test-bucket",
|
||||
access_key_id="GOOG_FAKE",
|
||||
secret_access_key="fake_secret",
|
||||
)
|
||||
return Workspace(
|
||||
{
|
||||
"/gcs": (GCSResource(config), MountMode.WRITE),
|
||||
"/tmp": (RAMResource(), MountMode.WRITE),
|
||||
},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
|
||||
|
||||
async def _run(ws, cmd):
|
||||
io = await ws.execute(cmd)
|
||||
return await io.stdout_str(), io
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_echo_glob_expands(s3_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
out, io = await _run(s3_ws, "echo /data/data/*.txt")
|
||||
assert io.exit_code == 0
|
||||
assert "report.txt" in out
|
||||
assert "notes.txt" in out
|
||||
assert "config.json" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_for_loop_glob(s3_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
out, io = await _run(
|
||||
s3_ws, "for f in /data/data/*.txt; do echo file:$f; done")
|
||||
assert io.exit_code == 0
|
||||
assert "file:/data/data/report.txt" in out
|
||||
assert "file:/data/data/notes.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_grep_glob(s3_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
out, io = await _run(s3_ws, "grep line /data/data/*.txt")
|
||||
assert io.exit_code == 0
|
||||
assert "line1" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_echo_glob_expands(gcs_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
out, io = await _run(gcs_ws, "echo /gcs/data/*.txt")
|
||||
assert io.exit_code == 0
|
||||
assert "report.txt" in out
|
||||
assert "notes.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_for_loop_glob(gcs_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
out, io = await _run(
|
||||
gcs_ws, "for f in /gcs/data/*.txt; do echo file:$f; done")
|
||||
assert io.exit_code == 0
|
||||
assert "report.txt" in out
|
||||
assert "notes.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_grep_no_match_exit_code(gcs_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
out, io = await _run(gcs_ws, "grep NONEXISTENT /gcs/data/report.txt")
|
||||
assert io.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gcs_grep_match_exit_code(gcs_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
out, io = await _run(gcs_ws, "grep line1 /gcs/data/report.txt")
|
||||
assert io.exit_code == 0
|
||||
assert "line1" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_glob_no_match_keeps_literal(s3_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
out, io = await _run(s3_ws, "echo /data/data/*.xyz")
|
||||
assert out.strip() == "/data/data/*.xyz"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_mount_cp_with_gcs(multi_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
await multi_ws.execute("cp /gcs/data/report.txt /tmp/r.txt")
|
||||
out, io = await _run(multi_ws, "cat /tmp/r.txt")
|
||||
assert "line1" in out
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_cat_single_from_glob(s3_ws):
|
||||
with patch_async_session(S3_OBJECTS):
|
||||
out, io = await _run(s3_ws, "cat /data/data/*.csv")
|
||||
assert io.exit_code == 0
|
||||
assert "a,b" in out
|
||||
@@ -0,0 +1,108 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws():
|
||||
mem = RAMResource()
|
||||
asyncio.run(
|
||||
mem.write("/big.txt",
|
||||
data=b"\n".join(f"line {i}".encode() for i in range(10000))))
|
||||
asyncio.run(
|
||||
mem.write("/small.txt", data=b"apple\nbanana\napricot\ncherry\n"))
|
||||
asyncio.run(mem.write("/dupes.txt", data=b"a\na\nb\nb\nc\n"))
|
||||
asyncio.run(
|
||||
mem.write("/csv.txt",
|
||||
data=b"name,age,city\nalice,30,nyc\nbob,25,sf\n"))
|
||||
return Workspace(
|
||||
{"/data": (mem, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_grep_head_streams(ws):
|
||||
io = await ws.execute("cat /data/big.txt | grep 'line 1' | head -n 3")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_head_early_termination(ws):
|
||||
io = await ws.execute("cat /data/big.txt | head -n 5")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == 5
|
||||
assert lines[0] == "line 0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_cut_head(ws):
|
||||
io = await ws.execute("cat /data/csv.txt | cut -d , -f 1 | head -n 2")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
assert lines[0] == "name"
|
||||
assert lines[1] == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_sort_head(ws):
|
||||
io = await ws.execute("cat /data/small.txt | sort | head -n 2")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
assert lines == sorted(lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_uniq(ws):
|
||||
io = await ws.execute("cat /data/dupes.txt | uniq")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert lines == ["a", "b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_tr_grep(ws):
|
||||
io = await ws.execute("cat /data/small.txt | tr a A | grep Ap")
|
||||
result = await io.stdout_str()
|
||||
assert "Apple" in result or "Apricot" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_grep_wc_l(ws):
|
||||
io = await ws.execute("cat /data/small.txt | grep a | wc -l")
|
||||
assert (await io.stdout_str()).strip() == "3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_path_output(ws):
|
||||
io = await ws.execute("find /data -name '*.txt'")
|
||||
result = await io.stdout_str()
|
||||
assert "/data/" in result
|
||||
|
||||
|
||||
def test_execute_via_asyncio_run(ws):
|
||||
|
||||
async def _run():
|
||||
io = await ws.execute("cat /data/small.txt | head -n 2")
|
||||
return (await io.stdout_str()).strip().split("\n")
|
||||
|
||||
lines = asyncio.run(_run())
|
||||
assert len(lines) == 2
|
||||
@@ -0,0 +1,76 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.notion.config import NotionConfig
|
||||
from mirage.resource.notion.notion import NotionResource
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("NOTION_API_KEY"),
|
||||
reason="NOTION_API_KEY not set",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return NotionConfig(api_key=os.environ["NOTION_API_KEY"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource(config):
|
||||
return NotionResource(config)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root(resource):
|
||||
from mirage.core.notion.readdir import readdir
|
||||
entries = await readdir(resource.accessor, "/", resource.index)
|
||||
assert any("pages" in e for e in entries)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_pages(resource):
|
||||
from mirage.core.notion.readdir import readdir
|
||||
entries = await readdir(resource.accessor, "/pages", resource.index)
|
||||
assert len(entries) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_page_json(resource):
|
||||
from mirage.core.notion.read import read
|
||||
from mirage.core.notion.readdir import readdir
|
||||
pages = await readdir(resource.accessor, "/pages", resource.index)
|
||||
if not pages:
|
||||
pytest.skip("No pages found")
|
||||
first_page = pages[0]
|
||||
data = await read(resource.accessor, f"{first_page}/page.json",
|
||||
resource.index)
|
||||
page = json.loads(data)
|
||||
assert "page_id" in page
|
||||
assert "title" in page
|
||||
assert "url" in page
|
||||
assert "markdown" in page
|
||||
assert "blocks" in page
|
||||
assert isinstance(page["blocks"], list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search(resource):
|
||||
from mirage.core.notion.pages import search_pages
|
||||
results = await search_pages(resource.config, query="", page_size=5)
|
||||
assert isinstance(results, list)
|
||||
@@ -0,0 +1,658 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import DEFAULT_SESSION_ID, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws():
|
||||
mem = RAMResource()
|
||||
asyncio.run(mem.write("/hello.txt", data=b"hello world\n"))
|
||||
asyncio.run(mem.write("/numbers.txt", data=b"3\n1\n2\n1\n3\n"))
|
||||
asyncio.run(
|
||||
mem.write(
|
||||
"/log.txt",
|
||||
data=b"INFO start\nERROR fail\nINFO ok\nERROR bad\nINFO done\n"))
|
||||
asyncio.run(mem.mkdir("/subdir"))
|
||||
asyncio.run(mem.write("/subdir/a.txt", data=b"aaa\n"))
|
||||
asyncio.run(mem.write("/subdir/b.txt", data=b"bbb\n"))
|
||||
asyncio.run(mem.write("/config.json", data=b'{"key": "value"}\n'))
|
||||
lines = "\n".join(f"row {i}" for i in range(5000)) + "\n"
|
||||
asyncio.run(mem.write("/big.txt", data=lines.encode()))
|
||||
ws = Workspace(
|
||||
{"/data": (mem, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
ws.get_session(DEFAULT_SESSION_ID).cwd = "/"
|
||||
return ws
|
||||
|
||||
|
||||
# --- Pipes ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_grep_sort_uniq(ws):
|
||||
io = await ws.execute("cat /data/numbers.txt | sort | uniq")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert lines == ["1", "2", "3"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_grep_wc(ws):
|
||||
io = await ws.execute("grep ERROR /data/log.txt | wc -l")
|
||||
assert (await io.stdout_str()).strip() == "2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_head_stops_early(ws):
|
||||
io = await ws.execute("cat /data/big.txt | head -n 3")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[0] == "row 0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_tail(ws):
|
||||
io = await ws.execute("cat /data/log.txt | tail -n 2")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
assert lines[-1] == "INFO done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triple_pipe(ws):
|
||||
io = await ws.execute("cat /data/log.txt | grep INFO | head -n 2")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
assert all("INFO" in line for line in lines)
|
||||
|
||||
|
||||
# --- Control flow ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_and_success(ws):
|
||||
io = await ws.execute("cat /data/hello.txt && echo done")
|
||||
assert b"done" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_and_failure_short_circuits(ws):
|
||||
io = await ws.execute(
|
||||
"grep NONEXISTENT /data/hello.txt && echo should_not_appear")
|
||||
assert b"should_not_appear" not in (io.stdout or b"")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_or_fallback(ws):
|
||||
io = await ws.execute("grep NONEXISTENT /data/hello.txt || echo fallback")
|
||||
assert b"fallback" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semicolon_runs_both(ws):
|
||||
io = await ws.execute("echo first ; echo second")
|
||||
assert b"second" in io.stdout
|
||||
|
||||
|
||||
# --- Redirects ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_stdout_to_file(ws):
|
||||
await ws.execute("echo written > /data/out.txt")
|
||||
io = await ws.execute("cat /data/out.txt")
|
||||
assert b"written" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_append(ws):
|
||||
await ws.execute("echo line1 > /data/append.txt")
|
||||
await ws.execute("echo line2 >> /data/append.txt")
|
||||
io = await ws.execute("cat /data/append.txt")
|
||||
out = await io.stdout_str()
|
||||
assert "line1" in out
|
||||
assert "line2" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_on_or_chain(ws):
|
||||
io = await ws.execute("grep hello /data/hello.txt > /data/out.txt || "
|
||||
"echo fallback > /data/out.txt; "
|
||||
"cat /data/out.txt")
|
||||
assert "hello" in (await io.stdout_str())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_on_and_chain(ws):
|
||||
io = await ws.execute("echo first > /data/chain.txt && "
|
||||
"echo second >> /data/chain.txt; "
|
||||
"cat /data/chain.txt")
|
||||
assert "first" in (await io.stdout_str())
|
||||
assert "second" in (await io.stdout_str())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_stdin(ws):
|
||||
io = await ws.execute("grep world < /data/hello.txt")
|
||||
assert b"world" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heredoc(ws):
|
||||
io = await ws.execute("cat << EOF\nhello heredoc\nEOF")
|
||||
assert b"hello heredoc" in io.stdout
|
||||
|
||||
|
||||
# --- Subshell isolation ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subshell_cd_isolated(ws):
|
||||
await ws.execute("cd /data")
|
||||
await ws.execute("(cd /data/subdir)")
|
||||
assert ws.get_session(DEFAULT_SESSION_ID).cwd == "/data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subshell_export_isolated(ws):
|
||||
await ws.execute("(export LEAK=yes)")
|
||||
assert "LEAK" not in ws.get_session(DEFAULT_SESSION_ID).env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subshell_inherits_parent_env(ws):
|
||||
await ws.execute("export INHERITED=true")
|
||||
io = await ws.execute("(printenv INHERITED)")
|
||||
assert b"true" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_subshell(ws):
|
||||
await ws.execute("((export DEEP=yes))")
|
||||
assert "DEEP" not in ws.get_session(DEFAULT_SESSION_ID).env
|
||||
|
||||
|
||||
# --- Background jobs ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_basic(ws):
|
||||
await ws.execute("cat /data/hello.txt &")
|
||||
io = await ws.execute("wait %1")
|
||||
assert b"hello" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_isolation_env(ws):
|
||||
await ws.execute("export BG_VAR=leaked &")
|
||||
await ws.execute("wait %1")
|
||||
assert "BG_VAR" not in ws.get_session(DEFAULT_SESSION_ID).env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_isolation_cwd(ws):
|
||||
await ws.execute("cd /data &")
|
||||
await ws.execute("wait %1")
|
||||
assert ws.get_session(DEFAULT_SESSION_ID).cwd == "/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_sees_parent_env(ws):
|
||||
await ws.execute("export VISIBLE=yes")
|
||||
await ws.execute("printenv VISIBLE &")
|
||||
io = await ws.execute("wait %1")
|
||||
assert b"yes" in io.stdout
|
||||
|
||||
|
||||
# --- Session: cd + env ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_then_relative_cat(ws):
|
||||
await ws.execute("cd /data/subdir")
|
||||
io = await ws.execute("cat a.txt")
|
||||
assert b"aaa" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_nested_relative(ws):
|
||||
await ws.execute("cd /data")
|
||||
await ws.execute("cd subdir")
|
||||
io = await ws.execute("cat b.txt")
|
||||
assert b"bbb" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_then_variable_expansion(ws):
|
||||
await ws.execute("export PATTERN=ERROR")
|
||||
io = await ws.execute("grep $PATTERN /data/log.txt | wc -l")
|
||||
assert (await io.stdout_str()).strip() == "2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_unset_cycle(ws):
|
||||
await ws.execute("export TMP=val")
|
||||
assert ws.get_session(DEFAULT_SESSION_ID).env["TMP"] == "val"
|
||||
await ws.execute("unset TMP")
|
||||
assert "TMP" not in ws.get_session(DEFAULT_SESSION_ID).env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printenv_shows_all(ws):
|
||||
await ws.execute("export A=1")
|
||||
await ws.execute("export B=2")
|
||||
io = await ws.execute("printenv")
|
||||
out = await io.stdout_str()
|
||||
assert "A=1" in out
|
||||
assert "B=2" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printenv_single_key(ws):
|
||||
await ws.execute("export SECRET=abc")
|
||||
io = await ws.execute("printenv SECRET")
|
||||
assert (await io.stdout_str()).strip() == "abc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printenv_missing_key(ws):
|
||||
io = await ws.execute("printenv NOSUCH")
|
||||
assert io.exit_code == 1
|
||||
|
||||
|
||||
# --- Multi-session isolation ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_sessions_isolated_cwd(ws):
|
||||
sa = ws.create_session("s-a")
|
||||
sa.cwd = "/"
|
||||
sb = ws.create_session("s-b")
|
||||
sb.cwd = "/"
|
||||
await ws.execute("cd /data", session_id="s-a")
|
||||
await ws.execute("cd /data/subdir", session_id="s-b")
|
||||
assert sa.cwd == "/data"
|
||||
assert sb.cwd == "/data/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_sessions_isolated_env(ws):
|
||||
ws.create_session("s-a")
|
||||
ws.create_session("s-b")
|
||||
await ws.execute("export X=from_a", session_id="s-a")
|
||||
await ws.execute("export X=from_b", session_id="s-b")
|
||||
assert ws.get_session("s-a").env["X"] == "from_a"
|
||||
assert ws.get_session("s-b").env["X"] == "from_b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_env_not_visible_cross_session(ws):
|
||||
ws.create_session("s-a")
|
||||
ws.create_session("s-b")
|
||||
await ws.execute("export PRIVATE=yes", session_id="s-a")
|
||||
io = await ws.execute("printenv PRIVATE", session_id="s-b")
|
||||
assert io.exit_code == 1
|
||||
|
||||
|
||||
# --- For loops ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_loop_basic(ws):
|
||||
io = await ws.execute(
|
||||
"for f in /data/subdir/a.txt /data/subdir/b.txt; do cat $f; done")
|
||||
out = await io.stdout_str()
|
||||
assert "aaa" in out
|
||||
assert "bbb" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_loop_variable_restored(ws):
|
||||
await ws.execute("export i=original")
|
||||
await ws.execute("for i in 1 2 3; do echo $i; done")
|
||||
assert ws.get_session(DEFAULT_SESSION_ID).env["i"] == "original"
|
||||
|
||||
|
||||
# --- If/else ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_if_true_branch(ws):
|
||||
io = await ws.execute(
|
||||
"if grep -q world /data/hello.txt; then echo found; else echo nope; fi"
|
||||
)
|
||||
assert b"found" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_if_false_branch(ws):
|
||||
io = await ws.execute(
|
||||
"if grep -q NOPE /data/hello.txt; then echo found; else echo nope; fi")
|
||||
assert b"nope" in io.stdout
|
||||
|
||||
|
||||
# --- Complex combined ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_export_grep_pipe(ws):
|
||||
await ws.execute("cd /data")
|
||||
await ws.execute("export TERM=ERROR")
|
||||
io = await ws.execute("grep $TERM log.txt | wc -l")
|
||||
assert (await io.stdout_str()).strip() == "2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subshell_with_redirect(ws):
|
||||
await ws.execute("(echo from_subshell) > /data/sub_out.txt")
|
||||
io = await ws.execute("cat /data/sub_out.txt")
|
||||
assert b"from_subshell" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipe_into_redirect(ws):
|
||||
await ws.execute("grep ERROR /data/log.txt | sort > /data/errors.txt")
|
||||
io = await ws.execute("cat /data/errors.txt")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
assert lines[0] == "ERROR bad"
|
||||
assert lines[1] == "ERROR fail"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_with_pipe(ws):
|
||||
await ws.execute("cat /data/numbers.txt | sort | uniq &")
|
||||
io = await ws.execute("wait %1")
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert sorted(lines) == ["1", "2", "3"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_tracks_session_id(ws):
|
||||
ws.create_session("s-hist")
|
||||
ws.get_session("s-hist").cwd = "/"
|
||||
await ws.execute("echo tracked", session_id="s-hist")
|
||||
records = [e for e in await ws.history() if e["session"] == "s-hist"]
|
||||
assert len(records) == 1
|
||||
assert records[0]["command"] == "echo tracked"
|
||||
|
||||
|
||||
# --- grep exit codes ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_no_match_exit_code(ws):
|
||||
io = await ws.execute("grep NONEXISTENT /data/hello.txt")
|
||||
assert io.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_match_exit_code(ws):
|
||||
io = await ws.execute("grep hello /data/hello.txt")
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_q_match(ws):
|
||||
io = await ws.execute("grep -q world /data/hello.txt")
|
||||
assert io.exit_code == 0
|
||||
assert not (await io.stdout_str()).strip()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_q_no_match(ws):
|
||||
io = await ws.execute("grep -q NONEXISTENT /data/hello.txt")
|
||||
assert io.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_and_short_circuit(ws):
|
||||
io = await ws.execute(
|
||||
"grep NONEXISTENT /data/hello.txt && echo should_not_appear")
|
||||
assert b"should_not_appear" not in (io.stdout or b"")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_or_fallback(ws):
|
||||
io = await ws.execute("grep NONEXISTENT /data/hello.txt || echo fallback")
|
||||
assert b"fallback" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_if_condition(ws):
|
||||
io = await ws.execute(
|
||||
"if grep -q world /data/hello.txt; then echo found; else echo nope; fi"
|
||||
)
|
||||
assert b"found" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_if_no_match(ws):
|
||||
io = await ws.execute(
|
||||
"if grep -q NOPE /data/hello.txt; then echo found; else echo nope; fi")
|
||||
assert b"nope" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_pipe_no_match_last_stage_wins(ws):
|
||||
io = await ws.execute("grep NONEXISTENT /data/hello.txt | sort")
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_pipe_match(ws):
|
||||
io = await ws.execute("grep ERROR /data/log.txt | sort")
|
||||
assert io.exit_code == 0
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
assert lines[0] == "ERROR bad"
|
||||
assert lines[1] == "ERROR fail"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_no_match_then_or_chain(ws):
|
||||
io = await ws.execute(
|
||||
"grep NOPE /data/hello.txt || grep ERROR /data/log.txt | head -n 1")
|
||||
assert b"ERROR" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_count_no_match(ws):
|
||||
io = await ws.execute("grep -c NONEXISTENT /data/hello.txt")
|
||||
assert (await io.stdout_str()).strip() == "0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_count_match(ws):
|
||||
io = await ws.execute("grep -c ERROR /data/log.txt")
|
||||
assert io.exit_code == 0
|
||||
assert (await io.stdout_str()).strip() == "2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_invert_match(ws):
|
||||
io = await ws.execute("grep -v ERROR /data/log.txt")
|
||||
assert io.exit_code == 0
|
||||
lines = (await io.stdout_str()).strip().split("\n")
|
||||
assert all("ERROR" not in line for line in lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_invert_no_output(ws):
|
||||
io = await ws.execute("echo hello | grep -v hello")
|
||||
assert not (await io.stdout_str()).strip()
|
||||
|
||||
|
||||
# --- rg exit codes ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_no_match_exit_code(ws):
|
||||
io = await ws.execute("rg NONEXISTENT /data/hello.txt")
|
||||
assert io.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_match_exit_code(ws):
|
||||
io = await ws.execute("rg hello /data/hello.txt")
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_no_match_and_chain(ws):
|
||||
io = await ws.execute("rg NONEXISTENT /data/hello.txt && echo found")
|
||||
assert b"found" not in (io.stdout or b"")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_no_match_or_chain(ws):
|
||||
io = await ws.execute("rg NONEXISTENT /data/hello.txt || echo fallback")
|
||||
assert b"fallback" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_pipe_no_match(ws):
|
||||
io = await ws.execute("rg NONEXISTENT /data/hello.txt | wc -l")
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_match_pipe_head(ws):
|
||||
io = await ws.execute("rg INFO /data/log.txt | head -n 1")
|
||||
assert io.exit_code == 0
|
||||
assert b"INFO" in io.stdout
|
||||
|
||||
|
||||
# --- diff exit codes ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_identical_files(ws):
|
||||
await ws.execute("echo same > /data/diff_a.txt")
|
||||
await ws.execute("echo same > /data/diff_b.txt")
|
||||
io = await ws.execute("diff /data/diff_a.txt /data/diff_b.txt")
|
||||
assert io.exit_code == 0
|
||||
assert not (await io.stdout_str()).strip()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_different_files(ws):
|
||||
await ws.execute("echo aaa > /data/diff_a.txt")
|
||||
await ws.execute("echo bbb > /data/diff_b.txt")
|
||||
io = await ws.execute("diff /data/diff_a.txt /data/diff_b.txt")
|
||||
assert io.exit_code == 1
|
||||
assert (await io.stdout_str()).strip()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_and_chain(ws):
|
||||
await ws.execute("echo same > /data/diff_a.txt")
|
||||
await ws.execute("echo same > /data/diff_b.txt")
|
||||
io = await ws.execute(
|
||||
"diff /data/diff_a.txt /data/diff_b.txt && echo identical")
|
||||
assert b"identical" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_or_chain(ws):
|
||||
await ws.execute("echo aaa > /data/diff_a.txt")
|
||||
await ws.execute("echo bbb > /data/diff_b.txt")
|
||||
io = await ws.execute(
|
||||
"diff /data/diff_a.txt /data/diff_b.txt || echo different")
|
||||
assert b"different" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_if_identical(ws):
|
||||
await ws.execute("echo same > /data/diff_a.txt")
|
||||
await ws.execute("echo same > /data/diff_b.txt")
|
||||
cmd = ("if diff /data/diff_a.txt /data/diff_b.txt;"
|
||||
" then echo same; else echo changed; fi")
|
||||
io = await ws.execute(cmd)
|
||||
assert b"same" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_if_different(ws):
|
||||
await ws.execute("echo aaa > /data/diff_a.txt")
|
||||
await ws.execute("echo bbb > /data/diff_b.txt")
|
||||
cmd = ("if diff /data/diff_a.txt /data/diff_b.txt;"
|
||||
" then echo same; else echo changed; fi")
|
||||
io = await ws.execute(cmd)
|
||||
assert b"changed" in io.stdout
|
||||
|
||||
|
||||
# --- find (verify already correct) ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_existing(ws):
|
||||
io = await ws.execute("find /data/subdir")
|
||||
assert io.exit_code == 0
|
||||
assert b"a.txt" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_with_name(ws):
|
||||
io = await ws.execute("find /data/subdir -name a.txt")
|
||||
assert io.exit_code == 0
|
||||
assert b"a.txt" in io.stdout
|
||||
|
||||
|
||||
# --- Complex combined scenarios ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_no_match_pipe_redirect(ws):
|
||||
cmd = ("grep NONEXISTENT /data/log.txt > /data/result.txt"
|
||||
" || echo none > /data/result.txt")
|
||||
await ws.execute(cmd)
|
||||
io = await ws.execute("cat /data/result.txt")
|
||||
assert b"none" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_match_and_diff(ws):
|
||||
await ws.execute("grep ERROR /data/log.txt > /data/errors.txt")
|
||||
await ws.execute("echo 'ERROR fail\nERROR bad' > /data/expected.txt")
|
||||
io = await ws.execute("diff /data/errors.txt /data/expected.txt")
|
||||
assert io.exit_code in (0, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_subshell_isolation(ws):
|
||||
io = await ws.execute("(rg NONEXISTENT /data/hello.txt) || echo recovered")
|
||||
assert b"recovered" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_background_exit_code(ws):
|
||||
await ws.execute("grep ERROR /data/log.txt &")
|
||||
io = await ws.execute("wait %1")
|
||||
assert io.exit_code == 0
|
||||
assert b"ERROR" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_no_match_background(ws):
|
||||
await ws.execute("grep NONEXISTENT /data/log.txt &")
|
||||
io = await ws.execute("wait %1")
|
||||
assert io.exit_code == 1
|
||||
@@ -0,0 +1,363 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.s3 import COMMANDS as _S3_COMMANDS
|
||||
from mirage.io.cachable_iterator import CachableAsyncIterator
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[3] / "data"
|
||||
|
||||
s3_cat = next(fn for fn in _S3_COMMANDS for rc in fn._registered_commands
|
||||
if rc.name == "cat" and rc.filetype is None)
|
||||
LAST_MODIFIED = datetime(2026, 3, 26, tzinfo=timezone.utc)
|
||||
|
||||
_CORE_MODULES = [
|
||||
"mirage.core.s3.read",
|
||||
"mirage.core.s3.write",
|
||||
"mirage.core.s3.stat",
|
||||
"mirage.core.s3.readdir",
|
||||
"mirage.core.s3.find",
|
||||
"mirage.core.s3.du",
|
||||
"mirage.core.s3.stream",
|
||||
"mirage.core.s3.copy",
|
||||
"mirage.core.s3.rename",
|
||||
"mirage.core.s3.unlink",
|
||||
"mirage.core.s3.rmdir",
|
||||
"mirage.core.s3.rm",
|
||||
"mirage.core.s3.mkdir",
|
||||
"mirage.core.s3.create",
|
||||
"mirage.core.s3.truncate",
|
||||
]
|
||||
|
||||
|
||||
class MockS3Error(Exception):
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.response = {"Error": {"Code": code}}
|
||||
|
||||
|
||||
class AsyncMockBody:
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self._data = data
|
||||
|
||||
async def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
async def iter_chunks(self, chunk_size: int = 8192):
|
||||
for i in range(0, len(self._data), chunk_size):
|
||||
yield self._data[i:i + chunk_size]
|
||||
|
||||
|
||||
class AsyncMockPaginator:
|
||||
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self.objects = objects
|
||||
|
||||
async def paginate(self,
|
||||
Bucket: str,
|
||||
Prefix: str = "",
|
||||
Delimiter: str | None = None):
|
||||
del Bucket
|
||||
if Delimiter == "/":
|
||||
yield _paginate_directory(self.objects, Prefix)
|
||||
else:
|
||||
yield _paginate_flat(self.objects, Prefix)
|
||||
|
||||
|
||||
class AsyncMockS3Client:
|
||||
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self.objects = objects
|
||||
|
||||
async def get_object(self,
|
||||
Bucket: str,
|
||||
Key: str,
|
||||
Range: str | None = None) -> dict:
|
||||
del Bucket
|
||||
if Key not in self.objects:
|
||||
raise MockS3Error("NoSuchKey")
|
||||
data = self.objects[Key]
|
||||
if Range is not None:
|
||||
data = _slice_range(data, Range)
|
||||
return {"Body": AsyncMockBody(data)}
|
||||
|
||||
async def head_object(self, Bucket: str, Key: str) -> dict:
|
||||
del Bucket
|
||||
if Key not in self.objects:
|
||||
raise MockS3Error("NoSuchKey")
|
||||
return {
|
||||
"ContentLength": len(self.objects[Key]),
|
||||
"LastModified": LAST_MODIFIED,
|
||||
"ETag": f'"{Key}"',
|
||||
}
|
||||
|
||||
def get_paginator(self, name: str) -> AsyncMockPaginator:
|
||||
assert name == "list_objects_v2"
|
||||
return AsyncMockPaginator(self.objects)
|
||||
|
||||
async def put_object(self, Bucket: str, Key: str, Body: bytes) -> None:
|
||||
self.objects[Key] = Body
|
||||
|
||||
async def delete_object(self, Bucket: str, Key: str) -> None:
|
||||
self.objects.pop(Key, None)
|
||||
|
||||
async def copy_object(self, Bucket: str, CopySource: dict,
|
||||
Key: str) -> None:
|
||||
src_key = CopySource["Key"]
|
||||
if src_key in self.objects:
|
||||
self.objects[Key] = self.objects[src_key]
|
||||
|
||||
async def delete_objects(self, Bucket: str, Delete: dict) -> None:
|
||||
for obj in Delete.get("Objects", []):
|
||||
self.objects.pop(obj["Key"], None)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class MockAsyncSession:
|
||||
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self._client = AsyncMockS3Client(objects)
|
||||
|
||||
def client(self, **kwargs):
|
||||
return self._client
|
||||
|
||||
|
||||
def _paginate_directory(objects, prefix):
|
||||
common_prefixes: set[str] = set()
|
||||
contents: list[dict[str, object]] = []
|
||||
for key, data in sorted(objects.items()):
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
relative = key[len(prefix):]
|
||||
if not relative:
|
||||
continue
|
||||
if "/" in relative:
|
||||
child = relative.split("/", 1)[0]
|
||||
common_prefixes.add(prefix + child + "/")
|
||||
continue
|
||||
contents.append({"Key": key, "Size": len(data)})
|
||||
return {
|
||||
"CommonPrefixes": [{
|
||||
"Prefix": v
|
||||
} for v in sorted(common_prefixes)],
|
||||
"Contents": contents,
|
||||
}
|
||||
|
||||
|
||||
def _paginate_flat(objects, prefix):
|
||||
return {
|
||||
"Contents": [{
|
||||
"Key": k,
|
||||
"Size": len(v)
|
||||
} for k, v in sorted(objects.items()) if k.startswith(prefix)]
|
||||
}
|
||||
|
||||
|
||||
def _slice_range(data: bytes, range_spec: str) -> bytes:
|
||||
if not range_spec.startswith("bytes="):
|
||||
return data
|
||||
bounds = range_spec.removeprefix("bytes=").split("-", 1)
|
||||
start = int(bounds[0]) if bounds[0] else 0
|
||||
end = int(bounds[1]) if bounds[1] else len(data) - 1
|
||||
return data[start:end + 1]
|
||||
|
||||
|
||||
def _load_example_jsonl(limit: int = 20) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
with (DATA_DIR / "example.jsonl").open("rb") as handle:
|
||||
for _ in range(limit):
|
||||
line = handle.readline()
|
||||
if not line:
|
||||
break
|
||||
chunks.append(line)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _s3_objects() -> dict[str, bytes]:
|
||||
return {
|
||||
"data/example.json": (DATA_DIR / "example.json").read_bytes(),
|
||||
"data/example.jsonl": _load_example_jsonl(),
|
||||
"reports/summary.txt": b"alpha report\nbeta report\n",
|
||||
"archive/2026/q1/deep.txt": b"deep archive\n",
|
||||
}
|
||||
|
||||
|
||||
def _s3_backend() -> S3Resource:
|
||||
config = S3Config(
|
||||
bucket="test-bucket",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="fake",
|
||||
aws_secret_access_key="fake",
|
||||
)
|
||||
return S3Resource(config)
|
||||
|
||||
|
||||
def _patch_async_session(objects):
|
||||
mock_session = MockAsyncSession(objects)
|
||||
stack = ExitStack()
|
||||
for mod in _CORE_MODULES:
|
||||
stack.enter_context(
|
||||
patch(f"{mod}.async_session", return_value=mock_session))
|
||||
return stack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws():
|
||||
objects = _s3_objects()
|
||||
with _patch_async_session(objects):
|
||||
yield Workspace(
|
||||
{
|
||||
"/s3/": (_s3_backend(), MountMode.READ),
|
||||
"/tmp/": (RAMResource(), MountMode.WRITE),
|
||||
},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_sort_lists_expected_s3_files(ws):
|
||||
objects = _s3_objects()
|
||||
with _patch_async_session(objects):
|
||||
io = await ws.execute("find /s3 -maxdepth 2 -type f | sort")
|
||||
assert (await io.stdout_str()).strip().splitlines() == [
|
||||
"/s3/data/example.json",
|
||||
"/s3/data/example.jsonl",
|
||||
"/s3/reports/summary.txt",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_report_through_redirect_chain(ws):
|
||||
objects = _s3_objects()
|
||||
with _patch_async_session(objects):
|
||||
io = await ws.execute(
|
||||
"echo '=== /s3/data/example.json ===' > /tmp/file_report.txt && "
|
||||
"file /s3/data/example.json >> /tmp/file_report.txt && "
|
||||
"echo >> /tmp/file_report.txt && "
|
||||
"echo '=== /s3/data/example.jsonl ===' >> /tmp/file_report.txt && "
|
||||
"file /s3/data/example.jsonl >> /tmp/file_report.txt && "
|
||||
"echo >> /tmp/file_report.txt && "
|
||||
"echo '=== /s3/reports/summary.txt ===' "
|
||||
">> /tmp/file_report.txt && "
|
||||
"file /s3/reports/summary.txt >> /tmp/file_report.txt && "
|
||||
"echo >> /tmp/file_report.txt && "
|
||||
"cat /tmp/file_report.txt")
|
||||
assert (await io.stdout_str()).strip().splitlines() == [
|
||||
"=== /s3/data/example.json ===",
|
||||
"/s3/data/example.json: json",
|
||||
"",
|
||||
"=== /s3/data/example.jsonl ===",
|
||||
"/s3/data/example.jsonl: json",
|
||||
"",
|
||||
"=== /s3/reports/summary.txt ===",
|
||||
"/s3/reports/summary.txt: text",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wc_report_through_redirect_chain(ws):
|
||||
objects = _s3_objects()
|
||||
with _patch_async_session(objects):
|
||||
io = await ws.execute(
|
||||
"echo -n '/s3/data/example.json ' > /tmp/size_report.txt && "
|
||||
"wc -c /s3/data/example.json >> /tmp/size_report.txt && "
|
||||
"echo -n '/s3/data/example.jsonl ' >> /tmp/size_report.txt && "
|
||||
"wc -c /s3/data/example.jsonl >> /tmp/size_report.txt && "
|
||||
"cat /tmp/size_report.txt")
|
||||
json_size = len(objects["data/example.json"])
|
||||
jsonl_size = len(objects["data/example.jsonl"])
|
||||
assert (await io.stdout_str()).strip().splitlines() == [
|
||||
f"/s3/data/example.json {json_size} /s3/data/example.json",
|
||||
f"/s3/data/example.jsonl {jsonl_size} /s3/data/example.jsonl",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_then_jq_with_and_or_list(ws):
|
||||
objects = _s3_objects()
|
||||
with _patch_async_session(objects):
|
||||
io = await ws.execute(
|
||||
"grep -l mirage /s3/data/example.jsonl "
|
||||
"> /tmp/search_report.txt && "
|
||||
"echo >> /tmp/search_report.txt && "
|
||||
"jq .company /s3/data/example.json >> /tmp/search_report.txt || "
|
||||
"echo missing > /tmp/search_report.txt; "
|
||||
"cat /tmp/search_report.txt")
|
||||
assert (await io.stdout_str()).strip().splitlines() == [
|
||||
"/s3/data/example.jsonl",
|
||||
"",
|
||||
'"Strukto"',
|
||||
]
|
||||
|
||||
|
||||
def _resolved(original: str) -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key(original, "/s3"),
|
||||
virtual=original,
|
||||
directory=original,
|
||||
resolved=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_multifile_caches_materialized_bytes_per_file():
|
||||
"""Regression: multi-file cat on a streaming backend must register fully
|
||||
materialized bytes per path in io.reads (not un-exhausted cachables). A
|
||||
per-file cachable cannot preserve stdout identity, so the cache-fill
|
||||
background drain raced the consumer on the same network stream and
|
||||
poisoned each file's cache slot. Materialized bytes cache deterministically
|
||||
with no drain and no race."""
|
||||
objects = _s3_objects()
|
||||
backend = _s3_backend()
|
||||
with _patch_async_session(objects):
|
||||
a = _resolved("/s3/reports/summary.txt")
|
||||
b = _resolved("/s3/archive/2026/q1/deep.txt")
|
||||
source, io = await s3_cat(backend.accessor, [a, b], index=None)
|
||||
|
||||
assert io.reads[
|
||||
"/reports/summary.txt"] == b"alpha report\nbeta report\n"
|
||||
assert io.reads["/archive/2026/q1/deep.txt"] == b"deep archive\n"
|
||||
assert all(isinstance(v, bytes) for v in io.reads.values())
|
||||
|
||||
combined = b"".join([chunk async for chunk in source])
|
||||
assert combined == b"alpha report\nbeta report\ndeep archive\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_single_file_keeps_streaming_cachable():
|
||||
"""The single-file path must stay a streaming cachable returned AS stdout
|
||||
(identity preserved) so large files still stream, not materialize."""
|
||||
objects = _s3_objects()
|
||||
backend = _s3_backend()
|
||||
with _patch_async_session(objects):
|
||||
a = _resolved("/s3/reports/summary.txt")
|
||||
source, io = await s3_cat(backend.accessor, [a], index=None)
|
||||
assert isinstance(source, CachableAsyncIterator)
|
||||
assert io.reads["/reports/summary.txt"] is source
|
||||
@@ -0,0 +1,43 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.resource.slack import SlackConfig, SlackResource
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_grep_glob_expanded_to_60_paths_is_one_native_call():
|
||||
slack = SlackResource(
|
||||
config=SlackConfig(token="xoxb-test", search_token="xoxp-test"))
|
||||
ws = Workspace({"/slack": (slack, MountMode.READ)}, mode=MountMode.READ)
|
||||
fake_payload = (b'{"messages":{"matches":[{"channel":{"name":"general",'
|
||||
b'"id":"C1"},"ts":"1700000000.0","text":"hello"}]}}')
|
||||
expanded = " ".join(
|
||||
f"/slack/channels/general__C1/2026-{m:02d}-{d:02d}/chat.jsonl"
|
||||
for m in range(1, 5) for d in range(1, 16))
|
||||
try:
|
||||
with patch(
|
||||
"mirage.commands.builtin.slack.grep.search_messages",
|
||||
new=AsyncMock(return_value=fake_payload),
|
||||
) as fake_search:
|
||||
result = await ws.execute(f"grep -i hello {expanded}")
|
||||
assert fake_search.await_count == 1
|
||||
assert result.exit_code == 0
|
||||
assert b"hello" in (result.stdout or b"")
|
||||
finally:
|
||||
await ws.close()
|
||||
@@ -0,0 +1,443 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import make_resource_ws, run, run_exit
|
||||
|
||||
FILES = {
|
||||
"logs/app.log": (b"2026-01-01 INFO startup\n"
|
||||
b"2026-01-02 ERROR connection refused\n"
|
||||
b"2026-01-03 INFO request handled\n"
|
||||
b"2026-01-04 WARN slow query\n"
|
||||
b"2026-01-05 ERROR timeout\n"
|
||||
b"2026-01-06 INFO shutdown\n"),
|
||||
"logs/access.log": (b"GET /api/users 200\n"
|
||||
b"POST /api/users 201\n"
|
||||
b"GET /api/users 200\n"
|
||||
b"DELETE /api/users/1 404\n"
|
||||
b"GET /api/health 200\n"),
|
||||
"data/scores.csv":
|
||||
b"alice,90\nbob,75\ncharlie,90\nalice,85\nbob,95\n",
|
||||
"data/words.txt":
|
||||
b"hello\nworld\nhello\nfoo\nbar\nfoo\nhello\n",
|
||||
"data/numbers.txt":
|
||||
b"3\n1\n4\n1\n5\n9\n2\n6\n5\n3\n",
|
||||
"src/main.py":
|
||||
b"import os\nimport sys\nprint('hello')\n",
|
||||
"src/utils.py":
|
||||
b"def add(a, b):\n return a + b\n",
|
||||
"config.json":
|
||||
b'{"name": "mirage", "version": "1.0"}\n',
|
||||
"empty.txt":
|
||||
b"",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(params=["ram", "s3", "disk"])
|
||||
def ws(request, tmp_path):
|
||||
yield from make_resource_ws(request, tmp_path, FILES)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nested loops
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nested_for_with_file_ops(ws):
|
||||
cmd = ("for d in logs data; do "
|
||||
"for f in $(find /data/$d -type f | sort); do "
|
||||
"echo \"$d: $f\"; done; done")
|
||||
result = run(ws, cmd)
|
||||
assert "logs: /data/logs/app.log" in result
|
||||
assert "data: /data/data/scores.csv" in result
|
||||
|
||||
|
||||
def test_for_with_if_and_grep(ws):
|
||||
cmd = ("for f in $(find /data/logs -type f | sort); do "
|
||||
"if grep -q ERROR $f; then echo \"ERRORS: $f\"; fi; done")
|
||||
result = run(ws, cmd)
|
||||
assert "ERRORS: /data/logs/app.log" in result
|
||||
assert "ERRORS: /data/logs/access.log" not in result
|
||||
|
||||
|
||||
def test_while_read_with_nested_if(ws):
|
||||
cmd = ("find /data/data -type f | sort | "
|
||||
"while read f; do "
|
||||
"if [ \"$(wc -l < $f)\" -gt 3 ]; then "
|
||||
"echo \"big: $f\"; "
|
||||
"else echo \"small: $f\"; fi; done")
|
||||
result = run(ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
assert len(lines) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-stage pipelines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sort_uniq_count_pipeline(ws):
|
||||
cmd = "cat /data/data/words.txt | sort | uniq -c | sort -rn | head -n 1"
|
||||
result = run(ws, cmd).split()
|
||||
assert result[0] == "3"
|
||||
assert result[1] == "hello"
|
||||
|
||||
|
||||
def test_grep_pipe_cut_pipe_sort_pipe_uniq(ws):
|
||||
cmd = ("cat /data/logs/access.log | cut -d ' ' -f 1 | sort | uniq -c "
|
||||
"| sort -rn")
|
||||
result = run(ws, cmd).strip()
|
||||
lines = result.splitlines()
|
||||
assert len(lines) == 3
|
||||
first = lines[0].split()
|
||||
assert first[0] == "3"
|
||||
assert first[1] == "GET"
|
||||
|
||||
|
||||
def test_five_stage_pipeline(ws):
|
||||
cmd = ("cat /data/data/numbers.txt | sort -n | uniq | head -n 3 "
|
||||
"| tr '\\n' ','")
|
||||
result = run(ws, cmd)
|
||||
assert result.startswith("1,2,3")
|
||||
|
||||
|
||||
def test_grep_count_errors_across_files(ws):
|
||||
cmd = "grep -c ERROR /data/logs/app.log"
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command substitution in pipelines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_command_sub_in_echo(ws):
|
||||
cmd = "echo \"lines: $(wc -l < /data/data/words.txt)\""
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "lines: 7"
|
||||
|
||||
|
||||
def test_nested_command_sub(ws):
|
||||
cmd = "echo $(echo $(echo deep))"
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "deep"
|
||||
|
||||
|
||||
def test_command_sub_in_for_values(ws):
|
||||
cmd = ("for line in $(grep ERROR /data/logs/app.log | cut -d ' ' -f 1); "
|
||||
"do echo \"date:$line\"; done")
|
||||
result = run(ws, cmd)
|
||||
assert "date:2026-01-02" in result
|
||||
assert "date:2026-01-05" in result
|
||||
|
||||
|
||||
def test_command_sub_in_test(ws):
|
||||
cmd = ("if [ $(grep -c ERROR /data/logs/app.log) -gt 1 ]; then "
|
||||
"echo many_errors; else echo few_errors; fi")
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "many_errors"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# While read with processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_while_read_with_variable_transform(ws):
|
||||
cmd = ("echo -e 'alice\\nbob\\ncharlie' | "
|
||||
"while read name; do echo \"user:$name\"; done")
|
||||
result = run(ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
assert lines == ["user:alice", "user:bob", "user:charlie"]
|
||||
|
||||
|
||||
def test_while_read_counter(ws):
|
||||
cmd = ("export count=0; "
|
||||
"cat /data/data/words.txt | sort -u | "
|
||||
"while read w; do "
|
||||
"export count=$((count+1)); echo \"$count:$w\"; done")
|
||||
result = run(ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
assert lines[0] == "1:bar"
|
||||
|
||||
|
||||
def test_while_read_pipe_to_wc(ws):
|
||||
cmd = ("grep ERROR /data/logs/app.log | "
|
||||
"while read line; do echo \"ALERT: $line\"; done | wc -l")
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "2"
|
||||
|
||||
|
||||
def test_while_read_with_grep_filter(ws):
|
||||
cmd = ("cat /data/logs/access.log | "
|
||||
"while read line; do echo $line; done | grep 200 | wc -l")
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "3"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subshell and grouping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_subshell_pipe_chain(ws):
|
||||
cmd = "(echo hello; echo world) | sort | tr a-z A-Z"
|
||||
result = run(ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
assert lines == ["HELLO", "WORLD"]
|
||||
|
||||
|
||||
def test_brace_group_redirect(ws):
|
||||
cmd = "{ echo first; echo second; } > /data/out.txt; cat /data/out.txt"
|
||||
result = run(ws, cmd)
|
||||
assert result == "first\nsecond\n"
|
||||
|
||||
|
||||
def test_subshell_variable_isolation(ws):
|
||||
cmd = "export X=outer; (export X=inner; echo $X); echo $X"
|
||||
result = run(ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
assert lines == ["inner", "outer"]
|
||||
|
||||
|
||||
def test_subshell_function_def_no_leak(ws):
|
||||
cmd = "(only_inner() { echo inner; }); only_inner 2>/dev/null || echo gone"
|
||||
assert run(ws, cmd).strip() == "gone"
|
||||
|
||||
|
||||
def test_subshell_function_redef_isolation(ws):
|
||||
cmd = "greet() { echo outer; }; (greet() { echo inner; }; greet); greet"
|
||||
lines = run(ws, cmd).strip().splitlines()
|
||||
assert lines == ["inner", "outer"]
|
||||
|
||||
|
||||
def test_subshell_function_inherited(ws):
|
||||
cmd = "greet() { echo hi; }; (greet)"
|
||||
assert run(ws, cmd).strip() == "hi"
|
||||
|
||||
|
||||
def test_subshell_positional_isolation(ws):
|
||||
cmd = "set -- a b c; (set -- x); echo $# $1"
|
||||
assert run(ws, cmd).strip() == "3 a"
|
||||
|
||||
|
||||
def test_subshell_positional_inherited(ws):
|
||||
cmd = "set -- a b; (echo $1 $2)"
|
||||
assert run(ws, cmd).strip() == "a b"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Functions with pipelines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_function_in_pipeline(ws):
|
||||
cmd = "upper() { tr a-z A-Z; }; echo hello | upper"
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "HELLO"
|
||||
|
||||
|
||||
def test_function_with_args_in_loop(ws):
|
||||
cmd = ("classify() { case $1 in "
|
||||
"*.py) echo \"python: $1\";; "
|
||||
"*.txt) echo \"text: $1\";; "
|
||||
"*.json) echo \"json: $1\";; "
|
||||
"*.csv) echo \"csv: $1\";; "
|
||||
"*.log) echo \"log: $1\";; "
|
||||
"*) echo \"other: $1\";; esac; }; "
|
||||
"find /data -type f | sort | "
|
||||
"while read f; do classify $f; done")
|
||||
result = run(ws, cmd)
|
||||
assert "python: /data/src/main.py" in result
|
||||
assert "json: /data/config.json" in result
|
||||
assert "log: /data/logs/app.log" in result
|
||||
assert "csv: /data/data/scores.csv" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conditional chains (&&, ||)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_and_chain_with_grep(ws):
|
||||
cmd = ("grep -q ERROR /data/logs/app.log && "
|
||||
"echo 'has errors' || echo 'clean'")
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "has errors"
|
||||
|
||||
|
||||
def test_or_chain_fallback(ws):
|
||||
cmd = ("grep -q FATAL /data/logs/app.log && "
|
||||
"echo 'has fatal' || echo 'no fatal'")
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "no fatal"
|
||||
|
||||
|
||||
def test_chained_conditionals(ws):
|
||||
cmd = ("grep -q ERROR /data/logs/app.log && "
|
||||
"grep -q INFO /data/logs/app.log && "
|
||||
"echo 'has both'")
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "has both"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redirects combined with pipes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pipe_with_output_redirect(ws):
|
||||
cmd = ("grep ERROR /data/logs/app.log | sort > /data/errors.txt; "
|
||||
"cat /data/errors.txt")
|
||||
result = run(ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
assert len(lines) == 2
|
||||
assert lines == sorted(lines)
|
||||
|
||||
|
||||
def test_append_redirect_in_loop(ws):
|
||||
cmd = ("for x in one two three; do "
|
||||
"echo $x >> /data/result.txt; done; cat /data/result.txt")
|
||||
result = run(ws, cmd)
|
||||
assert result == "one\ntwo\nthree\n"
|
||||
|
||||
|
||||
def test_stdin_redirect_in_pipeline(ws):
|
||||
cmd = "sort < /data/data/numbers.txt | uniq | wc -l"
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "7"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process substitution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_input_process_substitution(ws):
|
||||
cmd = "cat <(echo hello)"
|
||||
result = run(ws, cmd)
|
||||
assert "hello" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sed and tr pipelines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sed_in_pipeline(ws):
|
||||
cmd = "cat /data/logs/app.log | grep ERROR | sed 's/ERROR/CRITICAL/'"
|
||||
result = run(ws, cmd)
|
||||
assert "CRITICAL" in result
|
||||
assert "ERROR" not in result
|
||||
|
||||
|
||||
def test_tr_multiple_transforms(ws):
|
||||
cmd = "echo 'Hello World' | tr A-Z a-z | tr ' ' '_'"
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "hello_world"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Complex real-world patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_log_analysis_pipeline(ws):
|
||||
cmd = ("cat /data/logs/app.log | grep -v INFO | "
|
||||
"cut -d ' ' -f 2 | sort | uniq -c | sort -rn")
|
||||
result = run(ws, cmd).strip()
|
||||
lines = result.splitlines()
|
||||
first = lines[0].split()
|
||||
assert first[0] == "2"
|
||||
assert first[1] == "ERROR"
|
||||
|
||||
|
||||
def test_find_grep_count_pattern(ws):
|
||||
cmd = ("find /data/src -name '*.py' -type f | sort | "
|
||||
"while read f; do "
|
||||
"echo \"$(grep -c import $f) $f\"; done")
|
||||
result = run(ws, cmd)
|
||||
assert "2 /data/src/main.py" in result
|
||||
|
||||
|
||||
def test_csv_processing_pipeline(ws):
|
||||
cmd = ("cat /data/data/scores.csv | sort -t, -k2 -rn | head -n 1 "
|
||||
"| cut -d, -f1")
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "bob"
|
||||
|
||||
|
||||
def test_word_frequency_full_pipeline(ws):
|
||||
cmd = ("cat /data/data/words.txt | sort | uniq -c | sort -rn | "
|
||||
"while read count word; do "
|
||||
"echo \"$word appears $count times\"; done | head -n 2")
|
||||
result = run(ws, cmd)
|
||||
lines = result.strip().splitlines()
|
||||
assert "hello appears 3 times" in lines[0]
|
||||
assert "foo appears 2 times" in lines[1]
|
||||
|
||||
|
||||
def test_while_read_with_command_sub_body(ws):
|
||||
cmd = ("find /data -name '*.py' -type f | sort | "
|
||||
"while read f; do "
|
||||
"echo \"$f: $(wc -l < $f) lines\"; done")
|
||||
result = run(ws, cmd)
|
||||
assert "/data/src/main.py: 3 lines" in result
|
||||
assert "/data/src/utils.py: 2 lines" in result
|
||||
|
||||
|
||||
def test_multiline_script_with_function_and_loop(ws):
|
||||
cmd = ("count_matches() { grep -c $1 $2; }; "
|
||||
"find /data/logs -type f | sort | "
|
||||
"while read f; do "
|
||||
"echo \"$f: $(count_matches ERROR $f) errors\"; done")
|
||||
result = run(ws, cmd)
|
||||
assert "/data/logs/app.log: 2 errors" in result
|
||||
assert "/data/logs/access.log: 0 errors" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty_file_in_pipeline(ws):
|
||||
cmd = "cat /data/empty.txt | wc -l"
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "0"
|
||||
|
||||
|
||||
def test_while_read_empty_input(ws):
|
||||
cmd = ("cat /data/empty.txt | "
|
||||
"while read line; do echo \"got: $line\"; done; echo done")
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "done"
|
||||
|
||||
|
||||
def test_deeply_nested_command_sub(ws):
|
||||
cmd = "echo $(echo $(echo $(echo nested)))"
|
||||
result = run(ws, cmd).strip()
|
||||
assert result == "nested"
|
||||
|
||||
|
||||
def test_pipe_exit_code_last_command(ws):
|
||||
code = run_exit(ws, "echo hello | grep world")
|
||||
assert code != 0
|
||||
|
||||
|
||||
def test_pipe_exit_code_success(ws):
|
||||
code = run_exit(ws, "echo hello | grep hello")
|
||||
assert code == 0
|
||||
@@ -0,0 +1,294 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
|
||||
from mirage.core.s3.write import write_bytes
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.types import DriftPolicy, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.snapshot import ContentDriftError
|
||||
|
||||
LIVE_BUCKET = os.environ.get("MIRAGE_LIVE_S3_BUCKET") or os.environ.get(
|
||||
"AWS_S3_BUCKET")
|
||||
LIVE_REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
||||
LIVE_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
|
||||
LIVE_SECRET = os.environ.get("AWS_SECRET_ACCESS_KEY")
|
||||
|
||||
_LIVE_READY = bool(LIVE_BUCKET and LIVE_KEY_ID and LIVE_SECRET)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _LIVE_READY,
|
||||
reason=("set MIRAGE_LIVE_S3_BUCKET (or AWS_S3_BUCKET) plus "
|
||||
"AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY to run live S3 tests"),
|
||||
)
|
||||
|
||||
|
||||
def _config() -> S3Config:
|
||||
return S3Config(
|
||||
bucket=LIVE_BUCKET,
|
||||
region=LIVE_REGION,
|
||||
aws_access_key_id=LIVE_KEY_ID,
|
||||
aws_secret_access_key=LIVE_SECRET,
|
||||
)
|
||||
|
||||
|
||||
def _boto_client():
|
||||
return boto3.client(
|
||||
"s3",
|
||||
region_name=LIVE_REGION,
|
||||
aws_access_key_id=LIVE_KEY_ID,
|
||||
aws_secret_access_key=LIVE_SECRET,
|
||||
)
|
||||
|
||||
|
||||
def _versioning_enabled() -> bool:
|
||||
try:
|
||||
resp = _boto_client().get_bucket_versioning(Bucket=LIVE_BUCKET)
|
||||
except Exception:
|
||||
return False
|
||||
return resp.get("Status") == "Enabled"
|
||||
|
||||
|
||||
def _probe_key() -> str:
|
||||
return f"mirage-drift-probe-{uuid.uuid4().hex[:8]}.txt"
|
||||
|
||||
|
||||
def _mount(prefix: str = "/s3/") -> dict:
|
||||
return {prefix: (S3Resource(_config()), MountMode.WRITE)}
|
||||
|
||||
|
||||
def _override(prefix: str = "/s3/") -> dict:
|
||||
return {prefix: S3Resource(_config())}
|
||||
|
||||
|
||||
def _cleanup_key(key: str) -> None:
|
||||
client = _boto_client()
|
||||
try:
|
||||
if _versioning_enabled():
|
||||
versions = client.list_object_versions(Bucket=LIVE_BUCKET,
|
||||
Prefix=key).get(
|
||||
"Versions", [])
|
||||
for v in versions:
|
||||
if v.get("Key") == key:
|
||||
client.delete_object(Bucket=LIVE_BUCKET,
|
||||
Key=key,
|
||||
VersionId=v["VersionId"])
|
||||
markers = client.list_object_versions(Bucket=LIVE_BUCKET,
|
||||
Prefix=key).get(
|
||||
"DeleteMarkers", [])
|
||||
for m in markers:
|
||||
if m.get("Key") == key:
|
||||
client.delete_object(Bucket=LIVE_BUCKET,
|
||||
Key=key,
|
||||
VersionId=m["VersionId"])
|
||||
else:
|
||||
client.delete_object(Bucket=LIVE_BUCKET, Key=key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_live_strict_raises_on_etag_drift(tmp_path):
|
||||
"""Snapshot with a recorded read, mutate the live object via boto so
|
||||
its ETag changes, load with STRICT: the eager drift check must raise
|
||||
ContentDriftError before any user-visible read.
|
||||
|
||||
Requires versioning OFF: on a versioned bucket, stat() captures a
|
||||
revision and STRICT installs a pin (which intentionally bypasses
|
||||
drift detection). That path is covered by the version-pin test.
|
||||
"""
|
||||
if _versioning_enabled():
|
||||
pytest.skip(f"bucket {LIVE_BUCKET} is versioned; STRICT will pin "
|
||||
"instead of drift-checking. See the version-pin test "
|
||||
"for the versioned path.")
|
||||
key = _probe_key()
|
||||
probe = f"/s3/{key}"
|
||||
client = _boto_client()
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"v1 bytes\n")
|
||||
try:
|
||||
src = Workspace(_mount(), mode=MountMode.WRITE)
|
||||
result = asyncio.run(src.execute(f"cat {probe}"))
|
||||
assert b"v1 bytes" in result.stdout
|
||||
|
||||
snap = tmp_path / "drift.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"v2 mutated\n")
|
||||
|
||||
dst = Workspace.load(snap, resources=_override())
|
||||
with pytest.raises(ContentDriftError) as exc_info:
|
||||
asyncio.run(dst.execute(f"cat {probe}"))
|
||||
assert exc_info.value.path == probe
|
||||
assert (exc_info.value.snapshot_fingerprint
|
||||
!= exc_info.value.live_fingerprint)
|
||||
finally:
|
||||
_cleanup_key(key)
|
||||
|
||||
|
||||
def test_live_no_drift_passes(tmp_path):
|
||||
"""Control: snapshot + load with identical bytes on the bucket must
|
||||
succeed. Guards against false-positive drift on the live ETag path.
|
||||
"""
|
||||
key = _probe_key()
|
||||
probe = f"/s3/{key}"
|
||||
client = _boto_client()
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"stable\n")
|
||||
try:
|
||||
src = Workspace(_mount(), mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute(f"cat {probe}"))
|
||||
|
||||
snap = tmp_path / "stable.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst = Workspace.load(snap, resources=_override())
|
||||
result = asyncio.run(dst.execute(f"cat {probe}"))
|
||||
assert b"stable" in result.stdout
|
||||
finally:
|
||||
_cleanup_key(key)
|
||||
|
||||
|
||||
def test_live_pin_records_agent_version_not_snapshot_time_version(tmp_path):
|
||||
"""Race-fix regression test.
|
||||
|
||||
Agent reads V1 at T1. Between T1 and snapshot at T3, an external
|
||||
actor mutates the object to V2. Old design (live stat at snapshot
|
||||
time) would capture V2's VersionId and pin replay to V2, serving
|
||||
bytes the agent never saw. New design records VersionId at READ
|
||||
time (from the GET response headers), so the snapshot pins to V1
|
||||
and serves V1 bytes on cache miss.
|
||||
|
||||
Skipped on non-versioned buckets (no revision = no pin).
|
||||
"""
|
||||
if not _versioning_enabled():
|
||||
pytest.skip("requires versioning")
|
||||
|
||||
key = _probe_key()
|
||||
probe = f"/s3/{key}"
|
||||
client = _boto_client()
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"v1\n")
|
||||
try:
|
||||
src = Workspace(_mount(), mode=MountMode.WRITE)
|
||||
result = asyncio.run(src.execute(f"cat {probe}"))
|
||||
assert b"v1" in result.stdout
|
||||
|
||||
# Race: upstream changes BEFORE snapshot fires
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"v2-racy\n")
|
||||
|
||||
snap = tmp_path / "racy.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst = Workspace.load(snap, resources=_override())
|
||||
dst._cache.evict_paths([probe])
|
||||
result = asyncio.run(dst.execute(f"cat {probe}"))
|
||||
assert result.stdout == b"v1\n", (
|
||||
f"snapshot pinned the wrong VersionId; served {result.stdout!r} "
|
||||
"instead of the V1 the agent actually saw")
|
||||
finally:
|
||||
_cleanup_key(key)
|
||||
|
||||
|
||||
def test_live_version_pin_serves_original_on_versioned_bucket(tmp_path):
|
||||
"""End-to-end pin: with bucket versioning enabled, snapshot captures
|
||||
the live VersionId. After the bucket head moves on, STRICT load
|
||||
serves the original recorded bytes via the pin, not the current
|
||||
head, and does not raise.
|
||||
|
||||
Skipped (xfail) when the bucket is not versioned; the pin path
|
||||
requires real S3 versioning to exercise.
|
||||
"""
|
||||
if not _versioning_enabled():
|
||||
pytest.skip(
|
||||
f"bucket {LIVE_BUCKET} does not have versioning enabled; "
|
||||
"run `aws s3api put-bucket-versioning --bucket "
|
||||
f"{LIVE_BUCKET} --versioning-configuration Status=Enabled` "
|
||||
"to exercise the pin path")
|
||||
|
||||
key = _probe_key()
|
||||
probe = f"/s3/{key}"
|
||||
client = _boto_client()
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"original\n")
|
||||
try:
|
||||
src = Workspace(_mount(), mode=MountMode.WRITE)
|
||||
result = asyncio.run(src.execute(f"cat {probe}"))
|
||||
assert b"original" in result.stdout
|
||||
|
||||
snap = tmp_path / "pin.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"mutated\n")
|
||||
|
||||
dst = Workspace.load(snap, resources=_override())
|
||||
dst._cache.evict_paths([probe])
|
||||
result = asyncio.run(dst.execute(f"cat {probe}"))
|
||||
assert result.stdout == b"original\n", (
|
||||
"pinned read should serve the recorded version, "
|
||||
f"got {result.stdout!r}")
|
||||
finally:
|
||||
_cleanup_key(key)
|
||||
|
||||
|
||||
def test_live_off_policy_serves_current(tmp_path):
|
||||
"""drift_policy=OFF must skip both the drift check and any pin
|
||||
installation, evict the snapshot cache, and serve the live bytes.
|
||||
Smokes the regression where the pin was installed even when the
|
||||
caller explicitly asked for live state.
|
||||
"""
|
||||
key = _probe_key()
|
||||
probe = f"/s3/{key}"
|
||||
client = _boto_client()
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"original\n")
|
||||
try:
|
||||
src = Workspace(_mount(), mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute(f"cat {probe}"))
|
||||
|
||||
snap = tmp_path / "off.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"mutated\n")
|
||||
|
||||
dst = Workspace.load(snap,
|
||||
resources=_override(),
|
||||
drift_policy=DriftPolicy.OFF)
|
||||
assert dst.revisions == {}
|
||||
result = asyncio.run(dst.execute(f"cat {probe}"))
|
||||
assert b"mutated" in result.stdout
|
||||
finally:
|
||||
_cleanup_key(key)
|
||||
|
||||
|
||||
def test_live_stat_populates_revision_when_versioned(tmp_path):
|
||||
"""Smoke: on a versioned bucket, S3Resource.stat must populate
|
||||
FileStat.revision so capture_fingerprints records it. Tightens the
|
||||
contract that downstream pin tests rely on.
|
||||
"""
|
||||
if not _versioning_enabled():
|
||||
pytest.skip("bucket not versioned")
|
||||
key = _probe_key()
|
||||
probe = f"/s3/{key}"
|
||||
client = _boto_client()
|
||||
client.put_object(Bucket=LIVE_BUCKET, Key=key, Body=b"x\n")
|
||||
try:
|
||||
resource = S3Resource(_config())
|
||||
asyncio.run(write_bytes(resource.accessor, probe, b"x\n"))
|
||||
stat = asyncio.run(resource._ops["stat"](resource.accessor, probe))
|
||||
assert stat.fingerprint is not None
|
||||
assert stat.revision is not None, (
|
||||
"versioned bucket head should carry a VersionId")
|
||||
finally:
|
||||
_cleanup_key(key)
|
||||
Reference in New Issue
Block a user