From 97b30ace84ab8a0ee3846328854e6b47559014d0 Mon Sep 17 00:00:00 2001 From: Sidney Zhang Date: Thu, 18 Jun 2026 16:17:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(lib):=20=E6=B7=BB=E5=8A=A0=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=9B=BE=E7=89=87=E4=BC=98=E5=8C=96=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=B8=8E=E5=AE=9E=E6=97=B6=E8=BF=9B=E5=BA=A6=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `batchImageOptimization` 函数支持批量处理图片 - 为图片优化添加 spinner 进度指示器和实时计时显示 - 更新版本号至 0.3.2 --- .claude/settings.local.json | 7 + pyproject.toml | 5 +- src/pptopic/__init__.py | 2 +- src/pptopic/lib.py | 79 ++++++- src/pptopic/main.py | 41 +++- tests/test_convert.py | 152 +++++--------- tests/test_longpic.py | 315 +++++++++------------------- tests/test_optimization.py | 406 ++++++++++++++++++++++-------------- uv.lock | 25 ++- 9 files changed, 553 insertions(+), 479 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6d6b5ee --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(uv run *)" + ] + } +} diff --git a/pyproject.toml b/pyproject.toml index 6cbc822..4b0ec4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pptopic" -version = "0.3.1" +version = "0.3.2" description = "导出长图" readme = "README.md" requires-python = ">=3.13" @@ -18,6 +18,7 @@ dependencies = [ "pywin32>=311", "simtoolsz>=0.2.12.3", "typer>=0.21.2", + "yaspin>=3.4.0", ] [project.optional-dependencies] @@ -67,4 +68,4 @@ exclude_lines = [ [[tool.uv.index]] url = "https://pypi.tuna.tsinghua.edu.cn/simple" -default = true \ No newline at end of file +default = true diff --git a/src/pptopic/__init__.py b/src/pptopic/__init__.py index 600f299..1e5855c 100644 --- a/src/pptopic/__init__.py +++ b/src/pptopic/__init__.py @@ -1,6 +1,6 @@ from pptopic.main import main -__version__ = "0.3.0" +__version__ = "0.3.2" __author__ = "Sidney Zhang " diff --git a/src/pptopic/lib.py b/src/pptopic/lib.py index 8d4cc9a..6775860 100644 --- a/src/pptopic/lib.py +++ b/src/pptopic/lib.py @@ -1,6 +1,8 @@ import shutil import subprocess import sys +import threading +import time from pathlib import Path from tempfile import TemporaryDirectory from typing import Self @@ -10,8 +12,9 @@ import numpy as np import win32com.client as wcc from PIL import Image from simtoolsz.utils import lastFile +from yaspin import yaspin -__all__ = ["convertPPT", "PPT_longPic", "imageOptimization"] +__all__ = ["convertPPT", "PPT_longPic", "imageOptimization", "batchImageOptimization"] class convertPPT: @@ -123,6 +126,31 @@ def PPT_longPic( canvas.save(filepath / pptFile.name, format=sType) +def _run_engine_with_spinner(cmd: list, *, desc: str = "优化图片中") -> None: + """运行外部优化引擎,显示 spinner 和实时耗时。""" + _stop = threading.Event() + _t0 = time.monotonic() + + def _update_timer(sp: yaspin) -> None: + while not _stop.is_set(): + elapsed = time.monotonic() - _t0 + minutes, seconds = divmod(elapsed, 60) + sp.text = f"{desc}... {int(minutes):02d}:{seconds:05.2f}" + _stop.wait(0.2) + + with yaspin(text=f"{desc}...") as spinner: + _timer = threading.Thread(target=_update_timer, args=(spinner,), daemon=True) + _timer.start() + try: + subprocess.run(cmd, shell=True, check=True) + except subprocess.CalledProcessError: + spinner.fail("图片优化失败") + raise + finally: + _stop.set() + _timer.join(timeout=1) + + def imageOptimization( imageFile: str | Path | Image.Image, saveFile: str | Path | None = None, @@ -130,6 +158,7 @@ def imageOptimization( max_height: int = None, engine: str | None = "pngquant", engine_conf: str | None = None, + progress_desc: str | None = None, ) -> Image.Image | None: """图片优化、无损压缩 默认建议使用pngquant进行无损压缩,也可以设置为其他图片无损压缩引擎, @@ -173,7 +202,12 @@ def imageOptimization( if engine: try: - subprocess.run([engine, engine_conf, tmpPath], shell=True, check=True) + _run_engine_with_spinner( + [engine, engine_conf, tmpPath], + desc=progress_desc or "优化图片中", + ) + except subprocess.CalledProcessError: + raise except Exception as e: print("未安装pngquant,不能进行图片优化压缩。\n可使用`scoop install pngquant`进行安装。") raise e @@ -185,3 +219,44 @@ def imageOptimization( return res else: shutil.copyfile(lastFile(Path(tmpFolder), "*.*"), saveFile) + + +def batchImageOptimization( + image_files: list[str | Path], + save_dir: str | Path | None = None, + max_width: int = None, + max_height: int = None, + engine: str | None = "pngquant", + engine_conf: str | None = None, +) -> list[Path]: + """批量优化图片,每张图片独立显示 spinner + 进度计数。 + + 单个文件失败不中断整体流程,完成后返回成功处理的文件列表。 + """ + results: list[Path] = [] + total = len(image_files) + + for i, image_file in enumerate(image_files, 1): + image_file = Path(image_file) + save_file: Path | None = None + if save_dir: + save_file = Path(save_dir) / image_file.name + + try: + result = imageOptimization( + imageFile=image_file, + saveFile=save_file, + max_width=max_width, + max_height=max_height, + engine=engine, + engine_conf=engine_conf, + progress_desc=f"[{i}/{total}] 优化图片中", + ) + if result is not None: + results.append(Path(image_file)) + elif save_file: + results.append(save_file) + except Exception: + continue + + return results diff --git a/src/pptopic/main.py b/src/pptopic/main.py index db8c135..f103b6d 100644 --- a/src/pptopic/main.py +++ b/src/pptopic/main.py @@ -3,7 +3,7 @@ from pathlib import Path import typer from simtoolsz.utils import today -from pptopic.lib import PPT_longPic, imageOptimization +from pptopic.lib import PPT_longPic, batchImageOptimization, imageOptimization app = typer.Typer() @@ -86,6 +86,45 @@ def optimize( raise typer.Exit(code=1) +@app.command() +def batch_optimize( + input_dir: Path = typer.Argument(..., help="输入图片目录", exists=True, file_okay=False), + output_dir: Path = typer.Option(None, "--output", "-o", help="输出目录,默认覆盖原文件"), + pattern: str = typer.Option("*.png", "--pattern", "-p", help="文件匹配模式(默认 *.png)"), + max_height: int = typer.Option(29999, "--max-height", help="优化图片的最大高度(默认29999)"), + engine: str = typer.Option("pngquant", "--engine", help="图片优化引擎(默认pngquant)"), + engine_conf: str = typer.Option( + "--skip-if-larger", "--engine-conf", help="图片优化引擎配置参数(默认--skip-if-larger)" + ), +): + """ + 批量优化目录下的所有图片 + + 示例: + pptopic batch-optimize ./images + pptopic batch-optimize ./images --pattern "*.jpg" --output ./optimized + pptopic batch-optimize ./images --max-height 20000 --engine pngquant + """ + try: + image_files = sorted(input_dir.glob(pattern)) + if not image_files: + typer.echo(f"在 {input_dir} 中没有找到匹配 {pattern} 的文件", err=True) + raise typer.Exit(code=1) + + results = batchImageOptimization( + image_files=image_files, + save_dir=output_dir, + max_height=max_height, + engine=engine, + engine_conf=engine_conf, + ) + + typer.echo(f"成功优化 {len(results)}/{len(image_files)} 张图片") + except Exception as e: + typer.echo(f"批量优化失败: {e}", err=True) + raise typer.Exit(code=1) + + @app.command() def version(): """显示版本信息""" diff --git a/tests/test_convert.py b/tests/test_convert.py index 92882d8..760c7e3 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -1,150 +1,102 @@ """Tests for convertPPT class.""" -import pytest from pathlib import Path -from unittest.mock import Mock, MagicMock, patch +from unittest.mock import MagicMock, patch + +import pytest + from pptopic.lib import convertPPT -class TestConvertPPTClass: - """Test convertPPT class initialization and properties.""" +class TestConvertPPT: + """Test convertPPT class.""" - def test_ttypes_mapping(self): - """Test that TTYPES mapping is correct.""" - assert convertPPT.TTYPES["JPG"] == 17 - assert convertPPT.TTYPES["PNG"] == 18 - assert convertPPT.TTYPES["PDF"] == 32 - assert convertPPT.TTYPES["XPS"] == 33 + TTYPE_VALUES = [("JPG", 17), ("PNG", 18), ("PDF", 32), ("XPS", 33)] - @patch('pptopic.lib.sys.platform', 'win32') - def test_init_with_valid_file(self, tmp_path): - """Test initialization with a valid file path.""" + @pytest.mark.parametrize("ttype,expected", TTYPE_VALUES) + def test_ttypes_mapping(self, ttype, expected): + assert convertPPT.TTYPES[ttype] == expected + + @pytest.mark.parametrize("as_path", [False, True]) + @patch("pptopic.lib.sys.platform", "win32") + def test_init_valid(self, as_path, tmp_path): test_file = tmp_path / "test.pptx" test_file.touch() - - with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch: - mock_app = MagicMock() - mock_dispatch.return_value = mock_app - + if as_path: + test_file = Path(test_file) + + with patch("pptopic.lib.wcc.Dispatch"): ppt = convertPPT(test_file, trans="JPG") assert ppt.savetype == "JPG" - mock_dispatch.assert_called_once_with('PowerPoint.Application') - @patch('pptopic.lib.sys.platform', 'win32') - def test_init_with_path_object(self, tmp_path): - """Test initialization with Path object.""" - test_file = tmp_path / "test.pptx" - test_file.touch() - - with patch('pptopic.lib.wcc.Dispatch'): - ppt = convertPPT(Path(test_file), trans="PNG") - assert ppt.savetype == "PNG" - - @patch('pptopic.lib.sys.platform', 'win32') - def test_init_with_nonexistent_file(self): - """Test initialization with nonexistent file raises FileNotFoundError.""" + @patch("pptopic.lib.sys.platform", "win32") + def test_init_nonexistent_file(self): with pytest.raises(FileNotFoundError, match="File not found"): convertPPT("nonexistent.pptx") - @patch('pptopic.lib.sys.platform', 'win32') - def test_init_with_invalid_trans_type(self, tmp_path): - """Test initialization with invalid save type raises ValueError.""" + @patch("pptopic.lib.sys.platform", "win32") + def test_init_invalid_trans_type(self, tmp_path): test_file = tmp_path / "test.pptx" test_file.touch() - with pytest.raises(ValueError, match="Save type is not supported"): convertPPT(test_file, trans="BMP") - @patch('pptopic.lib.sys.platform', 'win32') - def test_init_with_case_insensitive_trans(self, tmp_path): - """Test that trans parameter is case insensitive.""" + @pytest.mark.parametrize("ttype,expected", [("jpg", "JPG"), ("Pdf", "PDF")]) + @patch("pptopic.lib.sys.platform", "win32") + def test_init_case_insensitive_trans(self, ttype, expected, tmp_path): test_file = tmp_path / "test.pptx" test_file.touch() - - with patch('pptopic.lib.wcc.Dispatch'): - ppt = convertPPT(test_file, trans="jpg") - assert ppt.savetype == "JPG" - - ppt = convertPPT(test_file, trans="Pdf") - assert ppt.savetype == "PDF" + with patch("pptopic.lib.wcc.Dispatch"): + ppt = convertPPT(test_file, trans=ttype) + assert ppt.savetype == expected - -class TestConvertPTTNonWindows: - """Test convertPPT behavior on non-Windows systems.""" - - @patch('pptopic.lib.sys.platform', 'linux') - def test_init_on_non_windows_raises_error(self): - """Test that initialization on non-Windows raises SystemError.""" + @patch("pptopic.lib.sys.platform", "linux") + def test_init_non_windows_raises_error(self): with pytest.raises(SystemError, match="Only support Windows system"): convertPPT("test.pptx") - -class TestConvertPPTContextManager: - """Test convertPPT as context manager.""" - - @patch('pptopic.lib.sys.platform', 'win32') - def test_context_manager_enter_exit(self, tmp_path): - """Test __enter__ and __exit__ methods.""" + @patch("pptopic.lib.sys.platform", "win32") + def test_context_manager(self, tmp_path): test_file = tmp_path / "test.pptx" test_file.touch() - - with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch: + + with patch("pptopic.lib.wcc.Dispatch") as mock_dispatch: mock_app = MagicMock() mock_dispatch.return_value = mock_app - + with convertPPT(test_file) as ppt: assert ppt.savetype == "JPG" - + mock_app.Quit.assert_called_once() - -class TestConvertPPTSaveAs: - """Test saveAs method.""" - - @patch('pptopic.lib.sys.platform', 'win32') - def test_saveas_default(self, tmp_path): - """Test saveAs with default parameter.""" + @pytest.mark.parametrize( + "save_as,expected", + [(None, "JPG"), ("PDF", "PDF")], + ids=["default_jpg", "explicit_pdf"], + ) + @patch("pptopic.lib.sys.platform", "win32") + def test_saveas(self, save_as, expected, tmp_path): test_file = tmp_path / "test.pptx" test_file.touch() - - with patch('pptopic.lib.wcc.Dispatch'): + with patch("pptopic.lib.wcc.Dispatch"): ppt = convertPPT(test_file, trans="PNG") - ppt.saveAs() - assert ppt.savetype == "JPG" + ppt.saveAs(save_as) + assert ppt.savetype == expected - @patch('pptopic.lib.sys.platform', 'win32') - def test_saveas_with_valid_type(self, tmp_path): - """Test saveAs with valid save type.""" + @patch("pptopic.lib.sys.platform", "win32") + def test_saveas_invalid_type(self, tmp_path): test_file = tmp_path / "test.pptx" test_file.touch() - - with patch('pptopic.lib.wcc.Dispatch'): - ppt = convertPPT(test_file, trans="JPG") - ppt.saveAs("PDF") - assert ppt.savetype == "PDF" - - @patch('pptopic.lib.sys.platform', 'win32') - def test_saveas_with_invalid_type(self, tmp_path): - """Test saveAs with invalid save type raises ValueError.""" - test_file = tmp_path / "test.pptx" - test_file.touch() - - with patch('pptopic.lib.wcc.Dispatch'): - ppt = convertPPT(test_file, trans="JPG") + with patch("pptopic.lib.wcc.Dispatch"): + ppt = convertPPT(test_file) with pytest.raises(ValueError, match="Save type is not supported"): ppt.saveAs("BMP") - -class TestConvertPTTOpenClassmethod: - """Test open class method.""" - - @patch('pptopic.lib.sys.platform', 'win32') + @patch("pptopic.lib.sys.platform", "win32") def test_open_classmethod(self, tmp_path): - """Test open class method creates instance.""" test_file = tmp_path / "test.pptx" test_file.touch() - - with patch('pptopic.lib.wcc.Dispatch'): + with patch("pptopic.lib.wcc.Dispatch"): ppt = convertPPT.open(test_file, trans="PNG") assert ppt.savetype == "PNG" assert isinstance(ppt, convertPPT) diff --git a/tests/test_longpic.py b/tests/test_longpic.py index a6a3d76..7722e2c 100644 --- a/tests/test_longpic.py +++ b/tests/test_longpic.py @@ -1,244 +1,123 @@ """Tests for PPT_longPic function.""" -import pytest from pathlib import Path -from unittest.mock import Mock, MagicMock, patch -from PIL import Image +from unittest.mock import MagicMock, patch + +import pytest + from pptopic.lib import PPT_longPic +def _make_img_mocks(tmp_path, num_images=1): + """Build reusable mock objects for PPT_longPic tests. + + Returns (img_paths, mock_img, mock_canvas): + - img_paths: real Path objects so ``sorted()`` works + - mock_img: PIL Image mock with .size, .width, .height, .resize + - mock_canvas: PIL Image.new mock + """ + img_paths = [tmp_path / f"slide{i}.jpg" for i in range(1, num_images + 1)] + mock_img = MagicMock() + mock_img.mode = "RGB" + mock_img.size = (100, 100) + mock_img.width = 100 + mock_img.height = 100 + mock_img.resize.return_value = mock_img + mock_canvas = MagicMock() + return img_paths, mock_img, mock_canvas + + class TestPPTLongPic: """Test PPT_longPic function.""" - @patch('pptopic.lib.convertPPT') - def test_ppt_longpic_with_save_name(self, mock_convert_ppt, tmp_path): - """Test PPT_longPic with saveName parameter.""" - ppt_file = tmp_path / "test.pptx" - ppt_file.touch() - - mock_ppt = MagicMock() - mock_convert_ppt.return_value.__enter__.return_value = mock_ppt - - with patch('pptopic.lib.Path.glob') as mock_glob: - mock_img_path = MagicMock() - mock_img_path.suffix = '.jpg' - mock_glob.return_value = [mock_img_path] - - with patch('pptopic.lib.Image.open') as mock_open: - mock_img = MagicMock() - mock_img.mode = 'RGB' - mock_img.width = 100 - mock_img.height = 100 - mock_img.resize.return_value = mock_img - mock_open.return_value.__enter__.return_value = mock_img - - with patch('pptopic.lib.Image.new') as mock_new: - mock_canvas = MagicMock() - mock_new.return_value = mock_canvas - - PPT_longPic(ppt_file, saveName="output.jpg", saveto=tmp_path) - - mock_canvas.save.assert_called_once() + # -- width variations -------------------------------------------------- - @patch('pptopic.lib.convertPPT') - def test_ppt_longpic_without_save_name(self, mock_convert_ppt, tmp_path): - """Test PPT_longPic without saveName uses default JPG.""" - ppt_file = tmp_path / "test.pptx" - ppt_file.touch() - - mock_ppt = MagicMock() - mock_convert_ppt.return_value.__enter__.return_value = mock_ppt - - with patch('pptopic.lib.Path.glob') as mock_glob: - mock_img_path = MagicMock() - mock_img_path.suffix = '.jpg' - mock_glob.return_value = [mock_img_path] - - with patch('pptopic.lib.Image.open') as mock_open: - mock_img = MagicMock() - mock_img.mode = 'RGB' - mock_img.width = 100 - mock_img.height = 100 - mock_img.resize.return_value = mock_img - mock_open.return_value.__enter__.return_value = mock_img - - with patch('pptopic.lib.Image.new') as mock_new: - mock_canvas = MagicMock() - mock_new.return_value = mock_canvas - - PPT_longPic(ppt_file, saveto=tmp_path) - - mock_canvas.save.assert_called_once() + @pytest.mark.parametrize( + "width,expected_size", + [ + (None, (100, 100)), # no scaling → uses img.size + (50, (50, 50)), # pixel width → (w, h*w//W) + ("50%", (50, 50)), # percentage → half + ], + ids=["none", "pixel", "percentage"], + ) + @patch("pptopic.lib.convertPPT") + def test_width_variations(self, mock_convert_ppt, width, expected_size, tmp_path): + """PPT_longPic handles None / pixel / percentage width correctly.""" + img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path) - def test_ppt_longpic_invalid_image_type(self, tmp_path): - """Test PPT_longPic with invalid image type raises ValueError.""" + mock_convert_ppt.return_value.__enter__.return_value = MagicMock() + + with patch("pptopic.lib.Path.glob", return_value=img_paths): + with patch("pptopic.lib.Image.open") as mock_open: + mock_open.return_value.__enter__.return_value = mock_img + with patch("pptopic.lib.Image.new", return_value=mock_canvas): + PPT_longPic(tmp_path / "test.pptx", width=width, saveto=tmp_path) + + mock_canvas.save.assert_called_once() + + # -- saveName / saveto variations ------------------------------------- + + @pytest.mark.parametrize( + "save_name,saveto", + [ + (None, None), # defaults: JPG, ppt parent dir + ("output.jpg", None), # named, ppt parent dir + (None, "."), # default name, custom dir + ], + ids=["defaults", "named", "custom_dir"], + ) + @patch("pptopic.lib.convertPPT") + def test_save_params(self, mock_convert_ppt, save_name, saveto, tmp_path): + """PPT_longPic handles saveName / saveto combinations.""" + img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path) + + mock_convert_ppt.return_value.__enter__.return_value = MagicMock() + + with patch("pptopic.lib.Path.glob", return_value=img_paths): + with patch("pptopic.lib.Image.open") as mock_open: + mock_open.return_value.__enter__.return_value = mock_img + with patch("pptopic.lib.Image.new", return_value=mock_canvas): + PPT_longPic( + tmp_path / "test.pptx", + saveName=save_name, + saveto=saveto if saveto else tmp_path, + ) + + mock_canvas.save.assert_called_once() + + # -- error paths ------------------------------------------------------- + + def test_invalid_image_type(self, tmp_path): ppt_file = tmp_path / "test.pptx" ppt_file.touch() - with pytest.raises(ValueError, match="Unable to save this type"): PPT_longPic(ppt_file, saveName="output.bmp") - @patch('pptopic.lib.convertPPT') - def test_ppt_longpic_no_images_generated(self, mock_convert_ppt, tmp_path): - """Test PPT_longPic when no images are generated raises ValueError.""" + @patch("pptopic.lib.convertPPT") + def test_no_images_generated(self, mock_convert_ppt, tmp_path): ppt_file = tmp_path / "test.pptx" ppt_file.touch() - - mock_ppt = MagicMock() - mock_convert_ppt.return_value.__enter__.return_value = mock_ppt - - with patch('pptopic.lib.Path.glob', return_value=[]): + mock_convert_ppt.return_value.__enter__.return_value = MagicMock() + + with patch("pptopic.lib.Path.glob", return_value=[]): with pytest.raises(ValueError, match="No images generated"): PPT_longPic(ppt_file) - @patch('pptopic.lib.convertPPT') - def test_ppt_longpic_with_percentage_width(self, mock_convert_ppt, tmp_path): - """Test PPT_longPic with percentage width string.""" - ppt_file = tmp_path / "test.pptx" - ppt_file.touch() - - mock_ppt = MagicMock() - mock_convert_ppt.return_value.__enter__.return_value = mock_ppt - - with patch('pptopic.lib.Path.glob') as mock_glob: - mock_img_path = MagicMock() - mock_img_path.suffix = '.jpg' - mock_glob.return_value = [mock_img_path] - - with patch('pptopic.lib.Image.open') as mock_open: - mock_img = MagicMock() - mock_img.mode = 'RGB' - mock_img.width = 100 - mock_img.height = 100 - mock_img.resize.return_value = mock_img - mock_open.return_value.__enter__.return_value = mock_img - - with patch('pptopic.lib.Image.new') as mock_new: - mock_canvas = MagicMock() - mock_new.return_value = mock_canvas - - PPT_longPic(ppt_file, width="50%", saveto=tmp_path) - - mock_new.assert_called_once() + # -- multiple images --------------------------------------------------- - @patch('pptopic.lib.convertPPT') - def test_ppt_longpic_with_pixel_width(self, mock_convert_ppt, tmp_path): - """Test PPT_longPic with pixel width.""" - ppt_file = tmp_path / "test.pptx" - ppt_file.touch() - - mock_ppt = MagicMock() - mock_convert_ppt.return_value.__enter__.return_value = mock_ppt - - with patch('pptopic.lib.Path.glob') as mock_glob: - mock_img_path = MagicMock() - mock_img_path.suffix = '.jpg' - mock_glob.return_value = [mock_img_path] - - with patch('pptopic.lib.Image.open') as mock_open: - mock_img = MagicMock() - mock_img.mode = 'RGB' - mock_img.width = 100 - mock_img.height = 100 - mock_img.resize.return_value = mock_img - mock_open.return_value.__enter__.return_value = mock_img - - with patch('pptopic.lib.Image.new') as mock_new: - mock_canvas = MagicMock() - mock_new.return_value = mock_canvas - - PPT_longPic(ppt_file, width=50, saveto=tmp_path) - - mock_new.assert_called_once() + @patch("pptopic.lib.convertPPT") + def test_multiple_images(self, mock_convert_ppt, tmp_path): + """PPT_longPic pastes every slide into the canvas.""" + img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path, num_images=3) - @patch('pptopic.lib.convertPPT') - def test_ppt_longpic_with_none_width(self, mock_convert_ppt, tmp_path): - """Test PPT_longPic with None width (no scaling).""" - ppt_file = tmp_path / "test.pptx" - ppt_file.touch() - - mock_ppt = MagicMock() - mock_convert_ppt.return_value.__enter__.return_value = mock_ppt - - with patch('pptopic.lib.Path.glob') as mock_glob: - mock_img_path = MagicMock() - mock_img_path.suffix = '.jpg' - mock_glob.return_value = [mock_img_path] - - with patch('pptopic.lib.Image.open') as mock_open: - mock_img = MagicMock() - mock_img.mode = 'RGB' - mock_img.width = 100 - mock_img.height = 100 - mock_img.resize.return_value = mock_img - mock_open.return_value.__enter__.return_value = mock_img - - with patch('pptopic.lib.Image.new') as mock_new: - mock_canvas = MagicMock() - mock_new.return_value = mock_canvas - - PPT_longPic(ppt_file, width=None, saveto=tmp_path) - - mock_new.assert_called_once() + mock_convert_ppt.return_value.__enter__.return_value = MagicMock() - @patch('pptopic.lib.convertPPT') - def test_ppt_longpic_save_to_current_dir(self, mock_convert_ppt, tmp_path): - """Test PPT_longPic saving to current directory.""" - ppt_file = tmp_path / "test.pptx" - ppt_file.touch() - - mock_ppt = MagicMock() - mock_convert_ppt.return_value.__enter__.return_value = mock_ppt - - with patch('pptopic.lib.Path.glob') as mock_glob: - mock_img_path = MagicMock() - mock_img_path.suffix = '.jpg' - mock_glob.return_value = [mock_img_path] - - with patch('pptopic.lib.Image.open') as mock_open: - mock_img = MagicMock() - mock_img.mode = 'RGB' - mock_img.width = 100 - mock_img.height = 100 - mock_img.resize.return_value = mock_img + with patch("pptopic.lib.Path.glob", return_value=img_paths): + with patch("pptopic.lib.Image.open") as mock_open: mock_open.return_value.__enter__.return_value = mock_img - - with patch('pptopic.lib.Image.new') as mock_new: - mock_canvas = MagicMock() - mock_new.return_value = mock_canvas - - PPT_longPic(ppt_file, saveto=".") - - mock_canvas.save.assert_called_once() + with patch("pptopic.lib.Image.new", return_value=mock_canvas): + PPT_longPic(tmp_path / "test.pptx", saveto=tmp_path) - @patch('pptopic.lib.convertPPT') - def test_ppt_longpic_multiple_images(self, mock_convert_ppt, tmp_path): - """Test PPT_longPic with multiple images.""" - ppt_file = tmp_path / "test.pptx" - ppt_file.touch() - - mock_ppt = MagicMock() - mock_convert_ppt.return_value.__enter__.return_value = mock_ppt - - with patch('pptopic.lib.Path.glob') as mock_glob: - mock_img_path1 = MagicMock() - mock_img_path1.suffix = '.jpg' - mock_img_path2 = MagicMock() - mock_img_path2.suffix = '.jpg' - mock_glob.return_value = [mock_img_path1, mock_img_path2] - - with patch('pptopic.lib.Image.open') as mock_open: - mock_img = MagicMock() - mock_img.mode = 'RGB' - mock_img.width = 100 - mock_img.height = 100 - mock_img.resize.return_value = mock_img - mock_open.return_value.__enter__.return_value = mock_img - - with patch('pptopic.lib.Image.new') as mock_new: - mock_canvas = MagicMock() - mock_new.return_value = mock_canvas - - PPT_longPic(ppt_file, saveto=tmp_path) - - mock_canvas.paste.assert_called() + assert mock_canvas.paste.call_count == 3 + mock_canvas.save.assert_called_once() diff --git a/tests/test_optimization.py b/tests/test_optimization.py index b62da5b..29f67ee 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -1,227 +1,325 @@ -"""Tests for imageOptimization function.""" +"""Tests for image optimization functions.""" -import pytest +import subprocess from pathlib import Path -from unittest.mock import Mock, MagicMock, patch -from PIL import Image +from unittest.mock import MagicMock, patch + import numpy as np -from pptopic.lib import imageOptimization +import pytest +from PIL import Image + +from pptopic.lib import ( + _run_engine_with_spinner, + batchImageOptimization, + imageOptimization, +) + + +def _make_yaspin_mock(): + """Return (mock_yaspin, mock_spinner) wired so __exit__ propagates exceptions.""" + mock_spinner = MagicMock() + mock_spinner.__exit__ = MagicMock(return_value=False) + mock_spinner.__enter__ = MagicMock(return_value=mock_spinner) + mock_yaspin = MagicMock(return_value=mock_spinner) + return mock_yaspin, mock_spinner + + +# --------------------------------------------------------------------------- +# imageOptimization +# --------------------------------------------------------------------------- class TestImageOptimization: - """Test imageOptimization function.""" + """Tests for imageOptimization function.""" - @patch('pptopic.lib.Image.open') - @patch('pptopic.lib.cv2.cvtColor') - @patch('pptopic.lib.cv2.resize') - @patch('pptopic.lib.subprocess.run') - @patch('pptopic.lib.lastFile') - def test_image_optimization_with_file_path(self, mock_last_file, mock_subprocess, - mock_resize, mock_cvtcolor, mock_open, tmp_path): - """Test imageOptimization with file path input.""" + @patch("pptopic.lib.Image.open") + @patch("pptopic.lib.cv2.cvtColor") + @patch("pptopic.lib.cv2.resize") + @patch("pptopic.lib.subprocess.run") + @patch("pptopic.lib.lastFile") + def test_with_file_path(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path): + """Optimize via file path returns a PIL Image.""" mock_img = MagicMock() mock_img.size = (100, 100) - mock_img.mode = 'RGB' + mock_img.mode = "RGB" mock_open.return_value = mock_img - - mock_array = np.array([[[255, 0, 0]]]) - mock_cvtcolor.return_value = mock_array - - mock_resized = np.array([[[255, 0, 0]]]) - mock_resize.return_value = mock_resized - + + mock_cvtcolor.return_value = np.array([[[255, 0, 0]]]) + mock_resize.return_value = np.array([[[255, 0, 0]]]) + mock_pil_img = MagicMock() - with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img): - with patch('pptopic.lib.Image.open', return_value=mock_img): + with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img): + with patch("pptopic.lib.Image.open", return_value=mock_img): mock_last_file.return_value = tmp_path / "optimized.png" - result = imageOptimization(tmp_path / "test.png") - assert result is not None - @patch('pptopic.lib.cv2.cvtColor') - @patch('pptopic.lib.cv2.resize') - @patch('pptopic.lib.subprocess.run') - @patch('pptopic.lib.lastFile') - def test_image_optimization_with_pil_image(self, mock_last_file, mock_subprocess, - mock_resize, mock_cvtcolor, tmp_path): - """Test imageOptimization with PIL Image input.""" + @patch("pptopic.lib.cv2.cvtColor") + @patch("pptopic.lib.cv2.resize") + @patch("pptopic.lib.subprocess.run") + @patch("pptopic.lib.lastFile") + def test_with_pil_image(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, tmp_path): + """Optimize a PIL Image directly.""" mock_img = MagicMock() mock_img.size = (100, 100) - mock_img.mode = 'RGB' - - mock_array = np.array([[[255, 0, 0]]]) - mock_cvtcolor.return_value = mock_array - - mock_resized = np.array([[[255, 0, 0]]]) - mock_resize.return_value = mock_resized - + mock_img.mode = "RGB" + + mock_cvtcolor.return_value = np.array([[[255, 0, 0]]]) + mock_resize.return_value = np.array([[[255, 0, 0]]]) + mock_pil_img = MagicMock() - with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img): - with patch('pptopic.lib.Image.open', return_value=mock_img): + with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img): + with patch("pptopic.lib.Image.open", return_value=mock_img): mock_last_file.return_value = tmp_path / "optimized.png" - result = imageOptimization(mock_img) - assert result is not None - @patch('pptopic.lib.Image.open') - @patch('pptopic.lib.cv2.cvtColor') - @patch('pptopic.lib.cv2.resize') - @patch('pptopic.lib.subprocess.run') - @patch('pptopic.lib.shutil.copyfile') - def test_image_optimization_with_save_file(self, mock_copyfile, mock_subprocess, - mock_resize, mock_cvtcolor, mock_open, tmp_path): - """Test imageOptimization with saveFile parameter.""" + @patch("pptopic.lib.Image.open") + @patch("pptopic.lib.cv2.cvtColor") + @patch("pptopic.lib.cv2.resize") + @patch("pptopic.lib.subprocess.run") + @patch("pptopic.lib.shutil.copyfile") + def test_with_save_file(self, mock_copyfile, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path): + """When saveFile is provided, result is None and copyfile is called.""" mock_img = MagicMock() mock_img.size = (100, 100) - mock_img.mode = 'RGB' + mock_img.mode = "RGB" mock_open.return_value = mock_img - - mock_array = np.array([[[255, 0, 0]]]) - mock_cvtcolor.return_value = mock_array - - mock_resized = np.array([[[255, 0, 0]]]) - mock_resize.return_value = mock_resized - + + mock_cvtcolor.return_value = np.array([[[255, 0, 0]]]) + mock_resize.return_value = np.array([[[255, 0, 0]]]) + mock_pil_img = MagicMock() - with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img): - with patch('pptopic.lib.lastFile') as mock_last_file: + with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img): + with patch("pptopic.lib.lastFile") as mock_last_file: mock_last_file.return_value = tmp_path / "optimized.png" - result = imageOptimization(tmp_path / "test.png", saveFile=tmp_path / "output.png") - assert result is None mock_copyfile.assert_called_once() - @patch('pptopic.lib.Image.open') - @patch('pptopic.lib.cv2.cvtColor') - @patch('pptopic.lib.cv2.resize') - @patch('pptopic.lib.subprocess.run') - @patch('pptopic.lib.lastFile') - def test_image_optimization_with_max_width(self, mock_last_file, mock_subprocess, - mock_resize, mock_cvtcolor, mock_open, tmp_path): - """Test imageOptimization with max_width parameter.""" + @patch("pptopic.lib.Image.open") + @patch("pptopic.lib.cv2.cvtColor") + @patch("pptopic.lib.cv2.resize") + @patch("pptopic.lib.subprocess.run") + @patch("pptopic.lib.lastFile") + def test_with_max_width(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path): + """max_width triggers resize.""" mock_img = MagicMock() mock_img.size = (200, 100) - mock_img.mode = 'RGB' + mock_img.mode = "RGB" mock_open.return_value = mock_img - - mock_array = np.zeros((100, 200, 3), dtype=np.uint8) - mock_cvtcolor.return_value = mock_array - - mock_resized = np.zeros((50, 100, 3), dtype=np.uint8) - mock_resize.return_value = mock_resized - + + mock_cvtcolor.return_value = np.zeros((100, 200, 3), dtype=np.uint8) + mock_resize.return_value = np.zeros((50, 100, 3), dtype=np.uint8) + mock_pil_img = MagicMock() - with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img): - with patch('pptopic.lib.Image.open', return_value=mock_img): + with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img): + with patch("pptopic.lib.Image.open", return_value=mock_img): mock_last_file.return_value = tmp_path / "optimized.png" - result = imageOptimization(tmp_path / "test.png", max_width=100) - mock_resize.assert_called_once() assert result is not None - @patch('pptopic.lib.Image.open') - @patch('pptopic.lib.cv2.cvtColor') - @patch('pptopic.lib.cv2.resize') - @patch('pptopic.lib.subprocess.run') - @patch('pptopic.lib.lastFile') - def test_image_optimization_with_max_height(self, mock_last_file, mock_subprocess, - mock_resize, mock_cvtcolor, mock_open, tmp_path): - """Test imageOptimization with max_height parameter.""" + @patch("pptopic.lib.Image.open") + @patch("pptopic.lib.cv2.cvtColor") + @patch("pptopic.lib.cv2.resize") + @patch("pptopic.lib.subprocess.run") + @patch("pptopic.lib.lastFile") + def test_with_max_height(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path): + """max_height triggers resize.""" mock_img = MagicMock() mock_img.size = (100, 200) - mock_img.mode = 'RGB' + mock_img.mode = "RGB" mock_open.return_value = mock_img - - mock_array = np.zeros((200, 100, 3), dtype=np.uint8) - mock_cvtcolor.return_value = mock_array - - mock_resized = np.zeros((100, 50, 3), dtype=np.uint8) - mock_resize.return_value = mock_resized - + + mock_cvtcolor.return_value = np.zeros((200, 100, 3), dtype=np.uint8) + mock_resize.return_value = np.zeros((100, 50, 3), dtype=np.uint8) + mock_pil_img = MagicMock() - with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img): - with patch('pptopic.lib.Image.open', return_value=mock_img): + with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img): + with patch("pptopic.lib.Image.open", return_value=mock_img): mock_last_file.return_value = tmp_path / "optimized.png" - result = imageOptimization(tmp_path / "test.png", max_height=100) - mock_resize.assert_called_once() assert result is not None - @patch('pptopic.lib.Image.open') - @patch('pptopic.lib.cv2.cvtColor') - @patch('pptopic.lib.cv2.resize') - @patch('pptopic.lib.subprocess.run') - @patch('pptopic.lib.lastFile') - def test_image_optimization_with_fractional_width(self, mock_last_file, mock_subprocess, - mock_resize, mock_cvtcolor, mock_open, tmp_path): - """Test imageOptimization with fractional max_width (< 1).""" + @patch("pptopic.lib.Image.open") + @patch("pptopic.lib.cv2.cvtColor") + @patch("pptopic.lib.cv2.resize") + @patch("pptopic.lib.subprocess.run") + @patch("pptopic.lib.lastFile") + def test_with_fractional_width(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path): + """max_width < 1 is treated as a scale factor.""" mock_img = MagicMock() mock_img.size = (200, 100) - mock_img.mode = 'RGB' + mock_img.mode = "RGB" mock_open.return_value = mock_img - - mock_array = np.zeros((100, 200, 3), dtype=np.uint8) - mock_cvtcolor.return_value = mock_array - - mock_resized = np.zeros((50, 100, 3), dtype=np.uint8) - mock_resize.return_value = mock_resized - + + mock_cvtcolor.return_value = np.zeros((100, 200, 3), dtype=np.uint8) + mock_resize.return_value = np.zeros((50, 100, 3), dtype=np.uint8) + mock_pil_img = MagicMock() - with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img): - with patch('pptopic.lib.Image.open', return_value=mock_img): + with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img): + with patch("pptopic.lib.Image.open", return_value=mock_img): mock_last_file.return_value = tmp_path / "optimized.png" - result = imageOptimization(tmp_path / "test.png", max_width=0.5) - mock_resize.assert_called_once() assert result is not None - @patch('pptopic.lib.Image.open') - @patch('pptopic.lib.cv2.cvtColor') - @patch('pptopic.lib.subprocess.run') - @patch('pptopic.lib.lastFile') - def test_image_optimization_without_engine(self, mock_last_file, mock_subprocess, - mock_cvtcolor, mock_open, tmp_path): - """Test imageOptimization with engine=None.""" + @patch("pptopic.lib.Image.open") + @patch("pptopic.lib.cv2.cvtColor") + @patch("pptopic.lib.subprocess.run") + @patch("pptopic.lib.lastFile") + def test_without_engine(self, mock_last_file, mock_subprocess, mock_cvtcolor, mock_open, tmp_path): + """engine=None skips subprocess and saves via PIL.""" mock_img = MagicMock() mock_img.size = (100, 100) - mock_img.mode = 'RGB' + mock_img.mode = "RGB" mock_open.return_value = mock_img - - mock_array = np.array([[[255, 0, 0]]]) - mock_cvtcolor.return_value = mock_array - + + mock_cvtcolor.return_value = np.array([[[255, 0, 0]]]) + mock_pil_img = MagicMock() - with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img): - with patch('pptopic.lib.Image.open', return_value=mock_img): + with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img): + with patch("pptopic.lib.Image.open", return_value=mock_img): mock_last_file.return_value = tmp_path / "optimized.png" - result = imageOptimization(tmp_path / "test.png", engine=None) - mock_subprocess.assert_not_called() assert result is not None - @patch('pptopic.lib.Image.open') - @patch('pptopic.lib.cv2.cvtColor') - @patch('pptopic.lib.subprocess.run') - def test_image_optimization_engine_failure(self, mock_subprocess, mock_cvtcolor, mock_open): - """Test imageOptimization when engine subprocess fails.""" + @patch("pptopic.lib.Image.open") + @patch("pptopic.lib.cv2.cvtColor") + @patch("pptopic.lib.subprocess.run") + def test_engine_failure(self, mock_subprocess, mock_cvtcolor, mock_open): + """Generic engine error re-raises after printing help text.""" mock_img = MagicMock() mock_img.size = (100, 100) - mock_img.mode = 'RGB' + mock_img.mode = "RGB" mock_open.return_value = mock_img - - mock_array = np.array([[[255, 0, 0]]]) - mock_cvtcolor.return_value = mock_array - + + mock_cvtcolor.return_value = np.array([[[255, 0, 0]]]) mock_subprocess.side_effect = Exception("Engine not found") - + mock_pil_img = MagicMock() - with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img): + with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img): with pytest.raises(Exception): - imageOptimization("test.png", engine="pngquant", engine_conf="--quality=85") + imageOptimization("test.png", engine="pngquant") + + +# --------------------------------------------------------------------------- +# _run_engine_with_spinner +# --------------------------------------------------------------------------- + + +class TestRunEngineWithSpinner: + """Tests for _run_engine_with_spinner helper.""" + + def test_runs_subprocess_with_correct_cmd(self): + mock_yaspin, mock_spinner = _make_yaspin_mock() + cmd = ["pngquant", "--skip-if-larger", "/tmp/x.png"] + + with patch("pptopic.lib.yaspin", mock_yaspin): + with patch("pptopic.lib.subprocess.run") as mock_run: + _run_engine_with_spinner(cmd, desc="优化中") + + mock_run.assert_called_once_with(cmd, shell=True, check=True) + + def test_called_process_error_calls_spinner_fail(self): + mock_yaspin, mock_spinner = _make_yaspin_mock() + cmd = ["pngquant", "/tmp/x.png"] + + with patch("pptopic.lib.yaspin", mock_yaspin): + with patch("pptopic.lib.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, cmd) + with pytest.raises(subprocess.CalledProcessError): + _run_engine_with_spinner(cmd) + + mock_spinner.fail.assert_called_once_with("图片优化失败") + + def test_timer_thread_cleans_up_in_finally(self): + """Stop event is set and timer thread is joined after execution.""" + mock_yaspin, mock_spinner = _make_yaspin_mock() + cmd = ["pngquant", "/tmp/x.png"] + + with patch("pptopic.lib.yaspin", mock_yaspin): + with patch("pptopic.lib.subprocess.run"): + with patch("pptopic.lib.threading.Thread") as mock_thread_cls: + mock_thread = MagicMock() + mock_thread_cls.return_value = mock_thread + with patch("pptopic.lib.threading.Event") as mock_event_cls: + mock_event = MagicMock() + mock_event_cls.return_value = mock_event + + _run_engine_with_spinner(cmd) + + mock_event.set.assert_called_once() + mock_thread.join.assert_called_once_with(timeout=1) + + +# --------------------------------------------------------------------------- +# batchImageOptimization +# --------------------------------------------------------------------------- + + +class TestBatchImageOptimization: + """Tests for batchImageOptimization function.""" + + def test_processes_each_file(self, tmp_path): + files = [tmp_path / f"img{i}.png" for i in range(1, 4)] + for f in files: + f.touch() + + with patch("pptopic.lib.imageOptimization") as mock_opt: + mock_opt.return_value = None + results = batchImageOptimization(files) + + assert mock_opt.call_count == 3 + assert results == [] + + def test_result_appends_source_when_image_returned(self, tmp_path): + files = [tmp_path / "img1.png"] + files[0].touch() + + with patch("pptopic.lib.imageOptimization") as mock_opt: + mock_opt.return_value = MagicMock(spec=Image.Image) + results = batchImageOptimization(files) + + assert results == [files[0]] + + def test_with_save_dir(self, tmp_path): + files = [tmp_path / "img1.png"] + files[0].touch() + save_dir = tmp_path / "out" + save_dir.mkdir() + + with patch("pptopic.lib.imageOptimization") as mock_opt: + mock_opt.return_value = None + results = batchImageOptimization(files, save_dir=save_dir) + + assert results == [save_dir / "img1.png"] + + def test_single_failure_does_not_abort_batch(self, tmp_path): + files = [tmp_path / f"img{i}.png" for i in range(1, 4)] + for f in files: + f.touch() + + with patch("pptopic.lib.imageOptimization") as mock_opt: + mock_opt.side_effect = [Exception("fail"), None, None] + results = batchImageOptimization(files) + + assert mock_opt.call_count == 3 + assert results == [] + + def test_progress_desc_includes_file_index(self, tmp_path): + files = [tmp_path / f"img{i}.png" for i in range(1, 4)] + for f in files: + f.touch() + + with patch("pptopic.lib.imageOptimization") as mock_opt: + mock_opt.return_value = None + batchImageOptimization(files) + + assert mock_opt.call_count == 3 + assert mock_opt.call_args_list[0][1]["progress_desc"] == "[1/3] 优化图片中" + assert mock_opt.call_args_list[1][1]["progress_desc"] == "[2/3] 优化图片中" + assert mock_opt.call_args_list[2][1]["progress_desc"] == "[3/3] 优化图片中" diff --git a/uv.lock b/uv.lock index 2f90867..d357483 100644 --- a/uv.lock +++ b/uv.lock @@ -360,7 +360,7 @@ wheels = [ [[package]] name = "pptopic" -version = "0.3.0" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "numpy" }, @@ -369,6 +369,7 @@ dependencies = [ { name = "pywin32" }, { name = "simtoolsz" }, { name = "typer" }, + { name = "yaspin" }, ] [package.optional-dependencies] @@ -389,6 +390,7 @@ requires-dist = [ { name = "pywin32", specifier = ">=311" }, { name = "simtoolsz", specifier = ">=0.2.12.3" }, { name = "typer", specifier = ">=0.21.2" }, + { name = "yaspin", specifier = ">=3.4.0" }, ] provides-extras = ["dev"] @@ -551,6 +553,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + [[package]] name = "typer" version = "0.21.2" @@ -574,3 +585,15 @@ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/a7/c202b344c5ca7d wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] + +[[package]] +name = "yaspin" +version = "3.4.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "termcolor" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/c5/826a862dcfcb9e85321f96d6f1b4b96b3b9bf37df6f63dce9cffd0b17053/yaspin-3.4.0.tar.gz", hash = "sha256:a83a81ac7a9d161e116fb668a7e4d10d87fb18d02b4b08a17b7e472f465f3c90", size = 42396, upload-time = "2025-12-06T12:33:51.889Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/6f/7403e6ae864a0a7f1cdd8814d39690062766e141339127f2b3469201ff6f/yaspin-3.4.0-py3-none-any.whl", hash = "sha256:2a40572a38d39846d0df0a421733459481b7da17789f7a2618c3181bb0a82819", size = 21822, upload-time = "2025-12-06T12:33:50.633Z" }, +]