codex-image-generation 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,526 @@
1
+ """Validate local image bytes and preserve each successfully saved asset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import hashlib
7
+ import io
8
+ import os
9
+ import re
10
+ import shutil
11
+ import stat
12
+ import tempfile
13
+ import unicodedata
14
+ import warnings
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any
19
+ from uuid import uuid4
20
+
21
+ from PIL import Image, UnidentifiedImageError
22
+
23
+ from .errors import ImageToolError
24
+
25
+ MAX_INPUT_BYTES = 50_000_000
26
+ MAX_OUTPUT_BYTES = 100 * 1024 * 1024
27
+ FORMATS = {"png": "image/png", "jpeg": "image/jpeg", "webp": "image/webp"}
28
+ EXTENSIONS = {"png": ".png", "jpeg": ".jpg", "webp": ".webp"}
29
+ _RESERVED_NAMES = {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}
30
+ _RESERVED_NAMES.update(f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10))
31
+ _RESERVED_NAMES.update(f"{prefix}{number}" for prefix in ("COM", "LPT") for number in "¹²³")
32
+ _UNSUPPORTED_LINK_ERRORS = {
33
+ errno.EPERM, errno.EXDEV, errno.ENOSYS,
34
+ getattr(errno, "ENOTSUP", errno.EPERM),
35
+ getattr(errno, "EOPNOTSUPP", errno.EPERM),
36
+ }
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ImageInfo:
41
+ format: str
42
+ width: int
43
+ height: int
44
+ has_alpha: bool
45
+ has_transparency: bool = False
46
+
47
+ @property
48
+ def mime_type(self) -> str:
49
+ return FORMATS[self.format]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class PreparedImage:
54
+ data: bytes
55
+ info: ImageInfo
56
+ revised_prompt: str | None = None
57
+ call_id: str | None = None
58
+ metadata: dict[str, Any] = field(default_factory=dict)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class GeneratedImage:
63
+ path: Path
64
+ data: bytes
65
+ mime_type: str
66
+ width: int
67
+ height: int
68
+ revised_prompt: str | None = None
69
+ call_id: str | None = None
70
+ metadata: dict[str, Any] = field(default_factory=dict)
71
+
72
+
73
+ @dataclass
74
+ class SaveResult:
75
+ images: list[GeneratedImage] = field(default_factory=list)
76
+ errors: list[dict[str, Any]] = field(default_factory=list)
77
+ warnings: list[str] = field(default_factory=list)
78
+
79
+
80
+ def _identity(path: Path) -> tuple[int, int] | None:
81
+ try:
82
+ info = path.lstat()
83
+ except FileNotFoundError:
84
+ return None
85
+ if not stat.S_ISREG(info.st_mode):
86
+ return None
87
+ return info.st_dev, info.st_ino
88
+
89
+
90
+ def _utf16_length(value: str) -> int:
91
+ try:
92
+ return len(value.encode("utf-16-le")) // 2
93
+ except UnicodeError:
94
+ raise ImageToolError("filename 包含无效 Unicode 字符。") from None
95
+
96
+
97
+ def _filename_limit(directory: Path) -> int | None:
98
+ if os.name == "nt" or not hasattr(os, "pathconf"):
99
+ return None
100
+ try:
101
+ limit = os.pathconf(directory, "PC_NAME_MAX")
102
+ return limit if limit > 0 else None
103
+ except (OSError, ValueError):
104
+ return None
105
+
106
+
107
+ @dataclass
108
+ class SavePlan:
109
+ cwd: Path
110
+ output_dir: Path
111
+ filename: str
112
+ output_format: str
113
+ _locks: dict[Path, str] = field(default_factory=dict, repr=False)
114
+ _temporary_paths: dict[Path, tuple[int, int]] = field(default_factory=dict, repr=False)
115
+ _lock_identities: dict[Path, tuple[int, int]] = field(default_factory=dict, repr=False)
116
+ _complete_locks: set[Path] = field(default_factory=set, repr=False)
117
+ _released: bool = field(default=False, repr=False)
118
+ _next_ordinal: int = field(default=1, repr=False)
119
+ _name_limit: int | None = field(default=None, repr=False)
120
+
121
+ @property
122
+ def project_dir(self) -> Path:
123
+ """Compatibility alias for callers migrating from the former API."""
124
+ return self.cwd
125
+
126
+ def _lock_path(self, filename: str) -> Path:
127
+ # Locks live in the target directory, so only the stem identifies them.
128
+ # Conservatively share reservations on case/normalization equivalents,
129
+ # including macOS volumes whose rules differ from os.path.normcase().
130
+ canonical = unicodedata.normalize("NFC", Path(filename).stem)
131
+ canonical = unicodedata.normalize("NFC", canonical.casefold())
132
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
133
+ return self.output_dir / f".codex-image-{digest}.lock"
134
+
135
+ def _acquire(self, filename: str) -> Path:
136
+ if self._released:
137
+ raise ImageToolError("图片保存预留已经释放,不能继续使用。")
138
+ lock_path = self._lock_path(filename)
139
+ token = f"{os.getpid()}:{uuid4().hex}"
140
+ with lock_path.open("xb") as handle:
141
+ self._locks[lock_path] = token
142
+ identity = _identity(lock_path)
143
+ if identity is not None:
144
+ self._lock_identities[lock_path] = identity
145
+ encoded = token.encode("ascii")
146
+ if handle.write(encoded) != len(encoded):
147
+ raise OSError(errno.ENOSPC, "Incomplete lock write")
148
+ handle.flush()
149
+ self._complete_locks.add(lock_path)
150
+ return lock_path
151
+
152
+ def _release_lock(self, lock_path: Path) -> None:
153
+ token = self._locks.get(lock_path)
154
+ if token is None:
155
+ return
156
+ try:
157
+ # A replaced lock is no longer ours. Never clear another request's lock.
158
+ identity = _identity(lock_path)
159
+ if identity != self._lock_identities.get(lock_path) or (
160
+ lock_path in self._complete_locks and lock_path.read_bytes() != token.encode("ascii")
161
+ ):
162
+ self._locks.pop(lock_path, None)
163
+ self._lock_identities.pop(lock_path, None)
164
+ self._complete_locks.discard(lock_path)
165
+ return
166
+ lock_path.unlink()
167
+ except FileNotFoundError:
168
+ pass
169
+ except OSError:
170
+ return
171
+ self._locks.pop(lock_path, None)
172
+ self._lock_identities.pop(lock_path, None)
173
+ self._complete_locks.discard(lock_path)
174
+
175
+ def _remember_temporary(self, path: Path) -> None:
176
+ identity = _identity(path)
177
+ if identity is not None:
178
+ self._temporary_paths[path] = identity
179
+
180
+ def _cleanup_temporary(self, path: Path) -> None:
181
+ identity = self._temporary_paths.get(path)
182
+ if identity is None:
183
+ return
184
+ try:
185
+ if _identity(path) == identity:
186
+ path.unlink()
187
+ except FileNotFoundError:
188
+ pass
189
+ except OSError:
190
+ return
191
+ self._temporary_paths.pop(path, None)
192
+
193
+ def _probe_target(self, target: Path) -> None:
194
+ # Check the exact destination name, including filesystem length rules.
195
+ # The cooperating request lock remains held after this empty probe is removed.
196
+ with target.open("xb"):
197
+ self._remember_temporary(target)
198
+ target.unlink()
199
+ self._temporary_paths.pop(target, None)
200
+
201
+ def _numbered_name(self, ordinal: int, actual_format: str | None = None) -> str:
202
+ name = Path(self.filename)
203
+ suffix = name.suffix
204
+ if actual_format is not None and normalize_format(suffix[1:]) != actual_format:
205
+ suffix = EXTENSIONS[actual_format]
206
+ if ordinal == 1 and suffix == name.suffix:
207
+ return self.filename
208
+ tail = (f"_{ordinal}" if ordinal > 1 else "") + suffix
209
+ stem = name.stem
210
+ while stem:
211
+ candidate = stem + tail
212
+ if _utf16_length(candidate) <= 255 and (
213
+ self._name_limit is None or len(os.fsencode(candidate)) <= self._name_limit
214
+ ):
215
+ return candidate
216
+ stem = stem[:-1]
217
+ raise ImageToolError("输出文件名无法容纳图片序号,请使用更短的 filename。")
218
+
219
+ def paths(self, count: int) -> list[Path]:
220
+ """Preview nominal names; actual later names may skip occupied numbers."""
221
+ return [self.output_dir / self._numbered_name(index + 1) for index in range(count)]
222
+
223
+ def _next_target(self, actual_format: str) -> Path:
224
+ if self._released:
225
+ raise ImageToolError("图片保存预留已经释放,不能继续使用。")
226
+ ordinal = self._next_ordinal
227
+ if ordinal == 1:
228
+ self._next_ordinal = 2
229
+ name = self._numbered_name(1, actual_format)
230
+ target = self.output_dir / name
231
+ if not target.exists() and not target.is_symlink():
232
+ try:
233
+ if name != self.filename:
234
+ self._probe_target(target)
235
+ return target
236
+ except FileExistsError:
237
+ if name == self.filename:
238
+ raise
239
+ elif name == self.filename:
240
+ raise FileExistsError(errno.EEXIST, "Reserved destination already exists")
241
+ # A corrected-format destination may already exist; preserve it and
242
+ # choose a numbered name for this valid, already generated image.
243
+ ordinal = 2
244
+ for ordinal in range(ordinal, ordinal + 10000):
245
+ name = self._numbered_name(ordinal, actual_format)
246
+ target = self.output_dir / name
247
+ if target.exists() or target.is_symlink():
248
+ continue
249
+ try:
250
+ lock_path = self._acquire(name)
251
+ except FileExistsError:
252
+ continue
253
+ try:
254
+ self._probe_target(target)
255
+ except FileExistsError:
256
+ self._release_lock(lock_path)
257
+ continue
258
+ except BaseException:
259
+ self._release_lock(lock_path)
260
+ raise
261
+ self._next_ordinal = ordinal + 1
262
+ return target
263
+ raise ImageToolError("无法找到可用的图片序号,请更换 filename。")
264
+
265
+ def release(self) -> list[dict[str, Any]]:
266
+ """Release only this request's reservations and unfinished temporary files.
267
+
268
+ Successful images are never tracked as temporary files. Cleanup is idempotent
269
+ and best-effort so that a cleanup problem cannot replace a successful result.
270
+ Calling it again retries any cleanup that the filesystem temporarily rejected.
271
+ """
272
+ self._released = True
273
+ for path in list(self._temporary_paths):
274
+ self._cleanup_temporary(path)
275
+ for path in list(self._locks):
276
+ self._release_lock(path)
277
+ if self._temporary_paths or self._locks:
278
+ return [{
279
+ "code": "cleanup_incomplete",
280
+ "message": "部分本请求的临时文件或预留锁无法清理,请检查输出目录;已保存图片不受影响。",
281
+ "call_id": None,
282
+ }]
283
+ return []
284
+
285
+ def __enter__(self) -> SavePlan:
286
+ return self
287
+
288
+ def __exit__(self, *args: Any) -> None:
289
+ self.release()
290
+
291
+
292
+ def normalize_format(value: str) -> str:
293
+ if not isinstance(value, str):
294
+ raise ImageToolError("output_format 必须是 png、jpeg 或 webp。")
295
+ result = value.strip().lower()
296
+ if result == "jpg":
297
+ result = "jpeg"
298
+ if result not in FORMATS:
299
+ raise ImageToolError("只支持 PNG、JPEG/JPG 和 WebP 图片格式,不支持 SVG 或 GIF 输出。")
300
+ return result
301
+
302
+
303
+ def _validate_filename(filename: str) -> None:
304
+ if not isinstance(filename, str) or not filename or filename in {".", ".."}:
305
+ raise ImageToolError("filename 必须是非空文件名。")
306
+ if re.search(r'[<>:"/\\|?*\x00-\x1f\x7f]', filename) or filename.endswith((".", " ")):
307
+ raise ImageToolError("filename 只能是文件名,不能包含目录、控制字符或系统不允许的字符。")
308
+ # Windows also treats "NUL .png" and superscript COM/LPT digits as device names.
309
+ if filename.split(".", 1)[0].rstrip(" .").upper() in _RESERVED_NAMES:
310
+ raise ImageToolError("filename 不能使用 Windows 保留名称。")
311
+ if _utf16_length(filename) > 255:
312
+ raise ImageToolError("filename 太长,不能超过 255 个 UTF-16 单位;表情等字符通常占两个单位。")
313
+
314
+
315
+ def resolve_format(output_format: str | None, filename: str | None) -> str:
316
+ inferred = None
317
+ if filename is not None:
318
+ _validate_filename(filename)
319
+ suffix = Path(filename).suffix
320
+ if suffix:
321
+ inferred = normalize_format(suffix[1:])
322
+ requested = normalize_format(output_format) if output_format is not None else None
323
+ if requested and inferred and requested != inferred:
324
+ raise ImageToolError("filename 的扩展名与 output_format 不一致,请调整其中一项。")
325
+ return requested or inferred or "png"
326
+
327
+
328
+ def resolve_cwd(cwd: str) -> Path:
329
+ if not isinstance(cwd, str) or not cwd.strip():
330
+ raise ImageToolError("请由 Codex 传入当前任务的绝对路径 cwd。")
331
+ try:
332
+ directory = Path(cwd).expanduser()
333
+ if not directory.is_absolute():
334
+ raise ImageToolError("cwd 必须是当前任务的绝对路径。")
335
+ directory = directory.resolve(strict=True)
336
+ if not directory.is_dir():
337
+ raise ImageToolError("cwd 必须指向已有的任务目录。")
338
+ except (OSError, RuntimeError, ValueError):
339
+ raise ImageToolError("无法访问 cwd,请检查任务目录是否存在。") from None
340
+ return directory
341
+
342
+
343
+ def resolve_project(project_dir: str) -> Path:
344
+ """Compatibility alias; paths now refer to the current task directory."""
345
+ return resolve_cwd(project_dir)
346
+
347
+
348
+ def resolve_project_path(project_dir: Path, value: str, *, label: str) -> Path:
349
+ if not isinstance(value, str) or not value.strip():
350
+ raise ImageToolError(f"{label} 必须是非空路径。")
351
+ try:
352
+ path = Path(value).expanduser()
353
+ if path.is_absolute():
354
+ return path.resolve()
355
+ path = (project_dir / path).resolve()
356
+ if not path.is_relative_to(project_dir):
357
+ raise ImageToolError(f"相对 {label} 不能指向当前任务目录之外;请明确提供绝对路径。")
358
+ return path
359
+ except (OSError, RuntimeError, ValueError):
360
+ raise ImageToolError(f"{label} 不是可用的文件系统路径。") from None
361
+
362
+
363
+ def prepare_save(
364
+ cwd: str,
365
+ output_dir: str | None = None,
366
+ filename: str | None = None,
367
+ output_format: str | None = None,
368
+ ) -> SavePlan:
369
+ directory_root = resolve_cwd(cwd)
370
+ image_format = resolve_format(output_format, filename)
371
+ directory = resolve_project_path(
372
+ directory_root, "." if output_dir is None else output_dir, label="output_dir"
373
+ )
374
+ if filename is None:
375
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
376
+ filename = f"image_{stamp}_{uuid4().hex[:12]}{EXTENSIONS[image_format]}"
377
+ elif not Path(filename).suffix:
378
+ filename += EXTENSIONS[image_format]
379
+ _validate_filename(filename)
380
+ plan = SavePlan(directory_root, directory, filename, image_format)
381
+ try:
382
+ directory.mkdir(parents=True, exist_ok=True)
383
+ plan._name_limit = _filename_limit(directory)
384
+ target = directory / filename
385
+ if target.exists() or target.is_symlink():
386
+ raise ImageToolError("目标文件已存在,请更换 filename;工具不会覆盖已有图片。")
387
+ try:
388
+ plan._acquire(filename)
389
+ except FileExistsError:
390
+ raise ImageToolError("目标文件已由另一个生成请求预留,请等待其完成;不会删除其他请求的锁。") from None
391
+ plan._probe_target(target)
392
+ except BaseException as exc:
393
+ plan.release()
394
+ if isinstance(exc, ImageToolError):
395
+ raise
396
+ if isinstance(exc, (OSError, ValueError)):
397
+ raise ImageToolError("无法预留真实目标文件名,请检查文件名长度、输出路径和目录权限。") from None
398
+ raise
399
+ return plan
400
+
401
+
402
+ def inspect_image(data: bytes, *, input_image: bool = False) -> ImageInfo:
403
+ limit = MAX_INPUT_BYTES if input_image else MAX_OUTPUT_BYTES
404
+ if not data or len(data) >= limit:
405
+ label = "输入图片必须小于 50 MB" if input_image else "返回图片必须小于 100 MiB"
406
+ raise ImageToolError(f"{label},且不能是空文件。")
407
+ try:
408
+ with warnings.catch_warnings():
409
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
410
+ with Image.open(io.BytesIO(data)) as image:
411
+ image_format = (image.format or "").lower()
412
+ if image_format not in FORMATS:
413
+ raise ImageToolError("图片内容必须是 PNG、JPEG 或 WebP,修改扩展名不能转换格式。")
414
+ if getattr(image, "n_frames", 1) != 1:
415
+ raise ImageToolError("仅支持静态图片,请先将动画转换为单帧图片。")
416
+ width, height = image.size
417
+ has_alpha = "A" in image.getbands() or "transparency" in image.info
418
+ image.verify()
419
+ # Decode pixels as well: verify() alone does not catch every truncated JPEG.
420
+ with Image.open(io.BytesIO(data)) as image:
421
+ image.load()
422
+ has_transparency = (
423
+ has_alpha and image.convert("RGBA").getchannel("A").getextrema()[0] < 255
424
+ )
425
+ except ImageToolError:
426
+ raise
427
+ except (UnidentifiedImageError, OSError, ValueError, SyntaxError, Image.DecompressionBombError, Image.DecompressionBombWarning):
428
+ raise ImageToolError("图片内容损坏、无法解码或像素数量过大。") from None
429
+ return ImageInfo(image_format, width, height, has_alpha, has_transparency)
430
+
431
+
432
+ def read_input_image(project_dir: Path, path: str) -> tuple[bytes, ImageInfo]:
433
+ source = resolve_project_path(project_dir, path, label="参考图片路径")
434
+ try:
435
+ if source.stat().st_size >= MAX_INPUT_BYTES:
436
+ raise ImageToolError("参考图片或蒙版必须小于 50 MB。")
437
+ with source.open("rb") as stream:
438
+ data = stream.read(MAX_INPUT_BYTES)
439
+ except OSError:
440
+ raise ImageToolError("无法读取参考图片或蒙版,请检查路径及文件权限。") from None
441
+ return data, inspect_image(data, input_image=True)
442
+
443
+
444
+ def _write_temporary(plan: SavePlan, data: bytes) -> Path:
445
+ with tempfile.NamedTemporaryFile(
446
+ mode="wb", prefix=".codex-image-", suffix=".tmp", dir=plan.output_dir, delete=False
447
+ ) as stream:
448
+ path = Path(stream.name)
449
+ plan._remember_temporary(path)
450
+ if stream.write(data) != len(data):
451
+ raise OSError(errno.ENOSPC, "Incomplete image write")
452
+ stream.flush()
453
+ os.fsync(stream.fileno())
454
+ return path
455
+
456
+
457
+ def _publish_temporary(plan: SavePlan, temporary: Path, target: Path) -> None:
458
+ if os.name == "nt":
459
+ # On Windows os.rename fails if the destination already exists.
460
+ os.rename(temporary, target)
461
+ plan._temporary_paths.pop(temporary, None)
462
+ return
463
+ try:
464
+ # Hard-link publication is atomic and never replaces an existing destination.
465
+ os.link(temporary, target)
466
+ except FileExistsError:
467
+ raise
468
+ except OSError as exc:
469
+ if exc.errno not in _UNSUPPORTED_LINK_ERRORS:
470
+ raise
471
+ # Filesystems without hard links still get a complete staged image first,
472
+ # followed by an exclusive target write, with partial targets cleaned up.
473
+ with target.open("xb") as output:
474
+ plan._remember_temporary(target)
475
+ with temporary.open("rb") as source:
476
+ shutil.copyfileobj(source, output)
477
+ output.flush()
478
+ os.fsync(output.fileno())
479
+ plan._temporary_paths.pop(target, None)
480
+ plan._cleanup_temporary(temporary)
481
+
482
+
483
+ def save_images(plan: SavePlan, images: list[PreparedImage]) -> SaveResult:
484
+ """Save independent completed items; later failures never remove earlier assets."""
485
+ if plan._released:
486
+ raise ImageToolError("图片保存预留已经释放,不能继续使用。")
487
+ result = SaveResult()
488
+ for index, image in enumerate(images):
489
+ if image.info.format not in FORMATS:
490
+ result.errors.append({
491
+ "code": "unsupported_format",
492
+ "message": "返回图片不是可保存的 PNG、JPEG 或 WebP 格式,该图片未保存。",
493
+ "call_id": image.call_id,
494
+ "index": index,
495
+ })
496
+ continue
497
+ try:
498
+ metadata = dict(image.metadata)
499
+ target = plan._next_target(image.info.format)
500
+ temporary = _write_temporary(plan, image.data)
501
+ _publish_temporary(plan, temporary, target)
502
+ except (OSError, ImageToolError, ValueError, TypeError) as exc:
503
+ # Failed writes can leave owned temporary/partial files, but successful
504
+ # final assets were removed from that tracking set before this point.
505
+ for path in list(plan._temporary_paths):
506
+ plan._cleanup_temporary(path)
507
+ result.errors.append({
508
+ "code": "target_exists" if isinstance(exc, FileExistsError) else "save_failed",
509
+ "message": (
510
+ "目标文件已存在,该图片未保存,原文件未覆盖。"
511
+ if isinstance(exc, FileExistsError)
512
+ else "该图片保存失败,请检查输出目录及可用空间;其他已保存图片仍保留。"
513
+ ),
514
+ "call_id": image.call_id,
515
+ "index": index,
516
+ })
517
+ continue
518
+ if image.info.format != plan.output_format:
519
+ result.warnings.append(
520
+ f"第 {index + 1} 张图片的实际格式为 {image.info.format},与请求的 {plan.output_format} 不同;已按真实格式另存为 {target.name}。"
521
+ )
522
+ result.images.append(GeneratedImage(
523
+ target, image.data, image.info.mime_type, image.info.width, image.info.height,
524
+ image.revised_prompt, image.call_id, metadata,
525
+ ))
526
+ return result