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
175 lines
5.9 KiB
Python
175 lines
5.9 KiB
Python
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from mirage.cache.index import IndexCacheStore, IndexEntry
|
|
from mirage.core.dify._client import list_all_documents
|
|
from mirage.utils.path import gnu_basename, parent
|
|
|
|
|
|
async def ensure_tree(accessor,
|
|
index: IndexCacheStore,
|
|
prefix: str = "") -> None:
|
|
root_key = mount_root(prefix)
|
|
listing = await index.list_dir(root_key)
|
|
if listing.entries is not None:
|
|
return
|
|
|
|
# list_all_documents already filters to visible documents.
|
|
documents = await list_all_documents(accessor.config)
|
|
dir_entries = build_dir_entries(
|
|
documents,
|
|
prefix,
|
|
accessor.config.slug_metadata_name,
|
|
)
|
|
for directory in sorted(dir_entries):
|
|
await index.set_dir(
|
|
directory, sorted(dir_entries[directory],
|
|
key=lambda item: item[0]))
|
|
|
|
|
|
def build_dir_entries(
|
|
documents: list[dict[str, Any]],
|
|
prefix: str,
|
|
slug_metadata_name: str = "slug",
|
|
) -> dict[str, list[tuple[str, IndexEntry]]]:
|
|
files: dict[str, dict[str, Any]] = {}
|
|
raw_slugs: dict[str, str] = {}
|
|
has_slugs: dict[str, bool] = {}
|
|
for document in documents:
|
|
slug, has_slug = extract_slug(document, slug_metadata_name)
|
|
path = normalize_slug(slug)
|
|
if path in files:
|
|
value = path.strip("/")
|
|
raise ValueError(
|
|
f"Duplicate {slug_metadata_name} '{value}': documents "
|
|
f"'{files[path].get('id')}' and '{document.get('id')}' share "
|
|
"the same path.")
|
|
files[path] = document
|
|
raw_slugs[path] = str(slug)
|
|
has_slugs[path] = has_slug
|
|
|
|
raise_on_collisions(files)
|
|
directories = collect_directories(set(files))
|
|
dir_entries: dict[str, list[tuple[str, IndexEntry]]] = {
|
|
virtual_path(directory, prefix): []
|
|
for directory in directories
|
|
}
|
|
for directory in sorted(directories):
|
|
if directory == "/":
|
|
continue
|
|
entry = IndexEntry(
|
|
id=directory.strip("/"),
|
|
name=gnu_basename(directory),
|
|
resource_type="folder",
|
|
)
|
|
dir_entries[virtual_path(parent(directory), prefix)].append(
|
|
(entry.name, entry))
|
|
|
|
for path, document in sorted(files.items()):
|
|
entry = IndexEntry(
|
|
id=str(document["id"]),
|
|
name=gnu_basename(path),
|
|
resource_type="file",
|
|
size=extract_document_size(document),
|
|
remote_time=timestamp_to_iso(document.get("created_at")),
|
|
extra={
|
|
"slug": path.strip("/"),
|
|
"slug_metadata_name": slug_metadata_name,
|
|
"raw_slug": raw_slugs[path],
|
|
"has_slug": has_slugs[path],
|
|
"tokens": document.get("tokens"),
|
|
"indexing_status": document.get("indexing_status"),
|
|
"data_source_type": document.get("data_source_type"),
|
|
},
|
|
)
|
|
dir_entries[virtual_path(parent(path), prefix)].append(
|
|
(entry.name, entry))
|
|
return dir_entries
|
|
|
|
|
|
def extract_slug(document: dict[str, Any],
|
|
slug_metadata_name: str = "slug") -> tuple[str, bool]:
|
|
metadata = document.get("doc_metadata")
|
|
if isinstance(metadata, list):
|
|
for item in metadata:
|
|
if (isinstance(item, dict)
|
|
and item.get("name") == slug_metadata_name):
|
|
value = item.get("value")
|
|
if value is not None:
|
|
return str(value), True
|
|
if (isinstance(metadata, dict)
|
|
and metadata.get(slug_metadata_name) is not None):
|
|
return str(metadata[slug_metadata_name]), True
|
|
return str(document["name"]), False
|
|
|
|
|
|
def normalize_slug(value: str) -> str:
|
|
parts = [part for part in value.strip("/").split("/") if part]
|
|
if not parts:
|
|
raise ValueError("Invalid empty Dify document slug.")
|
|
invalid = {".", ".."}
|
|
for part in parts:
|
|
if part in invalid:
|
|
raise ValueError(f"Invalid Dify document slug segment: {part!r}")
|
|
return "/" + "/".join(parts)
|
|
|
|
|
|
def raise_on_collisions(files: dict[str, dict[str, Any]]) -> None:
|
|
paths = set(files)
|
|
for path in sorted(paths):
|
|
parts = path.strip("/").split("/")
|
|
for index in range(1, len(parts)):
|
|
ancestor = "/" + "/".join(parts[:index])
|
|
if ancestor in paths:
|
|
raise ValueError(
|
|
"Path collision: document "
|
|
f"'{files[ancestor].get('id')}' uses file path "
|
|
f"'{ancestor.strip('/')}' but document "
|
|
f"'{files[path].get('id')}' requires it as a directory "
|
|
"prefix.")
|
|
|
|
|
|
def collect_directories(paths: set[str]) -> set[str]:
|
|
directories = {"/"}
|
|
for path in paths:
|
|
parts = path.strip("/").split("/")
|
|
for index in range(1, len(parts)):
|
|
directories.add("/" + "/".join(parts[:index]))
|
|
return directories
|
|
|
|
|
|
def extract_document_size(document: dict[str, Any]) -> int | None:
|
|
candidates = (
|
|
document.get("data_source_detail_dict"),
|
|
document.get("data_source_info"),
|
|
)
|
|
for candidate in candidates:
|
|
if isinstance(candidate, dict):
|
|
upload_file = candidate.get("upload_file")
|
|
if isinstance(upload_file, dict):
|
|
size = upload_file.get("size")
|
|
if isinstance(size, int):
|
|
return size
|
|
return None
|
|
|
|
|
|
def timestamp_to_iso(value: object) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, (int, float)):
|
|
return datetime.fromtimestamp(value, timezone.utc).isoformat()
|
|
return str(value)
|
|
|
|
|
|
def mount_root(prefix: str) -> str:
|
|
return prefix.rstrip("/") or "/"
|
|
|
|
|
|
def virtual_path(path: str, prefix: str) -> str:
|
|
root = mount_root(prefix)
|
|
if path == "/":
|
|
return root
|
|
if root == "/":
|
|
return path
|
|
return root + path
|