bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
261 lines
9.6 KiB
Python
261 lines
9.6 KiB
Python
# ========= 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 dataclasses
|
|
import time
|
|
from collections.abc import Callable
|
|
|
|
from mirage.io import IOResult
|
|
from mirage.io.types import ByteSource
|
|
from mirage.types import FileType, PathSpec, word_text
|
|
from mirage.utils.path import CycleError, resolve_path
|
|
from mirage.workspace.mount.namespace import Namespace
|
|
from mirage.workspace.session import Session
|
|
from mirage.workspace.types import ExecutionNode
|
|
|
|
|
|
def _split_flags(
|
|
args: list[str | PathSpec],
|
|
known: str,
|
|
) -> tuple[set[str], list[str | PathSpec]]:
|
|
flags: set[str] = set()
|
|
operands: list[str | PathSpec] = []
|
|
parsing = True
|
|
for arg in args:
|
|
s = arg.virtual if isinstance(arg, PathSpec) else str(arg)
|
|
if parsing and s == "--":
|
|
parsing = False
|
|
continue
|
|
if (parsing and s != "-" and len(s) >= 2 and s.startswith("-")
|
|
and all(c in known for c in s[1:])):
|
|
flags.update(s[1:])
|
|
continue
|
|
parsing = False
|
|
operands.append(arg)
|
|
return flags, operands
|
|
|
|
|
|
def link_flags(args: list[str | PathSpec], known: str) -> set[str]:
|
|
flags, _ = _split_flags(args, known)
|
|
return flags
|
|
|
|
|
|
def _abs(arg: str | PathSpec, cwd: str) -> str:
|
|
if isinstance(arg, PathSpec):
|
|
return arg.virtual
|
|
return resolve_path(arg, cwd)
|
|
|
|
|
|
def handle_ln(
|
|
namespace: Namespace,
|
|
session: Session,
|
|
args: list[str | PathSpec],
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
flags, operands = _split_flags(args, "sfnv")
|
|
if len(operands) < 2:
|
|
err = b"ln: missing file operand\n"
|
|
return None, IOResult(exit_code=1,
|
|
stderr=err), ExecutionNode(command="ln",
|
|
exit_code=1,
|
|
stderr=err)
|
|
# GNU: with more than two operands the last must be a directory;
|
|
# namespace links never name directories, so this is always an error
|
|
# (an expanded multi-match glob source lands here).
|
|
if len(operands) > 2:
|
|
err = (f"ln: target '{word_text(operands[-1])}' "
|
|
f"is not a directory\n").encode()
|
|
return None, IOResult(exit_code=1,
|
|
stderr=err), ExecutionNode(command="ln",
|
|
exit_code=1,
|
|
stderr=err)
|
|
link_abs = _abs(operands[1], session.cwd)
|
|
target_typed = word_text(operands[0])
|
|
exists = namespace.is_link(link_abs) and "f" not in flags
|
|
if namespace.is_mount_root(link_abs) or exists:
|
|
err = (f"ln: failed to create symbolic link "
|
|
f"'{word_text(operands[1])}': File exists\n").encode()
|
|
return None, IOResult(exit_code=1,
|
|
stderr=err), ExecutionNode(command="ln",
|
|
exit_code=1,
|
|
stderr=err)
|
|
namespace.symlink(link_abs, target_typed, time.time())
|
|
out = None
|
|
if "v" in flags:
|
|
out = (f"'{word_text(operands[1])}' -> '{target_typed}'\n").encode()
|
|
return out, IOResult(), ExecutionNode(command="ln", exit_code=0)
|
|
|
|
|
|
def follow_paths(
|
|
namespace: Namespace,
|
|
items: list[str | PathSpec],
|
|
) -> list[str | PathSpec]:
|
|
"""Rewrite path operands through the symlink table (open(2) semantics).
|
|
|
|
Non-path items and paths that resolve to themselves pass through
|
|
untouched. A rewritten spec keeps the user-typed form in ``raw_path``
|
|
so error messages still name the operand as typed; the mount re-stamps
|
|
``resource_path`` at dispatch.
|
|
|
|
Args:
|
|
namespace (Namespace): addressing authority holding the link table.
|
|
items (list[str | PathSpec]): classified command parts.
|
|
|
|
Raises:
|
|
CycleError: when a path loops past the hop limit (ELOOP).
|
|
"""
|
|
out: list[str | PathSpec] = []
|
|
for item in items:
|
|
if not isinstance(item, PathSpec):
|
|
out.append(item)
|
|
continue
|
|
try:
|
|
virtual = namespace.follow(item.virtual)
|
|
except CycleError:
|
|
raise CycleError(item.raw_path) from None
|
|
if virtual == item.virtual:
|
|
out.append(item)
|
|
continue
|
|
out.append(
|
|
dataclasses.replace(item,
|
|
virtual=virtual,
|
|
directory=virtual[:virtual.rfind("/") + 1]
|
|
or "/",
|
|
resource_path=""))
|
|
return out
|
|
|
|
|
|
def strip_link_operands(
|
|
namespace: Namespace,
|
|
items: list[str | PathSpec],
|
|
) -> tuple[list[str | PathSpec], int]:
|
|
"""Unlink and drop ``rm`` operands that are symlinks.
|
|
|
|
GNU ``rm`` removes the link itself and never follows it; a dangling
|
|
link removes fine. Remaining operands stay for backend dispatch.
|
|
|
|
Args:
|
|
namespace (Namespace): addressing authority holding the link table.
|
|
items (list[str | PathSpec]): classified command parts.
|
|
|
|
Returns:
|
|
tuple[list[str | PathSpec], int]: surviving parts and the number
|
|
of link entries removed.
|
|
"""
|
|
removed = 0
|
|
kept: list[str | PathSpec] = []
|
|
for item in items:
|
|
if isinstance(item, PathSpec) and namespace.is_link(item.virtual):
|
|
namespace.unlink(item.virtual)
|
|
removed += 1
|
|
continue
|
|
kept.append(item)
|
|
return kept, removed
|
|
|
|
|
|
async def prepare_mv(
|
|
namespace: Namespace,
|
|
dispatch: Callable,
|
|
items: list[str | PathSpec],
|
|
) -> tuple[list[str | PathSpec], str | None, tuple[ByteSource | None, IOResult,
|
|
ExecutionNode] | None]:
|
|
"""Adjust a two-operand ``mv`` for symlink operands.
|
|
|
|
A link source renames the link entry itself (into a destination
|
|
directory when one exists, mirroring rename(2) preceded by mv's dst
|
|
stat). A link destination whose target is a directory is followed
|
|
(mv moves into it); any other link destination is replaced, so its
|
|
entry must drop once the backend move succeeds.
|
|
|
|
Args:
|
|
namespace (Namespace): addressing authority holding the link table.
|
|
dispatch (Callable): op dispatcher used to stat the destination.
|
|
items (list[str | PathSpec]): classified command parts.
|
|
|
|
Returns:
|
|
tuple: (possibly rewritten parts, link path to unlink after a
|
|
successful backend move, early result when the mv completed as a
|
|
pure namespace rename).
|
|
"""
|
|
paths = [p for p in items if isinstance(p, PathSpec)]
|
|
if len(paths) != 2:
|
|
return items, None, None
|
|
src, dst = paths
|
|
|
|
if namespace.is_link(src.virtual):
|
|
target_dst = dst.virtual
|
|
stat = await _stat_or_none(dispatch, dst)
|
|
if stat is not None and stat.type == FileType.DIRECTORY:
|
|
target_dst = (dst.virtual.rstrip("/") + "/" +
|
|
src.virtual.rsplit("/", 1)[-1])
|
|
namespace.unlink(target_dst)
|
|
namespace.rename(src.virtual, target_dst)
|
|
return items, None, (None, IOResult(),
|
|
ExecutionNode(command="mv", exit_code=0))
|
|
|
|
if namespace.is_link(dst.virtual):
|
|
followed = namespace.follow(dst.virtual)
|
|
stat = await _stat_or_none(dispatch, PathSpec.from_str_path(followed))
|
|
if stat is not None and stat.type == FileType.DIRECTORY:
|
|
return follow_paths(namespace, items), None, None
|
|
return items, dst.virtual, None
|
|
|
|
return items, None, None
|
|
|
|
|
|
async def _stat_or_none(dispatch: Callable, path: PathSpec):
|
|
"""Stat a path via dispatch, mapping a missing file to ``None``.
|
|
|
|
Args:
|
|
dispatch (Callable): op dispatcher.
|
|
path (PathSpec): path to stat.
|
|
"""
|
|
# A missing destination is an expected mv case (plain rename), not an
|
|
# error to surface.
|
|
try:
|
|
stat, _ = await dispatch("stat", path)
|
|
except FileNotFoundError:
|
|
return None
|
|
return stat
|
|
|
|
|
|
def handle_readlink(
|
|
namespace: Namespace,
|
|
session: Session,
|
|
args: list[str | PathSpec],
|
|
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
|
|
flags, operands = _split_flags(args, "fenm")
|
|
if not operands:
|
|
err = b"readlink: missing operand\n"
|
|
return None, IOResult(exit_code=1,
|
|
stderr=err), ExecutionNode(command="readlink",
|
|
exit_code=1,
|
|
stderr=err)
|
|
lines: list[str] = []
|
|
exit_code = 0
|
|
for op in operands:
|
|
target = namespace.readlink(_abs(op, session.cwd))
|
|
if target is None:
|
|
exit_code = 1
|
|
continue
|
|
lines.append(target)
|
|
if not lines:
|
|
return None, IOResult(exit_code=exit_code), ExecutionNode(
|
|
command="readlink", exit_code=exit_code)
|
|
if "n" in flags:
|
|
text = "".join(lines)
|
|
else:
|
|
text = "".join(line + "\n" for line in lines)
|
|
return text.encode(), IOResult(exit_code=exit_code), ExecutionNode(
|
|
command="readlink", exit_code=exit_code)
|