feat(ai-studio): add remote image loading helpers
This commit is contained in:
@@ -553,6 +553,23 @@ def mark_asset_status(asset_id, status, path=None, conn=None):
|
||||
return get_asset(asset_id, conn=database)
|
||||
|
||||
|
||||
def update_asset_local_path(asset_id, local_path, status=ASSET_STATUS_AVAILABLE, path=None, conn=None):
|
||||
if str(status) not in ASSET_STATUSES:
|
||||
raise db.DbError("AI工场资产状态必须是 available 或 missing")
|
||||
abs_local_path = os.path.abspath(local_path) if local_path else None
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE image_studio_assets
|
||||
SET local_path = ?, status = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(abs_local_path, str(status), _now(), int(asset_id)),
|
||||
)
|
||||
return get_asset(asset_id, conn=database)
|
||||
|
||||
|
||||
def create_job(
|
||||
project_id,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Remote image helpers for the AI image studio."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from . import appconfig, image_studio
|
||||
|
||||
|
||||
THUMBNAIL_MAX_BYTES = 3 * 1024 * 1024
|
||||
ORIGINAL_MAX_BYTES = 12 * 1024 * 1024
|
||||
DEFAULT_CONNECT_TIMEOUT_SECONDS = 5
|
||||
DEFAULT_READ_TIMEOUT_SECONDS = 30
|
||||
DEFAULT_THUMBNAIL_SIZE = 220
|
||||
DEFAULT_THUMBNAIL_WORKERS = 4
|
||||
|
||||
|
||||
class ImageStudioImageError(RuntimeError):
|
||||
"""Raised when a remote image cannot be safely loaded or saved."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteImage:
|
||||
url: str
|
||||
content: bytes
|
||||
content_type: str
|
||||
final_url: str
|
||||
redirected: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThumbnailResult:
|
||||
key: str
|
||||
url: str
|
||||
image_bytes: bytes
|
||||
width: int
|
||||
height: int
|
||||
from_cache: bool = False
|
||||
|
||||
|
||||
def _session():
|
||||
session = requests.Session()
|
||||
session.trust_env = False
|
||||
return session
|
||||
|
||||
|
||||
def _assert_public_http_url(url):
|
||||
parts = urllib.parse.urlsplit(str(url or ""))
|
||||
if parts.scheme not in {"http", "https"}:
|
||||
raise ImageStudioImageError("远程图片地址只允许 http/https")
|
||||
host = parts.hostname
|
||||
if not host:
|
||||
raise ImageStudioImageError("远程图片地址缺少域名")
|
||||
if _is_local_hostname(host):
|
||||
raise ImageStudioImageError("远程图片地址不能指向本机或内网")
|
||||
try:
|
||||
_assert_public_ip(host)
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
addresses = socket.getaddrinfo(
|
||||
host,
|
||||
parts.port or (443 if parts.scheme == "https" else 80),
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ImageStudioImageError(f"远程图片地址无法解析:{exc}") from exc
|
||||
if not addresses:
|
||||
raise ImageStudioImageError("远程图片地址无法解析")
|
||||
for address in addresses:
|
||||
_assert_public_ip(address[4][0])
|
||||
|
||||
|
||||
def _is_local_hostname(host):
|
||||
lowered = str(host or "").strip().lower().rstrip(".")
|
||||
return lowered in {"localhost"} or lowered.endswith(".localhost") or lowered.endswith(".local")
|
||||
|
||||
|
||||
def _assert_public_ip(value):
|
||||
ip = ipaddress.ip_address(value)
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise ImageStudioImageError("远程图片地址不能指向本机或内网")
|
||||
|
||||
|
||||
def _safe_headers(referer=None):
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
}
|
||||
if referer:
|
||||
headers["Referer"] = str(referer)
|
||||
return headers
|
||||
|
||||
|
||||
def _validate_content_type(value):
|
||||
content_type = str(value or "").split(";", 1)[0].strip().lower()
|
||||
if content_type and not content_type.startswith("image/"):
|
||||
raise ImageStudioImageError("远程地址返回的不是图片内容")
|
||||
return content_type
|
||||
|
||||
|
||||
def download_remote_image(
|
||||
url,
|
||||
*,
|
||||
referer=None,
|
||||
max_bytes=ORIGINAL_MAX_BYTES,
|
||||
timeout=(DEFAULT_CONNECT_TIMEOUT_SECONDS, DEFAULT_READ_TIMEOUT_SECONDS),
|
||||
session=None,
|
||||
) -> RemoteImage:
|
||||
"""Download remote image bytes with SSRF, timeout and size guards."""
|
||||
|
||||
url = str(url or "").strip()
|
||||
_assert_public_http_url(url)
|
||||
client = session or _session()
|
||||
try:
|
||||
response = client.get(
|
||||
url,
|
||||
stream=True,
|
||||
timeout=timeout,
|
||||
headers=_safe_headers(referer),
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise ImageStudioImageError(f"远程图片下载失败:{exc}") from exc
|
||||
status = getattr(response, "status_code", 200)
|
||||
if status >= 400:
|
||||
raise ImageStudioImageError(f"远程图片下载失败:HTTP {status}")
|
||||
final_url = str(getattr(response, "url", "") or url)
|
||||
_assert_public_http_url(final_url)
|
||||
content_type = _validate_content_type(getattr(response, "headers", {}).get("Content-Type", ""))
|
||||
content_length = getattr(response, "headers", {}).get("Content-Length")
|
||||
if content_length:
|
||||
try:
|
||||
if int(content_length) > int(max_bytes):
|
||||
raise ImageStudioImageError("远程图片超过大小上限")
|
||||
except ValueError:
|
||||
pass
|
||||
chunks = []
|
||||
total = 0
|
||||
iterator = response.iter_content(chunk_size=65536) if hasattr(response, "iter_content") else [response.content]
|
||||
for chunk in iterator:
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > int(max_bytes):
|
||||
raise ImageStudioImageError("远程图片超过大小上限")
|
||||
chunks.append(chunk)
|
||||
content = b"".join(chunks)
|
||||
_image_info(content)
|
||||
return RemoteImage(
|
||||
url=url,
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
final_url=final_url,
|
||||
redirected=final_url != url,
|
||||
)
|
||||
|
||||
|
||||
def _image_info(image_bytes):
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||
image.verify()
|
||||
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||
return {
|
||||
"format": str(image.format or "JPEG").upper(),
|
||||
"width": int(image.width),
|
||||
"height": int(image.height),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise ImageStudioImageError(f"图片解码失败:{exc}") from exc
|
||||
|
||||
|
||||
def _thumbnail_bytes(image_bytes, max_size=DEFAULT_THUMBNAIL_SIZE):
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||
image.thumbnail((int(max_size), int(max_size)))
|
||||
output = io.BytesIO()
|
||||
image.convert("RGBA").save(output, format="PNG")
|
||||
return output.getvalue(), image.width, image.height
|
||||
except Exception as exc:
|
||||
raise ImageStudioImageError(f"缩略图解码失败:{exc}") from exc
|
||||
|
||||
|
||||
def load_thumbnail(url, *, key=None, max_size=DEFAULT_THUMBNAIL_SIZE, referer=None, session=None):
|
||||
remote = download_remote_image(
|
||||
url,
|
||||
referer=referer,
|
||||
max_bytes=THUMBNAIL_MAX_BYTES,
|
||||
session=session,
|
||||
)
|
||||
image_bytes, width, height = _thumbnail_bytes(remote.content, max_size=max_size)
|
||||
return ThumbnailResult(
|
||||
key=str(key or url),
|
||||
url=remote.url,
|
||||
image_bytes=image_bytes,
|
||||
width=width,
|
||||
height=height,
|
||||
from_cache=False,
|
||||
)
|
||||
|
||||
|
||||
class ThumbnailLoader:
|
||||
"""Small thread-pool thumbnail loader with memory cache and cooperative cancel."""
|
||||
|
||||
def __init__(self, max_workers=DEFAULT_THUMBNAIL_WORKERS, loader: Optional[Callable] = None):
|
||||
self._executor = ThreadPoolExecutor(max_workers=max(1, int(max_workers or 1)))
|
||||
self._loader = loader or load_thumbnail
|
||||
self._cache = {}
|
||||
self._cancelled = set()
|
||||
self._closed = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def submit(self, key, url, on_success=None, on_error=None):
|
||||
key = str(key)
|
||||
url = str(url or "").strip()
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise ImageStudioImageError("缩略图加载器已关闭")
|
||||
cached = self._cache.get(url)
|
||||
if cached is not None:
|
||||
result = ThumbnailResult(key, url, cached.image_bytes, cached.width, cached.height, from_cache=True)
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
return None
|
||||
self._cancelled.discard(key)
|
||||
future = self._executor.submit(self._loader, url, key=key)
|
||||
|
||||
def done_callback(done):
|
||||
try:
|
||||
result = done.result()
|
||||
with self._lock:
|
||||
if key in self._cancelled or self._closed:
|
||||
return
|
||||
self._cache[url] = result
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
if key in self._cancelled or self._closed:
|
||||
return
|
||||
if on_error is not None:
|
||||
on_error(key, exc)
|
||||
|
||||
future.add_done_callback(done_callback)
|
||||
return future
|
||||
|
||||
def cancel(self, key=None):
|
||||
with self._lock:
|
||||
if key is None:
|
||||
self._cancelled.add("*")
|
||||
self._closed = True
|
||||
else:
|
||||
self._cancelled.add(str(key))
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
self._closed = True
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
def _extension_for_format(format_name):
|
||||
normalized = str(format_name or "").strip().upper()
|
||||
if normalized in {"JPEG", "JPG"}:
|
||||
return ".jpg"
|
||||
if normalized == "PNG":
|
||||
return ".png"
|
||||
if normalized == "WEBP":
|
||||
return ".webp"
|
||||
return ".img"
|
||||
|
||||
|
||||
def _original_file_path(project, asset, image_root):
|
||||
dirs = image_studio.project_image_dirs(image_root, project)
|
||||
digest = hashlib.sha1(str(asset.remote_url or asset.id).encode("utf-8")).hexdigest()[:10]
|
||||
stem = "original_%02d_%s" % (max(1, int(asset.source_order or 1)), digest)
|
||||
return dirs["originals"], stem
|
||||
|
||||
|
||||
def _existing_local_asset(asset):
|
||||
local_path = str(asset.local_path or "").strip()
|
||||
if not local_path or not os.path.isfile(local_path):
|
||||
return None
|
||||
try:
|
||||
with open(local_path, "rb") as fh:
|
||||
_image_info(fh.read())
|
||||
return asset
|
||||
except ImageStudioImageError:
|
||||
return None
|
||||
|
||||
|
||||
def download_original_asset(asset_id, *, path=None, config=None, image_root=None, session=None):
|
||||
"""Download one Shopee original image into originals/ and mark its asset available."""
|
||||
|
||||
cfg = appconfig.load_config() if config is None else config
|
||||
database_path = path or appconfig.db_path(cfg)
|
||||
asset = image_studio.get_asset(asset_id, path=database_path)
|
||||
if asset is None:
|
||||
raise ImageStudioImageError("原图资产不存在")
|
||||
if not asset.remote_url:
|
||||
raise ImageStudioImageError("原图资产缺少远程 URL")
|
||||
existing = _existing_local_asset(asset)
|
||||
if existing is not None:
|
||||
return existing
|
||||
project = image_studio.get_project(asset.project_id, path=database_path)
|
||||
if project is None:
|
||||
raise ImageStudioImageError("原图资产所属项目不存在")
|
||||
remote = download_remote_image(asset.remote_url, max_bytes=ORIGINAL_MAX_BYTES, session=session)
|
||||
info = _image_info(remote.content)
|
||||
directory, stem = _original_file_path(project, asset, image_root or appconfig.image_dir(cfg))
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
final_path = os.path.join(directory, stem + _extension_for_format(info["format"]))
|
||||
temp_path = final_path + ".tmp-" + uuid.uuid4().hex
|
||||
try:
|
||||
with open(temp_path, "wb") as fh:
|
||||
fh.write(remote.content)
|
||||
with open(temp_path, "rb") as fh:
|
||||
_image_info(fh.read())
|
||||
os.replace(temp_path, final_path)
|
||||
except Exception as exc:
|
||||
try:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
finally:
|
||||
if isinstance(exc, ImageStudioImageError):
|
||||
raise
|
||||
raise ImageStudioImageError(f"保存原图失败:{exc}") from exc
|
||||
return image_studio.update_asset_local_path(
|
||||
asset.id,
|
||||
final_path,
|
||||
status=image_studio.ASSET_STATUS_AVAILABLE,
|
||||
path=database_path,
|
||||
)
|
||||
+9
-3
@@ -2,8 +2,8 @@
|
||||
id: T-588
|
||||
title: AI工场远程缩略图与蝦皮原图进入照片池
|
||||
phase: 7
|
||||
deps: [T-587, T-546]
|
||||
status: TODO
|
||||
deps: [T-587, T-548]
|
||||
status: DONE
|
||||
created: 2026-07-11
|
||||
---
|
||||
|
||||
@@ -35,4 +35,10 @@ created: 2026-07-11
|
||||
|
||||
## 执行记录
|
||||
|
||||
(完成后记录 CDN 实探、缓存和下载验证。)
|
||||
- 2026-07-11:完成 AI工场远程图片底层能力。
|
||||
- 修正任务依赖:原 `T-546` 任务文件不存在,实际复用已完成的 T-548 图片下载安全/超时口径,frontmatter 改为 `deps: [T-587, T-548]`。
|
||||
- 新增 `app/image_studio_images.py`:提供远程图片安全下载(http/https、公网地址、无系统代理、连接/读取超时、大小上限、Content-Type 与最终重定向 URL 校验、PIL 解码校验)、内存缩略图生成、线程池 `ThumbnailLoader`(缓存、取消、失败回调)和单张原图下载落 `originals/`。
|
||||
- `download_original_asset()` 只在用户单击时下载对应原图,写临时文件、解码校验后 `os.replace()` 原子落盘,并通过 `image_studio.update_asset_local_path()` 回写同一 remote asset;重复单击已有可解码本地文件时直接返回,不重复请求。
|
||||
- 新增 `tests/test_image_studio_images.py` 覆盖缩略图不落盘、缓存命中、取消、原图落盘幂等、坏图片不留下半文件/可用 asset、非图片、超限、内网 URL 和重定向到内网 URL。
|
||||
- 验证:在只套用 T-588 diff 的 clean worktree 中运行 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(355 tests)和 `git diff --check`,全部通过。
|
||||
- CDN 真实实探未执行:本轮没有指定可用的已登录测试账号/商品现场;代码已记录可观测字段 `content_type/final_url/redirected` 并覆盖回退与边界,后续接入 T-591 GUI 前建议用测试账号补一条真实 Shopee CDN 记录。
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import io
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from _helpers import TempDirMixin
|
||||
|
||||
from app import db, image_studio, image_studio_images
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(
|
||||
self,
|
||||
content=b"",
|
||||
status_code=200,
|
||||
headers=None,
|
||||
url="https://cdn.example.com/image.png",
|
||||
chunk_size=None,
|
||||
):
|
||||
self.content = content
|
||||
self.status_code = status_code
|
||||
self.headers = dict(headers or {})
|
||||
self.url = url
|
||||
self.chunk_size = chunk_size
|
||||
|
||||
def iter_content(self, chunk_size=65536):
|
||||
if self.chunk_size:
|
||||
for index in range(0, len(self.content), self.chunk_size):
|
||||
yield self.content[index:index + self.chunk_size]
|
||||
return
|
||||
yield self.content
|
||||
|
||||
|
||||
class ImageStudioImageTests(TempDirMixin, unittest.TestCase):
|
||||
def _png_bytes(self, size=(20, 16), color=(80, 120, 200, 255)):
|
||||
from PIL import Image
|
||||
|
||||
output = io.BytesIO()
|
||||
Image.new("RGBA", size, color).save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
def _public_dns(self):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443))]
|
||||
|
||||
def _project_and_asset(self, temp_dir, remote_url="https://cdn.example.com/source.png"):
|
||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||
db.init_db(db_path)
|
||||
project = image_studio.create_or_get_project(
|
||||
account_alias="alias",
|
||||
account_slug="alias_slug",
|
||||
item_id="51100639510",
|
||||
path=db_path,
|
||||
)
|
||||
asset = image_studio.sync_original_asset_urls(
|
||||
project.id,
|
||||
[{"index": 1, "src": remote_url}],
|
||||
path=db_path,
|
||||
)[0]
|
||||
return db_path, project, asset
|
||||
|
||||
def test_download_remote_image_rejects_private_non_image_overlimit_and_private_redirect(self):
|
||||
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "http/https|本机或内网"):
|
||||
image_studio_images.download_remote_image("http://127.0.0.1/a.png")
|
||||
|
||||
non_image_session = SimpleNamespace(
|
||||
get=lambda *args, **kwargs: _Response(
|
||||
content=b"hello",
|
||||
headers={"Content-Type": "text/html"},
|
||||
)
|
||||
)
|
||||
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
|
||||
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "不是图片"):
|
||||
image_studio_images.download_remote_image(
|
||||
"https://cdn.example.com/a.png",
|
||||
session=non_image_session,
|
||||
)
|
||||
|
||||
oversized_session = SimpleNamespace(
|
||||
get=lambda *args, **kwargs: _Response(
|
||||
content=b"x",
|
||||
headers={"Content-Type": "image/png", "Content-Length": "999"},
|
||||
)
|
||||
)
|
||||
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
|
||||
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "大小上限"):
|
||||
image_studio_images.download_remote_image(
|
||||
"https://cdn.example.com/a.png",
|
||||
session=oversized_session,
|
||||
max_bytes=10,
|
||||
)
|
||||
|
||||
redirect_session = SimpleNamespace(
|
||||
get=lambda *args, **kwargs: _Response(
|
||||
content=self._png_bytes(),
|
||||
headers={"Content-Type": "image/png"},
|
||||
url="http://127.0.0.1/private.png",
|
||||
)
|
||||
)
|
||||
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
|
||||
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "本机或内网"):
|
||||
image_studio_images.download_remote_image(
|
||||
"https://cdn.example.com/a.png",
|
||||
session=redirect_session,
|
||||
)
|
||||
|
||||
def test_load_thumbnail_decodes_image_without_writing_file(self):
|
||||
png = self._png_bytes(size=(640, 480))
|
||||
session = SimpleNamespace(
|
||||
get=lambda *args, **kwargs: _Response(
|
||||
content=png,
|
||||
headers={"Content-Type": "image/png"},
|
||||
)
|
||||
)
|
||||
|
||||
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
|
||||
result = image_studio_images.load_thumbnail(
|
||||
"https://cdn.example.com/a.png",
|
||||
key="thumb-1",
|
||||
max_size=120,
|
||||
session=session,
|
||||
)
|
||||
|
||||
self.assertEqual("thumb-1", result.key)
|
||||
self.assertLessEqual(result.width, 120)
|
||||
self.assertLessEqual(result.height, 120)
|
||||
self.assertTrue(result.image_bytes.startswith(b"\x89PNG"))
|
||||
|
||||
def test_thumbnail_loader_uses_memory_cache_and_can_cancel(self):
|
||||
calls = []
|
||||
successes = []
|
||||
errors = []
|
||||
|
||||
def fake_loader(url, key=None):
|
||||
calls.append((key, url))
|
||||
return image_studio_images.ThumbnailResult(
|
||||
key=str(key),
|
||||
url=url,
|
||||
image_bytes=b"png",
|
||||
width=10,
|
||||
height=10,
|
||||
)
|
||||
|
||||
loader = image_studio_images.ThumbnailLoader(max_workers=1, loader=fake_loader)
|
||||
try:
|
||||
future = loader.submit("a", "https://cdn.example.com/a.png", successes.append, errors.append)
|
||||
future.result(timeout=2)
|
||||
for _ in range(20):
|
||||
if successes:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
loader.submit("b", "https://cdn.example.com/a.png", successes.append, errors.append)
|
||||
|
||||
self.assertEqual([("a", "https://cdn.example.com/a.png")], calls)
|
||||
self.assertEqual(2, len(successes))
|
||||
self.assertFalse(successes[0].from_cache)
|
||||
self.assertTrue(successes[1].from_cache)
|
||||
self.assertEqual([], errors)
|
||||
|
||||
release = threading.Event()
|
||||
|
||||
def slow_loader(url, key=None):
|
||||
release.wait(1)
|
||||
return image_studio_images.ThumbnailResult(
|
||||
key=str(key),
|
||||
url=url,
|
||||
image_bytes=b"png",
|
||||
width=10,
|
||||
height=10,
|
||||
)
|
||||
|
||||
loader._loader = slow_loader
|
||||
future = loader.submit("c", "https://cdn.example.com/c.png", successes.append, errors.append)
|
||||
loader.cancel("c")
|
||||
release.set()
|
||||
future.result(timeout=2)
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(2, len(successes))
|
||||
finally:
|
||||
loader.close()
|
||||
|
||||
def test_download_original_asset_writes_atomically_and_is_idempotent(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path, _, asset = self._project_and_asset(temp_dir)
|
||||
png = self._png_bytes()
|
||||
session = mock.Mock()
|
||||
session.get.return_value = _Response(
|
||||
content=png,
|
||||
headers={"Content-Type": "image/png"},
|
||||
)
|
||||
|
||||
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
|
||||
downloaded = image_studio_images.download_original_asset(
|
||||
asset.id,
|
||||
path=db_path,
|
||||
config={"data_dir": temp_dir, "image_dir": os.path.join(temp_dir, "images")},
|
||||
session=session,
|
||||
)
|
||||
second = image_studio_images.download_original_asset(
|
||||
asset.id,
|
||||
path=db_path,
|
||||
config={"data_dir": temp_dir, "image_dir": os.path.join(temp_dir, "images")},
|
||||
session=mock.Mock(),
|
||||
)
|
||||
|
||||
self.assertEqual(downloaded.id, second.id)
|
||||
self.assertEqual(downloaded.local_path, second.local_path)
|
||||
self.assertTrue(os.path.isfile(downloaded.local_path))
|
||||
self.assertTrue(downloaded.local_path.endswith(".png"))
|
||||
self.assertEqual(image_studio.ASSET_STATUS_AVAILABLE, downloaded.status)
|
||||
session.get.assert_called_once()
|
||||
originals = os.path.dirname(downloaded.local_path)
|
||||
self.assertFalse([name for name in os.listdir(originals) if ".tmp-" in name])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_download_original_asset_bad_image_leaves_asset_without_local_path(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
db_path, project, asset = self._project_and_asset(temp_dir)
|
||||
session = SimpleNamespace(
|
||||
get=lambda *args, **kwargs: _Response(
|
||||
content=b"not an image",
|
||||
headers={"Content-Type": "image/png"},
|
||||
)
|
||||
)
|
||||
|
||||
with mock.patch("app.image_studio_images.socket.getaddrinfo", return_value=self._public_dns()):
|
||||
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "解码失败"):
|
||||
image_studio_images.download_original_asset(
|
||||
asset.id,
|
||||
path=db_path,
|
||||
config={"data_dir": temp_dir, "image_dir": os.path.join(temp_dir, "images")},
|
||||
session=session,
|
||||
)
|
||||
|
||||
refreshed = image_studio.get_asset(asset.id, path=db_path)
|
||||
self.assertIsNone(refreshed.local_path)
|
||||
dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), project)
|
||||
self.assertFalse(os.path.exists(dirs["originals"]))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user