Files
2026-05-23 12:21:09 +05:30

217 lines
6.4 KiB
Python

import hashlib
import os
import shutil
try:
from PIL import Image
except Exception: # pragma: no cover
Image = None
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".tif"}
VIDEO_EXTS = {".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v"}
AUDIO_EXTS = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac"}
TEXT_EXTS = {".txt", ".json", ".yaml", ".yml", ".md", ".csv", ".log", ".py", ".js"}
def safe_realpath(path):
if not path:
raise ValueError("path is required")
return os.path.realpath(os.path.expanduser(path))
def _leaf_path(path):
"""Resolve the parent directory but keep the final component unresolved,
so destructive operations act on a symlink itself, not its target."""
if not path:
raise ValueError("path is required")
expanded = os.path.expanduser(path)
parent = os.path.dirname(expanded) or "."
return os.path.join(os.path.realpath(parent), os.path.basename(expanded))
def _next_available_path(path):
stem, ext = os.path.splitext(path)
index = 1
candidate = path
while os.path.exists(candidate):
candidate = "{}_{:05d}{}".format(stem, index, ext)
index += 1
return candidate
def classify_kind(name):
ext = os.path.splitext(name)[1].lower()
if ext in IMAGE_EXTS:
return "image"
if ext in VIDEO_EXTS:
return "video"
if ext in AUDIO_EXTS:
return "audio"
if ext in TEXT_EXTS:
return "text"
return "other"
def _entry(path):
st = os.stat(path)
is_dir = os.path.isdir(path)
name = os.path.basename(path)
return {
"name": name,
"path": path,
"is_dir": is_dir,
"size": 0 if is_dir else st.st_size,
"mtime": st.st_mtime,
"ctime": st.st_ctime,
"ext": "" if is_dir else os.path.splitext(name)[1].lower(),
"kind": "folder" if is_dir else classify_kind(name),
}
_SORT_KEYS = {
"name": lambda e: e["name"].lower(),
"size": lambda e: e["size"],
"mtime": lambda e: e["mtime"],
"kind": lambda e: e["kind"],
}
def list_dir(path, show_hidden=False, sort="name"):
real = safe_realpath(path)
if not os.path.exists(real):
raise FileNotFoundError(real)
if not os.path.isdir(real):
raise NotADirectoryError(real)
entries = []
for name in os.listdir(real):
if not show_hidden and name.startswith("."):
continue
try:
entries.append(_entry(os.path.join(real, name)))
except OSError:
continue # skip unreadable entries
key = _SORT_KEYS.get(sort, _SORT_KEYS["name"])
entries.sort(key=lambda e: (not e["is_dir"], key(e)))
parent = os.path.dirname(real)
return {
"cwd": real,
"parent": None if parent == real else parent,
"entries": entries,
}
def _reject_separators(name, label):
if not name or os.path.sep in name or (os.path.altsep and os.path.altsep in name):
raise ValueError("{} must be a bare name".format(label))
if name in (".", ".."):
raise ValueError("{} must not be '.' or '..'".format(label))
def rename_path(path, new_name):
real = _leaf_path(path)
_reject_separators(new_name, "new_name")
target = os.path.join(os.path.dirname(real), new_name)
if os.path.exists(target):
raise FileExistsError(target)
os.rename(real, target)
return {"ok": True, "path": target}
def make_dir(parent, name):
real_parent = safe_realpath(parent)
_reject_separators(name, "name")
target = os.path.join(real_parent, name)
os.makedirs(target, exist_ok=False)
return {"ok": True, "path": target}
def delete_paths(paths):
results = []
for p in paths:
try:
real = _leaf_path(p)
if os.path.isdir(real) and not os.path.islink(real):
shutil.rmtree(real)
else:
os.remove(real)
results.append({"path": real, "ok": True})
except OSError as e:
results.append({"path": p, "ok": False, "error": str(e)})
return results
def move_paths(paths, dest, copy=False):
real_dest = safe_realpath(dest)
if not os.path.isdir(real_dest):
raise NotADirectoryError(real_dest)
results = []
for p in paths:
try:
real = _leaf_path(p)
target = os.path.join(real_dest, os.path.basename(real))
if os.path.exists(target):
raise FileExistsError(target)
if copy:
if os.path.isdir(real):
shutil.copytree(real, target)
else:
shutil.copy2(real, target)
else:
shutil.move(real, target)
results.append({"path": real, "ok": True, "target": target})
except OSError as e:
results.append({"path": p, "ok": False, "error": str(e)})
return results
def save_upload_bytes(dest, filename, data):
real_dest = safe_realpath(dest)
if not os.path.isdir(real_dest):
raise NotADirectoryError(real_dest)
safe_name = os.path.basename(filename or "")
if not safe_name:
raise ValueError("invalid filename")
target = os.path.join(real_dest, safe_name)
target = _next_available_path(target)
with open(target, "wb") as f:
f.write(data)
return {"ok": True, "name": os.path.basename(target), "path": target}
def make_thumbnail(path, cache_dir, size=256):
if Image is None:
raise RuntimeError("PIL not available")
cache_dir = os.path.realpath(os.path.expanduser(cache_dir))
real = safe_realpath(path)
if classify_kind(os.path.basename(real)) != "image":
raise ValueError("not an image")
st = os.stat(real)
key = hashlib.sha1(f"{real}:{st.st_mtime}:{size}".encode()).hexdigest()
os.makedirs(cache_dir, exist_ok=True)
out = os.path.join(cache_dir, key + ".jpg")
if os.path.exists(out):
return out
with Image.open(real) as im:
im = im.convert("RGB")
im.thumbnail((size, size))
im.save(out, "JPEG", quality=85)
return out
def get_properties(path):
real = safe_realpath(path)
if not os.path.exists(real):
raise FileNotFoundError(real)
info = _entry(real)
if info["kind"] == "image" and Image is not None:
try:
with Image.open(real) as im:
info["width"], info["height"] = im.size
except Exception:
pass
return info