From 02cc31d296149109b2896eca5492d0710310bddb Mon Sep 17 00:00:00 2001 From: ShiYu Date: Mon, 13 Oct 2025 17:37:38 +0800 Subject: [PATCH] feat: add FTP and SFTP adapters for file storage --- pyproject.toml | 1 + services/adapters/ftp.py | 628 ++++++++++++++++++++++++++++++++++++++ services/adapters/sftp.py | 447 +++++++++++++++++++++++++++ uv.lock | 124 ++++++++ 4 files changed, 1200 insertions(+) create mode 100644 services/adapters/ftp.py create mode 100644 services/adapters/sftp.py diff --git a/pyproject.toml b/pyproject.toml index d6894b6..b0c793a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,4 +21,5 @@ dependencies = [ "tortoise-orm>=0.25.1", "uvicorn>=0.37.0", "pymilvus[milvus-lite]>=2.6.2", + "paramiko>=4.0.0", ] diff --git a/services/adapters/ftp.py b/services/adapters/ftp.py new file mode 100644 index 0000000..36ae6ef --- /dev/null +++ b/services/adapters/ftp.py @@ -0,0 +1,628 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import List, Dict, Tuple, AsyncIterator, Optional + +from fastapi import HTTPException +from fastapi.responses import StreamingResponse +from ftplib import FTP, error_perm +import mimetypes + +from models import StorageAdapter +from services.logging import LogService + + +def _join_remote(root: str, rel: str) -> str: + root = (root or "/").rstrip("/") or "/" + rel = (rel or "").lstrip("/") + if not rel: + return root + return f"{root}/{rel}" + + +def _parse_mlst_line(line: str) -> Dict[str, str]: + out: Dict[str, str] = {} + try: + facts, _, name = line.partition(" ") + for part in facts.split(";"): + if not part or "=" not in part: + continue + k, v = part.split("=", 1) + out[k.strip().lower()] = v.strip() + if name: + out["name"] = name.strip() + except Exception: + pass + return out + + +def _parse_modify_to_epoch(mod: str) -> int: + # Formats we may see: YYYYMMDDHHMMSS or YYYYMMDDHHMMSS(.sss) + try: + mod = mod.strip() + mod = mod.split(".")[0] + if len(mod) >= 14: + y = int(mod[0:4]) + m = int(mod[4:6]) + d = int(mod[6:8]) + hh = int(mod[8:10]) + mm = int(mod[10:12]) + ss = int(mod[12:14]) + import datetime as _dt + return int(_dt.datetime(y, m, d, hh, mm, ss, tzinfo=_dt.timezone.utc).timestamp()) + except Exception: + return 0 + return 0 + + +@dataclass +class _Range: + start: int + end: Optional[int] # inclusive + + +class FTPAdapter: + def __init__(self, record: StorageAdapter): + self.record = record + cfg = record.config + self.host: str = cfg.get("host") + self.port: int = int(cfg.get("port", 21)) + self.username: Optional[str] = cfg.get("username") + self.password: Optional[str] = cfg.get("password") + self.passive: bool = bool(cfg.get("passive", True)) + self.timeout: int = int(cfg.get("timeout", 15)) + self.root_path: str = cfg.get("root", "/") or "/" + + if not self.host: + raise ValueError("FTP adapter requires 'host'") + + def get_effective_root(self, sub_path: str | None) -> str: + base = self.root_path.rstrip("/") or "/" + if sub_path: + return _join_remote(base, sub_path) + return base + + def _connect(self) -> FTP: + ftp = FTP() + ftp.connect(self.host, self.port, timeout=self.timeout) + if self.username: + ftp.login(self.username, self.password or "") + else: + ftp.login() + ftp.set_pasv(self.passive) + return ftp + + async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]: + path = _join_remote(root, rel.strip('/')) + + def _do_list() -> List[Dict]: + ftp = self._connect() + try: + ftp.cwd(path) + except error_perm as e: + # path may be file + ftp.quit() + raise NotADirectoryError(rel) from e + + entries: List[Dict] = [] + # Try MLSD first + try: + for name, facts in ftp.mlsd(): + if name in (".", ".."): + continue + is_dir = (facts.get("type") == "dir") + size = int(facts.get("size") or 0) + mtime = _parse_modify_to_epoch(facts.get("modify") or "") + entries.append({ + "name": name, + "is_dir": is_dir, + "size": 0 if is_dir else size, + "mtime": mtime, + "type": "dir" if is_dir else "file", + }) + ftp.quit() + return entries + except Exception: + # Fallback to NLST + probing + pass + + names = [] + try: + names = ftp.nlst() + except Exception: + ftp.quit() + return [] + + for name in names: + if name in (".", ".."): + continue + is_dir = False + size = 0 + mtime = 0 + try: + # If we can CWD, it's a directory + ftp.cwd(_join_remote(path, name)) + ftp.cwd(path) + is_dir = True + except Exception: + is_dir = False + try: + size = ftp.size(_join_remote(path, name)) or 0 + except Exception: + size = 0 + try: + mdtm = ftp.sendcmd("MDTM " + _join_remote(path, name)) + # Example: '213 20241012XXXXXX' + if mdtm.startswith("213 "): + mtime = _parse_modify_to_epoch(mdtm.split(" ", 1)[1]) + except Exception: + pass + entries.append({ + "name": name, + "is_dir": is_dir, + "size": 0 if is_dir else int(size or 0), + "mtime": int(mtime or 0), + "type": "dir" if is_dir else "file", + }) + ftp.quit() + return entries + + entries = await asyncio.to_thread(_do_list) + + reverse = sort_order.lower() == "desc" + + def get_sort_key(item): + key = (not item["is_dir"],) + f = sort_by.lower() + if f == "name": + key += (item["name"].lower(),) + elif f == "size": + key += (item.get("size", 0),) + elif f == "mtime": + key += (item.get("mtime", 0),) + else: + key += (item["name"].lower(),) + return key + + entries.sort(key=get_sort_key, reverse=reverse) + total = len(entries) + start = (page_num - 1) * page_size + end = start + page_size + return entries[start:end], total + + async def read_file(self, root: str, rel: str) -> bytes: + path = _join_remote(root, rel) + + def _do_read() -> bytes: + ftp = self._connect() + try: + chunks: List[bytes] = [] + ftp.retrbinary("RETR " + path, lambda b: chunks.append(b)) + return b"".join(chunks) + except error_perm as e: + if str(e).startswith("550"): + raise FileNotFoundError(rel) + raise + finally: + try: + ftp.quit() + except Exception: + pass + + return await asyncio.to_thread(_do_read) + + async def write_file(self, root: str, rel: str, data: bytes): + path = _join_remote(root, rel) + + def _ensure_dirs(ftp: FTP, dir_path: str): + parts = [p for p in dir_path.strip("/").split("/") if p] + cur = "/" + for p in parts: + cur = _join_remote(cur, p) + try: + ftp.mkd(cur) + except Exception: + pass + + def _do_write(): + ftp = self._connect() + try: + parent = "/" if "/" not in path.strip("/") else path.rsplit("/", 1)[0] + _ensure_dirs(ftp, parent) + from io import BytesIO + bio = BytesIO(data) + ftp.storbinary("STOR " + path, bio) + finally: + try: + ftp.quit() + except Exception: + pass + + await asyncio.to_thread(_do_write) + await LogService.info( + "adapter:ftp", + f"Wrote file to {rel}", + details={"adapter_id": self.record.id, "path": path, "size": len(data)}, + ) + + async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): + # KISS: 聚合后一次性写入 + buf = bytearray() + async for chunk in data_iter: + if chunk: + buf.extend(chunk) + await self.write_file(root, rel, bytes(buf)) + return len(buf) + + async def mkdir(self, root: str, rel: str): + path = _join_remote(root, rel) + + def _do_mkdir(): + ftp = self._connect() + try: + parts = [p for p in path.strip("/").split("/") if p] + cur = "/" + for p in parts: + cur = _join_remote(cur, p) + try: + ftp.mkd(cur) + except Exception: + pass + finally: + try: + ftp.quit() + except Exception: + pass + + await asyncio.to_thread(_do_mkdir) + await LogService.info("adapter:ftp", f"Created directory {rel}", details={"adapter_id": self.record.id, "path": path}) + + async def delete(self, root: str, rel: str): + path = _join_remote(root, rel) + + def _do_delete(): + ftp = self._connect() + try: + # Try file delete + try: + ftp.delete(path) + return + except Exception: + pass + + # Recursively delete dir + def _rm_tree(dir_path: str): + try: + ftp.cwd(dir_path) + except Exception: + return + items = [] + try: + for name, facts in ftp.mlsd(): + if name in (".", ".."): + continue + items.append((name, facts.get("type") == "dir")) + except Exception: + try: + names = ftp.nlst() + except Exception: + names = [] + for n in names: + if n in (".", ".."): + continue + # Best-effort dir check + try: + ftp.cwd(_join_remote(dir_path, n)) + ftp.cwd(dir_path) + items.append((n, True)) + except Exception: + items.append((n, False)) + for n, is_dir in items: + child = _join_remote(dir_path, n) + if is_dir: + _rm_tree(child) + else: + try: + ftp.delete(child) + except Exception: + pass + try: + ftp.rmd(dir_path) + except Exception: + pass + + _rm_tree(path) + finally: + try: + ftp.quit() + except Exception: + pass + + await asyncio.to_thread(_do_delete) + await LogService.info("adapter:ftp", f"Deleted {rel}", details={"adapter_id": self.record.id, "path": path}) + + async def move(self, root: str, src_rel: str, dst_rel: str): + src = _join_remote(root, src_rel) + dst = _join_remote(root, dst_rel) + + def _do_move(): + ftp = self._connect() + try: + # Ensure dst parent exists + parent = "/" if "/" not in dst.strip("/") else dst.rsplit("/", 1)[0] + parts = [p for p in parent.strip("/").split("/") if p] + cur = "/" + for p in parts: + cur = _join_remote(cur, p) + try: + ftp.mkd(cur) + except Exception: + pass + ftp.rename(src, dst) + finally: + try: + ftp.quit() + except Exception: + pass + + await asyncio.to_thread(_do_move) + await LogService.info("adapter:ftp", f"Moved {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst}) + + async def rename(self, root: str, src_rel: str, dst_rel: str): + await self.move(root, src_rel, dst_rel) + + async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False): + src = _join_remote(root, src_rel) + dst = _join_remote(root, dst_rel) + + # naive implementation: download then upload; recursively for dirs + async def _is_dir(path: str) -> bool: + def _probe() -> bool: + ftp = self._connect() + try: + try: + ftp.cwd(path) + return True + except Exception: + return False + finally: + try: + ftp.quit() + except Exception: + pass + return await asyncio.to_thread(_probe) + + if await _is_dir(src): + # list children, create dst dir, copy recursively + await self.mkdir(root, dst_rel) + + children, _ = await self.list_dir(root, src_rel, page_num=1, page_size=10_000) + for ent in children: + child_src = f"{src_rel.rstrip('/')}/{ent['name']}" + child_dst = f"{dst_rel.rstrip('/')}/{ent['name']}" + await self.copy(root, child_src, child_dst, overwrite) + await LogService.info( + "adapter:ftp", f"Copied directory {src_rel} to {dst_rel}", + details={"adapter_id": self.record.id, "src": src, "dst": dst} + ) + return + + # file + data = await self.read_file(root, src_rel) + if not overwrite: + # best-effort existence check + try: + await self.stat_file(root, dst_rel) + raise FileExistsError(dst_rel) + except FileNotFoundError: + pass + await self.write_file(root, dst_rel, data) + await LogService.info("adapter:ftp", f"Copied {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst}) + + async def stat_file(self, root: str, rel: str): + path = _join_remote(root, rel) + + def _do_stat(): + ftp = self._connect() + try: + # Try MLST + try: + resp: List[str] = [] + ftp.retrlines("MLST " + path, resp.append) + # The last line usually contains facts + facts = {} + if resp: + facts = _parse_mlst_line(resp[-1]) + name = rel.split("/")[-1] + t = facts.get("type") or "file" + is_dir = t == "dir" + size = int(facts.get("size") or 0) + mtime = _parse_modify_to_epoch(facts.get("modify") or "") + return { + "name": name, + "is_dir": is_dir, + "size": 0 if is_dir else size, + "mtime": mtime, + "type": "dir" if is_dir else "file", + "path": path, + } + except Exception: + pass + + # Probe directory + try: + ftp.cwd(path) + return { + "name": rel.split("/")[-1], + "is_dir": True, + "size": 0, + "mtime": 0, + "type": "dir", + "path": path, + } + except Exception: + pass + + # Treat as file + try: + size = ftp.size(path) or 0 + except Exception: + size = 0 + try: + mdtm = ftp.sendcmd("MDTM " + path) + mtime = _parse_modify_to_epoch(mdtm.split(" ", 1)[1]) if mdtm.startswith("213 ") else 0 + except Exception: + mtime = 0 + return { + "name": rel.split("/")[-1], + "is_dir": False, + "size": int(size or 0), + "mtime": int(mtime or 0), + "type": "file", + "path": path, + } + except error_perm as e: + if str(e).startswith("550"): + raise FileNotFoundError(rel) + raise + finally: + try: + ftp.quit() + except Exception: + pass + + return await asyncio.to_thread(_do_stat) + + async def stream_file(self, root: str, rel: str, range_header: str | None): + path = _join_remote(root, rel) + # Get size (best-effort) + def _get_size() -> Optional[int]: + ftp = self._connect() + try: + try: + return int(ftp.size(path) or 0) + except Exception: + return None + finally: + try: + ftp.quit() + except Exception: + pass + + total_size = await asyncio.to_thread(_get_size) + mime, _ = mimetypes.guess_type(rel) + content_type = mime or "application/octet-stream" + + rng: Optional[_Range] = None + status = 200 + headers = {"Accept-Ranges": "bytes", "Content-Type": content_type} + if range_header and range_header.startswith("bytes=") and total_size is not None: + try: + s, e = (range_header.removeprefix("bytes=").split("-", 1)) + start = int(s) if s.strip() else 0 + end = int(e) if e.strip() else (total_size - 1) + if start >= total_size: + raise HTTPException(416, detail="Requested Range Not Satisfiable") + if end >= total_size: + end = total_size - 1 + rng = _Range(start, end) + status = 206 + headers["Content-Range"] = f"bytes {start}-{end}/{total_size}" + headers["Content-Length"] = str(end - start + 1) + except ValueError: + raise HTTPException(400, detail="Invalid Range header") + elif total_size is not None: + headers["Content-Length"] = str(total_size) + + queue: asyncio.Queue[Optional[bytes]] = asyncio.Queue(maxsize=8) + + class _Stop(Exception): + pass + + def _worker(): + ftp = self._connect() + remaining = None + if rng is not None: + remaining = (rng.end - rng.start + 1) if rng.end is not None else None + + def _cb(chunk: bytes): + nonlocal remaining + if not chunk: + return + try: + if remaining is not None: + if len(chunk) > remaining: + part = chunk[:remaining] + queue.put_nowait(part) + remaining = 0 + raise _Stop() + else: + queue.put_nowait(chunk) + remaining -= len(chunk) + if remaining <= 0: + raise _Stop() + else: + queue.put_nowait(chunk) + except _Stop: + raise + except Exception: + # queue full or event loop closed + raise _Stop() + + try: + if rng is not None: + ftp.retrbinary("RETR " + path, _cb, rest=rng.start) + else: + ftp.retrbinary("RETR " + path, _cb) + queue.put_nowait(None) + except _Stop: + try: + queue.put_nowait(None) + except Exception: + pass + except error_perm as e: + try: + queue.put_nowait(None) + except Exception: + pass + if str(e).startswith("550"): + pass + finally: + try: + ftp.quit() + except Exception: + pass + + async def agen(): + worker_fut = asyncio.to_thread(_worker) + try: + while True: + chunk = await queue.get() + if chunk is None: + break + yield chunk + finally: + try: + await worker_fut + except Exception: + pass + + return StreamingResponse(agen(), status_code=status, headers=headers, media_type=content_type) + + +ADAPTER_TYPE = "ftp" + +CONFIG_SCHEMA = [ + {"key": "host", "label": "主机", "type": "string", "required": True, "placeholder": "ftp.example.com"}, + {"key": "port", "label": "端口", "type": "number", "required": False, "default": 21}, + {"key": "username", "label": "用户名", "type": "string", "required": False}, + {"key": "password", "label": "密码", "type": "password", "required": False}, + {"key": "passive", "label": "被动模式", "type": "boolean", "required": False, "default": True}, + {"key": "timeout", "label": "超时(秒)", "type": "number", "required": False, "default": 15}, + {"key": "root", "label": "根路径", "type": "string", "required": False, "default": "/"}, +] + + +def ADAPTER_FACTORY(rec: StorageAdapter): + return FTPAdapter(rec) diff --git a/services/adapters/sftp.py b/services/adapters/sftp.py new file mode 100644 index 0000000..3eb8898 --- /dev/null +++ b/services/adapters/sftp.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +import asyncio +import mimetypes +import stat as statmod +from typing import List, Dict, Tuple, AsyncIterator, Optional + +from fastapi import HTTPException +from fastapi.responses import StreamingResponse +import paramiko + +from models import StorageAdapter +from services.logging import LogService + + +def _join_remote(root: str, rel: str) -> str: + root = (root or "/").rstrip("/") or "/" + rel = (rel or "").lstrip("/") + if not rel: + return root + return f"{root}/{rel}" + + +class SFTPAdapter: + def __init__(self, record: StorageAdapter): + self.record = record + cfg = record.config + self.host: str = cfg.get("host") + self.port: int = int(cfg.get("port", 22)) + self.username: str | None = cfg.get("username") + self.password: str | None = cfg.get("password") + self.timeout: int = int(cfg.get("timeout", 15)) + self.root_path: str = cfg.get("root") # 必填 + self.allow_unknown_host: bool = bool(cfg.get("allow_unknown_host", True)) + + if not self.host: + raise ValueError("SFTP adapter requires 'host'") + if not self.username or not self.password: + raise ValueError("SFTP adapter requires 'username' and 'password'") + if not self.root_path: + raise ValueError("SFTP adapter requires 'root'") + + def get_effective_root(self, sub_path: str | None) -> str: + base = self.root_path.rstrip("/") or "/" + if sub_path: + return _join_remote(base, sub_path) + return base + + def _connect(self) -> paramiko.SFTPClient: + ssh = paramiko.SSHClient() + if self.allow_unknown_host: + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect( + hostname=self.host, + port=self.port, + username=self.username, + password=self.password, + timeout=self.timeout, + allow_agent=False, + look_for_keys=False, + ) + return ssh.open_sftp() + + async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Tuple[List[Dict], int]: + path = _join_remote(root, rel) + + def _do_list() -> List[Dict]: + sftp = self._connect() + try: + attrs = sftp.listdir_attr(path) + entries: List[Dict] = [] + for a in attrs: + name = a.filename + is_dir = statmod.S_ISDIR(a.st_mode) + entries.append({ + "name": name, + "is_dir": is_dir, + "size": 0 if is_dir else int(a.st_size or 0), + "mtime": int(a.st_mtime or 0), + "type": "dir" if is_dir else "file", + }) + return entries + finally: + try: + sftp.close() + except Exception: + pass + + entries = await asyncio.to_thread(_do_list) + + reverse = sort_order.lower() == "desc" + + def get_sort_key(item): + key = (not item["is_dir"],) + f = sort_by.lower() + if f == "name": + key += (item["name"].lower(),) + elif f == "size": + key += (item.get("size", 0),) + elif f == "mtime": + key += (item.get("mtime", 0),) + else: + key += (item["name"].lower(),) + return key + + entries.sort(key=get_sort_key, reverse=reverse) + total = len(entries) + start = (page_num - 1) * page_size + end = start + page_size + return entries[start:end], total + + async def read_file(self, root: str, rel: str) -> bytes: + path = _join_remote(root, rel) + + def _do_read() -> bytes: + sftp = self._connect() + try: + with sftp.open(path, "rb") as f: + return f.read() + except FileNotFoundError: + raise + except IOError as e: + if getattr(e, "errno", None) == 2: + raise FileNotFoundError(rel) + raise + finally: + try: + sftp.close() + except Exception: + pass + + return await asyncio.to_thread(_do_read) + + async def write_file(self, root: str, rel: str, data: bytes): + path = _join_remote(root, rel) + + def _ensure_dirs(sftp: paramiko.SFTPClient, dir_path: str): + parts = [p for p in dir_path.strip("/").split("/") if p] + cur = "/" + for p in parts: + cur = _join_remote(cur, p) + try: + sftp.mkdir(cur) + except IOError: + # likely exists + pass + + def _do_write(): + sftp = self._connect() + try: + parent = "/" if "/" not in path.strip("/") else path.rsplit("/", 1)[0] + _ensure_dirs(sftp, parent) + with sftp.open(path, "wb") as f: + f.write(data) + finally: + try: + sftp.close() + except Exception: + pass + + await asyncio.to_thread(_do_write) + await LogService.info("adapter:sftp", f"Wrote file to {rel}", details={"adapter_id": self.record.id, "path": path, "size": len(data)}) + + async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]): + buf = bytearray() + async for chunk in data_iter: + if chunk: + buf.extend(chunk) + await self.write_file(root, rel, bytes(buf)) + return len(buf) + + async def mkdir(self, root: str, rel: str): + path = _join_remote(root, rel) + + def _do_mkdir(): + sftp = self._connect() + try: + parts = [p for p in path.strip("/").split("/") if p] + cur = "/" + for p in parts: + cur = _join_remote(cur, p) + try: + sftp.mkdir(cur) + except IOError: + pass + finally: + try: + sftp.close() + except Exception: + pass + + await asyncio.to_thread(_do_mkdir) + await LogService.info("adapter:sftp", f"Created directory {rel}", details={"adapter_id": self.record.id, "path": path}) + + async def delete(self, root: str, rel: str): + path = _join_remote(root, rel) + + def _do_delete(): + sftp = self._connect() + try: + # Try file remove first + try: + sftp.remove(path) + return + except IOError: + pass + + def _rm_tree(dp: str): + try: + for a in sftp.listdir_attr(dp): + child = _join_remote(dp, a.filename) + if statmod.S_ISDIR(a.st_mode): + _rm_tree(child) + else: + try: + sftp.remove(child) + except Exception: + pass + sftp.rmdir(dp) + except IOError: + pass + + _rm_tree(path) + finally: + try: + sftp.close() + except Exception: + pass + + await asyncio.to_thread(_do_delete) + await LogService.info("adapter:sftp", f"Deleted {rel}", details={"adapter_id": self.record.id, "path": path}) + + async def move(self, root: str, src_rel: str, dst_rel: str): + src = _join_remote(root, src_rel) + dst = _join_remote(root, dst_rel) + + def _do_move(): + sftp = self._connect() + try: + # ensure dst parent exists + parent = "/" if "/" not in dst.strip("/") else dst.rsplit("/", 1)[0] + parts = [p for p in parent.strip("/").split("/") if p] + cur = "/" + for p in parts: + cur = _join_remote(cur, p) + try: + sftp.mkdir(cur) + except IOError: + pass + sftp.rename(src, dst) + finally: + try: + sftp.close() + except Exception: + pass + + await asyncio.to_thread(_do_move) + await LogService.info("adapter:sftp", f"Moved {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst}) + + async def rename(self, root: str, src_rel: str, dst_rel: str): + await self.move(root, src_rel, dst_rel) + + async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False): + src = _join_remote(root, src_rel) + dst = _join_remote(root, dst_rel) + + def _is_dir() -> bool: + sftp = self._connect() + try: + st = sftp.stat(src) + return statmod.S_ISDIR(st.st_mode) + finally: + try: + sftp.close() + except Exception: + pass + + if await asyncio.to_thread(_is_dir): + await self.mkdir(root, dst_rel) + + children, _ = await self.list_dir(root, src_rel, page_num=1, page_size=10_000) + for ent in children: + child_src = f"{src_rel.rstrip('/')}/{ent['name']}" + child_dst = f"{dst_rel.rstrip('/')}/{ent['name']}" + await self.copy(root, child_src, child_dst, overwrite) + await LogService.info("adapter:sftp", f"Copied directory {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst}) + return + + # file copy + data = await self.read_file(root, src_rel) + if not overwrite: + try: + await self.stat_file(root, dst_rel) + raise FileExistsError(dst_rel) + except FileNotFoundError: + pass + await self.write_file(root, dst_rel, data) + await LogService.info("adapter:sftp", f"Copied {src_rel} to {dst_rel}", details={"adapter_id": self.record.id, "src": src, "dst": dst}) + + async def stat_file(self, root: str, rel: str): + path = _join_remote(root, rel) + + def _do_stat(): + sftp = self._connect() + try: + st = sftp.stat(path) + is_dir = statmod.S_ISDIR(st.st_mode) + info = { + "name": rel.split("/")[-1], + "is_dir": is_dir, + "size": 0 if is_dir else int(st.st_size or 0), + "mtime": int(st.st_mtime or 0), + "type": "dir" if is_dir else "file", + "path": path, + } + return info + except FileNotFoundError: + raise + except IOError as e: + if getattr(e, "errno", None) == 2: + raise FileNotFoundError(rel) + raise + finally: + try: + sftp.close() + except Exception: + pass + + return await asyncio.to_thread(_do_stat) + + async def exists(self, root: str, rel: str) -> bool: + try: + await self.stat_file(root, rel) + return True + except FileNotFoundError: + return False + except Exception: + return False + + async def stream_file(self, root: str, rel: str, range_header: str | None): + path = _join_remote(root, rel) + + def _get_stat(): + sftp = self._connect() + try: + st = sftp.stat(path) + return int(st.st_size or 0) + finally: + try: + sftp.close() + except Exception: + pass + + file_size = await asyncio.to_thread(_get_stat) + if file_size is None: + raise HTTPException(404, detail="File not found") + + mime, _ = mimetypes.guess_type(rel) + content_type = mime or "application/octet-stream" + + start = 0 + end = file_size - 1 + status = 200 + headers = { + "Accept-Ranges": "bytes", + "Content-Type": content_type, + "Content-Length": str(file_size), + } + + if range_header and range_header.startswith("bytes="): + try: + s, e = (range_header.removeprefix("bytes=").split("-", 1)) + if s.strip(): + start = int(s) + if e.strip(): + end = int(e) + if start >= file_size: + raise HTTPException(416, detail="Requested Range Not Satisfiable") + if end >= file_size: + end = file_size - 1 + status = 206 + headers["Content-Length"] = str(end - start + 1) + headers["Content-Range"] = f"bytes {start}-{end}/{file_size}" + except ValueError: + raise HTTPException(400, detail="Invalid Range header") + + queue: asyncio.Queue[Optional[bytes]] = asyncio.Queue(maxsize=8) + + def _worker(): + sftp = self._connect() + try: + with sftp.open(path, "rb") as f: + f.seek(start) + remaining = end - start + 1 + chunk_size = 64 * 1024 + while remaining > 0: + to_read = chunk_size if remaining > chunk_size else remaining + data = f.read(to_read) + if not data: + break + try: + queue.put_nowait(data) + except Exception: + break + remaining -= len(data) + try: + queue.put_nowait(None) + except Exception: + pass + finally: + try: + sftp.close() + except Exception: + pass + + async def agen(): + worker_fut = asyncio.to_thread(_worker) + try: + while True: + chunk = await queue.get() + if chunk is None: + break + yield chunk + finally: + try: + await worker_fut + except Exception: + pass + + return StreamingResponse(agen(), status_code=status, headers=headers, media_type=content_type) + + +ADAPTER_TYPE = "sftp" + +CONFIG_SCHEMA = [ + {"key": "host", "label": "主机", "type": "string", "required": True, "placeholder": "sftp.example.com"}, + {"key": "port", "label": "端口", "type": "number", "required": False, "default": 22}, + {"key": "username", "label": "用户名", "type": "string", "required": True}, + {"key": "password", "label": "密码", "type": "password", "required": True}, + {"key": "root", "label": "根路径", "type": "string", "required": True, "placeholder": "/data"}, + {"key": "timeout", "label": "超时(秒)", "type": "number", "required": False, "default": 15}, + {"key": "allow_unknown_host", "label": "允许未知主机指纹", "type": "boolean", "required": False, "default": True}, +] + + +def ADAPTER_FACTORY(rec: StorageAdapter): + return SFTPAdapter(rec) diff --git a/uv.lock b/uv.lock index 42cb040..72c3312 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "platform_python_implementation == 'PyPy'", +] [[package]] name = "aioboto3" @@ -312,6 +317,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/9b/e301418629f7bfdf72db9e80ad6ed9d1b83c487c471803eaa6464c511a01/cryptography-46.0.2.tar.gz", hash = "sha256:21b6fc8c71a3f9a604f028a329e5560009cc4a3a828bfea5fcba8eb7647d88fe", size = 749293, upload-time = "2025-10-01T00:29:11.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/98/7a8df8c19a335c8028414738490fc3955c0cecbfdd37fcc1b9c3d04bd561/cryptography-46.0.2-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3e32ab7dd1b1ef67b9232c4cf5e2ee4cd517d4316ea910acaaa9c5712a1c663", size = 7261255, upload-time = "2025-10-01T00:27:22.947Z" }, + { url = "https://files.pythonhosted.org/packages/c6/38/b2adb2aa1baa6706adc3eb746691edd6f90a656a9a65c3509e274d15a2b8/cryptography-46.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1fd1a69086926b623ef8126b4c33d5399ce9e2f3fac07c9c734c2a4ec38b6d02", size = 4297596, upload-time = "2025-10-01T00:27:25.258Z" }, + { url = "https://files.pythonhosted.org/packages/e4/27/0f190ada240003119488ae66c897b5e97149292988f556aef4a6a2a57595/cryptography-46.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb7fb9cd44c2582aa5990cf61a4183e6f54eea3172e54963787ba47287edd135", size = 4450899, upload-time = "2025-10-01T00:27:27.458Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/e4744105ab02fdf6bb58ba9a816e23b7a633255987310b4187d6745533db/cryptography-46.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9066cfd7f146f291869a9898b01df1c9b0e314bfa182cef432043f13fc462c92", size = 4300382, upload-time = "2025-10-01T00:27:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/33/fb/bf9571065c18c04818cb07de90c43fc042c7977c68e5de6876049559c72f/cryptography-46.0.2-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:97e83bf4f2f2c084d8dd792d13841d0a9b241643151686010866bbd076b19659", size = 4017347, upload-time = "2025-10-01T00:27:30.767Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/fc51856b9b16155ca071080e1a3ad0c3a8e86616daf7eb018d9565b99baa/cryptography-46.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:4a766d2a5d8127364fd936572c6e6757682fc5dfcbdba1632d4554943199f2fa", size = 4983500, upload-time = "2025-10-01T00:27:32.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/0f51e926799025e31746d454ab2e36f8c3f0d41592bc65cb9840368d3275/cryptography-46.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fab8f805e9675e61ed8538f192aad70500fa6afb33a8803932999b1049363a08", size = 4482591, upload-time = "2025-10-01T00:27:34.869Z" }, + { url = "https://files.pythonhosted.org/packages/86/96/4302af40b23ab8aa360862251fb8fc450b2a06ff24bc5e261c2007f27014/cryptography-46.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1e3b6428a3d56043bff0bb85b41c535734204e599c1c0977e1d0f261b02f3ad5", size = 4300019, upload-time = "2025-10-01T00:27:37.029Z" }, + { url = "https://files.pythonhosted.org/packages/9b/59/0be12c7fcc4c5e34fe2b665a75bc20958473047a30d095a7657c218fa9e8/cryptography-46.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:1a88634851d9b8de8bb53726f4300ab191d3b2f42595e2581a54b26aba71b7cc", size = 4950006, upload-time = "2025-10-01T00:27:40.272Z" }, + { url = "https://files.pythonhosted.org/packages/55/1d/42fda47b0111834b49e31590ae14fd020594d5e4dadd639bce89ad790fba/cryptography-46.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:be939b99d4e091eec9a2bcf41aaf8f351f312cd19ff74b5c83480f08a8a43e0b", size = 4482088, upload-time = "2025-10-01T00:27:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/60f583f69aa1602c2bdc7022dae86a0d2b837276182f8c1ec825feb9b874/cryptography-46.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f13b040649bc18e7eb37936009b24fd31ca095a5c647be8bb6aaf1761142bd1", size = 4425599, upload-time = "2025-10-01T00:27:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/d1/57/d8d4134cd27e6e94cf44adb3f3489f935bde85f3a5508e1b5b43095b917d/cryptography-46.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bdc25e4e01b261a8fda4e98618f1c9515febcecebc9566ddf4a70c63967043b", size = 4697458, upload-time = "2025-10-01T00:27:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2b/531e37408573e1da33adfb4c58875013ee8ac7d548d1548967d94a0ae5c4/cryptography-46.0.2-cp311-abi3-win32.whl", hash = "sha256:8b9bf67b11ef9e28f4d78ff88b04ed0929fcd0e4f70bb0f704cfc32a5c6311ee", size = 3056077, upload-time = "2025-10-01T00:27:48.424Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cd/2f83cafd47ed2dc5a3a9c783ff5d764e9e70d3a160e0df9a9dcd639414ce/cryptography-46.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:758cfc7f4c38c5c5274b55a57ef1910107436f4ae842478c4989abbd24bd5acb", size = 3512585, upload-time = "2025-10-01T00:27:50.521Z" }, + { url = "https://files.pythonhosted.org/packages/00/36/676f94e10bfaa5c5b86c469ff46d3e0663c5dc89542f7afbadac241a3ee4/cryptography-46.0.2-cp311-abi3-win_arm64.whl", hash = "sha256:218abd64a2e72f8472c2102febb596793347a3e65fafbb4ad50519969da44470", size = 2927474, upload-time = "2025-10-01T00:27:52.91Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/47fc6223a341f26d103cb6da2216805e08a37d3b52bee7f3b2aee8066f95/cryptography-46.0.2-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:bda55e8dbe8533937956c996beaa20266a8eca3570402e52ae52ed60de1faca8", size = 7198626, upload-time = "2025-10-01T00:27:54.8Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/d66a8591207c28bbe4ac7afa25c4656dc19dc0db29a219f9809205639ede/cryptography-46.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7155c0b004e936d381b15425273aee1cebc94f879c0ce82b0d7fecbf755d53a", size = 4287584, upload-time = "2025-10-01T00:27:57.018Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/fac3ab6302b928e0398c269eddab5978e6c1c50b2b77bb5365ffa8633b37/cryptography-46.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a61c154cc5488272a6c4b86e8d5beff4639cdb173d75325ce464d723cda0052b", size = 4433796, upload-time = "2025-10-01T00:27:58.631Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/24392e5d3c58e2d83f98fe5a2322ae343360ec5b5b93fe18bc52e47298f5/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:9ec3f2e2173f36a9679d3b06d3d01121ab9b57c979de1e6a244b98d51fea1b20", size = 4292126, upload-time = "2025-10-01T00:28:00.643Z" }, + { url = "https://files.pythonhosted.org/packages/ed/38/3d9f9359b84c16c49a5a336ee8be8d322072a09fac17e737f3bb11f1ce64/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2fafb6aa24e702bbf74de4cb23bfa2c3beb7ab7683a299062b69724c92e0fa73", size = 3993056, upload-time = "2025-10-01T00:28:02.8Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a3/4c44fce0d49a4703cc94bfbe705adebf7ab36efe978053742957bc7ec324/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:0c7ffe8c9b1fcbb07a26d7c9fa5e857c2fe80d72d7b9e0353dcf1d2180ae60ee", size = 4967604, upload-time = "2025-10-01T00:28:04.783Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/49d73218747c8cac16bb8318a5513fde3129e06a018af3bc4dc722aa4a98/cryptography-46.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5840f05518caa86b09d23f8b9405a7b6d5400085aa14a72a98fdf5cf1568c0d2", size = 4465367, upload-time = "2025-10-01T00:28:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/1b/64/9afa7d2ee742f55ca6285a54386ed2778556a4ed8871571cb1c1bfd8db9e/cryptography-46.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:27c53b4f6a682a1b645fbf1cd5058c72cf2f5aeba7d74314c36838c7cbc06e0f", size = 4291678, upload-time = "2025-10-01T00:28:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/50/48/1696d5ea9623a7b72ace87608f6899ca3c331709ac7ebf80740abb8ac673/cryptography-46.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:512c0250065e0a6b286b2db4bbcc2e67d810acd53eb81733e71314340366279e", size = 4931366, upload-time = "2025-10-01T00:28:10.74Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/9dfc778401a334db3b24435ee0733dd005aefb74afe036e2d154547cb917/cryptography-46.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:07c0eb6657c0e9cca5891f4e35081dbf985c8131825e21d99b4f440a8f496f36", size = 4464738, upload-time = "2025-10-01T00:28:12.491Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b1/abcde62072b8f3fd414e191a6238ce55a0050e9738090dc6cded24c12036/cryptography-46.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48b983089378f50cba258f7f7aa28198c3f6e13e607eaf10472c26320332ca9a", size = 4419305, upload-time = "2025-10-01T00:28:14.145Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1f/3d2228492f9391395ca34c677e8f2571fb5370fe13dc48c1014f8c509864/cryptography-46.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e6f6775eaaa08c0eec73e301f7592f4367ccde5e4e4df8e58320f2ebf161ea2c", size = 4681201, upload-time = "2025-10-01T00:28:15.951Z" }, + { url = "https://files.pythonhosted.org/packages/de/77/b687745804a93a55054f391528fcfc76c3d6bfd082ce9fb62c12f0d29fc1/cryptography-46.0.2-cp314-cp314t-win32.whl", hash = "sha256:e8633996579961f9b5a3008683344c2558d38420029d3c0bc7ff77c17949a4e1", size = 3022492, upload-time = "2025-10-01T00:28:17.643Z" }, + { url = "https://files.pythonhosted.org/packages/60/a5/8d498ef2996e583de0bef1dcc5e70186376f00883ae27bf2133f490adf21/cryptography-46.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:48c01988ecbb32979bb98731f5c2b2f79042a6c58cc9a319c8c2f9987c7f68f9", size = 3496215, upload-time = "2025-10-01T00:28:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/ee67aaef459a2706bc302b15889a1a8126ebe66877bab1487ae6ad00f33d/cryptography-46.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8e2ad4d1a5899b7caa3a450e33ee2734be7cc0689010964703a7c4bcc8dd4fd0", size = 2919255, upload-time = "2025-10-01T00:28:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/d5/bb/fa95abcf147a1b0bb94d95f53fbb09da77b24c776c5d87d36f3d94521d2c/cryptography-46.0.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a08e7401a94c002e79dc3bc5231b6558cd4b2280ee525c4673f650a37e2c7685", size = 7248090, upload-time = "2025-10-01T00:28:22.846Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/f42071ce0e3ffbfa80a88feadb209c779fda92a23fbc1e14f74ebf72ef6b/cryptography-46.0.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d30bc11d35743bf4ddf76674a0a369ec8a21f87aaa09b0661b04c5f6c46e8d7b", size = 4293123, upload-time = "2025-10-01T00:28:25.072Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/1fdbd2e5c1ba822828d250e5a966622ef00185e476d1cd2726b6dd135e53/cryptography-46.0.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bca3f0ce67e5a2a2cf524e86f44697c4323a86e0fd7ba857de1c30d52c11ede1", size = 4439524, upload-time = "2025-10-01T00:28:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/5e4989a7d102d4306053770d60f978c7b6b1ea2ff8c06e0265e305b23516/cryptography-46.0.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ff798ad7a957a5021dcbab78dfff681f0cf15744d0e6af62bd6746984d9c9e9c", size = 4297264, upload-time = "2025-10-01T00:28:29.327Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/b56f847d220cb1d6d6aef5a390e116ad603ce13a0945a3386a33abc80385/cryptography-46.0.2-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cb5e8daac840e8879407acbe689a174f5ebaf344a062f8918e526824eb5d97af", size = 4011872, upload-time = "2025-10-01T00:28:31.479Z" }, + { url = "https://files.pythonhosted.org/packages/e1/80/2971f214b066b888944f7b57761bf709ee3f2cf805619a18b18cab9b263c/cryptography-46.0.2-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:3f37aa12b2d91e157827d90ce78f6180f0c02319468a0aea86ab5a9566da644b", size = 4978458, upload-time = "2025-10-01T00:28:33.267Z" }, + { url = "https://files.pythonhosted.org/packages/a5/84/0cb0a2beaa4f1cbe63ebec4e97cd7e0e9f835d0ba5ee143ed2523a1e0016/cryptography-46.0.2-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e38f203160a48b93010b07493c15f2babb4e0f2319bbd001885adb3f3696d21", size = 4472195, upload-time = "2025-10-01T00:28:36.039Z" }, + { url = "https://files.pythonhosted.org/packages/30/8b/2b542ddbf78835c7cd67b6fa79e95560023481213a060b92352a61a10efe/cryptography-46.0.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d19f5f48883752b5ab34cff9e2f7e4a7f216296f33714e77d1beb03d108632b6", size = 4296791, upload-time = "2025-10-01T00:28:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/78/12/9065b40201b4f4876e93b9b94d91feb18de9150d60bd842a16a21565007f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:04911b149eae142ccd8c9a68892a70c21613864afb47aba92d8c7ed9cc001023", size = 4939629, upload-time = "2025-10-01T00:28:39.654Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9e/6507dc048c1b1530d372c483dfd34e7709fc542765015425f0442b08547f/cryptography-46.0.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8b16c1ede6a937c291d41176934268e4ccac2c6521c69d3f5961c5a1e11e039e", size = 4471988, upload-time = "2025-10-01T00:28:41.822Z" }, + { url = "https://files.pythonhosted.org/packages/b1/86/d025584a5f7d5c5ec8d3633dbcdce83a0cd579f1141ceada7817a4c26934/cryptography-46.0.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:747b6f4a4a23d5a215aadd1d0b12233b4119c4313df83ab4137631d43672cc90", size = 4422989, upload-time = "2025-10-01T00:28:43.608Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/536370418b38a15a61bbe413006b79dfc3d2b4b0eafceb5581983f973c15/cryptography-46.0.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b275e398ab3a7905e168c036aad54b5969d63d3d9099a0a66cc147a3cc983be", size = 4685578, upload-time = "2025-10-01T00:28:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/15/52/ea7e2b1910f547baed566c866fbb86de2402e501a89ecb4871ea7f169a81/cryptography-46.0.2-cp38-abi3-win32.whl", hash = "sha256:0b507c8e033307e37af61cb9f7159b416173bdf5b41d11c4df2e499a1d8e007c", size = 3036711, upload-time = "2025-10-01T00:28:47.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/9e/171f40f9c70a873e73c2efcdbe91e1d4b1777a03398fa1c4af3c56a2477a/cryptography-46.0.2-cp38-abi3-win_amd64.whl", hash = "sha256:f9b2dc7668418fb6f221e4bf701f716e05e8eadb4f1988a2487b11aedf8abe62", size = 3500007, upload-time = "2025-10-01T00:28:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7c/15ad426257615f9be8caf7f97990cf3dcbb5b8dd7ed7e0db581a1c4759dd/cryptography-46.0.2-cp38-abi3-win_arm64.whl", hash = "sha256:91447f2b17e83c9e0c89f133119d83f94ce6e0fb55dd47da0a959316e6e9cfa1", size = 2918153, upload-time = "2025-10-01T00:28:51.003Z" }, +] + [[package]] name = "fastapi" version = "0.116.1" @@ -335,6 +396,7 @@ dependencies = [ { name = "aiofiles" }, { name = "bcrypt" }, { name = "fastapi" }, + { name = "paramiko" }, { name = "passlib", extra = ["bcrypt"] }, { name = "pillow" }, { name = "pyjwt" }, @@ -355,6 +417,7 @@ requires-dist = [ { name = "aiofiles", specifier = ">=25.1.0" }, { name = "bcrypt", specifier = ">=3.2.2,<4.0" }, { name = "fastapi", specifier = ">=0.116.1" }, + { name = "paramiko", specifier = ">=4.0.0" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pillow", specifier = ">=11.3.0" }, { name = "pyjwt", specifier = ">=2.10.1" }, @@ -555,6 +618,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "invoke" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, +] + [[package]] name = "iso8601" version = "2.1.0" @@ -760,6 +832,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] +[[package]] +name = "paramiko" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, +] + [[package]] name = "passlib" version = "1.7.4" @@ -1022,6 +1109,43 @@ milvus-lite = [ { name = "milvus-lite", marker = "sys_platform != 'win32'" }, ] +[[package]] +name = "pynacl" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c6/a3124dee667a423f2c637cfd262a54d67d8ccf3e160f3c50f622a85b7723/pynacl-1.6.0.tar.gz", hash = "sha256:cb36deafe6e2bce3b286e5d1f3e1c246e0ccdb8808ddb4550bb2792f2df298f2", size = 3505641, upload-time = "2025-09-10T23:39:22.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/24/1b639176401255605ba7c2b93a7b1eb1e379e0710eca62613633eb204201/pynacl-1.6.0-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:f46386c24a65383a9081d68e9c2de909b1834ec74ff3013271f1bca9c2d233eb", size = 384141, upload-time = "2025-09-10T23:38:28.675Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7b/874efdf57d6bf172db0df111b479a553c3d9e8bb4f1f69eb3ffff772d6e8/pynacl-1.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dea103a1afcbc333bc0e992e64233d360d393d1e63d0bc88554f572365664348", size = 808132, upload-time = "2025-09-10T23:38:38.995Z" }, + { url = "https://files.pythonhosted.org/packages/f3/61/9b53f5913f3b75ac3d53170cdb897101b2b98afc76f4d9d3c8de5aa3ac05/pynacl-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:04f20784083014e265ad58c1b2dd562c3e35864b5394a14ab54f5d150ee9e53e", size = 1407253, upload-time = "2025-09-10T23:38:40.492Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0a/b138916b22bbf03a1bdbafecec37d714e7489dd7bcaf80cd17852f8b67be/pynacl-1.6.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbcc4452a1eb10cd5217318c822fde4be279c9de8567f78bad24c773c21254f8", size = 843719, upload-time = "2025-09-10T23:38:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/01/3b/17c368197dfb2c817ce033f94605a47d0cc27901542109e640cef263f0af/pynacl-1.6.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fed9fe1bec9e7ff9af31cd0abba179d0e984a2960c77e8e5292c7e9b7f7b5d", size = 1445441, upload-time = "2025-09-10T23:38:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/35/3c/f79b185365ab9be80cd3cd01dacf30bf5895f9b7b001e683b369e0bb6d3d/pynacl-1.6.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:10d755cf2a455d8c0f8c767a43d68f24d163b8fe93ccfaabfa7bafd26be58d73", size = 825691, upload-time = "2025-09-10T23:38:34.832Z" }, + { url = "https://files.pythonhosted.org/packages/f7/1f/8b37d25e95b8f2a434a19499a601d4d272b9839ab8c32f6b0fc1e40c383f/pynacl-1.6.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:536703b8f90e911294831a7fbcd0c062b837f3ccaa923d92a6254e11178aaf42", size = 1410726, upload-time = "2025-09-10T23:38:36.893Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/5a4a4cf9913014f83d615ad6a2df9187330f764f606246b3a744c0788c03/pynacl-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b08eab48c9669d515a344fb0ef27e2cbde847721e34bba94a343baa0f33f1f4", size = 801035, upload-time = "2025-09-10T23:38:42.109Z" }, + { url = "https://files.pythonhosted.org/packages/bf/60/40da6b0fe6a4d5fd88f608389eb1df06492ba2edca93fca0b3bebff9b948/pynacl-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5789f016e08e5606803161ba24de01b5a345d24590a80323379fc4408832d290", size = 1371854, upload-time = "2025-09-10T23:38:44.16Z" }, + { url = "https://files.pythonhosted.org/packages/44/b2/37ac1d65008f824cba6b5bf68d18b76d97d0f62d7a032367ea69d4a187c8/pynacl-1.6.0-cp314-cp314t-win32.whl", hash = "sha256:4853c154dc16ea12f8f3ee4b7e763331876316cc3a9f06aeedf39bcdca8f9995", size = 230345, upload-time = "2025-09-10T23:38:48.276Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5a/9234b7b45af890d02ebee9aae41859b9b5f15fb4a5a56d88e3b4d1659834/pynacl-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:347dcddce0b4d83ed3f32fd00379c83c425abee5a9d2cd0a2c84871334eaff64", size = 243103, upload-time = "2025-09-10T23:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2c/c1a0f19d720ab0af3bc4241af2bdf4d813c3ecdcb96392b5e1ddf2d8f24f/pynacl-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2d6cd56ce4998cb66a6c112fda7b1fdce5266c9f05044fa72972613bef376d15", size = 187778, upload-time = "2025-09-10T23:38:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/63/37/87c72df19857c5b3b47ace6f211a26eb862ada495cc96daa372d96048fca/pynacl-1.6.0-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:f4b3824920e206b4f52abd7de621ea7a44fd3cb5c8daceb7c3612345dfc54f2e", size = 382610, upload-time = "2025-09-10T23:38:49.459Z" }, + { url = "https://files.pythonhosted.org/packages/0c/64/3ce958a5817fd3cc6df4ec14441c43fd9854405668d73babccf77f9597a3/pynacl-1.6.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:16dd347cdc8ae0b0f6187a2608c0af1c8b7ecbbe6b4a06bff8253c192f696990", size = 798744, upload-time = "2025-09-10T23:38:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8a/3f0dd297a0a33fa3739c255feebd0206bb1df0b44c52fbe2caf8e8bc4425/pynacl-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16c60daceee88d04f8d41d0a4004a7ed8d9a5126b997efd2933e08e93a3bd850", size = 1397879, upload-time = "2025-09-10T23:39:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/41/94/028ff0434a69448f61348d50d2c147dda51aabdd4fbc93ec61343332174d/pynacl-1.6.0-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25720bad35dfac34a2bcdd61d9e08d6bfc6041bebc7751d9c9f2446cf1e77d64", size = 833907, upload-time = "2025-09-10T23:38:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/52/bc/a5cff7f8c30d5f4c26a07dfb0bcda1176ab8b2de86dda3106c00a02ad787/pynacl-1.6.0-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bfaa0a28a1ab718bad6239979a5a57a8d1506d0caf2fba17e524dbb409441cf", size = 1436649, upload-time = "2025-09-10T23:38:52.783Z" }, + { url = "https://files.pythonhosted.org/packages/7a/20/c397be374fd5d84295046e398de4ba5f0722dc14450f65db76a43c121471/pynacl-1.6.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ef214b90556bb46a485b7da8258e59204c244b1b5b576fb71848819b468c44a7", size = 817142, upload-time = "2025-09-10T23:38:54.4Z" }, + { url = "https://files.pythonhosted.org/packages/12/30/5efcef3406940cda75296c6d884090b8a9aad2dcc0c304daebb5ae99fb4a/pynacl-1.6.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:49c336dd80ea54780bcff6a03ee1a476be1612423010472e60af83452aa0f442", size = 1401794, upload-time = "2025-09-10T23:38:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/be/e1/a8fe1248cc17ccb03b676d80fa90763760a6d1247da434844ea388d0816c/pynacl-1.6.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f3482abf0f9815e7246d461fab597aa179b7524628a4bc36f86a7dc418d2608d", size = 772161, upload-time = "2025-09-10T23:39:01.93Z" }, + { url = "https://files.pythonhosted.org/packages/a3/76/8a62702fb657d6d9104ce13449db221a345665d05e6a3fdefb5a7cafd2ad/pynacl-1.6.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:140373378e34a1f6977e573033d1dd1de88d2a5d90ec6958c9485b2fd9f3eb90", size = 1370720, upload-time = "2025-09-10T23:39:03.531Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/9e9e9b777a1c4c8204053733e1a0269672c0bd40852908c9ad6b6eaba82c/pynacl-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6b393bc5e5a0eb86bb85b533deb2d2c815666665f840a09e0aa3362bb6088736", size = 791252, upload-time = "2025-09-10T23:39:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d972ce3d92ae05c9091363cf185e8646933f91c376e97b8be79ea6e96c22/pynacl-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a25cfede801f01e54179b8ff9514bd7b5944da560b7040939732d1804d25419", size = 1362910, upload-time = "2025-09-10T23:39:06.924Z" }, + { url = "https://files.pythonhosted.org/packages/35/2c/ee0b373a1861f66a7ca8bdb999331525615061320dd628527a50ba8e8a60/pynacl-1.6.0-cp38-abi3-win32.whl", hash = "sha256:dcdeb41c22ff3c66eef5e63049abf7639e0db4edee57ba70531fc1b6b133185d", size = 226461, upload-time = "2025-09-10T23:39:11.894Z" }, + { url = "https://files.pythonhosted.org/packages/75/f7/41b6c0b9dd9970173b6acc026bab7b4c187e4e5beef2756d419ad65482da/pynacl-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:cf831615cc16ba324240de79d925eacae8265b7691412ac6b24221db157f6bd1", size = 238802, upload-time = "2025-09-10T23:39:08.966Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0f/462326910c6172fa2c6ed07922b22ffc8e77432b3affffd9e18f444dbfbb/pynacl-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:84709cea8f888e618c21ed9a0efdb1a59cc63141c403db8bf56c469b71ad56f2", size = 183846, upload-time = "2025-09-10T23:39:10.552Z" }, +] + [[package]] name = "pypika-tortoise" version = "0.6.2"