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
240 lines
7.7 KiB
Python
240 lines
7.7 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 posixpath
|
|
|
|
|
|
def norm(path: str) -> str:
|
|
"""Normalize a virtual path to a leading-slash, no-trailing-slash key.
|
|
|
|
Args:
|
|
path: A virtual path string.
|
|
|
|
Returns:
|
|
The path with surrounding slashes collapsed to a single leading
|
|
slash (``"foo/bar/"`` -> ``"/foo/bar"``, ``""`` -> ``"/"``).
|
|
"""
|
|
return "/" + path.strip("/")
|
|
|
|
|
|
def parent(path: str) -> str:
|
|
"""Return the parent directory of a normalized virtual key.
|
|
|
|
Args:
|
|
path: A normalized virtual path (leading slash, no trailing slash).
|
|
|
|
Returns:
|
|
The path with its last segment removed (``"/a/b"`` -> ``"/a"``),
|
|
or ``"/"`` when there is no parent segment.
|
|
"""
|
|
i = path.rfind("/")
|
|
return path[:i] if i > 0 else "/"
|
|
|
|
|
|
def resolve_path(path: str, cwd: str) -> str:
|
|
"""Resolve a relative path against cwd.
|
|
|
|
Example::
|
|
|
|
resolve_path("../file.txt", "/data/sub/")
|
|
→ "/data/file.txt"
|
|
resolve_path("/abs/path", "/ignored")
|
|
→ "/abs/path"
|
|
"""
|
|
if not path.startswith("/"):
|
|
path = cwd.rstrip("/") + "/" + path
|
|
resolved = posixpath.normpath(path)
|
|
if resolved.startswith("//"):
|
|
resolved = "/" + resolved.lstrip("/")
|
|
return resolved
|
|
|
|
|
|
MAX_SYMLINK_HOPS = 40
|
|
|
|
|
|
class CycleError(Exception):
|
|
"""Raised when symlink resolution exceeds the maximum hop count.
|
|
|
|
Mirrors POSIX ELOOP (a loop such as ``a -> b -> a`` or an unbounded
|
|
expansion such as ``a -> a/x``). Command boundaries render this as the
|
|
GNU ``strerror`` text "Too many levels of symbolic links".
|
|
"""
|
|
|
|
|
|
def _is_link_prefix(key: str, path: str) -> bool:
|
|
return path == key or path.startswith(key + "/")
|
|
|
|
|
|
def resolve_symlinks(path: str, links: dict[str, str]) -> str:
|
|
"""Resolve symlink prefixes in ``path`` until stable.
|
|
|
|
Repeatedly replaces the longest dict key that is a path-boundary prefix
|
|
of ``path`` with its target, mirroring filesystem symlink following
|
|
(``/a/b`` is a prefix of ``/a/b/c`` but not ``/a/bc``). Relative targets
|
|
are resolved against the link's own parent directory.
|
|
|
|
Args:
|
|
path (str): An absolute virtual path.
|
|
links (dict[str, str]): Map of link virtual-path to target.
|
|
|
|
Returns:
|
|
str: The path with all symlink prefixes resolved.
|
|
|
|
Raises:
|
|
CycleError: If resolution exceeds ``MAX_SYMLINK_HOPS`` (a loop or
|
|
unbounded expansion), matching POSIX ELOOP.
|
|
"""
|
|
if not links:
|
|
return path
|
|
for _ in range(MAX_SYMLINK_HOPS):
|
|
best: str | None = None
|
|
for key in links:
|
|
if _is_link_prefix(key, path) and (best is None
|
|
or len(key) > len(best)):
|
|
best = key
|
|
if best is None:
|
|
return path
|
|
target = links[best]
|
|
if not target.startswith("/"):
|
|
target = norm(parent(best) + "/" + target)
|
|
path = target + path[len(best):]
|
|
raise CycleError(path)
|
|
|
|
|
|
def expand_tilde(word: str, home: str | None) -> str:
|
|
"""Expand a leading ``~`` against the home directory.
|
|
|
|
``~`` alone or ``~/rest`` expands to ``home`` (or ``home/rest``).
|
|
``~user`` and any non-leading ``~`` are left unchanged, matching
|
|
bash behavior when no matching user exists. When ``home`` is ``None``
|
|
(``$HOME`` unset/empty), a leading ``~`` is left literal, mirroring
|
|
GNU bash with no home directory.
|
|
|
|
Args:
|
|
word: The unexpanded word.
|
|
home: The home directory to substitute for ``~``, or ``None``.
|
|
|
|
Returns:
|
|
The word with a leading ``~`` resolved, or the word unchanged.
|
|
"""
|
|
if home is None:
|
|
return word
|
|
if word == "~":
|
|
return home
|
|
if word.startswith("~/"):
|
|
return home.rstrip("/") + word[1:]
|
|
return word
|
|
|
|
|
|
def rebase_raw(paths: list[str], original: str, raw: str) -> list[str]:
|
|
"""Rewrite the base of walked output paths to the as-typed form.
|
|
|
|
Used by walkers like ``find``/``grep -r``: results are absolute (start
|
|
path plus subpath), but when the start path was typed relatively the
|
|
output should show it that way. Maps :func:`rebase_one` over ``paths``.
|
|
|
|
Because :func:`rebase_one` only rewrites the leading base prefix, this
|
|
also works on formatted lines whose path is the prefix, e.g. grep's
|
|
``path:line``.
|
|
|
|
Example::
|
|
|
|
rebase_raw(["/data/sub/x", "/data/y"], "/data", ".")
|
|
-> ["./sub/x", "./y"]
|
|
rebase_raw(["/data/sub/x:hit"], "/data/sub", "sub")
|
|
-> ["sub/x:hit"]
|
|
rebase_raw(["/data/x"], "/data", "/data") # absolute arg
|
|
-> ["/data/x"] # unchanged
|
|
|
|
Args:
|
|
paths (list[str]): Absolute result paths (or ``path:...`` lines)
|
|
produced by walking ``original``.
|
|
original (str): The resolved absolute start path.
|
|
raw (str): The as-typed start path (``PathSpec.raw_path``); equal
|
|
to ``original`` leaves ``paths`` unchanged (the
|
|
absolute-argument case).
|
|
|
|
Returns:
|
|
list[str]: ``paths`` with each ``original`` base replaced by
|
|
``raw``.
|
|
"""
|
|
if raw == original:
|
|
return paths
|
|
return [rebase_one(p, original, raw) for p in paths]
|
|
|
|
|
|
def rebase_one(path: str, original: str, raw: str) -> str:
|
|
"""Rewrite a single path's ``original`` base to the as-typed ``raw``.
|
|
|
|
Only the leading ``original`` prefix is rewritten, so any suffix after
|
|
the path (e.g. grep's ``:line``) is preserved untouched.
|
|
|
|
Example::
|
|
|
|
rebase_one("/data/sub/x", "/data", ".") -> "./sub/x"
|
|
rebase_one("/data/sub", "/data/sub", "sub") -> "sub"
|
|
rebase_one("/data/x:hit", "/data", ".") -> "./x:hit"
|
|
rebase_one("/other/x", "/data", ".") -> "/other/x" # no match
|
|
rebase_one("/data/x", "/data", "/data") -> "/data/x" # absolute
|
|
|
|
Args:
|
|
path (str): An absolute path at or under ``original`` (optionally with
|
|
a trailing ``:...`` suffix).
|
|
original (str): The resolved absolute base (traversal root).
|
|
raw (str): The as-typed base (``PathSpec.raw_path``); equal to
|
|
``original`` leaves ``path`` unchanged.
|
|
|
|
Returns:
|
|
str: ``path`` with its ``original`` base replaced by ``raw``.
|
|
"""
|
|
if raw == original:
|
|
return path
|
|
base = original.rstrip("/")
|
|
if path == base:
|
|
return raw
|
|
if path.startswith(base + "/"):
|
|
return raw.rstrip("/") + path[len(base):]
|
|
return path
|
|
|
|
|
|
def gnu_basename(path: str, suffix: str | None = None) -> str:
|
|
i = len(path)
|
|
while i > 0 and path[i - 1] == "/":
|
|
i -= 1
|
|
if i == 0:
|
|
return "/" if path else ""
|
|
j = path.rfind("/", 0, i)
|
|
base = path[j + 1:i]
|
|
if suffix and base != suffix and base.endswith(suffix):
|
|
base = base[:len(base) - len(suffix)]
|
|
return base
|
|
|
|
|
|
def gnu_dirname(path: str) -> str:
|
|
if path == "":
|
|
return "."
|
|
i = len(path)
|
|
while i > 0 and path[i - 1] == "/":
|
|
i -= 1
|
|
if i == 0:
|
|
return "/"
|
|
j = path.rfind("/", 0, i)
|
|
if j == -1:
|
|
return "."
|
|
while j > 0 and path[j - 1] == "/":
|
|
j -= 1
|
|
if j == 0:
|
|
return "/"
|
|
return path[:j]
|