- Rewrite test_filevault.py for new API (depth=9, hex pairs, content-addressable) - Rewrite test_async_filevault.py to import from filevault module - Fix test_screenshot.py defaults (enabled=True, 1024x768) - Add backward compatibility exports to filevault.py - All 196 tests passing
661 lines
22 KiB
Python
661 lines
22 KiB
Python
"""
|
|
FileVault 2.0.0 - Hash-based file storage with sync and async support
|
|
|
|
A Vault manages a hash directory tree of files on a filesystem.
|
|
|
|
Features:
|
|
- Content-addressable storage (store by MD5 hash)
|
|
- Seed-based deterministic paths (store by arbitrary key)
|
|
- Configurable directory depth with hex pairs (256 dirs/level)
|
|
- Thread-safe file locking (fcntl)
|
|
- Atomic write operations
|
|
- Optional in-memory existence cache
|
|
- Both sync (Vault) and async (AsyncVault) implementations
|
|
|
|
Usage:
|
|
# Sync
|
|
vault = Vault("vault", depth=9)
|
|
path = vault.store(md5_hash, data, ".jpg")
|
|
data = vault.get(md5_hash)
|
|
|
|
# Async
|
|
vault = AsyncVault("vault", depth=9)
|
|
await vault.init()
|
|
path = await vault.store(md5_hash, data, ".jpg")
|
|
data = await vault.get(md5_hash)
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from hashlib import md5, sha256
|
|
from os import makedirs, path, rename
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Union
|
|
from uuid import uuid4
|
|
|
|
import aiofiles
|
|
import aiofiles.os
|
|
|
|
__version__ = "2.0.0"
|
|
__author__ = "Russell Ballestrini"
|
|
|
|
|
|
def _ensure_bytes(s: Union[str, bytes]) -> bytes:
|
|
"""Convert string to bytes if needed."""
|
|
if isinstance(s, str):
|
|
return s.encode("utf-8")
|
|
return s
|
|
|
|
|
|
def _hash_to_path(h: str, depth: int, ext: str = "") -> Path:
|
|
"""
|
|
Convert hash to directory path using hex pairs.
|
|
|
|
Example (depth=9): abcdef123456... -> ab/cd/ef/12/34/56/78/9a/bc/abcdef123456...ext
|
|
"""
|
|
parts = [h[i:i+2] for i in range(0, depth * 2, 2)]
|
|
if ext and not ext.startswith('.'):
|
|
ext = '.' + ext
|
|
return Path(*parts) / f"{h}{ext}"
|
|
|
|
|
|
class Vault:
|
|
"""
|
|
Synchronous hash-based file storage.
|
|
|
|
Supports two modes:
|
|
1. Content-addressable: store(hash, data) - use content's MD5 as key
|
|
2. Seed-based: create_filename(seed) - deterministic path from arbitrary seed
|
|
|
|
Directory structure uses hex pairs for 256 subdirs per level:
|
|
vault/ab/cd/ef/.../hash.ext
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
vaultpath: str = "vault",
|
|
depth: int = 9,
|
|
salt: str = "neopig",
|
|
enable_memory_cache: bool = False,
|
|
):
|
|
"""
|
|
Initialize vault.
|
|
|
|
Args:
|
|
vaultpath: Base directory for storage
|
|
depth: Directory tree depth (default 9 = 18 hex chars used)
|
|
salt: Salt for seed-based hashing
|
|
enable_memory_cache: Cache file existence checks in memory
|
|
"""
|
|
self.vaultpath = Path(vaultpath)
|
|
self.depth = depth
|
|
self.salt = _ensure_bytes(salt)
|
|
self.enable_memory_cache = enable_memory_cache
|
|
self._cache: Optional[Dict[str, bool]] = {} if enable_memory_cache else None
|
|
|
|
def init(self) -> None:
|
|
"""Create vault base directory."""
|
|
self.vaultpath.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Backwards compatibility
|
|
init_vault = init
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Content-Addressable Storage (by hash)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _get_hash_path(self, h: str, ext: str = "") -> Path:
|
|
"""Get full path for a content hash."""
|
|
return self.vaultpath / _hash_to_path(h, self.depth, ext)
|
|
|
|
def _find_by_hash(self, h: str) -> Optional[Path]:
|
|
"""Find file by hash prefix (any extension)."""
|
|
subdir = self._get_hash_path(h).parent
|
|
if not subdir.exists():
|
|
return None
|
|
for f in subdir.iterdir():
|
|
if f.name.startswith(h):
|
|
return f
|
|
return None
|
|
|
|
def store(self, content_hash: str, data: bytes, ext: str = "") -> Path:
|
|
"""
|
|
Store data by content hash.
|
|
|
|
Args:
|
|
content_hash: MD5 hash of the data (32 hex chars)
|
|
data: Raw bytes to store
|
|
ext: File extension (e.g., ".jpg" or "jpg")
|
|
|
|
Returns:
|
|
Path to stored file
|
|
"""
|
|
filepath = self._get_hash_path(content_hash, ext)
|
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(filepath, 'wb') as f:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
f.write(data)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
|
|
self._cache_set(str(filepath), True)
|
|
return filepath
|
|
|
|
def exists(self, content_hash: str) -> bool:
|
|
"""Check if content exists by hash."""
|
|
return self._find_by_hash(content_hash) is not None
|
|
|
|
def get(self, content_hash: str) -> Optional[bytes]:
|
|
"""Retrieve data by content hash."""
|
|
filepath = self._find_by_hash(content_hash)
|
|
if filepath is None:
|
|
return None
|
|
with open(filepath, 'rb') as f:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|
return f.read()
|
|
|
|
def get_path(self, content_hash: str) -> Optional[Path]:
|
|
"""Get filesystem path for stored content."""
|
|
return self._find_by_hash(content_hash)
|
|
|
|
def delete(self, content_hash: str) -> bool:
|
|
"""Delete content by hash. Returns True if deleted."""
|
|
filepath = self._find_by_hash(content_hash)
|
|
if filepath is None:
|
|
return False
|
|
filepath.unlink()
|
|
self._cache_set(str(filepath), False)
|
|
return True
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Seed-Based Storage (deterministic paths from arbitrary keys)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def create_filename(self, seed: str, ext: str = "", absolute: bool = True) -> str:
|
|
"""
|
|
Create deterministic path from seed string.
|
|
|
|
Args:
|
|
seed: Arbitrary string to hash
|
|
ext: File extension
|
|
absolute: Include vault base path
|
|
|
|
Returns:
|
|
Path string
|
|
"""
|
|
h = sha256(_ensure_bytes(seed) + self.salt).hexdigest()
|
|
rel_path = _hash_to_path(h, self.depth, ext)
|
|
if absolute:
|
|
return str(self.vaultpath / rel_path)
|
|
return str(rel_path)
|
|
|
|
def create_random_filename(self, ext: str = "", absolute: bool = True) -> str:
|
|
"""Create random vault path."""
|
|
return self.create_filename(str(uuid4()), ext, absolute)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# File Operations with Locking
|
|
# -------------------------------------------------------------------------
|
|
|
|
@contextlib.contextmanager
|
|
def file_lock(self, file_path: str, mode: str = "r"):
|
|
"""
|
|
Context manager for locked file access.
|
|
|
|
Args:
|
|
file_path: Path to file
|
|
mode: Open mode ('r', 'w', 'a', etc.)
|
|
|
|
Yields:
|
|
Locked file object
|
|
"""
|
|
p = Path(file_path)
|
|
if "w" in mode or "a" in mode:
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
lock_type = fcntl.LOCK_EX if ("w" in mode or "a" in mode) else fcntl.LOCK_SH
|
|
|
|
with open(file_path, mode) as f:
|
|
try:
|
|
fcntl.flock(f.fileno(), lock_type)
|
|
yield f
|
|
finally:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
|
|
def write_bytes(self, file_path: str, data: bytes) -> None:
|
|
"""Write bytes with locking."""
|
|
p = Path(file_path)
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(file_path, 'wb') as f:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
f.write(data)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
self._cache_set(file_path, True)
|
|
|
|
def read_bytes(self, file_path: str) -> Optional[bytes]:
|
|
"""Read bytes with locking."""
|
|
if not path.exists(file_path):
|
|
return None
|
|
with open(file_path, 'rb') as f:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|
return f.read()
|
|
|
|
def write_text(self, file_path: str, content: str) -> None:
|
|
"""Write text with locking."""
|
|
p = Path(file_path)
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(file_path, 'w') as f:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
f.write(content)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
self._cache_set(file_path, True)
|
|
|
|
def read_text(self, file_path: str, default: str = None) -> Optional[str]:
|
|
"""Read text with locking."""
|
|
if not path.exists(file_path):
|
|
return default
|
|
with open(file_path, 'r') as f:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|
return f.read()
|
|
|
|
def write_json(self, file_path: str, data: Any, indent: int = 2) -> None:
|
|
"""Atomically write JSON with temp file + rename."""
|
|
p = Path(file_path)
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
fd, temp_path = tempfile.mkstemp(
|
|
dir=str(p.parent),
|
|
prefix=f".tmp_{p.name}_",
|
|
suffix=".json"
|
|
)
|
|
try:
|
|
with open(temp_path, 'w') as f:
|
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
json.dump(data, f, indent=indent)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
rename(temp_path, file_path)
|
|
self._cache_set(file_path, True)
|
|
except Exception:
|
|
try:
|
|
os.unlink(temp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
finally:
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
|
|
def read_json(self, file_path: str, default: Any = None) -> Any:
|
|
"""Read JSON with locking."""
|
|
if not path.exists(file_path):
|
|
return default
|
|
try:
|
|
with self.file_lock(file_path, 'r') as f:
|
|
return json.load(f)
|
|
except (json.JSONDecodeError, IOError):
|
|
return default
|
|
|
|
def remove(self, file_path: str) -> bool:
|
|
"""Remove file. Returns True if removed."""
|
|
if not path.exists(file_path):
|
|
self._cache_set(file_path, False)
|
|
return False
|
|
os.remove(file_path)
|
|
self._cache_set(file_path, False)
|
|
return True
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Existence Cache
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _cache_set(self, file_path: str, exists: bool) -> None:
|
|
"""Update existence cache."""
|
|
if self._cache is not None:
|
|
self._cache[file_path] = exists
|
|
|
|
def _cache_get(self, file_path: str) -> Optional[bool]:
|
|
"""Check existence cache."""
|
|
if self._cache is not None:
|
|
return self._cache.get(file_path)
|
|
return None
|
|
|
|
def file_exists(self, file_path: str) -> bool:
|
|
"""Check if file exists (uses cache if enabled)."""
|
|
cached = self._cache_get(file_path)
|
|
if cached is not None:
|
|
return cached
|
|
exists = path.exists(file_path)
|
|
self._cache_set(file_path, exists)
|
|
return exists
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Clear existence cache."""
|
|
if self._cache is not None:
|
|
self._cache.clear()
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Statistics
|
|
# -------------------------------------------------------------------------
|
|
|
|
def stats(self) -> Dict[str, Any]:
|
|
"""Get vault statistics."""
|
|
count = 0
|
|
total_size = 0
|
|
for f in self.vaultpath.rglob('*'):
|
|
if f.is_file():
|
|
count += 1
|
|
total_size += f.stat().st_size
|
|
return {
|
|
'path': str(self.vaultpath),
|
|
'depth': self.depth,
|
|
'count': count,
|
|
'total_size': total_size,
|
|
'cache_enabled': self.enable_memory_cache,
|
|
'cache_entries': len(self._cache) if self._cache else 0,
|
|
}
|
|
|
|
# Backwards compatibility aliases
|
|
atomic_write_json = write_json
|
|
safe_read_json = read_json
|
|
write_text_file = write_text
|
|
read_text_file = read_text
|
|
remove_file = remove
|
|
mark_file_exists = _cache_set
|
|
|
|
|
|
class AsyncVault:
|
|
"""
|
|
Asynchronous hash-based file storage.
|
|
|
|
Same API as Vault but with async/await. Uses aiofiles for I/O
|
|
and asyncio.to_thread for CPU-bound operations.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
vaultpath: str = "vault",
|
|
depth: int = 9,
|
|
salt: str = "neopig",
|
|
enable_memory_cache: bool = False,
|
|
):
|
|
"""
|
|
Initialize async vault.
|
|
|
|
Args:
|
|
vaultpath: Base directory for storage
|
|
depth: Directory tree depth (default 9)
|
|
salt: Salt for seed-based hashing
|
|
enable_memory_cache: Cache file existence checks in memory
|
|
"""
|
|
self.vaultpath = Path(vaultpath)
|
|
self.depth = depth
|
|
self.salt = _ensure_bytes(salt)
|
|
self.enable_memory_cache = enable_memory_cache
|
|
self._cache: Optional[Dict[str, bool]] = {} if enable_memory_cache else None
|
|
self._initialized = False
|
|
|
|
async def init(self) -> None:
|
|
"""Create vault base directory."""
|
|
if self._initialized:
|
|
return
|
|
await aiofiles.os.makedirs(self.vaultpath, exist_ok=True)
|
|
self._initialized = True
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Content-Addressable Storage (by hash)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _get_hash_path(self, h: str, ext: str = "") -> Path:
|
|
"""Get full path for a content hash."""
|
|
return self.vaultpath / _hash_to_path(h, self.depth, ext)
|
|
|
|
async def _find_by_hash(self, h: str) -> Optional[Path]:
|
|
"""Find file by hash prefix (any extension)."""
|
|
subdir = self._get_hash_path(h).parent
|
|
|
|
def _find():
|
|
if not subdir.exists():
|
|
return None
|
|
for f in subdir.iterdir():
|
|
if f.name.startswith(h):
|
|
return f
|
|
return None
|
|
|
|
return await asyncio.to_thread(_find)
|
|
|
|
async def store(self, content_hash: str, data: bytes, ext: str = "") -> Path:
|
|
"""
|
|
Store data by content hash.
|
|
|
|
Args:
|
|
content_hash: MD5 hash of the data (32 hex chars)
|
|
data: Raw bytes to store
|
|
ext: File extension (e.g., ".jpg" or "jpg")
|
|
|
|
Returns:
|
|
Path to stored file
|
|
"""
|
|
filepath = self._get_hash_path(content_hash, ext)
|
|
await aiofiles.os.makedirs(filepath.parent, exist_ok=True)
|
|
|
|
async with aiofiles.open(filepath, 'wb') as f:
|
|
await f.write(data)
|
|
|
|
self._cache_set(str(filepath), True)
|
|
return filepath
|
|
|
|
async def exists(self, content_hash: str) -> bool:
|
|
"""Check if content exists by hash."""
|
|
return await self._find_by_hash(content_hash) is not None
|
|
|
|
async def get(self, content_hash: str) -> Optional[bytes]:
|
|
"""Retrieve data by content hash."""
|
|
filepath = await self._find_by_hash(content_hash)
|
|
if filepath is None:
|
|
return None
|
|
async with aiofiles.open(filepath, 'rb') as f:
|
|
return await f.read()
|
|
|
|
async def get_path(self, content_hash: str) -> Optional[Path]:
|
|
"""Get filesystem path for stored content."""
|
|
return await self._find_by_hash(content_hash)
|
|
|
|
async def delete(self, content_hash: str) -> bool:
|
|
"""Delete content by hash. Returns True if deleted."""
|
|
filepath = await self._find_by_hash(content_hash)
|
|
if filepath is None:
|
|
return False
|
|
await aiofiles.os.remove(filepath)
|
|
self._cache_set(str(filepath), False)
|
|
return True
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Seed-Based Storage
|
|
# -------------------------------------------------------------------------
|
|
|
|
def create_filename(self, seed: str, ext: str = "", absolute: bool = True) -> str:
|
|
"""Create deterministic path from seed string."""
|
|
h = sha256(_ensure_bytes(seed) + self.salt).hexdigest()
|
|
rel_path = _hash_to_path(h, self.depth, ext)
|
|
if absolute:
|
|
return str(self.vaultpath / rel_path)
|
|
return str(rel_path)
|
|
|
|
def create_random_filename(self, ext: str = "", absolute: bool = True) -> str:
|
|
"""Create random vault path."""
|
|
return self.create_filename(str(uuid4()), ext, absolute)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# File Operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def write_bytes(self, file_path: str, data: bytes) -> None:
|
|
"""Write bytes."""
|
|
p = Path(file_path)
|
|
await aiofiles.os.makedirs(p.parent, exist_ok=True)
|
|
async with aiofiles.open(file_path, 'wb') as f:
|
|
await f.write(data)
|
|
self._cache_set(file_path, True)
|
|
|
|
async def read_bytes(self, file_path: str) -> Optional[bytes]:
|
|
"""Read bytes."""
|
|
if not await aiofiles.os.path.exists(file_path):
|
|
return None
|
|
async with aiofiles.open(file_path, 'rb') as f:
|
|
return await f.read()
|
|
|
|
async def write_text(self, file_path: str, content: str) -> None:
|
|
"""Write text."""
|
|
p = Path(file_path)
|
|
await aiofiles.os.makedirs(p.parent, exist_ok=True)
|
|
async with aiofiles.open(file_path, 'w') as f:
|
|
await f.write(content)
|
|
self._cache_set(file_path, True)
|
|
|
|
async def read_text(self, file_path: str, default: str = None) -> Optional[str]:
|
|
"""Read text."""
|
|
if not await aiofiles.os.path.exists(file_path):
|
|
return default
|
|
async with aiofiles.open(file_path, 'r') as f:
|
|
return await f.read()
|
|
|
|
async def write_json(self, file_path: str, data: Any, indent: int = 2) -> None:
|
|
"""Atomically write JSON."""
|
|
p = Path(file_path)
|
|
await aiofiles.os.makedirs(p.parent, exist_ok=True)
|
|
|
|
# Create temp file
|
|
fd, temp_path = await asyncio.to_thread(
|
|
tempfile.mkstemp,
|
|
dir=str(p.parent),
|
|
prefix=f".tmp_{p.name}_",
|
|
suffix=".json"
|
|
)
|
|
try:
|
|
async with aiofiles.open(temp_path, 'w') as f:
|
|
await f.write(json.dumps(data, indent=indent))
|
|
|
|
await asyncio.to_thread(rename, temp_path, file_path)
|
|
self._cache_set(file_path, True)
|
|
except Exception:
|
|
try:
|
|
await aiofiles.os.remove(temp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
finally:
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
|
|
async def read_json(self, file_path: str, default: Any = None) -> Any:
|
|
"""Read JSON."""
|
|
if not await aiofiles.os.path.exists(file_path):
|
|
return default
|
|
try:
|
|
async with aiofiles.open(file_path, 'r') as f:
|
|
return json.loads(await f.read())
|
|
except (json.JSONDecodeError, IOError):
|
|
return default
|
|
|
|
async def remove(self, file_path: str) -> bool:
|
|
"""Remove file. Returns True if removed."""
|
|
if not await aiofiles.os.path.exists(file_path):
|
|
self._cache_set(file_path, False)
|
|
return False
|
|
await aiofiles.os.remove(file_path)
|
|
self._cache_set(file_path, False)
|
|
return True
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Existence Cache
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _cache_set(self, file_path: str, exists: bool) -> None:
|
|
"""Update existence cache."""
|
|
if self._cache is not None:
|
|
self._cache[file_path] = exists
|
|
|
|
def _cache_get(self, file_path: str) -> Optional[bool]:
|
|
"""Check existence cache."""
|
|
if self._cache is not None:
|
|
return self._cache.get(file_path)
|
|
return None
|
|
|
|
async def file_exists(self, file_path: str) -> bool:
|
|
"""Check if file exists (uses cache if enabled)."""
|
|
cached = self._cache_get(file_path)
|
|
if cached is not None:
|
|
return cached
|
|
exists = await aiofiles.os.path.exists(file_path)
|
|
self._cache_set(file_path, exists)
|
|
return exists
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Clear existence cache."""
|
|
if self._cache is not None:
|
|
self._cache.clear()
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Statistics
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def stats(self) -> Dict[str, Any]:
|
|
"""Get vault statistics."""
|
|
def _count():
|
|
count = 0
|
|
total_size = 0
|
|
for f in self.vaultpath.rglob('*'):
|
|
if f.is_file():
|
|
count += 1
|
|
total_size += f.stat().st_size
|
|
return count, total_size
|
|
|
|
count, total_size = await asyncio.to_thread(_count)
|
|
return {
|
|
'path': str(self.vaultpath),
|
|
'depth': self.depth,
|
|
'count': count,
|
|
'total_size': total_size,
|
|
'cache_enabled': self.enable_memory_cache,
|
|
'cache_entries': len(self._cache) if self._cache else 0,
|
|
}
|
|
|
|
|
|
def content_hash(data: bytes) -> str:
|
|
"""Generate MD5 hash of bytes for content-addressable storage."""
|
|
return md5(data).hexdigest()
|
|
|
|
|
|
def hash_to_path(h: str, depth: int = 9, ext: str = "") -> Path:
|
|
"""
|
|
Convert hash to directory path using hex pairs.
|
|
|
|
Example (depth=9): abcdef123456... -> ab/cd/ef/12/34/56/78/9a/bc/abcdef123456...ext
|
|
"""
|
|
return _hash_to_path(h, depth, ext)
|
|
|
|
|
|
# Backward compatibility exports
|
|
ensure_bytes = _ensure_bytes
|
|
|
|
|
|
def create_vault(*args, **kwargs) -> Vault:
|
|
"""Factory function for Vault (backward compatibility)."""
|
|
return Vault(*args, **kwargs)
|
|
|
|
|
|
def create_async_vault(*args, **kwargs) -> AsyncVault:
|
|
"""Factory function for AsyncVault (backward compatibility)."""
|
|
return AsyncVault(*args, **kwargs)
|