From 480836d5727b8c74c812f11f88105434bb81bef5 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:48:43 +0800 Subject: [PATCH 1/7] =?UTF-8?q?v2.7.9:=20=E4=BF=AE=E5=A4=8D=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E4=B8=8B=E8=BD=BD=E8=BF=9B=E5=BA=A6=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E7=A6=BB=E7=BA=BF=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 16 ++ .../sources/tutorial/15_download_progress.md | 2 + src/jmcomic/__init__.py | 2 +- src/jmcomic/cli.py | 17 ++ src/jmcomic/jm_plugin.py | 197 +++++++++++++++--- tests/test_jmcomic/test_jm_cli.py | 18 ++ .../test_jmcomic/test_jm_download_progress.py | 159 ++++++++++++++ 7 files changed, 377 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b480db24..24add6c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 条目分类参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 + +## [2.7.9] - 2026-10-08 + +### Summary + +修复并发下载时多个进度界面和日志区相互干扰的问题,并提供离线进度预览命令。 + +### Fixed + +- 同步和异步下载器共用一个动态进度界面与日志区,已结束任务保留到最后一个活动下载器退出,再统一关闭界面。 +- 界面启动或渲染失败时清理注册状态;界面退出失败时仍执行下载器资源释放,避免影响后续下载。 + +### Added + +- `jmcomic --dry-run` 使用随机模拟任务预览多任务进度和重试日志,不请求网络、不下载图片、不写日志文件,也不加载 `--option` 配置;不能与 `--no-progress` 同用。 + ## [2.7.8] - 2026-10-07 ### Summary diff --git a/assets/docs/sources/tutorial/15_download_progress.md b/assets/docs/sources/tutorial/15_download_progress.md index 21c85d553..cab251bba 100644 --- a/assets/docs/sources/tutorial/15_download_progress.md +++ b/assets/docs/sources/tutorial/15_download_progress.md @@ -37,6 +37,8 @@ > - 不支持:启动时显示一次说明,下载期间不刷新,结束后输出一次本子、章节和图片数量汇总。 > > 如果你使用 PyCharm,请在底部的 **Terminal** 中运行 `python script.py`,即可查看动态进度。 +> +> 你也可以使用 `--dry-run` 参数来模拟运行,体验效果,不会真的触发下载:`jmcomic 123 --dry-run` | 运行位置 | 下载期间 | 下载结束后 | diff --git a/src/jmcomic/__init__.py b/src/jmcomic/__init__.py index 215157598..8c969b8ec 100644 --- a/src/jmcomic/__init__.py +++ b/src/jmcomic/__init__.py @@ -2,7 +2,7 @@ # 被依赖方 <--- 使用方 # config <--- entity <--- toolkit <--- client <--- option <--- downloader -__version__ = '2.7.8' +__version__ = '2.7.9' from .jm_task_context import * from .api import * diff --git a/src/jmcomic/cli.py b/src/jmcomic/cli.py index ad1871db8..f54b6431c 100644 --- a/src/jmcomic/cli.py +++ b/src/jmcomic/cli.py @@ -31,6 +31,7 @@ class JmcomicUI: def __init__(self) -> None: self.option_path: Optional[str] = None self.progress_enabled = True + self.dry_run = False self.raw_id_list: List[str] = [] self.album_id_list: List[str] = [] self.photo_id_list: List[str] = [] @@ -59,7 +60,16 @@ def parse_arg(self): help='do not automatically enable the download progress plugin', ) + parser.add_argument( + '--dry-run', + action='store_true', + help='preview mock downloads without network requests or file writes; ignores --option', + ) + args = parser.parse_args() + if args.dry_run and not args.progress_enabled: + parser.error('--dry-run 用于预览进度条,不能与 --no-progress 同时使用') + self.dry_run = args.dry_run self.progress_enabled = args.progress_enabled option = args.option if len(option) == 0 or option == "''": @@ -91,6 +101,13 @@ def parse(text): def main(self): self.parse_arg() + if self.dry_run: + if importlib.util.find_spec('rich') is None: + raise SystemExit('预览进度条需要 rich,请先执行:pip install rich') + from .jm_plugin import DownloadProgressPlugin + DownloadProgressPlugin.preview(self.album_id_list, self.photo_id_list) + return + from .api import create_option, JmOption from .jm_task_context import jm_task_context with jm_task_context(cli_no_progress=not self.progress_enabled): diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index 859061e2d..62ea2c082 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -752,6 +752,8 @@ class ProgressDownloader(JmDownloader): progress_console = None progress_log_lines = deque(maxlen=6) active_progresses = set() + displayed_progresses = [] + progress_live = None progress_ui_lock = RLock() @staticmethod @@ -761,10 +763,11 @@ def display_id(entity_id): @classmethod def get_progress_console(cls): - if cls.progress_console is None: - from rich.console import Console - cls.progress_console = Console() - return cls.progress_console + with cls.progress_ui_lock: + if cls.progress_console is None: + from rich.console import Console + cls.progress_console = Console() + return cls.progress_console @classmethod def reset_progress_logs(cls): @@ -780,24 +783,61 @@ def configure_progress_log_lines(cls, max_lines): def append_progress_log(cls, message): with cls.progress_ui_lock: cls.progress_log_lines.append(message) - progresses = tuple(cls.active_progresses) + cls.refresh_progress_ui() - for progress in progresses: - try: - progress.refresh() - except Exception: - pass + @classmethod + def refresh_progress_ui(cls): + from rich.console import Group + + with cls.progress_ui_lock: + if cls.progress_live is not None: + cls.progress_live.update( + Group(cls.build_log_panel(), *cls.displayed_progresses), + refresh=True, + ) @classmethod def register_progress(cls, progress): if progress.console.is_interactive: + from rich.live import Live + with cls.progress_ui_lock: + if progress in cls.active_progresses: + return cls.active_progresses.add(progress) + cls.displayed_progresses.append(progress) + live_created = cls.progress_live is None + try: + if live_created: + # 所有下载器只提供进度内容,终端刷新由唯一的 Live 管理。 + cls.progress_live = Live(console=progress.console, auto_refresh=False) + cls.progress_live.start() + cls.refresh_progress_ui() + except BaseException: + # 启动或首帧渲染失败时撤销注册,让后续下载能重新建立界面。 + cls.active_progresses.discard(progress) + cls.displayed_progresses.remove(progress) + if live_created and cls.progress_live is not None: + try: + cls.progress_live.stop() + finally: + cls.progress_live = None + raise @classmethod def unregister_progress(cls, progress): with cls.progress_ui_lock: cls.active_progresses.discard(progress) + if cls.progress_live is not None and not cls.active_progresses: + try: + # 已结束任务保留到最后一个下载器退出,再统一留下最终画面。 + cls.refresh_progress_ui() + finally: + try: + cls.progress_live.stop() + finally: + cls.progress_live = None + cls.displayed_progresses.clear() @classmethod def build_log_panel(cls): @@ -833,12 +873,7 @@ def new_rich_progress(cls, console): TimeElapsedColumn, ) - class ProgressWithLogs(Progress): - def get_renderables(self): - yield cls.build_log_panel() - yield from super().get_renderables() - - return ProgressWithLogs( + return Progress( SpinnerColumn(style='bright_cyan'), TextColumn('{task.description}'), BarColumn( @@ -857,7 +892,7 @@ def get_renderables(self): def refresh_progress(self): if self.progress.console.is_interactive: - self.progress.refresh() + ProgressDownloader.refresh_progress_ui() def print_non_interactive_summary(self, photo_id=None): if self.progress.console.is_interactive: @@ -900,10 +935,9 @@ def start_progress(self, album_total=None, album_id=None): self.chapter_done = {} self.chapter_total = {} self.chapter_tasks = {} - console = self.get_progress_console() + console = ProgressDownloader.get_progress_console() self.progress = self.new_rich_progress(console) - self.progress.start() - self.register_progress(self.progress) + ProgressDownloader.register_progress(self.progress) self.album_task = None if album_total is not None: self.album_task = self.progress.add_task( @@ -970,14 +1004,16 @@ def stop_progress(self): progress = getattr(self, 'progress', None) if progress is None: return - self.unregister_progress(progress) - if progress.console.is_interactive: - progress.stop() - self.progress = None + try: + ProgressDownloader.unregister_progress(progress) + finally: + self.progress = None def __exit__(self, exc_type, exc_val, exc_tb): - self.stop_progress() - return super().__exit__(exc_type, exc_val, exc_tb) + try: + self.stop_progress() + finally: + super().__exit__(exc_type, exc_val, exc_tb) # noinspection attribute-outside-init @@ -995,7 +1031,6 @@ def start_progress(self, album_total=None, album_id=None): self.chapter_tasks = {} console = ProgressDownloader.get_progress_console() self.progress = ProgressDownloader.new_rich_progress(console) - self.progress.start() ProgressDownloader.register_progress(self.progress) self.album_task = None if album_total is not None: @@ -1067,14 +1102,16 @@ def stop_progress(self): progress = getattr(self, 'progress', None) if progress is None: return - ProgressDownloader.unregister_progress(progress) - if progress.console.is_interactive: - progress.stop() - self.progress = None + try: + ProgressDownloader.unregister_progress(progress) + finally: + self.progress = None async def __aexit__(self, exc_type, exc_val, exc_tb): - self.stop_progress() - return await super().__aexit__(exc_type, exc_val, exc_tb) + try: + self.stop_progress() + finally: + await super().__aexit__(exc_type, exc_val, exc_tb) class DownloadProgressPlugin(JmOptionPlugin): @@ -1082,6 +1119,100 @@ class DownloadProgressPlugin(JmOptionPlugin): plugin_dependencies = ('rich',) log_file = 'jmcomic-download.log' + @staticmethod + def preview(album_ids, photo_ids): + """使用模拟任务预览真实进度界面,不创建 Client、Option 或文件。""" + from datetime import datetime + from random import Random + from time import sleep + + random = Random() + + def log(topic, message, worker): + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + ProgressDownloader.append_progress_log( + f'{timestamp} [mock-worker-{worker}] [{topic}] {message}' + ) + + if not album_ids and not photo_ids: + album_ids = ['100001', '100002'] + console = ProgressDownloader.get_progress_console() + console.print('模拟下载:不请求网络、不写入图片或日志文件,也不加载 --option 配置。') + previews = [] + ProgressDownloader.reset_progress_logs() + try: + for kind, ids in (('本子', album_ids), ('章节', photo_ids)): + for entity_id in ids: + progress = ProgressDownloader.new_rich_progress(console) + name = f'{kind}-{ProgressDownloader.display_id(entity_id)}' + album_task = progress.add_task(name, total=2) if kind == '本子' else None + chapters = [] + for index in range(2 if album_task is not None else 1): + chapter_name = f'章节-JM{entity_id}{index + 1:02}' if album_task is not None else name + total = random.randint(12, 32) + task_id = progress.add_task( + f' {chapter_name}' if album_task is not None else chapter_name, + total=total, start=False, + ) + chapters.append({ + 'task_id': task_id, 'name': chapter_name, + 'next_tick': random.randint(0, 8), + 'interval': random.randint(1, 3), + 'started': False, 'retried': False, + }) + worker = len(previews) + 1 + previews.append((progress, album_task, chapters, name, worker)) + ProgressDownloader.register_progress(progress) + if album_task is not None: + log('album.before', f'本子获取成功: [{entity_id}], 章节数: [2], 标题: [模拟本子]', worker) + + tick = 0 + while any(not task.finished for progress, _, _, _, _ in previews for task in progress.tasks): + if console.is_interactive: + sleep(random.uniform(0.08, 0.16)) + for progress, album_task, chapters, name, worker in previews: + tasks = {task.id: task for task in progress.tasks} + for chapter in chapters: + task = tasks[chapter['task_id']] + if task.finished or tick < chapter['next_tick']: + continue + chapter_name = chapter['name'] + if not chapter['started']: + progress.start_task(task.id) + chapter['started'] = True + log('photo.before', f'开始下载章节: {chapter_name}, 图片数为[{int(task.total)}]', worker) + elif not chapter['retried'] and random.random() < 0.08: + chapter['retried'] = True + chapter['next_tick'] = tick + random.randint(3, 6) + log('image.retry', f'{chapter_name} 图片 {int(task.completed) + 1:05}.jpg 请求超时,准备重试', worker) + continue + advance = min(random.randint(1, 3), int(task.total - task.completed)) + for page in range(int(task.completed) + 1, int(task.completed) + advance + 1): + progress.advance(task.id) + log('image.after', f'图片下载完成: {chapter_name}/{page:05}.jpg [{page}/{int(task.total)}]', worker) + chapter['next_tick'] = tick + chapter['interval'] + random.randint(0, 1) + if task.finished: + progress.update(task.id, description=f'✓ {task.description}') + log('photo.after', f'章节下载完成: [{chapter_name}], 图片 {int(task.total)}/{int(task.total)}', worker) + if album_task is not None: + was_finished = tasks[album_task].finished + done = sum(tasks[chapter['task_id']].finished for chapter in chapters) + progress.update(album_task, completed=done) + if done == len(chapters) and not was_finished: + progress.update(album_task, description=f'✓ {name}') + log('album.after', f'本子下载完成: [{name}], 章节 {done}/{len(chapters)}', worker) + ProgressDownloader.refresh_progress_ui() + tick += 1 + + if not console.is_interactive: + for progress, album_task, chapters, name, worker in previews: + console.print(f'模拟完成:{name}') + finally: + for progress, album_task, chapters, name, worker in previews: + ProgressDownloader.unregister_progress(progress) + ProgressDownloader.reset_progress_logs() + console.print('模拟结束,未下载任何文件。') + @staticmethod def cli_no_progress_notice(): if not get_jm_task_context().get('cli_no_progress'): diff --git a/tests/test_jmcomic/test_jm_cli.py b/tests/test_jmcomic/test_jm_cli.py index ebbb44ef0..ac692244c 100644 --- a/tests/test_jmcomic/test_jm_cli.py +++ b/tests/test_jmcomic/test_jm_cli.py @@ -12,6 +12,24 @@ class Test_Cli(JmTestConfigurable): album_id = '123' + def test_dry_run_skips_option_and_real_downloads(self): + ui = JmcomicUI() + with patch('sys.argv', ['jmcomic', '100001', 'p200001', '--dry-run', '--option', 'unused.yml']), \ + patch('jmcomic.cli.importlib.util.find_spec', return_value=object()), \ + patch('jmcomic.api.create_option', side_effect=AssertionError('不能读取配置')), \ + patch('jmcomic.api.JmOption.default', side_effect=AssertionError('不能创建 Option')), \ + patch.object(ui, 'run', side_effect=AssertionError('不能执行真实下载')), \ + patch('jmcomic.jm_plugin.DownloadProgressPlugin.preview') as preview: + ui.main() + preview.assert_called_once_with(['100001'], ['200001']) + + def test_dry_run_rejects_no_progress(self): + with patch('sys.argv', ['jmcomic', '--dry-run', '--no-progress']), \ + patch('sys.stderr', StringIO()): + with self.assertRaises(SystemExit) as caught: + JmcomicUI().parse_arg() + self.assertEqual(2, caught.exception.code) + # ========== jmcomic 命令测试 ========== def test_jmcomic_progress_enabled_by_default(self): diff --git a/tests/test_jmcomic/test_jm_download_progress.py b/tests/test_jmcomic/test_jm_download_progress.py index 57b151e4d..fe2894f5e 100644 --- a/tests/test_jmcomic/test_jm_download_progress.py +++ b/tests/test_jmcomic/test_jm_download_progress.py @@ -93,6 +93,165 @@ async def check_photo(self, photo): class Test_DownloadProgress(unittest.TestCase): + def setUp(self): + # 渲染测试固定为支持 ANSI 的终端,不依赖测试宿主的 TERM 设置。 + terminal = patch.dict(os.environ, {'TERM': 'xterm-256color'}) + terminal.start() + self.addCleanup(terminal.stop) + + @unittest.skipUnless(RICH_INSTALLED, '需要安装 rich 才能测试彩色进度插件') + def test_subclass_and_async_downloader_share_console(self): + class CustomProgressDownloader(ProgressDownloader): + pass + + sync = object.__new__(CustomProgressDownloader) + async_downloader = object.__new__(AsyncProgressDownloader) + with patch.object(ProgressDownloader, 'progress_console', None), patch('sys.stdout', StringIO()): + try: + sync.start_progress(1, '101') + async_downloader.start_progress(1, '202') + self.assertIs(sync.progress.console, async_downloader.progress.console) + self.assertNotIn('progress_console', CustomProgressDownloader.__dict__) + finally: + sync.stop_progress() + async_downloader.stop_progress() + + @unittest.skipUnless(RICH_INSTALLED, '需要安装 rich 才能测试彩色进度插件') + def test_concurrent_sync_async_progress_keeps_completed_tasks_until_last_exit(self): + from rich.console import Console + + console = Console(file=StringIO(), force_terminal=True, force_interactive=True, width=100) + sync = object.__new__(ProgressDownloader) + async_downloader = object.__new__(AsyncProgressDownloader) + with patch.object(ProgressDownloader, 'progress_console', console): + try: + sync.start_progress(1, '101') + live = ProgressDownloader.progress_live + async_downloader.start_progress(1, '202') + self.assertIs(live, ProgressDownloader.progress_live) + completed_progress = sync.progress + ProgressDownloader.register_progress(completed_progress) + self.assertEqual(2, len(ProgressDownloader.displayed_progresses)) + sync.stop_progress() + self.assertIs(live, ProgressDownloader.progress_live) + self.assertIn(completed_progress, ProgressDownloader.displayed_progresses) + async_downloader.stop_progress() + self.assertIsNone(ProgressDownloader.progress_live) + self.assertFalse(ProgressDownloader.active_progresses) + self.assertFalse(ProgressDownloader.displayed_progresses) + sync.start_progress(1, '303') + self.assertIsNot(live, ProgressDownloader.progress_live) + self.assertEqual(1, len(ProgressDownloader.displayed_progresses)) + finally: + sync.stop_progress() + async_downloader.stop_progress() + + @unittest.skipUnless(RICH_INSTALLED, '需要安装 rich 才能测试彩色进度插件') + def test_failed_live_start_does_not_poison_next_download(self): + from rich.console import Console + + console = Console(file=StringIO(), force_terminal=True, force_interactive=True) + progress = ProgressDownloader.new_rich_progress(console) + with patch('rich.live.Live.start', side_effect=RuntimeError('render failed')): + with self.assertRaisesRegex(RuntimeError, 'render failed'): + ProgressDownloader.register_progress(progress) + self.assertIsNone(ProgressDownloader.progress_live) + self.assertFalse(ProgressDownloader.active_progresses) + self.assertFalse(ProgressDownloader.displayed_progresses) + try: + ProgressDownloader.register_progress(progress) + self.assertIsNotNone(ProgressDownloader.progress_live) + finally: + ProgressDownloader.unregister_progress(progress) + + @unittest.skipUnless(RICH_INSTALLED, '需要安装 rich 才能测试彩色进度插件') + def test_failed_final_refresh_releases_ui_and_downloader(self): + from rich.console import Console + + console = Console(file=StringIO(), force_terminal=True, force_interactive=True) + downloader = object.__new__(ProgressDownloader) + with patch.object(ProgressDownloader, 'progress_console', console): + downloader.start_progress(1, '101') + with patch.object(ProgressDownloader, 'refresh_progress_ui', side_effect=RuntimeError('render failed')): + with self.assertRaisesRegex(RuntimeError, 'render failed'): + downloader.stop_progress() + self.assertIsNone(downloader.progress) + self.assertIsNone(ProgressDownloader.progress_live) + self.assertFalse(ProgressDownloader.active_progresses) + self.assertFalse(ProgressDownloader.displayed_progresses) + + @unittest.skipUnless(RICH_INSTALLED, '需要安装 rich 才能测试彩色进度插件') + def test_preview_interrupt_releases_ui(self): + from rich.console import Console + + console = Console(file=StringIO(), force_terminal=True, force_interactive=True) + with patch.object(ProgressDownloader, 'progress_console', console), \ + patch('time.sleep', side_effect=KeyboardInterrupt): + with self.assertRaises(KeyboardInterrupt): + DownloadProgressPlugin.preview([], []) + self.assertIsNone(ProgressDownloader.progress_live) + self.assertFalse(ProgressDownloader.active_progresses) + self.assertFalse(ProgressDownloader.displayed_progresses) + self.assertFalse(ProgressDownloader.progress_log_lines) + + def test_async_exit_closes_resources_even_if_ui_stop_fails(self): + from unittest.mock import AsyncMock + + downloader = object.__new__(AsyncProgressDownloader) + + async def run(): + with patch.object(downloader, 'stop_progress', side_effect=RuntimeError('render failed')), \ + patch.object(JmAsyncDownloader, '__aexit__', new_callable=AsyncMock) as close: + with self.assertRaisesRegex(RuntimeError, 'render failed'): + await downloader.__aexit__(None, None, None) + close.assert_awaited_once_with(None, None, None) + + asyncio.run(run()) + + @unittest.skipUnless(RICH_INSTALLED, '需要安装 rich 才能测试彩色进度插件') + def test_preview_shares_one_live_and_releases_tasks_without_files(self): + from random import Random + from rich.console import Console + + output = StringIO() + original_console = ProgressDownloader.progress_console + ProgressDownloader.progress_console = Console( + file=output, force_terminal=True, force_interactive=True, width=100, + ) + observed = [] + observed_logs = [] + original_refresh = ProgressDownloader.refresh_progress_ui + + def check_frame(): + original_refresh() + observed_logs.extend(ProgressDownloader.progress_log_lines) + live = ProgressDownloader.progress_live + if live is not None: + observed.append(live) + rendered = ProgressDownloader.progress_console.render_lines(live.renderable) + text = '\n'.join(''.join(segment.text for segment in line) for line in rendered) + self.assertEqual(1, text.count('JMComic Logs')) + + try: + with patch('time.sleep'), \ + patch('random.Random', return_value=Random(1)), \ + patch('logging.FileHandler', side_effect=AssertionError('不能创建日志文件')), \ + patch.object(ProgressDownloader, 'refresh_progress_ui', side_effect=check_frame): + DownloadProgressPlugin.preview([], []) + self.assertTrue(observed) + self.assertTrue(all(live is observed[0] for live in observed)) + self.assertIsNone(ProgressDownloader.progress_live) + self.assertFalse(ProgressDownloader.active_progresses) + self.assertFalse(ProgressDownloader.displayed_progresses) + self.assertIn('✓ 本子-JM100001', output.getvalue()) + self.assertIn('✓ 本子-JM100002', output.getvalue()) + logs = '\n'.join(observed_logs) + for topic in ('album.before', 'photo.before', 'image.after', 'image.retry', 'photo.after', 'album.after'): + self.assertIn(f'[{topic}]', logs) + self.assertIn('[mock-worker-', logs) + finally: + ProgressDownloader.progress_console = original_console + def test_progress_display_id_adds_prefix_only_once(self): self.assertEqual('JM123456', ProgressDownloader.display_id('123456')) self.assertEqual('JM123456', ProgressDownloader.display_id('JM123456')) From 56190461570c3d17c86b36a7b724bfe024c8ec2b Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:11:42 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=E5=BC=8F=E5=8F=96=E6=B6=88=E4=B8=8B=E8=BD=BD=E4=B8=8E?= =?UTF-8?q?=E8=B7=A8=E5=B1=82=E5=85=B1=E4=BA=AB=20Runtime=20=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/benchmark.yml | 6 +- .github/workflows/close_specific_pr.yml | 4 +- .github/workflows/export_favorites.yml | 8 +- .github/workflows/release.yml | 8 +- .github/workflows/release_auto.yml | 8 +- .github/workflows/test_api.yml | 4 +- .github/workflows/test_html.yml | 4 +- CHANGELOG.md | 16 + assets/docs/mkdocs.yml | 1 + assets/docs/sources/api/download.md | 34 + .../docs/sources/tutorial/0_common_usage.md | 69 +- .../docs/sources/tutorial/14_async_usage.md | 76 ++- .../sources/tutorial/16_shared_executors.md | 303 +++++++++ pyproject.toml | 2 +- src/jmcomic/__init__.py | 4 +- src/jmcomic/api.py | 470 +++++++++---- src/jmcomic/cli.py | 5 +- src/jmcomic/jm_async_client.py | 1 - src/jmcomic/jm_async_downloader.py | 145 +++- src/jmcomic/jm_client_impl.py | 6 + src/jmcomic/jm_client_interface.py | 8 +- src/jmcomic/jm_config.py | 7 +- src/jmcomic/jm_downloader.py | 185 ++++-- src/jmcomic/jm_entity.py | 1 - src/jmcomic/jm_exception.py | 45 +- src/jmcomic/jm_feature.py | 4 +- src/jmcomic/jm_option.py | 3 +- src/jmcomic/jm_plugin.py | 9 +- src/jmcomic/jm_runtime.py | 308 +++++++++ src/jmcomic/jm_task_context.py | 189 +++++- src/jmcomic/jm_toolkit.py | 1 - tests/test_jmcomic/test_jm_api.py | 3 + tests/test_jmcomic/test_jm_async_custom.py | 30 +- tests/test_jmcomic/test_jm_cancellation.py | 609 +++++++++++++++++ tests/test_jmcomic/test_jm_cli.py | 46 +- tests/test_jmcomic/test_jm_client.py | 10 +- .../test_jmcomic/test_jm_download_manifest.py | 208 ++++-- .../test_jmcomic/test_jm_download_progress.py | 39 +- tests/test_jmcomic/test_jm_exception.py | 44 ++ tests/test_jmcomic/test_jm_release.py | 47 +- tests/test_jmcomic/test_jm_runtime.py | 281 ++++++++ tests/test_jmcomic/test_jm_task_context.py | 627 ++++++++++++++++-- 42 files changed, 3422 insertions(+), 456 deletions(-) create mode 100644 assets/docs/sources/tutorial/16_shared_executors.md create mode 100644 src/jmcomic/jm_runtime.py create mode 100644 tests/test_jmcomic/test_jm_cancellation.py create mode 100644 tests/test_jmcomic/test_jm_runtime.py diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index e534db734..dd928be5e 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -19,10 +19,10 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: '3.14' @@ -49,7 +49,7 @@ jobs: - name: Upload Benchmark Report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: benchmark-report path: PERFORMANCE_REPORT.md diff --git a/.github/workflows/close_specific_pr.yml b/.github/workflows/close_specific_pr.yml index 8ed49d3eb..ce890e337 100644 --- a/.github/workflows/close_specific_pr.yml +++ b/.github/workflows/close_specific_pr.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Close PR run: | @@ -36,4 +36,4 @@ jobs: gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body '${{ env.comment }}' gh pr close ${{ github.event.pull_request.number }} --repo ${{ github.repository }} env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/export_favorites.yml b/.github/workflows/export_favorites.yml index a00cdf64c..4375e3bc7 100644 --- a/.github/workflows/export_favorites.yml +++ b/.github/workflows/export_favorites.yml @@ -48,9 +48,11 @@ jobs: ZIP_FP: /home/runner/work/jmcomic/download/export.7z steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Python 3.11 - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.11" @@ -71,7 +73,7 @@ jobs: python workflow_export_favorites.py - name: 上传结果 - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: '导出的收藏夹' path: ${{ env.ZIP_FP }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f17f7722..9458b5856 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,12 @@ jobs: id-token: write contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Python 3.11 - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.11" @@ -31,7 +33,7 @@ jobs: python -m build - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/release_auto.yml b/.github/workflows/release_auto.yml index 0ef2d6cb1..c3829d119 100644 --- a/.github/workflows/release_auto.yml +++ b/.github/workflows/release_auto.yml @@ -14,10 +14,12 @@ jobs: contents: write if: startsWith(github.event.head_commit.message, 'v') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Python 3.11 - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.11" @@ -33,7 +35,7 @@ jobs: python -m build - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/test_api.yml b/.github/workflows/test_api.yml index e0e5d7558..35245d21f 100644 --- a/.github/workflows/test_api.yml +++ b/.github/workflows/test_api.yml @@ -27,10 +27,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/test_html.yml b/.github/workflows/test_html.yml index 8f44d7c9b..f8a45b50c 100644 --- a/.github/workflows/test_html.yml +++ b/.github/workflows/test_html.yml @@ -27,10 +27,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 24add6c74..03df2a95d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 条目分类参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [2.7.10] - 2026-10-08 + +### Summary + +本次更新支持协作式取消下载与共享 Runtime 调度。 + +### Added +- 新增 `JmTempRuntime`、`JmSyncRuntime` 与 `JmAsyncRuntime`;裸同步 Downloader 的局部调度使用临时单池 Runtime,顶层同步 API 或自定义同步调度可复用 `album/photo/image` 三层线程池,异步下载可复用 `decode` 线程池。 +- 新增 `DownloadControl` 和 `DownloadCancelledException`,支持通过任务上下文协作式取消同步与异步下载;`DownloadCancelledException` 支持通过 `completed_results` 与 `unfinished_ids` 读取批量取消时的已完成成果与未完成 ID。 +- `DownloadResult` 新增 `album` 和 `photo` 快捷实体访问属性。 +- 新增 `JTC` (Jm Task Context) 门面类,统一提供上下文、Runtime、Option 及取消控制器的快捷读取方法。 + +### Changed +- 下载调度统一使用标准库 Executor;顶层 API 和裸同步 Downloader 的临时调度显式关闭自己创建的 Runtime,`jm_task_context` 只传播字段;外部 Runtime 和 Executor 仍由调用方关闭。 +- 顶层下载会把 Runtime 和 Option 作为公开字段直接放入任务上下文;Runtime 不依赖 Context 或 Option,未配置的层级由实际调用点传入默认 worker 数。 + ## [2.7.9] - 2026-10-08 ### Summary diff --git a/assets/docs/mkdocs.yml b/assets/docs/mkdocs.yml index 003684fcb..99c00bed9 100644 --- a/assets/docs/mkdocs.yml +++ b/assets/docs/mkdocs.yml @@ -59,6 +59,7 @@ nav: - tutorial/13_export_and_feature.md - tutorial/14_async_usage.md - tutorial/15_download_progress.md + - tutorial/16_shared_executors.md plugins: - search diff --git a/assets/docs/sources/api/download.md b/assets/docs/sources/api/download.md index ce7d19654..f193c0cc4 100644 --- a/assets/docs/sources/api/download.md +++ b/assets/docs/sources/api/download.md @@ -17,3 +17,37 @@ options: members: - JmAsyncDownloader + +::: jmcomic.jm_downloader + options: + members: + - BaseDownloader + - JmDownloader + +::: jmcomic.jm_runtime + options: + members: + - JmRuntime + - JmTempRuntime + - JmSyncRuntime + - JmAsyncRuntime + +::: jmcomic.jm_exception + options: + members: + - DownloadCancelledException + +::: jmcomic.jm_task_context + options: + members: + - DownloadControl + - jm_task_context + - bind_jm_task_context + +::: jmcomic.jm_task_context.JTC + options: + members: + - get_runtime + - get_option + - get_control + - get_context diff --git a/assets/docs/sources/tutorial/0_common_usage.md b/assets/docs/sources/tutorial/0_common_usage.md index abe2818bf..216273987 100644 --- a/assets/docs/sources/tutorial/0_common_usage.md +++ b/assets/docs/sources/tutorial/0_common_usage.md @@ -534,11 +534,63 @@ cl = JmApiClient( ``` +## 取消下载 + +> 如果你想中途取消下载,最简单粗暴的方式是直接杀死进程,比如 `ctrl+c`,关闭终端窗口 等 +> +> 但是在 GUI、Web服务这类场景里,就不适合这么关闭。 +> +> 于是,jmcomic 提供了一种优雅停止的写法,可以使用 `DownloadControl` 这个类。 +> +> 这种写法更加可控: +> +> - **不影响主程序**:只停当前下载,GUI 界面或 Web 服务依然正常运作。 +> - **多任务统一取消**:多个本子一起下载时,可以被统一取消。 +> - **可携带取消理由**:调用 `cancel()` 传入的原因可以直接捕获,方便做提示和日志。 + +写法就两步: + +1. 创建 DownloadControl 对象,传入任务上下文 +2. 调用 DownloadControl 类的 cancel() 方法 + +```python +from threading import Thread +from time import sleep +from jmcomic import DownloadCancelledException, DownloadControl, download_album, jm_task_context + +# 创建取消控制器 +my_control = DownloadControl() + +def run_download(): + try: + # 使用 with jm_task_context 创建任务上下文,并传入 control 参数 + with jm_task_context(control=my_control): + # 支持传入单个本子或多个本子列表;多本子时中途取消会统一停止 + download_album(['123456', '789012', '345678']) + except DownloadCancelledException as e: + print(f'下载已取消: {e.reason}') + # 可直接从异常对象读取已完成成果与未完成的 ID + print(f'已完成本子: {[res.album.id for res in e.completed_results]}') + print(f'未完成本子: {e.unfinished_ids}') + +# 使用单独的下载线程执行下载 +# 主线程负责取消 +t = Thread(target=run_download) +t.start() + +# 模拟一段时间后,需要取消下载 +sleep(2) +# 可以传入取消原因,下载线程可通过上面的 e.reason 获取 +my_control.cancel("不想要了,取消掉吧") +# 等待下载线程结束 +t.join() +``` + ## 下载返回值 `download_album` 和 `download_photo` 下载完成后,单个 ID 返回 `DownloadResult`,多个 ID 返回 `BatchResult`。 -从 `result.detail` 可以取得下载的本子/章节的实体类: +从 `DownloadResult` 的 `detail` 字段可以取得下载的本子/章节的实体类: ```python from jmcomic import download_album, download_photo @@ -638,24 +690,26 @@ print('长图导出文件:', png_filepath_list) ### 批量下载的返回值 -传入多个 ID 时,返回值是 `BatchResult`。每一项成功下载对应一个 `DownloadResult`,失败任务则记录在 `failed` 中: +传入多个 ID 时,返回值是 `BatchResult`,通过这个对象可以取得成功和失败的下载结果:
完整示例:处理批量下载结果 ```python -from jmcomic import download_album +from jmcomic import download_album, DownloadResult # 同时下载多个本子 batch_result = download_album(['123', '456', '789']) -# BatchResult 继承 set,成功结果没有输入顺序保证 +# BatchResult 继承 set,直接遍历是只遍历成功结果 for result in batch_result: + result: DownloadResult + # result 的用法同上 album = result.detail - # 通过实体 ID 识别当前结果,不要用遍历位置对应输入列表 print(f'JM{album.id} 下载到: {album.save_path}') -# failed 的键是下载失败的 ID,值是记录失败原因的异常对象 +# failed 负责存放失败的下载结果,类型是dict +# key是下载失败的 ID,value是记录失败原因的异常对象 for album_id, error in batch_result.failed.items(): print(f'JM{album_id} 下载失败: {error}') @@ -666,7 +720,8 @@ print('是否全部成功:', batch_result.all_succeeded)
-下载单个 ID 时,请求本子失败会直接抛出异常;如果只有部分章节或图片失败,会在任务结束后汇总抛出 `PartialDownloadFailedException`,此时不会返回 `DownloadResult`。批量下载则继续执行其他任务,并把失败项集中放进 `batch_result.failed`。 +--- + ### 速查表 diff --git a/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md index 3482ef861..71f69ede6 100644 --- a/assets/docs/sources/tutorial/14_async_usage.md +++ b/assets/docs/sources/tutorial/14_async_usage.md @@ -260,9 +260,9 @@ client: async_impl: async_api ``` -## 10. 查看下载耗时 +## 10. 下载返回值 -异步下载完成后,可以直接查看自己总共等了多久,也可以继续查看具体是哪个本子、章节或图片比较慢。所有 `duration` 的单位都是秒。 +用法与同步版本完全一致,返回的 `DownloadResult` 包含了本子实体、保存路径、耗时以及下载清单(`manifest`)等专属字段: ```python import asyncio @@ -273,25 +273,67 @@ async def main(): result = await jmcomic.download_album_async('438696') album = result.detail - # 从调用 download_album_async 到返回,总共等了多久 - print(f'总共等待: {result.duration:.3f} 秒') + # 1. 实体与保存路径 + print(f'本子: JM{album.id} - {album.title}') + print(f'保存目录: {album.save_path}') - # 如果下载比较慢,可以继续查看具体慢在哪里 - print(f'下载本子用了: {album.duration:.3f} 秒') - for photo in album: - print(f'下载章节 {photo.id} 用了: {photo.duration:.3f} 秒') - for image in photo: - print(f'处理图片 {image.img_file_name} 用了: {image.duration:.3f} 秒') + # 2. 耗时统计(单位:秒) + print(f'总耗时: {result.duration:.3f} 秒, 本子处理耗时: {album.duration:.3f} 秒') + + # 3. 下载清单 manifest + manifest = result.manifest + print(f'全部本子图片的磁盘路径: {manifest.image_filepath_list}') asyncio.run(main()) ``` -| 字段 | 它告诉你什么 | -| --- | --- | -| `result.duration` | 从调用异步下载方法到返回,你总共等了多久 | -| `album.duration` | 下载这个本子花了多久,包含获取本子信息和整理下载结果 | -| `photo.duration` | 下载这个章节花了多久,包含获取或补全章节信息 | -| `image.duration` | 处理这张图片花了多久,包含检查缓存、下载、解密和保存 | +## 11. 取消下载 + +下面示例代码模拟了一个 GUI 场景中,有两个按钮,一个开始下载,一个取消下载,基于 `DownloadControl` 实现。 + +```python +import asyncio +from jmcomic import DownloadCancelledException, DownloadControl, download_album_async, jm_task_context + +# 界面持有的当前下载控制器与任务 +current_control = None +current_task = None + + +# 按钮 1:点击【开始下载】 +def on_btn_download_click(album_id: str) -> asyncio.Task: + global current_control, current_task + current_control = DownloadControl() + + async def _run(): + try: + with jm_task_context(control=current_control): + await download_album_async(album_id) + except DownloadCancelledException as e: + print(f'[UI 提示] 下载已取消: {e.reason}') + + current_task = asyncio.create_task(_run()) + return current_task -下载器可能同时处理多个章节或多张图片,所以把它们的耗时全部相加,不会得到本子的耗时,这是正常现象。同步下载中的这些字段含义相同。 + +# 按钮 2:点击【取消下载】 +def on_btn_cancel_click(): + if current_control: + current_control.cancel("用户点击了界面的取消按钮") + + +async def main(): + # 模拟用户点击【开始下载】 + task = on_btn_download_click('123456') + + # 模拟用户在界面等待一段时间后,点击了【取消下载】 + await asyncio.sleep(2) + on_btn_cancel_click() + + # 等待下载任务响应取消并安全收尾 + await task + + +asyncio.run(main()) +``` diff --git a/assets/docs/sources/tutorial/16_shared_executors.md b/assets/docs/sources/tutorial/16_shared_executors.md new file mode 100644 index 000000000..be23eeb63 --- /dev/null +++ b/assets/docs/sources/tutorial/16_shared_executors.md @@ -0,0 +1,303 @@ +# 下载并发调优 / 复用线程池 + +> 日常使用 jmcomic 下载时,不需要关心并发与配置线程池,jmcomic 内部会创建临时线程池来并发下载。 +> +> 但在以下两种场景下,你可能更希望精细控制: +> +> 1. **大批量下载**:一次性下载几十上百个本子,希望严格限制并发(例如“最多同时下 2 个本子,所有图片最多 8 个线程”),避免把网络或内存卡爆。 +> 2. **复用已有的线程池**:你的程序(如 Web 服务、后台调度器)本身已经维护了全局线程池,希望 JMComic 直接复用,避免重复创建和销毁线程。 +> +> 如果你的诉求是这些,那么本教程推荐你往下阅读。 +--- + +## 1、先算一笔账 + +> 下载本子时有多少线程并发? + +jmcomic 对下载分为三层,本子层、章节层、图片层 + +当你写 `download_album([123, 456])` 时,希望下载两个本子,每层的并发数如下: + +* **本子层**:取本子总数 + * 这个场景就是 **2**,两个本子 + +* **章节层**:取option配置 `threading.photo`,不配默认是当前系统的 CPU 核心数 + * 假设这个场景是跑在8核的cpu上,那就是 **8** + +* **图片层**:取option配置 `threading.image`,不配默认固定为 **30** 个线程。 + * 这个场景假设没配,就是 **30** + +各层级联调度的真实结构如下: + +```text +download_album([123, 456]) ──► 待下载 2 个本子 + +【本子层】同时跑 2 个本子 + │ + ├─► 本子 123 ── 各自独立创建 Photo 线程池 (默认 8 线程) + │ ├─► 章节 1 ── 各自独立创建 Image 线程池 (默认 30 线程) ──► 30 线程下载图片 + │ ├─► 章节 2 ── 各自独立创建 Image 线程池 (默认 30 线程) ──► 30 线程下载图片 + │ └─► ... (最多同时跑 8 个章节) + │ + └─► 本子 456 ── 各自独立创建 Photo 线程池 (默认 8 线程) + ├─► 章节 1 ── 各自独立创建 Image 线程池 (默认 30 线程) ──► 30 线程下载图片 + ├─► 章节 2 ── 各自独立创建 Image 线程池 (默认 30 线程) ──► 30 线程下载图片 + └─► ... (最多同时跑 8 个章节) +``` + +如果 123,456这个本子的章节和图片非常多,就有可能拉满全部并发,峰值线程数会达到 $2 \text{ (本子)} \times 8 \text{ (章节)} \times 30 \text{ (图片)} = \mathbf{480}$ **个并发线程!** + +这种**乘法级(连乘)级联膨胀**,会导致系统瞬时创建上千个系统线程,引起大量 CPU 上下文切换、内存占用飙升、网络连接数耗尽,甚至直接触发目标服务端的反爬限频或把自己的本地网络打瘫痪。 + +--- + +## 2、改配置减少并发 + +要减少并发,最简单直接的办法,就是把线程数改小一点。 + +直接在 option 中配置章节层和图片层的并发: + +```yaml +# option.yml +download: + threading: + photo: 2 # 每个本子同时只下载 2 个章节 + image: 5 # 每个章节同时只下载 5 张图片 +``` + +也可以在 Python 代码里直接修改: + +```python +from jmcomic import JmOption, download_album + +option = JmOption.default() +option.download.threading.photo = 2 +option.download.threading.image = 5 + +download_album([123, 456], option=option) +``` + +### 算一笔新账 + +同样下载 123 和 456 这两个本子,此时的峰值并发变成了: + +$$2 \text{ (本子)} \times 2 \text{ (章节)} \times 5 \text{ (图片)} = \mathbf{20} \text{ 个并发线程}$$ + +从 **480** 骤降到 **20**! + +### 减少本子层并发 + +上面改配置只管住了章节层和图片层。但只要你写的是 `download_album([123, 456])` 传列表,本子层默认依然是同时并发跑的。 + +如果要减少本子层并发,最直接的做法就是**不要传列表**,而是改成写两行 `download_album`: + +```python +from jmcomic import download_album + +# 改成两行:本子 123 下载完后,才开始下载本子 456 +download_album(123) +download_album(456) +``` + +这样本子层就完全不并发了(本子并发数直接降为 1)。再配合上面的配置(photo=2, image=5),同一时刻的峰值线程数就只有: + +$$1 \text{ (本子)} \times 2 \text{ (章节)} \times 5 \text{ (图片)} = \mathbf{10} \text{ 个并发线程}$$ + +--- + +## 3、仅仅改配置不够的场景 + +通过上面的改配置或者拆分下载,已经能够满足大部分日常需求。 + +但在以下场景中,你可能需要更精细的并发控制: + +##### 场景 1:一次性下载大量本子,希望限制同时下载的本子数(排队下载) + +> 如果你有几十个本子要下载,直接传列表 `download_album([1, 2, ...])` 会导致所有本子同时并发跑起来;而如果写成两行或者循环,又只能一个一个完全串行下载。 +> +> 如果你希望**同时下载 2 个本子,其余的排队等待**,只改局部配置是无法实现的。 + +##### 场景 2:希望所有图片下载共用线程池,严格卡死全局线程上限 + +> 每个章节下载时都会各自创建一套图片线程池。 +> +> 如果你希望**不管当前有多少个本子在下载,整个程序全局最多只允许 8 个线程在下载图片,大家共享同一个线程池**,就需要共享图片线程池,而纯配置无法实现。 + +##### 场景 3:复用外部已有的线程池 + +> 如果你的项目(例如 Web 服务、定时任务调度器)本身已经维护了全局的 `ThreadPoolExecutor`,希望 jmcomic 直接复用,避免每次下载都重复创建和销毁线程。 + + +## 4、使用 Runtime 精细控制并发 + +针对上面介绍的场景,jmcomic 提供了 **Runtime** 机制,目的是更加透明精细地控制并发。 + +你可以把 Runtime 理解为**线程池管家**。它主要为你提供以下能力: + +1. **控制【本子层】并发**:指定最多同时下载几个本子,其余本子排队; +2. **让【章节/图片层】共享线程池**:不管下载多少本子,章节和图片的底层线程池固定共享,不再连乘膨胀; +3. **支持复用外部线程池**:可以直接使用你创建的外部线程池 `ThreadPoolExecutor`。 + +> [!NOTE] +> +> 由于同步下载和异步下载是两套API,因此runtime的使用也有区别,需要挑选对应的实现类: +> +> - **同步下载**:使用 `JmSyncRuntime`。同步下载涉及“本子、章节、图片”三层,用它来统一管理和共享这三层的线程池。 +> - **异步下载**:使用 `JmAsyncRuntime`。异步下载的网络请求走的是 `asyncio` 协程,不占线程;只有图片解密与写盘需要线程,因此它只负责管理一个 `decode`(解码)线程池。 + +--- + +### 4.1 同步下载:使用 JmSyncRuntime + +#### 场景 A:自定义各层并发数(大批量下载防卡死) + +创建 `JmSyncRuntime` 并指定每一层的最大线程数: + +```python +from jmcomic import JmSyncRuntime, download_album + +# 1. 创建 JmSyncRuntime,推荐搭配 with,可以自动关闭runtime创建的线程池资源 +with JmSyncRuntime( + album_workers=2, # 最多同时下载 2 个本子(其余本子排队) + photo_workers=3, # 所有本子合计最多同时处理 3 个章节 + image_workers=8, # 所有章节合计最多同时下载 8 张图片 +): + # 2. 下载3个本子,这3个本子都受 Runtime 的并发约束 + download_album(['123456', '789012', '345678']) +``` + +**用了 Runtime 之后,并发线程数从“乘法”变成了“加法”**: +此时系统的总工作线程数恒定为: +$$2 \text{ (本子)} + 3 \text{ (章节)} + 8 \text{ (图片)} = \mathbf{13} \text{ 个线程}$$ + +无论你一次性传 10 个还是 100 个本子,总线程数都严格卡在 13 个,既不会爆内存,也不会把电脑或网络卡死。 + +> [!TIP] +> 如果某一层不传参数(例如不传 `photo_workers`),该层会使用系统默认值。 + +#### 场景 B:复用外部已有的线程池 + +如果你的程序已经有现成的 `ThreadPoolExecutor`,可以直接传给 Runtime 复用: + +```python +from concurrent.futures import ThreadPoolExecutor +from jmcomic import JmSyncRuntime, download_album + +# 假设程序中已有维护好的线程池 +with ThreadPoolExecutor(max_workers=2) as album_pool, \ + ThreadPoolExecutor(max_workers=3) as photo_pool, \ + ThreadPoolExecutor(max_workers=8) as image_pool: + + # 传入外部线程池。遵循“谁创建谁关闭”原则,Runtime 退出时不会关闭外部传入的线程池 + with JmSyncRuntime( + album_executor=album_pool, + photo_executor=photo_pool, + image_executor=image_pool, + ): + download_album(['123456', '789012']) + # 走到这里,album_pool、photo_pool、image_pool 都不会被 runtime 关闭 +``` + +--- + +### 4.2 异步下载:使用 JmAsyncRuntime + +异步下载与同步不同:网络请求由 `asyncio` 协程高效处理,不占线程;**只有图片的解密与保存需要用到后台线程池**。 + +因此,`JmAsyncRuntime` 只需要管理一个 `decode`(图片解码)线程池,配置非常简单。 + +#### 场景 A:指定解码并发线程数 + +```python +import asyncio +from jmcomic import JmAsyncRuntime, download_album_async + + +async def main(): + # 限制最多同时使用 4 个线程进行图片解密与保存 + with JmAsyncRuntime(decode_workers=4): + await download_album_async(['123456', '789012']) + + +asyncio.run(main()) +``` + +#### 场景 B:复用外部已有的解码线程池 + +```python +import asyncio +from concurrent.futures import ThreadPoolExecutor +from jmcomic import JmAsyncRuntime, download_album_async + + +async def main(): + # 借用现成的外部线程池 + with ThreadPoolExecutor(max_workers=4) as my_decode_pool: + with JmAsyncRuntime(decode_executor=my_decode_pool): + await download_album_async('123456') + + +asyncio.run(main()) +``` + +--- + +## 5、Web 场景示例:全局长效复用 Runtime + +在 FastAPI、Django 等常驻 Web 服务或后台常驻进程中,为每个请求频繁创建和销毁线程池会造成不必要的系统开销。 + +推荐的做法是:**应用启动时创建全局长效 Runtime,每个请求通过 `jm_task_context` 注入当前请求专属的上下文元数据(如 `session_id`、`user_id`)**。 + +```python +from jmcomic import ( + JmSyncRuntime, + download_album, + jm_task_context, +) + +# 1. 进程启动时单例初始化全局长效 Runtime,跨请求复用 +app_runtime = JmSyncRuntime( + album_workers=4, + photo_workers=8, + image_workers=16, +) + +# 2. 单个 Web 请求 / 后台作业处理函数 +def handle_download_request(request_id: str, user_id: str, album_ids: list): + # 将长效 runtime 与链路追踪字段装配入上下文 + with jm_task_context( + runtime=app_runtime, + session_id=request_id, + user_id=user_id, + ): + # 内部所有日志、插件、下载任务均透明享有 session_id,并严格受全局并发控制 + download_album(album_ids) +``` + +--- + +## 6、Runtime 生命周期与关闭规范 + +为避免线程泄漏并确保资源安全,JMComic 的 Runtime 严格遵循以下三条生命周期规范: + +1. **使用 `with runtime` 上下文管理** → 离开作用域自动关闭 + > 离开 `with` 作用域(无论是正常完成还是发生未捕获异常退出)时,会自动调用 `runtime.close()`,安全关闭 Runtime 自身创建并拥有的底层线程池。 + +2. **不用 `with`** → 不会自动关闭 + > 若直接创建 `runtime = JmSyncRuntime(...)` 而不搭配 `with`,Runtime 不会自动关闭,需要调用方在适当时机显式调用 `runtime.close()`。 + +3. **用户外传 Executor** → 遵循“谁创建、谁负责关闭”原则 + > 若在构造 Runtime 时传入了用户自定义的 `*_executor`,Runtime 在 `close()` 时**绝不会**关闭它,其生命周期全权由调用方自行管理。 + +--- + +## 7、常见场景速查 + +| 业务场景 | 推荐代码形态 | 核心优势 | +| :--- | :--- | :--- | +| **平时随手下载** | `download_album(id)` | 零配置,开箱即用 | +| **同步大批量下载限并发** | `with JmSyncRuntime(album_workers=2):`
    `download_album(ids)` | 自动生效与回收,防止并发膨胀卡死 | +| **异步控制解密并发** | `with JmAsyncRuntime(decode_workers=4):`
    `await download_album_async(ids)` | 仅限制图片解密写盘线程数,网络仍走协程 | +| **复用外部已有线程池** | 传入 `*_executor=你的线程池` | 严格遵循“谁创建谁关闭”,外部池不被关闭 | +| **Web常驻服务复用** | `with jm_task_context(runtime=app_runtime, session_id=...):`
    `download_album(ids)` | 进程级长效池跨请求复用,上下文日志透明注入 | + diff --git a/pyproject.toml b/pyproject.toml index be2224380..608f35ef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,4 +52,4 @@ jmcomic = "jmcomic.cli:main" jmv = "jmcomic.cli:view_main" [tool.setuptools.dynamic] -version = {attr = "jmcomic.__version__"} \ No newline at end of file +version = {attr = "jmcomic.__version__"} diff --git a/src/jmcomic/__init__.py b/src/jmcomic/__init__.py index 8c969b8ec..71cb50245 100644 --- a/src/jmcomic/__init__.py +++ b/src/jmcomic/__init__.py @@ -2,8 +2,10 @@ # 被依赖方 <--- 使用方 # config <--- entity <--- toolkit <--- client <--- option <--- downloader -__version__ = '2.7.9' +__version__ = '2.7.10' +from .jm_exception import DownloadCancelledException +from .jm_runtime import * from .jm_task_context import * from .api import * from .jm_plugin import * diff --git a/src/jmcomic/api.py b/src/jmcomic/api.py index 6ef842835..520d95968 100644 --- a/src/jmcomic/api.py +++ b/src/jmcomic/api.py @@ -2,73 +2,212 @@ from time import perf_counter from .jm_downloader import * -from .jm_task_context import bind_jm_task_context, jm_task_context +from .jm_exception import DownloadCancelledException, ExceptionTool +from .jm_runtime import JmAsyncRuntime, JmSyncRuntime +from .jm_task_context import ( + bind_jm_task_context, + JTC, + jm_task_context, +) __DOWNLOAD_API_RET = DownloadResult -def _download_type(download_api) -> str: - name = getattr(download_api, '__name__', download_api.__class__.__name__) - if name.endswith('_async'): - name = name[:-6] - if name.startswith('download_'): - name = name[9:] - return name - - def _finish_download_result(detail, dler, task_started_at): manifest = dler.manifest_dict[detail] manifest.duration = perf_counter() - task_started_at return DownloadResult(detail, dler) -def download_batch(download_api, - jm_id_iter: Union[Iterable, Generator], - option=None, - downloader=None, - **kwargs, - ) -> BatchResult: +def _ensure_option(option): + return JmModuleConfig.option_class().default() if option is None else option + + +def _raise_if_cancelled_chain(cause_exc: BaseException): + """在当前任务已被取消时,将底层异常转换为 DownloadCancelledException 并保留原始异常链。""" + control = JTC.get_control() + if control is not None and control.is_cancelled: + reason = control.reason + raise DownloadCancelledException( + reason, + { + ExceptionTool.CONTEXT_KEY_CONTROL: control, + ExceptionTool.CONTEXT_KEY_REASON: reason, + }, + ) from cause_exc + raise cause_exc + + +def _execute_sync_download( + action, + download_type: str, + jm_id, + option, + downloader, + extra, + check_exception: bool, + control=None, +): + option = _ensure_option(option) + task_started_at = perf_counter() + + with jm_task_context( + option=option, + download_type=download_type, + jm_id=str(jm_id), + task_started_at=task_started_at, + control=control, + ): + with new_downloader(option, downloader) as dler: + dler.add_features(extra) + try: + detail = action(dler) + if check_exception: + dler.raise_if_has_exception() + except DownloadCancelledException: + raise + except Exception as e: + _raise_if_cancelled_chain(e) + + return _finish_download_result(detail, dler, task_started_at) + + +async def _execute_async_download( + action, + download_type: str, + jm_id, + option, + downloader, + extra, + check_exception: bool, + control=None, +): + option = _ensure_option(option) + task_started_at = perf_counter() + + async with jm_task_context( + option=option, + download_type=download_type, + jm_id=str(jm_id), + task_started_at=task_started_at, + control=control, + ), new_async_downloader(option, downloader) as dler: + dler.add_features(extra) + try: + detail = await action(dler) + if check_exception: + dler.raise_if_has_exception() + except DownloadCancelledException: + raise + except Exception as e: + _raise_if_cancelled_chain(e) + + return _finish_download_result(detail, dler, task_started_at) + + +def download_batch( + download_api, + jm_id_iter: Union[Iterable, Generator], + option=None, + downloader=None, + download_type='batch', + control=None, + **kwargs, +) -> BatchResult: """ 批量下载 album / photo 一个album/photo,对应一个线程,对应一个option。 - 返回 BatchResult(set),支持 for album, dler in result 遍历。 + 返回 BatchResult,保持 FIFO 顺序,支持 for album, dler in result 遍历与按车号检索。 失败项收集在 result.failed 中,不会静默丢失。 :param download_api: 下载api :param jm_id_iter: jmid (album_id, photo_id) 的迭代器 :param option: 下载选项,所有的jmid共用一个option :param downloader: 下载器类 + :param download_type: 下载类型 ('album', 'photo' 等),默认 'batch' + :param control: 可选的取消控制器 (DownloadControl),用于外部协作式中断下载任务 """ - from common import multi_thread_launcher - - if option is None: - option = JmModuleConfig.option_class().default() - + option = _ensure_option(option) + # 1. 保序去重 ID 列表 + jm_ids = list(dict.fromkeys( + JmcomicText.parse_to_jm_id(jmid) + for jmid in jm_id_iter + )) result = BatchResult() - - download_type = _download_type(download_api) - - def _safe_download(aid): - """batch 内部的单任务包装:确保异常被收集而非静默丢失""" - with jm_task_context(download_type=download_type, jm_id=str(aid)): - try: - ret = download_api(aid, option, downloader, **kwargs) - result.add(ret) - except Exception as e: - jm_log('batch.failed', f'批量下载失败: [{aid}], 异常: [{e}]', e) - result.failed[str(aid)] = e - - multi_thread_launcher( - iter_objs=set( - JmcomicText.parse_to_jm_id(jmid) - for jmid in jm_id_iter - ), - apply_each_obj_func=bind_jm_task_context(_safe_download), - wait_finish=True - ) - - return result + if len(jm_ids) == 0: + return result + + with jm_task_context(option=option, control=control): + # 2. 单项下载执行闭包:在专属任务上下文中执行,并将异常封装为三元组返回,避免打断批处理调度 + def _download_one(aid): + with jm_task_context(download_type=download_type, jm_id=str(aid)): + try: + item = download_api(aid, option, downloader, **kwargs) + return aid, item, None + except Exception as error: + return aid, None, error + + runtime = JTC.get_runtime() + if runtime is not None and not isinstance(runtime, JmSyncRuntime): + raise TypeError('sync batch API requires JmSyncRuntime') + + cancellation_error = None + worker = bind_jm_task_context(_download_one) + + # 3. 调度本子层并发:外部有 JmSyncRuntime 则复用;没有则使用单层局部 Runtime 调度本子列表,不向下渗透污染子任务 + if runtime is not None: + futures = runtime.multi_thread_launcher( + iter_objs=jm_ids, + apply_each_obj_func=worker, + wait_finish=True, + level='album', + default_workers=len(jm_ids), + ) + else: + with JmTempRuntime(workers=len(jm_ids)) as launcher: + futures = launcher.multi_thread_launcher( + iter_objs=jm_ids, + apply_each_obj_func=worker, + wait_finish=True, + ) + + # 4. 收集 Future 执行结果:记录成功成果与失败异常 + completed_results = [] + succeeded_ids = set() + + for future in futures: + aid, item, error = future.result() + if error is None: + result.add(item) + if item is not None: + completed_results.append(item) + succeeded_ids.add(str(aid)) + continue + + # 取消属于主动中断,不当成下载失败记录进 failed 字典 + if isinstance(error, DownloadCancelledException): + if cancellation_error is None: + cancellation_error = error + continue + + # 记录失败日志并存入 failed + with jm_task_context(download_type=download_type, jm_id=str(aid)): + jm_log( + 'batch.failed', + f'批量下载失败: [{aid}], 异常: [{error}]', + error, + ) + result.failed[str(aid)] = error + + # 5. 收尾判断:若发生取消,补全已完成成果与未完成 ID 后再抛出 + if cancellation_error is not None: + cancellation_error.context[ExceptionTool.CONTEXT_KEY_COMPLETED_RESULTS] = completed_results + cancellation_error.context[ExceptionTool.CONTEXT_KEY_UNFINISHED_IDS] = [ + str(aid) for aid in jm_ids if str(aid) not in succeeded_ids + ] + raise cancellation_error + return result def download_album(jm_album_id, @@ -77,11 +216,12 @@ def download_album(jm_album_id, *, check_exception=True, extra=None, + control=None, ) -> Union[__DOWNLOAD_API_RET, Set[__DOWNLOAD_API_RET]]: """ 下载一个本子(album),包含其所有的章节(photo) - 当jm_album_id不是str或int时,视为批量下载,相当于调用 download_batch(download_album, jm_album_id, option, downloader) + 当jm_album_id不是str或int时,视为批量下载,相当于调用 download_batch(download_album, jm_album_id, option, downloader, control=control) :param jm_album_id: 本子的禁漫车号 :param option: 下载选项 @@ -90,23 +230,31 @@ def download_album(jm_album_id, 并上抛 PartialDownloadFailedException。多 ID 调用会转交 download_batch,此参数不生效; 请检查 BatchResult.failed,或自行封装 download_batch 实现所需的批量异常策略。 :param extra: 下载特性(Feature),下载时动态挂载的附加行为上下文。会自动根据上下文(如 album/photo 来源)自适应参数行为。支持单个 Feature、FeatureChain、或列表 + :param control: 可选的取消控制器 (DownloadControl),用于外部协作式中断下载任务 :return: 对于的本子实体类,下载器(如果是上述的批量情况,返回值为download_batch的返回值) """ if not isinstance(jm_album_id, (str, int)): - return download_batch(download_album, jm_album_id, option, downloader, extra=extra) - - task_started_at = perf_counter() - with jm_task_context(download_type='album', jm_id=str(jm_album_id), task_started_at=task_started_at): - with new_downloader(option, downloader) as dler: - # 下载类型已记录在 TaskContext 中,Feature 会据此选择执行钩子 - dler.add_features(extra) - album = dler.download_album(jm_album_id) - - if check_exception: - dler.raise_if_has_exception() - - return _finish_download_result(album, dler, task_started_at) + return download_batch( + download_album, + jm_album_id, + option, + downloader, + download_type='album', + extra=extra, + control=control, + ) + + return _execute_sync_download( + lambda dler: dler.download_album(jm_album_id), + 'album', + jm_album_id, + option, + downloader, + extra, + check_exception, + control=control, + ) def download_photo(jm_photo_id, @@ -115,6 +263,7 @@ def download_photo(jm_photo_id, *, check_exception=True, extra=None, + control=None, ): """ 下载一个章节(photo),参数同 download_album。 @@ -123,19 +272,26 @@ def download_photo(jm_photo_id, BatchResult.failed,或自行封装 download_batch 处理批量异常。 """ if not isinstance(jm_photo_id, (str, int)): - return download_batch(download_photo, jm_photo_id, option, downloader, extra=extra) - - task_started_at = perf_counter() - with jm_task_context(download_type='photo', jm_id=str(jm_photo_id), task_started_at=task_started_at): - with new_downloader(option, downloader) as dler: - # 下载类型已记录在 TaskContext 中,Feature 会据此选择执行钩子 - dler.add_features(extra) - photo = dler.download_photo(jm_photo_id) - - if check_exception: - dler.raise_if_has_exception() - - return _finish_download_result(photo, dler, task_started_at) + return download_batch( + download_photo, + jm_photo_id, + option, + downloader, + download_type='photo', + extra=extra, + control=control, + ) + + return _execute_sync_download( + lambda dler: dler.download_photo(jm_photo_id), + 'photo', + jm_photo_id, + option, + downloader, + extra, + check_exception, + control=control, + ) def new_downloader(option=None, downloader=None) -> JmDownloader: @@ -187,33 +343,38 @@ async def download_album_async(jm_album_id, *, check_exception=True, extra=None, + control=None, ): - """ - 异步下载一个本子(album),包含其所有的章节(photo)。 + """异步下载一个本子及其全部章节。 + + 当 ``jm_album_id`` 是可迭代对象时使用批量 API。单个 ID 返回 + ``DownloadResult``;其中 Downloader 的网络 client 已关闭,阻塞工作也已 + 收敛。通过 ``jm_task_context(runtime=JmAsyncRuntime(...))`` 提供的 decode + 执行器由调用方负责关闭。 - - 支持批量下载(当 jm_album_id 为可迭代对象时) - - 返回 (album, downloader) 元组,其中 downloader 的网络和线程池资源已关闭,仅用于读取下载结果 - - check_exception 仅当 jm_album_id 是单个 ID 时生效。多 ID 场景请检查 BatchResult.failed, - 或自行封装 download_batch_async 处理批量异常 + ``check_exception`` 只对单个 ID 生效。批量场景应检查 + ``BatchResult.failed``,或按需封装 ``download_batch_async``。 """ if not isinstance(jm_album_id, (str, int)): return await download_batch_async(download_album_async, jm_album_id, option, downloader, - extra=extra + download_type='album', + extra=extra, + control=control, ) - task_started_at = perf_counter() - with jm_task_context(download_type='album', jm_id=str(jm_album_id), task_started_at=task_started_at): - async with new_async_downloader(option, downloader) as dler: - dler.add_features(extra) - album = await dler.download_album(jm_album_id) - - if check_exception: - dler.raise_if_has_exception() - - return _finish_download_result(album, dler, task_started_at) + return await _execute_async_download( + lambda dler: dler.download_album(jm_album_id), + 'album', + jm_album_id, + option, + downloader, + extra, + check_exception, + control=control, + ) async def download_photo_async(jm_photo_id, @@ -222,67 +383,112 @@ async def download_photo_async(jm_photo_id, *, check_exception=True, extra=None, + control=None, ): - """ - 异步下载一个章节(photo)。 - 返回的 downloader 已关闭网络和线程池资源,仅用于读取下载结果。 - check_exception 仅当 jm_photo_id 是单个 ID 时生效。多 ID 场景请检查 - BatchResult.failed,或自行封装 download_batch_async 处理批量异常。 + """异步下载一个章节。 + + 返回的 Downloader 已关闭网络 client,阻塞工作也已收敛。通过 + ``jm_task_context(runtime=JmAsyncRuntime(...))`` 提供的 decode 执行器由调用方负责关闭。 + + ``check_exception`` 只对单个 ID 生效。批量场景应检查 + ``BatchResult.failed``,或按需封装 ``download_batch_async``。 """ if not isinstance(jm_photo_id, (str, int)): return await download_batch_async(download_photo_async, jm_photo_id, option, downloader, - extra=extra + download_type='photo', + extra=extra, + control=control, ) - task_started_at = perf_counter() - with jm_task_context(download_type='photo', jm_id=str(jm_photo_id), task_started_at=task_started_at): - async with new_async_downloader(option, downloader) as dler: - dler.add_features(extra) - photo = await dler.download_photo(jm_photo_id) - - if check_exception: - dler.raise_if_has_exception() - - return _finish_download_result(photo, dler, task_started_at) + return await _execute_async_download( + lambda dler: dler.download_photo(jm_photo_id), + 'photo', + jm_photo_id, + option, + downloader, + extra, + check_exception, + control=control, + ) -async def download_batch_async(download_api, - jm_id_iter, - option=None, - downloader=None, - **kwargs, - ) -> BatchResult: +async def download_batch_async( + download_api, + jm_id_iter, + option=None, + downloader=None, + download_type='batch', + control=None, + **kwargs, +) -> BatchResult: """ 异步批量下载 album / photo。 - 容错机制:单个 album/photo 失败不会中止整批,也不会丢失其它已完成结果。 - - 返回 BatchResult(set),失败项收集在 result.failed 中。 + - 返回 BatchResult,保持 FIFO 顺序,支持按车号检索,失败项收集在 result.failed 中。 + :param control: 可选的取消控制器 (DownloadControl),用于外部协作式中断下载任务 """ - if option is None: - option = JmModuleConfig.option_class().default() - + option = _ensure_option(option) jm_ids = list(dict.fromkeys(JmcomicText.parse_to_jm_id(jmid) for jmid in jm_id_iter)) - download_type = _download_type(download_api) + result = BatchResult() + if len(jm_ids) == 0: + return result async def _download_one(jmid): with jm_task_context(download_type=download_type, jm_id=str(jmid)): return await download_api(jmid, option, downloader, **kwargs) - results = await asyncio.gather( - *(_download_one(jmid) for jmid in jm_ids), - return_exceptions=True, - ) - - # 失败不抛出,但要记录到 result.failed,便于调用者排查 - result = BatchResult() - for jmid, r in zip(jm_ids, results): - if isinstance(r, BaseException): - with jm_task_context(download_type=download_type, jm_id=str(jmid)): - jm_log('async.batch.failed', f'批量下载失败: [{jmid}], 异常: [{r}]', r) - result.failed[str(jmid)] = r - else: - result.add(r) - - return result + runtime = JTC.get_runtime() + if runtime is not None and not isinstance(runtime, JmAsyncRuntime): + raise TypeError('async batch API requires JmAsyncRuntime') + + with jm_task_context(option=option, control=control): + tasks = [asyncio.create_task(_download_one(jmid)) for jmid in jm_ids] + # 等待所有异步任务执行完成 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 检查是否有任务被取消 + cancellation_error = None + for item in results: + if isinstance(item, DownloadCancelledException): + cancellation_error = item + break + + # 若发生取消,收集已完成成果与未完成 ID 后抛出,不当成下载失败记录进 failed + if cancellation_error is not None: + succeeded_ids = { + str(jmid) + for jmid, item in zip(jm_ids, results) + if not isinstance(item, BaseException) and item is not None + } + cancellation_error.context[ExceptionTool.CONTEXT_KEY_COMPLETED_RESULTS] = [ + item for item in results + if not isinstance(item, BaseException) and item is not None + ] + cancellation_error.context[ExceptionTool.CONTEXT_KEY_UNFINISHED_IDS] = [ + str(jmid) for jmid in jm_ids + if str(jmid) not in succeeded_ids + ] + raise cancellation_error + + # 外部协程取消信号 + for item in results: + if isinstance(item, asyncio.CancelledError): + raise item + + # 记录失败项并收集成功结果 + for jmid, item in zip(jm_ids, results): + if isinstance(item, BaseException): + with jm_task_context(download_type=download_type, jm_id=str(jmid)): + jm_log( + 'async.batch.failed', + f'批量下载失败: [{jmid}], 异常: [{item}]', + item, + ) + result.failed[str(jmid)] = item + else: + result.add(item) + + return result diff --git a/src/jmcomic/cli.py b/src/jmcomic/cli.py index f54b6431c..b48b2c765 100644 --- a/src/jmcomic/cli.py +++ b/src/jmcomic/cli.py @@ -152,6 +152,7 @@ def option_has_download_progress(option): def run(self, option): from .api import download_album, download_photo + from .jm_task_context import bind_jm_task_context from common import MultiTaskLauncher if len(self.album_id_list) == 0: @@ -163,11 +164,11 @@ def run(self, option): launcher = MultiTaskLauncher() launcher.create_task( - target=download_album, + target=bind_jm_task_context(download_album), args=(self.album_id_list, option) ) launcher.create_task( - target=download_photo, + target=bind_jm_task_context(download_photo), args=(self.photo_id_list, option) ) diff --git a/src/jmcomic/jm_async_client.py b/src/jmcomic/jm_async_client.py index 0567e8833..424377fa1 100644 --- a/src/jmcomic/jm_async_client.py +++ b/src/jmcomic/jm_async_client.py @@ -10,7 +10,6 @@ from copy import deepcopy from typing import Sequence from urllib.parse import urlencode - from curl_cffi.requests import AsyncSession from .jm_client_interface import ( diff --git a/src/jmcomic/jm_async_downloader.py b/src/jmcomic/jm_async_downloader.py index 3f89e24e0..8cac5374e 100644 --- a/src/jmcomic/jm_async_downloader.py +++ b/src/jmcomic/jm_async_downloader.py @@ -12,11 +12,16 @@ import os from concurrent.futures import ThreadPoolExecutor -from .jm_downloader import BaseDownloader, record_download_duration +from .jm_downloader import BaseDownloader, DownloadCallback, record_download_duration from .jm_entity import JmAlbumDetail, JmPhotoDetail, JmImageDetail from .jm_toolkit import JmImageTool from .jm_config import JmModuleConfig, jm_log -from .jm_task_context import bind_jm_task_context +from .jm_exception import DownloadCancelledException +from .jm_runtime import JmAsyncRuntime +from .jm_task_context import ( + bind_jm_task_context, + JTC, +) from .jm_option import JmOption @@ -50,8 +55,19 @@ def __init__(self, self._image_semaphore = asyncio.Semaphore(image_concurrency) self._photo_semaphore = asyncio.Semaphore(photo_concurrency) - # 解密线程池(CPU 密集操作卸载) - self._decode_pool = ThreadPoolExecutor(max_workers=decode_worker, thread_name_prefix='jm-async-decode') + if decode_worker is None: + # 对齐 ThreadPoolExecutor 的默认容量,并由调用点明确交给 Runtime。 + decode_worker = min(32, (os.cpu_count() or 1) + 4) + elif ( + isinstance(decode_worker, bool) + or not isinstance(decode_worker, int) + or decode_worker <= 0 + ): + raise ValueError( + f'decode_worker must be a positive integer, got {decode_worker!r}' + ) + self._decode_worker = decode_worker + self._decode_pool: ThreadPoolExecutor | None = None @classmethod def use(cls, *args, **kwargs): @@ -70,17 +86,38 @@ def use(cls, *args, **kwargs): # ====================================================================== async def _run_in_decode_pool(self, func, *args): - loop = asyncio.get_running_loop() - return await loop.run_in_executor( - self._decode_pool, - bind_jm_task_context(func), - *args, - ) + runtime = JTC.get_runtime() + if isinstance(runtime, JmAsyncRuntime): + executor = runtime.executor('decode', self._decode_worker) + else: + if self._decode_pool is None: + self._decode_pool = ThreadPoolExecutor( + max_workers=self._decode_worker, + thread_name_prefix='jm-async-decode', + ) + executor = self._decode_pool + + worker = bind_jm_task_context(func) + future = executor.submit(worker, *args) + waiter = asyncio.wrap_future(future) + try: + return await asyncio.shield(waiter) + except asyncio.CancelledError: + # 线程任务无法可靠中断,协程取消后仍需等待写盘等操作收尾。 + try: + await waiter + except (asyncio.CancelledError, DownloadCancelledException): + pass + except BaseException as drain_error: + jm_log('dler.cancel.drain.exception', drain_error) + raise @record_download_duration('album_started_at') async def download_album(self, album_id) -> JmAlbumDetail: """对齐 sync JmDownloader.download_album""" + self.raise_if_cancelled() album = await self.client.get_album_detail(album_id) + self.raise_if_cancelled() self.begin_manifest(album) try: await self.download_by_album_detail(album) @@ -106,7 +143,9 @@ async def download_by_album_detail(self, album: JmAlbumDetail): if photos: # photo 级并发由 _photo_semaphore 控制(默认 3),包裹整段 photo 下载(见 download_by_photo_detail)。 photo_tasks = [self._safe_download_photo(photo) for photo in photos] - await asyncio.gather(*photo_tasks) + # 对齐 sync 最大容错:普通异常由子任务记录,取消异常等待全部结束后再检查 + await asyncio.gather(*photo_tasks, return_exceptions=True) + self.raise_if_cancelled() await self.after_album(album) @@ -114,14 +153,19 @@ async def _safe_download_photo(self, photo: JmPhotoDetail): """包装 download_by_photo_detail,对齐 sync @catch_exception 的异常记录""" try: await self.download_by_photo_detail(photo) + except DownloadCancelledException: + raise except Exception as e: jm_log('photo.failed', f'章节下载失败: [{photo.id}], 异常: [{e}]', e) self.download_failed_photo.append((photo, e)) + self.raise_if_cancelled() @record_download_duration('photo_started_at') async def download_photo(self, photo_id) -> JmPhotoDetail: """对齐 sync JmDownloader.download_photo""" + self.raise_if_cancelled() photo = await self.client.get_photo_detail(photo_id) + self.raise_if_cancelled() self.begin_manifest(photo) try: await self.download_by_photo_detail(photo) @@ -135,11 +179,14 @@ async def download_by_photo_detail(self, photo: JmPhotoDetail): 异步下载一个章节的所有图片。 对齐 sync JmDownloader.download_by_photo_detail 的回调链路。 """ + self.raise_if_cancelled() photo.save_path = self.option.decide_image_save_dir(photo) # _photo_semaphore 包裹整段 photo 下载(check_photo + 全部图片), # 真正限制「同时下载的章节数」(对齐 sync:每个 photo 占用 photo 线程池一个槽位)。 # 章节内图片再由共享的 _image_semaphore 二级限流。 async with self._photo_semaphore: + # 排队期间可能被取消;执行 I/O 前必须再次检查。 + self.raise_if_cancelled() await self.client.check_photo(photo) await self.before_photo(photo) @@ -156,7 +203,9 @@ async def download_by_photo_detail(self, photo: JmPhotoDetail): self._safe_download_image(image) for image in image_list ] - await asyncio.gather(*download_tasks) + # 对齐 sync 最大容错:普通异常由子任务记录,取消异常等待全部结束后再检查 + await asyncio.gather(*download_tasks, return_exceptions=True) + self.raise_if_cancelled() await self.after_photo(photo) @@ -167,9 +216,12 @@ async def _safe_download_image(self, image: JmImageDetail): """ try: await self._download_single_image(image) + except DownloadCancelledException: + raise except Exception as e: jm_log('image.failed', f'图片下载失败: [{image.download_url}], 异常: [{e}]', e) self.download_failed_image.append((image, e)) + self.raise_if_cancelled() @record_download_duration('image_started_at') async def _download_single_image(self, image: JmImageDetail): @@ -194,6 +246,8 @@ async def _download_single_image(self, image: JmImageDetail): # 异步下载图片(受 image semaphore 限流,并将解密写盘过程也锁入信号量范围内,防大字节积压) async with self._image_semaphore: + # 排队期间可能被取消;执行 I/O 前必须再次检查。 + self.raise_if_cancelled() img_resp = await self.client.get_jm_image(image.download_url) img_bytes = img_resp.content @@ -259,36 +313,80 @@ def _save_raw(image_bytes, save_path, need_convert=False): # 生命周期 # ====================================================================== + async def _dispatch_plugin(self, group: str, **kwargs): + plugins = getattr(self.option, 'plugins', None) + if plugins is not None and not plugins.get(group): + return + await asyncio.to_thread(self.option.call_all_plugin, group, **kwargs) + + async def _dispatch_feature(self, when: str, **kwargs): + if len(self._feature_list) == 0: + return + await asyncio.to_thread(self._invoke_features_for, when, **kwargs) + async def before_album(self, album: JmAlbumDetail): - await self._run_in_decode_pool(super().before_album, album) + self.raise_if_cancelled() + DownloadCallback.before_album(self, album) + self.download_success_dict.setdefault(album, {}) + await self._dispatch_plugin('before_album', album=album, downloader=self) + self.raise_if_cancelled() async def after_album(self, album: JmAlbumDetail): - await self._run_in_decode_pool(super().after_album, album) + self.raise_if_cancelled() + DownloadCallback.after_album(self, album) + await self._dispatch_plugin('after_album', album=album, downloader=self) + self.raise_if_cancelled() + await self._dispatch_feature('after_album', album=album, downloader=self) async def before_photo(self, photo: JmPhotoDetail): - await self._run_in_decode_pool(super().before_photo, photo) + self.raise_if_cancelled() + DownloadCallback.before_photo(self, photo) + self.download_success_dict.setdefault(photo.from_album, {}) + self.download_success_dict[photo.from_album].setdefault(photo, []) + await self._dispatch_plugin('before_photo', photo=photo, downloader=self) + self.raise_if_cancelled() async def after_photo(self, photo: JmPhotoDetail): - await self._run_in_decode_pool(super().after_photo, photo) + self.raise_if_cancelled() + DownloadCallback.after_photo(self, photo) + await self._dispatch_plugin('after_photo', photo=photo, downloader=self) + self.raise_if_cancelled() + await self._dispatch_feature('after_photo', photo=photo, downloader=self) async def before_image(self, image: JmImageDetail, img_save_path: str): - await self._run_in_decode_pool(super().before_image, image, img_save_path) + self.raise_if_cancelled() + DownloadCallback.before_image(self, image, img_save_path) + await self._dispatch_plugin('before_image', image=image, downloader=self) + self.raise_if_cancelled() async def after_image(self, image: JmImageDetail, img_save_path: str): - await self._run_in_decode_pool(super().after_image, image, img_save_path) + photo = image.from_photo + album = photo.from_album + if album in self.download_success_dict: + self.download_success_dict[album].setdefault(photo, []).append((image.save_path, image)) + + DownloadCallback.after_image(self, image, img_save_path) + self.raise_if_cancelled() + await self._dispatch_plugin('after_image', image=image, downloader=self) + self.raise_if_cancelled() + await self._dispatch_feature('after_image', image=image, downloader=self) def shutdown(self): """关闭解密线程池""" - self._decode_pool.shutdown(wait=False) + if self._decode_pool is not None: + self._decode_pool.shutdown(wait=False) + self._decode_pool = None async def __aenter__(self): - # 创建并独占一个 async client(含 AsyncSession)。 - self.client = self.option.new_jm_async_client(max_clients=self._image_concurrency) + self.client = self.option.new_jm_async_client( + max_clients=self._image_concurrency + ) try: await self.client.setup() except BaseException: try: - await self.client.close() + if self.client is not None: + await self.client.close() except BaseException as cleanup_error: jm_log('dler.cleanup.exception', f'初始化失败后的资源清理也发生异常: {cleanup_error}', cleanup_error) @@ -299,13 +397,10 @@ async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): - # 关闭顺序:先关网络 client(释放 AsyncSession / libcurl multi handle / 后台任务), - # 再关解密线程池。两者都要在异常路径下保证释放。 try: if self.client is not None: await self.client.close() finally: - self.client = None self.shutdown() if exc_type is not None: diff --git a/src/jmcomic/jm_client_impl.py b/src/jmcomic/jm_client_impl.py index c124ddf63..94538aa23 100644 --- a/src/jmcomic/jm_client_impl.py +++ b/src/jmcomic/jm_client_impl.py @@ -275,6 +275,12 @@ def add_favorite_album(self, }, ) + if resp.status_code != 200: + self.check_special_http_code(resp) + self.raise_request_error(resp) + + self.require_resp_success_else_raise(resp, '/ajax/favorite_album') + res = resp.json() if res['status'] != 1: diff --git a/src/jmcomic/jm_client_interface.py b/src/jmcomic/jm_client_interface.py index 1b78d6073..3f5cb6b15 100644 --- a/src/jmcomic/jm_client_interface.py +++ b/src/jmcomic/jm_client_interface.py @@ -171,14 +171,14 @@ class JmDailyCheckinResp(JmResp): """ 每日签到打卡响应类 """ - CODE_SUCCESS = 0 # 签到成功 + CODE_SUCCESS = 0 # 签到成功 CODE_ALREADY_CHECKED_IN = 1 # 重复签到(今日已签) def __init__(self, resp, code: int, msg: str, raw_data: dict = None): super().__init__(resp) - self.code: int = code # 0=签到成功, 1=重复签到 - self.status: int = code # 兼容字段,值等同于 code - self.msg: str = msg # 透传服务端的提示信息或奖励内容 + self.code: int = code # 0=签到成功, 1=重复签到 + self.status: int = code # 兼容字段,值等同于 code + self.msg: str = msg # 透传服务端的提示信息或奖励内容 self.raw_data: dict = raw_data or {} def __repr__(self): diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py index 5e57ce88d..aed34ab8c 100644 --- a/src/jmcomic/jm_config.py +++ b/src/jmcomic/jm_config.py @@ -1,10 +1,9 @@ from __future__ import annotations import logging - from common import time_stamp, field_cache, ProxyBuilder -from .jm_task_context import JM_TASK_CONTEXT, get_jm_task_context +from .jm_task_context import JM_TASK_CONTEXT, JTC jm_logger = logging.getLogger('jmcomic') @@ -68,7 +67,7 @@ def default_jm_logging(topic: str, msg, e: BaseException | None = None): msg = str(msg) extra = { 'topic': topic, - JM_TASK_CONTEXT.name: get_jm_task_context(), + JM_TASK_CONTEXT.name: JTC.get_context(), } if e is not None: jm_logger.error(msg, extra=extra, exc_info=e) @@ -517,7 +516,7 @@ def new_postman(cls, session=False, **kwargs): 'cache': True, 'image': {'decode': True, 'suffix': None}, 'threading': { - 'image': 30, + 'image': 20, 'photo': None, }, }, diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py index 2846e547b..a5cf20ea2 100644 --- a/src/jmcomic/jm_downloader.py +++ b/src/jmcomic/jm_downloader.py @@ -1,11 +1,14 @@ -import os -import inspect from functools import wraps -from typing import NamedTuple +from typing import Dict, NamedTuple, Union from time import perf_counter from .jm_option import * -from .jm_task_context import bind_jm_task_context, get_jm_task_context, jm_task_context +from .jm_runtime import JmTempRuntime, JmSyncRuntime +from .jm_task_context import ( + bind_jm_task_context, + JTC, + jm_task_context, +) def record_download_duration(context_key: str, clock=None): @@ -28,7 +31,7 @@ async def async_wrapper(*args, **kwargs): entity = get_entity(args, kwargs) detail_call = isinstance(entity, Downloadable) # 顶层 ID 下载负责完整耗时,内部 detail 调用复用同一个计时上下文。 - if detail_call and get_jm_task_context().get(context_key) is not None: + if detail_call and JTC.get_context_value(context_key) is not None: return await func(*args, **kwargs) started_at = get_time() @@ -46,7 +49,7 @@ def wrapper(*args, **kwargs): entity = get_entity(args, kwargs) detail_call = isinstance(entity, Downloadable) # 顶层 ID 下载负责完整耗时,内部 detail 调用复用同一个计时上下文。 - if detail_call and get_jm_task_context().get(context_key) is not None: + if detail_call and JTC.get_context_value(context_key) is not None: return func(*args, **kwargs) started_at = get_time() @@ -79,9 +82,12 @@ def catch_exception(func): @wraps(func) def wrapper(self, *args, **kwargs): self: JmDownloader + self.raise_if_cancelled() try: return func(self, *args, **kwargs) except Exception as e: + if isinstance(e, DownloadCancelledException): + raise detail: JmBaseEntity = args[0] if detail.is_image(): detail: JmImageDetail @@ -196,9 +202,19 @@ def all_success(self) -> bool: def has_download_failures(self): return len(self.download_failed_image) != 0 or len(self.download_failed_photo) != 0 + @property + def is_cancelled(self) -> bool: + return JTC.is_cancelled() + + @classmethod + def raise_if_cancelled(cls) -> None: + """在当前下载作用域已取消时抛出异常,子类可重写该检查点。""" + JTC.raise_if_cancelled() + # 下面是回调方法 def before_album(self, album: JmAlbumDetail): + self.raise_if_cancelled() super().before_album(album) self.download_success_dict.setdefault(album, {}) self.option.call_all_plugin( @@ -206,18 +222,22 @@ def before_album(self, album: JmAlbumDetail): album=album, downloader=self, ) + self.raise_if_cancelled() def after_album(self, album: JmAlbumDetail): + self.raise_if_cancelled() super().after_album(album) self.option.call_all_plugin( 'after_album', album=album, downloader=self, ) + self.raise_if_cancelled() # 触发匹配 after_album 的 Feature self._invoke_features_for('after_album', album=album, downloader=self) def before_photo(self, photo: JmPhotoDetail): + self.raise_if_cancelled() super().before_photo(photo) self.download_success_dict.setdefault(photo.from_album, {}) self.download_success_dict[photo.from_album].setdefault(photo, []) @@ -226,35 +246,49 @@ def before_photo(self, photo: JmPhotoDetail): photo=photo, downloader=self, ) + self.raise_if_cancelled() def after_photo(self, photo: JmPhotoDetail): + self.raise_if_cancelled() super().after_photo(photo) self.option.call_all_plugin( 'after_photo', photo=photo, downloader=self, ) + self.raise_if_cancelled() # 触发匹配 after_photo 的 Feature self._invoke_features_for('after_photo', photo=photo, downloader=self) def before_image(self, image: JmImageDetail, img_save_path): + self.raise_if_cancelled() super().before_image(image, img_save_path) self.option.call_all_plugin( 'before_image', image=image, downloader=self, ) + self.raise_if_cancelled() def after_image(self, image: JmImageDetail, img_save_path): + # 1. 图片已成功落盘(或命中本地缓存),第一时间登记入成功清单 + photo = image.from_photo + album = photo.from_album + if album in self.download_success_dict: + self.download_success_dict[album].setdefault(photo, []).append((image.save_path, image)) + + # 2. 日志回调 super().after_image(image, img_save_path) + + # 3. 取消检查 + self.raise_if_cancelled() + + # 4. 插件回调 self.option.call_all_plugin( 'after_image', image=image, downloader=self, ) - photo = image.from_photo - album = photo.from_album - self.download_success_dict.get(album).get(photo).append((image.save_path, image)) def begin_manifest(self, detail: DetailEntity) -> DownloadManifest: manifest = DownloadManifest() @@ -310,7 +344,7 @@ def finish_manifest(self, detail: DetailEntity) -> DownloadManifest: def _require_feature_context() -> str: from .jm_toolkit import ExceptionTool - download_type = get_jm_task_context().get('download_type') + download_type = JTC.get_context_value('download_type') ExceptionTool.require_true( download_type in ('album', 'photo'), 'Feature 注册与执行必须位于下载任务上下文中,请使用 ' @@ -355,12 +389,17 @@ def _invoke_features_for(self, when: str, **kwargs): download_type = self._require_feature_context() for feature in self._feature_list: + self.raise_if_cancelled() if feature.should_invoke(when): try: feature.invoke(self.option, when=when, **kwargs) + except DownloadCancelledException: + raise except Exception as e: - jm_log('downloader.feature.exception', f'Feature执行失败: [{feature}], 下载类型: [{download_type}], 异常: [{e}]', + jm_log('downloader.feature.exception', + f'Feature执行失败: [{feature}], 下载类型: [{download_type}], 异常: [{e}]', e) + self.raise_if_cancelled() def raise_if_has_exception(self): if not self.has_download_failures: @@ -375,7 +414,7 @@ def raise_if_has_exception(self): ExceptionTool.raises( '\n'.join(msg_ls), - {'downloader': self}, + {ExceptionTool.CONTEXT_KEY_DOWNLOADER: self}, PartialDownloadFailedException, ) @@ -399,17 +438,65 @@ def duration(self) -> Optional[float]: """顶层下载耗时,单位:秒。""" return self.manifest.duration + @property + def album(self): + """下载的本子实体。""" + from .jm_entity import JmAlbumDetail + if isinstance(self.detail, JmAlbumDetail): + return self.detail + return getattr(self.detail, 'from_album', None) + + @property + def photo(self): + """下载的章节实体(若为章节下载)。""" + from .jm_entity import JmPhotoDetail + if isinstance(self.detail, JmPhotoDetail): + return self.detail + return None + -class BatchResult(set): +class BatchResult: """批量下载结果集。 - 继承 set,完全兼容旧写法 for album, dler in result。 - 新增 .failed 属性,记录失败的 jm_id 和对应异常。 + 保持 FIFO 插入顺序,兼容 for album, dler in result 遍历与解包。 + 支持按车号检索:result[aid], result.get(aid), aid in result。 """ def __init__(self): - super().__init__() + self.succeeded = [] self.failed: Dict[str, BaseException] = {} + self._id_map = {} + + def add(self, item, aid=None) -> None: + self.succeeded.append(item) + if aid is None: + detail = getattr(item, 'detail', item) + aid = getattr(detail, 'id', None) + if aid is not None: + self._id_map[str(aid).strip()] = item + + def get(self, aid, default=None): + """按车号安全获取成功结果。""" + return self._id_map.get(str(aid).strip(), default) + + def __getitem__(self, aid): + """按车号下标获取成功结果,不存在抛 KeyError。""" + key = str(aid).strip() + if key in self._id_map: + return self._id_map[key] + raise KeyError(aid) + + def __contains__(self, item) -> bool: + """支持车号判断 ('123' in result / 123 in result),亦向下兼容实体判断。""" + if isinstance(item, (str, int)) and str(item).strip() in self._id_map: + return True + return item in self.succeeded + + def __iter__(self): + return iter(self.succeeded) + + def __len__(self) -> int: + return len(self.succeeded) @property def all_succeeded(self) -> bool: @@ -417,8 +504,10 @@ def all_succeeded(self) -> bool: @property def total(self) -> int: - """预期总数(成功 + 失败)""" - return len(self) + len(self.failed) + return len(self.succeeded) + len(self.failed) + + def __repr__(self) -> str: + return f'' class JmDownloader(BaseDownloader): @@ -438,7 +527,9 @@ def create_client(self): @record_download_duration('album_started_at') def download_album(self, album_id): + self.raise_if_cancelled() album = self.client.get_album_detail(album_id) + self.raise_if_cancelled() self.begin_manifest(album) try: self.download_by_album_detail(album) @@ -455,13 +546,16 @@ def download_by_album_detail(self, album: JmAlbumDetail): self.execute_on_condition( iter_objs=album, apply=self.download_by_photo_detail, - count_batch=self.option.decide_photo_batch_count(album) + count_batch=self.option.decide_photo_batch_count(album), + level='photo', ) self.after_album(album) @record_download_duration('photo_started_at') def download_photo(self, photo_id): + self.raise_if_cancelled() photo = self.client.get_photo_detail(photo_id) + self.raise_if_cancelled() self.begin_manifest(photo) try: self.download_by_photo_detail(photo) @@ -480,7 +574,8 @@ def download_by_photo_detail(self, photo: JmPhotoDetail): self.execute_on_condition( iter_objs=photo, apply=self.download_by_image_detail, - count_batch=self.option.decide_image_batch_count(photo) + count_batch=self.option.decide_image_batch_count(photo), + level='image', ) self.after_photo(photo) @@ -509,36 +604,42 @@ def download_by_image_detail(self, image: JmImageDetail): self.after_image(image, img_save_path) - def execute_on_condition(self, - iter_objs: DetailEntity, - apply: Callable, - count_batch: int, - ): - """ - 调度本子/章节的下载 - """ - iter_objs = self.do_filter(iter_objs) - count_real = len(iter_objs) + def execute_on_condition(self, iter_objs, apply, count_batch, level=None): + """使用当前 Runtime 调度本子或章节的下载。""" + runtime = JTC.get_runtime() + ExceptionTool.require_true( + runtime is None or isinstance(runtime, JmSyncRuntime), + 'sync downloader requires JmSyncRuntime', + ) - if count_real == 0: + iter_objs = self.do_filter(iter_objs) + if len(iter_objs) == 0: return - apply = bind_jm_task_context(apply) + ExceptionTool.require_true( + not isinstance(count_batch, bool) and isinstance(count_batch, int) and count_batch > 0, + f'local download limit must be > 0, got {count_batch!r}', + ) - if count_batch >= count_real: - # 一个图/章节 对应 一个线程 - multi_thread_launcher( - iter_objs=iter_objs, - apply_each_obj_func=apply, - ) + self.raise_if_cancelled() + + if runtime is None: + with JmTempRuntime(workers=count_batch) as runtime: + runtime.multi_thread_launcher( + iter_objs=iter_objs, + apply_each_obj_func=bind_jm_task_context(apply), + ) else: - # 创建batch个线程的线程池 - thread_pool_executor( + runtime.multi_thread_launcher( iter_objs=iter_objs, - apply_each_obj_func=apply, - max_workers=count_batch, + apply_each_obj_func=bind_jm_task_context(apply), + level=level, + default_workers=count_batch, ) + # 批处理线程等待结束后,统一在此处检查取消状态 + self.raise_if_cancelled() + # 下面是对with语法的支持 def __enter__(self): diff --git a/src/jmcomic/jm_entity.py b/src/jmcomic/jm_entity.py index eb66959ab..0824762ae 100644 --- a/src/jmcomic/jm_entity.py +++ b/src/jmcomic/jm_entity.py @@ -1,6 +1,5 @@ from collections.abc import Sequence from functools import lru_cache - from common import * from .jm_config import * diff --git a/src/jmcomic/jm_exception.py b/src/jmcomic/jm_exception.py index f88392e44..36e27e30b 100644 --- a/src/jmcomic/jm_exception.py +++ b/src/jmcomic/jm_exception.py @@ -94,6 +94,35 @@ def downloader(self): return self.from_context(ExceptionTool.CONTEXT_KEY_DOWNLOADER) +class DownloadCancelledException(JmcomicException): + description = '下载任务已取消' + + def __init__(self, msg='download cancelled', context=None): + """ + 按 JMComic 异常的统一结构创建下载取消异常。 + """ + msg = str(msg or 'download cancelled') + context = dict(context or {}) + context.setdefault(ExceptionTool.CONTEXT_KEY_REASON, msg) + super().__init__(msg, context) + + @property + def control(self): + return self.from_context(ExceptionTool.CONTEXT_KEY_CONTROL) + + @property + def reason(self) -> str: + return str(self.context.get(ExceptionTool.CONTEXT_KEY_REASON, 'download cancelled')) + + @property + def completed_results(self): + return list(self.context.get(ExceptionTool.CONTEXT_KEY_COMPLETED_RESULTS) or []) + + @property + def unfinished_ids(self): + return list(self.context.get(ExceptionTool.CONTEXT_KEY_UNFINISHED_IDS) or []) + + class ExceptionTool: """ 抛异常的工具 @@ -107,6 +136,10 @@ class ExceptionTool: CONTEXT_KEY_MISSING_JM_ID = 'missing_jm_id' CONTEXT_KEY_DOWNLOADER = 'downloader' CONTEXT_KEY_RETRY_ERRORS = 'retry_errors' + CONTEXT_KEY_CONTROL = 'control' + CONTEXT_KEY_REASON = 'reason' + CONTEXT_KEY_COMPLETED_RESULTS = 'completed_results' + CONTEXT_KEY_UNFINISHED_IDS = 'unfinished_ids' @classmethod def raises(cls, @@ -198,20 +231,24 @@ def raise_missing(cls, ) @classmethod - def require_true(cls, case: bool, msg: str): + def require_true(cls, case: bool, msg: str, etype=None): if case: return - cls.raises(msg) + cls.raises(msg, etype=etype) @classmethod def replace_old_exception_executor(cls, raises: Callable[[Callable, str, dict], None]): old = cls.raises - def new(msg, context=None, _etype=None): + def new(msg, context=None, etype=None): if context is None: context = {} - raises(old, msg, context) + + def raise_original(msg, context=None, etype=etype): + return old(msg, context, etype) + + raises(raise_original, msg, context) cls.raises = new diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py index d93bf7dac..b4614dc65 100644 --- a/src/jmcomic/jm_feature.py +++ b/src/jmcomic/jm_feature.py @@ -17,7 +17,7 @@ download_album(id, option, extra=Feature.export_pdf + Feature.export_zip) """ from .jm_plugin import * -from .jm_task_context import get_jm_task_context +from .jm_task_context import JTC class Feature: @@ -37,7 +37,7 @@ class Feature: @property def jm_task_context(self) -> dict: """Return the current invocation's isolated task-context snapshot.""" - return get_jm_task_context() + return JTC.get_context() def should_invoke(self, when: str) -> bool: """ diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py index a712a5cf9..320fd52ca 100644 --- a/src/jmcomic/jm_option.py +++ b/src/jmcomic/jm_option.py @@ -1,6 +1,7 @@ -from .jm_client_impl import * from typing import Optional, Sequence, Union +from .jm_client_impl import * + class CacheRegistry: REGISTRY = {} diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index 62ea2c082..2eac8b09e 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -8,7 +8,7 @@ from .jm_option import * from .jm_async_downloader import JmAsyncDownloader from .jm_downloader import JmDownloader -from .jm_task_context import bind_jm_task_context, get_jm_task_context +from .jm_task_context import bind_jm_task_context, JTC class PluginValidationException(Exception): @@ -156,7 +156,7 @@ def invoke(self, **kwargs) -> None: @property def jm_task_context(self) -> dict: """Return the current invocation's isolated task-context snapshot.""" - return get_jm_task_context() + return JTC.get_context() @classmethod def build(cls, option: JmOption) -> 'JmOptionPlugin': @@ -1193,7 +1193,8 @@ def log(topic, message, worker): chapter['next_tick'] = tick + chapter['interval'] + random.randint(0, 1) if task.finished: progress.update(task.id, description=f'✓ {task.description}') - log('photo.after', f'章节下载完成: [{chapter_name}], 图片 {int(task.total)}/{int(task.total)}', worker) + log('photo.after', f'章节下载完成: [{chapter_name}], 图片 {int(task.total)}/{int(task.total)}', + worker) if album_task is not None: was_finished = tasks[album_task].finished done = sum(tasks[chapter['task_id']].finished for chapter in chapters) @@ -1215,7 +1216,7 @@ def log(topic, message, worker): @staticmethod def cli_no_progress_notice(): - if not get_jm_task_context().get('cli_no_progress'): + if not JTC.get_context_value('cli_no_progress'): return '' return ( diff --git a/src/jmcomic/jm_runtime.py b/src/jmcomic/jm_runtime.py new file mode 100644 index 000000000..71b1cadb2 --- /dev/null +++ b/src/jmcomic/jm_runtime.py @@ -0,0 +1,308 @@ +import concurrent.futures +from concurrent.futures import Executor, ProcessPoolExecutor, ThreadPoolExecutor +import threading +from threading import RLock +from typing import Callable, Dict, Optional, Set, Tuple + +from common import process_single_arg_to_args_and_kwargs + +__all__ = ( + 'JmRuntime', + 'JmTempRuntime', + 'JmSyncRuntime', + 'JmAsyncRuntime', +) + + +def validate_jm_workers(name: str, workers: int) -> int: + if isinstance(workers, bool) or not isinstance(workers, int) or workers <= 0: + raise ValueError(f'{name} must be a positive integer, got {workers!r}') + return workers + + +def validate_jm_executor(name: str, executor: Executor) -> Executor: + if not isinstance(executor, Executor): + raise TypeError(f'{name} must be a concurrent.futures.Executor') + + rejected_types = [ProcessPoolExecutor] + interpreter_pool = getattr(concurrent.futures, 'InterpreterPoolExecutor', None) + if interpreter_pool is not None: + rejected_types.append(interpreter_pool) + if isinstance(executor, tuple(rejected_types)): + raise TypeError( + f'{name} must be a same-process thread-based Executor; ' + f'{executor.__class__.__name__} is not supported' + ) + return executor + + +def normalize_jm_executor_config( + name: str, + workers: Optional[int], + executor: Optional[Executor], +) -> Tuple[Optional[int], Optional[Executor]]: + if workers is not None and executor is not None: + raise ValueError(f'{name}_workers and {name}_executor are mutually exclusive') + if executor is not None: + executor = validate_jm_executor(f'{name}_executor', executor) + if workers is not None: + workers = validate_jm_workers(f'{name}_workers', workers) + return workers, executor + + +class JmRuntime: + """ + 轻量 Runtime:管理 Executor 的配置、创建、调度和关闭。 + + 【生命周期与关闭规则规范】: + 1. with runtime 上下文管理: + 离开 with 作用域(正常完成或异常退出)时,会自动调用 runtime.close(), + 关闭由此 Runtime 自身创建并拥有的所有 Executor 线程池。 + 2. 用户外传 Executor: + 若在构造时传入了用户自定义的 executor,Runtime 在 close() 时绝不会关闭它, + 生命周期全权由调用方自行管理(严格遵循“谁创建、谁负责关闭”原则)。 + 3. 不使用 with(裸调用): + 绝不会自动关闭。必须由调用方显式调用 runtime.close(),或作为长效单例常驻复用。 + """ + + def __init__( + self, + configs: Dict[str, Tuple[Optional[int], Optional[Executor]]], + ): + self._workers = { + level: config[0] + for level, config in configs.items() + } + self._executors = { + level: config[1] + for level, config in configs.items() + } + self._owned_executors: Set[Executor] = set() + self._lock = RLock() + self._closed = False + self._ctx_cm = None + + def executor( + self, + level: Optional[str] = None, + default_workers: Optional[int] = None, + ) -> Executor: + """ + 返回指定层级的 Executor;未配置时按调用点给出的默认并发数创建。 + """ + if level is None: + raise TypeError('level is required for this Runtime') + + if default_workers is not None: + default_workers = validate_jm_workers( + f'{level}_default_workers', + default_workers, + ) + + with self._lock: + if self._closed: + raise RuntimeError('JmRuntime is closed') + + try: + executor = self._executors[level] + except KeyError as error: + raise ValueError(f'unknown runtime level: {level!r}') from error + + if executor is not None: + return executor + + workers = self._workers[level] + if workers is None: + workers = default_workers + self._workers[level] = workers + + if workers is None: + executor = ThreadPoolExecutor(thread_name_prefix=f'jm-{level}') + else: + executor = ThreadPoolExecutor( + max_workers=workers, + thread_name_prefix=f'jm-{level}', + ) + + self._executors[level] = executor + self._owned_executors.add(executor) + return executor + + def multi_thread_launcher( + self, + iter_objs, + apply_each_obj_func: Callable, + wait_finish=True, + *, + level: Optional[str] = None, + default_workers: Optional[int] = None, + ): + """ + 使用指定层级的 Executor 批量提交任务,并按需等待完成。 + """ + executor = self.executor(level, default_workers) + futures = [] + + try: + for obj in iter_objs: + args, kwargs = process_single_arg_to_args_and_kwargs(obj) + futures.append(executor.submit(apply_each_obj_func, *args, **kwargs)) + finally: + if wait_finish: + concurrent.futures.wait(futures) + + return futures + + def close(self) -> None: + """ + 关闭 Runtime 自建的 Executor;调用方传入的 Executor 保持可用。 + """ + with self._lock: + if self._closed: + return + self._closed = True + owned_executors = tuple(self._owned_executors) + self._owned_executors.clear() + + for executor in owned_executors: + executor.shutdown(wait=True) + + def __enter__(self): + from .jm_task_context import jm_task_context + self._ctx_cm = jm_task_context(runtime=self) + self._ctx_cm.__enter__() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + try: + if self._ctx_cm is not None: + self._ctx_cm.__exit__(exc_type, exc_val, exc_tb) + self._ctx_cm = None + finally: + self.close() + + async def __aenter__(self): + return self.__enter__() + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return self.__exit__(exc_type, exc_val, exc_tb) + + +class JmTempRuntime(JmRuntime): + """ + 临时 Runtime:用于单次同步下载中的局部并发(章节层、图片层或批量任务的本子层)。 + 退出即销毁自建线程池,不自动绑定上下文,不跨任务共享。 + """ + + def __init__( + self, + *, + workers: Optional[int] = None, + executor: Optional[Executor] = None, + ): + if workers is not None and executor is not None: + raise ValueError('workers and executor are mutually exclusive') + if workers is not None: + workers = validate_jm_workers('workers', workers) + if executor is not None: + executor = validate_jm_executor('executor', executor) + + super().__init__( + configs={ + 'default': (workers, executor), + }, + ) + + def executor( + self, + level: Optional[str] = 'default', + default_workers: Optional[int] = None, + ) -> Executor: + return super().executor(level or 'default', default_workers) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.close() + + +class JmSyncRuntime(JmRuntime): + """ + 同步下载 Runtime:分别管理 album、photo、image 三层 Executor。 + """ + + def __init__( + self, + *, + album_workers: Optional[int] = None, + album_executor: Optional[Executor] = None, + photo_workers: Optional[int] = None, + photo_executor: Optional[Executor] = None, + image_workers: Optional[int] = None, + image_executor: Optional[Executor] = None, + # 兼容别名 + id_workers: Optional[int] = None, + id_executor: Optional[Executor] = None, + ): + if album_workers is None and id_workers is not None: + album_workers = id_workers + if album_executor is None and id_executor is not None: + album_executor = id_executor + + external_executors = [ + executor + for executor in (album_executor, photo_executor, image_executor) + if executor is not None + ] + if len({id(executor) for executor in external_executors}) != len(external_executors): + raise ValueError( + 'album/photo/image levels must use different executor objects, ' + 'as sharing one executor across nested levels causes thread pool starvation deadlock' + ) + + album_config = normalize_jm_executor_config('album', album_workers, album_executor) + super().__init__( + configs={ + 'album': album_config, + 'photo': normalize_jm_executor_config( + 'photo', photo_workers, photo_executor + ), + 'image': normalize_jm_executor_config( + 'image', image_workers, image_executor + ), + }, + ) + + +class JmAsyncRuntime(JmRuntime): + """ + 异步下载 Runtime:管理图片解密、处理和同步 hook 的 decode Executor。 + """ + + def __init__( + self, + *, + decode_workers: Optional[int] = None, + decode_executor: Optional[Executor] = None, + ): + super().__init__( + configs={ + 'decode': normalize_jm_executor_config( + 'decode', decode_workers, decode_executor + ), + }, + ) + + def executor( + self, + level: Optional[str] = 'decode', + default_workers: Optional[int] = None, + ) -> Executor: + return super().executor(level or 'decode', default_workers) diff --git a/src/jmcomic/jm_task_context.py b/src/jmcomic/jm_task_context.py index 57a5e9e1d..0a64c6bbd 100644 --- a/src/jmcomic/jm_task_context.py +++ b/src/jmcomic/jm_task_context.py @@ -1,19 +1,21 @@ import inspect -from contextlib import contextmanager from contextvars import ContextVar from functools import wraps +from threading import Event, Lock from types import MappingProxyType from typing import Callable, Mapping, Optional +from .jm_runtime import JmRuntime __all__ = ( 'JM_TASK_CONTEXT', - 'jm_task_context', + 'DownloadControl', + 'JTC', 'get_jm_task_context', + 'jm_task_context', 'bind_jm_task_context', ) - _EMPTY_TASK_CONTEXT = MappingProxyType({}) JM_TASK_CONTEXT: ContextVar[Mapping] = ContextVar( 'jm_task_context', @@ -21,31 +23,178 @@ ) -def get_jm_task_context() -> dict: - """Return a mutable snapshot of the current JM task context.""" - return dict(JM_TASK_CONTEXT.get()) +class DownloadControl: + """ + 在线程间共享、线程安全且幂等的下载取消信号。 + """ + + def __init__(self): + self._event = Event() + self._lock = Lock() + self._reason: Optional[str] = None + + @property + def is_cancelled(self) -> bool: + return self._event.is_set() + + def cancel(self, reason: str = 'download cancelled') -> bool: + normalized_reason = str(reason or 'download cancelled') + with self._lock: + if self._event.is_set(): + return False + self._reason = normalized_reason + self._event.set() + return True + + @property + def reason(self) -> Optional[str]: + with self._lock: + return self._reason + + +class JTC: + """ + JMComic 任务上下文统一门面 (Jm Task Context)。 + """ + + @classmethod + def get_context(cls) -> dict: + """ + 返回当前 JM 任务上下文的可变快照。 + """ + return dict(JM_TASK_CONTEXT.get()) + + @classmethod + def get_context_value(cls, key: str, default=None): + """ + 获取当前 JM 任务上下文指定 key 的值,避免全量 dict 拷贝。 + """ + return JM_TASK_CONTEXT.get().get(key, default) + + @classmethod + def get_runtime(cls) -> Optional[JmRuntime]: + """ + 返回当前任务绑定的 JmRuntime;没有活动 Runtime 时返回 None。 + """ + return cls.get_context_value('runtime') + + @classmethod + def get_option(cls): + """ + 返回当前任务绑定的 JmOption;没有活动 Option 时返回 None。 + """ + return cls.get_context_value('option') + + @classmethod + def get_control(cls) -> Optional[DownloadControl]: + """ + 返回当前任务绑定的取消控制器 DownloadControl;未设置时返回 None。 + """ + control = cls.get_context_value('control') + if control is None: + return None + if not isinstance(control, DownloadControl): + raise TypeError( + 'jm_task_context control must be DownloadControl, ' + f'got {type(control)}' + ) + return control + + @classmethod + def is_cancelled(cls) -> bool: + """ + 当前任务是否已被标记取消。 + """ + control = cls.get_control() + return control is not None and control.is_cancelled + + @classmethod + def raise_if_cancelled(cls) -> None: + """ + 若当前任务已被标记取消,直接抛出 DownloadCancelledException。 + """ + control = cls.get_control() + if control is None or not control.is_cancelled: + return + from .jm_exception import DownloadCancelledException, ExceptionTool + raise DownloadCancelledException( + control.reason, + { + ExceptionTool.CONTEXT_KEY_CONTROL: control, + ExceptionTool.CONTEXT_KEY_REASON: control.reason, + }, + ) + + +# 兼容已发布的上下文查询入口,内部统一使用 JTC。 +get_jm_task_context = JTC.get_context + + +class jm_task_context: + """ + 临时绑定任务字段;只传播 Option/Runtime/Control,不管理资源生命周期。 + + 同时支持同步 with 与异步 async with 上下文管理协议。 + """ + + def __init__(self, *, option=None, runtime=None, control=None, **fields): + self.option = option + self.runtime = runtime + self.control = control + self.fields = fields + self.token = None + + def __enter__(self): + context = JTC.get_context() + parent_runtime = context.get('runtime') + + if self.runtime is not None and not isinstance(self.runtime, JmRuntime): + raise TypeError(f'runtime must be JmRuntime or None, got {type(self.runtime)}') + if ( + self.runtime is not None + and parent_runtime is not None + and self.runtime is not parent_runtime + ): + raise RuntimeError('another JmRuntime is already active in this task context') + + if self.control is not None and not isinstance(self.control, DownloadControl): + raise TypeError(f'control must be DownloadControl, got {type(self.control)}') + + context.update(self.fields) + if self.runtime is not None: + context['runtime'] = self.runtime + if self.option is not None: + context['option'] = self.option + if self.control is not None: + context['control'] = self.control + + self.token = JM_TASK_CONTEXT.set(MappingProxyType(context)) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.token is not None: + JM_TASK_CONTEXT.reset(self.token) + self.token = None + async def __aenter__(self): + return self.__enter__() -@contextmanager -def jm_task_context(**fields): - """Temporarily add fields to the current JM task context.""" - context = get_jm_task_context() - context.update(fields) - token = JM_TASK_CONTEXT.set(MappingProxyType(context)) - try: - yield - finally: - JM_TASK_CONTEXT.reset(token) + async def __aexit__(self, exc_type, exc_val, exc_tb): + return self.__exit__(exc_type, exc_val, exc_tb) def bind_jm_task_context(func: Callable, context: Optional[Mapping] = None) -> Callable: - """Bind a synchronous callable to a snapshot of the current task context.""" - if (inspect.iscoroutinefunction(func) - or inspect.iscoroutinefunction(getattr(func, '__call__', None))): + """ + 把完整任务上下文快照绑定到同步可调用对象。 + """ + if ( + inspect.iscoroutinefunction(func) + or inspect.iscoroutinefunction(getattr(func, '__call__', None)) + ): raise TypeError('bind_jm_task_context only supports synchronous callables') snapshot = MappingProxyType(dict( - get_jm_task_context() if context is None else context + JTC.get_context() if context is None else context )) @wraps(func) diff --git a/src/jmcomic/jm_toolkit.py b/src/jmcomic/jm_toolkit.py index bd0411ad3..8c967af0f 100644 --- a/src/jmcomic/jm_toolkit.py +++ b/src/jmcomic/jm_toolkit.py @@ -1,7 +1,6 @@ from datetime import datetime from html.parser import HTMLParser from urllib.parse import unquote, urlparse - from PIL import Image from .jm_exception import * diff --git a/tests/test_jmcomic/test_jm_api.py b/tests/test_jmcomic/test_jm_api.py index bf46f99cc..2cd4b437c 100644 --- a/tests/test_jmcomic/test_jm_api.py +++ b/tests/test_jmcomic/test_jm_api.py @@ -16,6 +16,9 @@ def test_callback_is_not_public_download_api(self): ): parameters = inspect.signature(download_api).parameters self.assertNotIn('callback', parameters) + self.assertNotIn('runtime', parameters) + self.assertNotIn('workers', parameters) + self.assertNotIn('executor', parameters) self.assertEqual( parameters['check_exception'].kind, inspect.Parameter.KEYWORD_ONLY, diff --git a/tests/test_jmcomic/test_jm_async_custom.py b/tests/test_jmcomic/test_jm_async_custom.py index e6cb93677..78631295d 100644 --- a/tests/test_jmcomic/test_jm_async_custom.py +++ b/tests/test_jmcomic/test_jm_async_custom.py @@ -8,6 +8,7 @@ from jmcomic.jm_async_downloader import JmAsyncDownloader from jmcomic.jm_client_interface import AsyncJmcomicClient import asyncio +from unittest.mock import patch class Test_Async_Custom(JmAsyncTestConfigurable): @@ -213,15 +214,23 @@ def test_async_setup_checks_cookies_for_each_session(self): AsyncJmApiClient._has_setup_domain = old_setup_domain def test_async_downloader_cleanup_when_setup_fails(self): - """真实 AsyncSession 初始化失败时,downloader 应回收 client 和线程池""" + """真实会话初始化失败时,也要关闭 Downloader 自建线程池。""" old_auto_update = JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN old_require_cookies = JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES old_app_cookies = JmModuleConfig.APP_COOKIES old_updated_domains = JmModuleConfig.DOMAIN_API_UPDATED_LIST old_setup_domain = AsyncJmApiClient._has_setup_domain + class ProbeAsyncRuntime(JmAsyncRuntime): + instances = [] + + def __init__(self): + super().__init__(decode_workers=1) + self.instances.append(self) + loop = asyncio.new_event_loop() downloader = None + runtime = None try: JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = False JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = True @@ -241,14 +250,21 @@ def test_async_downloader_cleanup_when_setup_fails(self): photo_concurrency=1, decode_worker=1, ) - with self.assertRaises(RequestRetryAllFailException): - loop.run_until_complete(downloader.__aenter__()) - + with patch( + 'jmcomic.jm_async_downloader.JmAsyncRuntime', + ProbeAsyncRuntime, + ): + with self.assertRaises(RequestRetryAllFailException): + loop.run_until_complete(downloader.__aenter__()) + + runtime = ProbeAsyncRuntime.instances[0] self.assertIsNone(downloader.client) - self.assertTrue(downloader._decode_pool._shutdown) + self.assertIsNone(JTC.get_runtime()) + with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'): + runtime.executor('decode', 1) finally: - if downloader is not None and not downloader._decode_pool._shutdown: - downloader.shutdown() + if runtime is not None: + runtime.close() loop.close() JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = old_auto_update JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = old_require_cookies diff --git a/tests/test_jmcomic/test_jm_cancellation.py b/tests/test_jmcomic/test_jm_cancellation.py new file mode 100644 index 000000000..bdb11f4bc --- /dev/null +++ b/tests/test_jmcomic/test_jm_cancellation.py @@ -0,0 +1,609 @@ +import asyncio +from concurrent.futures import Future +from types import SimpleNamespace +from threading import Thread +import unittest +from unittest.mock import AsyncMock, Mock, patch + +from jmcomic import ( + BaseDownloader, + DownloadCancelledException, + DownloadControl, + DownloadResult, + ExceptionTool, + JmAlbumDetail, + JmPhotoDetail, + JmImageDetail, + JmAsyncDownloader, + JmAsyncRuntime, + JmDownloader, + bind_jm_task_context, + download_album, + download_album_async, + download_batch, + download_batch_async, + JTC, + jm_task_context, +) + + +class Test_Cancellation(unittest.IsolatedAsyncioTestCase): + + def test_control_is_idempotent_and_visible_across_threads(self): + control = DownloadControl() + seen = [] + + with jm_task_context(control=control): + worker = bind_jm_task_context( + lambda: seen.append(JTC.get_control()) + ) + thread = Thread(target=worker) + thread.start() + thread.join() + + self.assertEqual(seen, [control]) + self.assertFalse(control.is_cancelled) + self.assertIsNone(control.reason) + self.assertTrue(control.cancel(123)) + self.assertFalse(control.cancel('ignored second reason')) + self.assertTrue(control.is_cancelled) + self.assertEqual(control.reason, '123') + + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException) as caught: + BaseDownloader.raise_if_cancelled() + + self.assertIs(caught.exception.control, control) + self.assertEqual(caught.exception.reason, '123') + self.assertEqual( + caught.exception.context, + { + ExceptionTool.CONTEXT_KEY_CONTROL: control, + ExceptionTool.CONTEXT_KEY_REASON: '123', + }, + ) + + def test_downloader_classmethod_is_overrideable(self): + BaseDownloader.raise_if_cancelled() + + control = DownloadControl() + control.cancel() + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException): + BaseDownloader.raise_if_cancelled() + + seen = [] + + class CustomDownloader(BaseDownloader): + + @classmethod + def raise_if_cancelled(cls) -> None: + seen.append(cls) + + object.__new__(CustomDownloader).raise_if_cancelled() + self.assertEqual(seen, [CustomDownloader]) + + def test_exception_keeps_normal_jmcomic_constructor_shape(self): + control = DownloadControl() + exception = DownloadCancelledException( + 'explicit reason', + { + ExceptionTool.CONTEXT_KEY_CONTROL: control, + ExceptionTool.CONTEXT_KEY_REASON: 'explicit reason', + }, + ) + + self.assertIs(exception.control, control) + self.assertEqual(exception.reason, 'explicit reason') + self.assertEqual(str(exception), 'explicit reason') + + def test_sync_api_uses_control_from_caller_context(self): + control = DownloadControl() + seen = [] + + class ProbeDownloader: + + def __init__(self, _option): + self.manifest_dict = {} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def add_features(self, _extra): + pass + + def download_album(self, _album_id): + seen.append(JTC.get_control()) + control.cancel('api probe') + BaseDownloader.raise_if_cancelled() + + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException): + download_album('123', option=object(), downloader=ProbeDownloader) + + self.assertEqual(seen, [control]) + + def test_sync_batch_raises_cancellation_instead_of_failed_result(self): + control = DownloadControl() + + def cancel_first(jmid, *_args, **_kwargs): + control.cancel(f'stopped at {jmid}') + BaseDownloader.raise_if_cancelled() + + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException): + download_batch(cancel_first, ['123', '456'], option=object()) + + def test_sync_batch_propagates_manual_cancellation_without_control(self): + def stop(_jmid, *_args, **_kwargs): + raise DownloadCancelledException('manual stop') + + with self.assertRaisesRegex(DownloadCancelledException, 'manual stop'): + download_batch(stop, ['123'], option=object()) + + def test_sync_batch_cancellation_exposes_completed_results_and_unfinished_ids(self): + control = DownloadControl() + + def make_album(aid): + return JmAlbumDetail( + album_id=aid, + scramble_id='220980', + name=f'album {aid}', + episode_list=[('456', '1', 'photo')], + page_count=5, + pub_date='2020-01-01', + update_date='2020-01-01', + likes='0', + views='0', + comment_count='0', + works=[], + actors=[], + authors=[], + tags=[], + ) + + def mock_download(jmid, *_args, **_kwargs): + if str(jmid) == '1': + album = make_album('1') + dler = object() + return DownloadResult(detail=album, downloader=dler) + if str(jmid) == '2': + control.cancel('stop at 2') + BaseDownloader.raise_if_cancelled() + return None + + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException) as ctx: + download_batch(mock_download, ['1', '2', '3'], option=object()) + + e = ctx.exception + self.assertEqual(e.reason, 'stop at 2') + self.assertEqual(len(e.completed_results), 1) + res = e.completed_results[0] + self.assertEqual(res.detail.id, '1') + self.assertEqual(res.album.id, '1') + self.assertIsNotNone(res.downloader) + self.assertEqual(set(e.unfinished_ids), {'2', '3'}) + + async def test_async_batch_cancellation_exposes_completed_results_and_unfinished_ids(self): + control = DownloadControl() + + def make_album(aid): + return JmAlbumDetail( + album_id=aid, + scramble_id='220980', + name=f'album {aid}', + episode_list=[('456', '1', 'photo')], + page_count=5, + pub_date='2020-01-01', + update_date='2020-01-01', + likes='0', + views='0', + comment_count='0', + works=[], + actors=[], + authors=[], + tags=[], + ) + + async def mock_download(jmid, *_args, **_kwargs): + if str(jmid) == '1': + album = make_album('1') + dler = object() + return DownloadResult(detail=album, downloader=dler) + if str(jmid) == '2': + control.cancel('stop at 2') + if control.is_cancelled: + raise DownloadCancelledException('stop at 2') + return None + + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException) as ctx: + await download_batch_async(mock_download, ['1', '2', '3'], option=object()) + + e = ctx.exception + self.assertEqual(e.reason, 'stop at 2') + self.assertEqual(len(e.completed_results), 1) + res = e.completed_results[0] + self.assertEqual(res.detail.id, '1') + self.assertEqual(res.album.id, '1') + self.assertIsNotNone(res.downloader) + self.assertEqual(e.unfinished_ids, ['2', '3']) + + def test_download_result_album_and_photo_properties(self): + album = JmAlbumDetail( + album_id='100', + scramble_id='220980', + name='album 100', + episode_list=[('456', '1', 'photo')], + page_count=5, + pub_date='2020-01-01', + update_date='2020-01-01', + likes='0', + views='0', + comment_count='0', + works=[], + actors=[], + authors=[], + tags=[], + ) + res_album = DownloadResult(detail=album, downloader=object()) + self.assertIs(res_album.album, album) + self.assertIsNone(res_album.photo) + + photo = JmPhotoDetail('200', 'photo 200', '0', 1, from_album=album) + res_photo = DownloadResult(detail=photo, downloader=object()) + self.assertIs(res_photo.album, album) + self.assertIs(res_photo.photo, photo) + + async def test_empty_batches_do_not_invoke_downloader_cancellation(self): + control = DownloadControl() + control.cancel('already stopped') + + with jm_task_context(control=control): + self.assertEqual( + set(download_batch(lambda *_args: None, [], option=object())), + set(), + ) + self.assertEqual( + set(await download_batch_async(lambda *_args: None, [], option=object())), + set(), + ) + + async def test_async_batch_prefers_control_cancellation_over_task_cancellation(self): + async def stop_differently(jmid, *_args, **_kwargs): + if str(jmid) == '1': + raise asyncio.CancelledError('external cancellation') + raise DownloadCancelledException('control cancellation') + + with self.assertRaisesRegex(DownloadCancelledException, 'control cancellation'): + await download_batch_async(stop_differently, ['1', '2'], option=object()) + + async def test_async_batch_waits_for_cancelled_children_to_finish(self): + ready = asyncio.Event() + never = asyncio.Event() + started = [] + finished = [] + + async def wait_until_cancelled(jmid, *_args, **_kwargs): + started.append(str(jmid)) + if len(started) == 2: + ready.set() + try: + await never.wait() + finally: + await asyncio.sleep(0) + finished.append(str(jmid)) + + batch = asyncio.create_task(download_batch_async( + wait_until_cancelled, + ['1', '2'], + option=object(), + )) + await ready.wait() + batch.cancel() + + with self.assertRaises(asyncio.CancelledError): + await batch + + self.assertCountEqual(finished, ['1', '2']) + + def test_feature_chain_stops_at_cancellation_boundary(self): + control = DownloadControl() + option = Mock() + downloader = BaseDownloader(option) + first = Mock() + second = Mock() + first.should_invoke.return_value = True + second.should_invoke.return_value = True + first.invoke.side_effect = lambda *_args, **_kwargs: control.cancel('feature stop') + downloader._feature_list = [first, second] + + with jm_task_context(download_type='album', control=control): + with self.assertRaises(DownloadCancelledException): + downloader._invoke_features_for('after_album', album=object()) + + first.invoke.assert_called_once() + second.invoke.assert_not_called() + + def test_sync_downloader_preserves_client_failure_on_low_level_call(self): + control = DownloadControl() + downloader = object.__new__(JmDownloader) + downloader.client = Mock() + + def fail_after_cancel(_album_id): + control.cancel('请求期间取消') + raise RuntimeError('请求失败') + + downloader.client.get_album_detail.side_effect = fail_after_cancel + + with jm_task_context(control=control): + with self.assertRaisesRegex(RuntimeError, '请求失败'): + downloader.download_album('123') + + def test_sync_api_converts_client_failure_to_cancellation(self): + control = DownloadControl() + + class FailingDownloader: + def __init__(self, _option): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + pass + + def add_features(self, _extra): + pass + + def raise_if_has_exception(self): + pass + + def download_album(self, _album_id): + control.cancel('请求期间取消') + raise RuntimeError('请求失败') + + with jm_task_context(control=control): + with self.assertRaisesRegex(DownloadCancelledException, '请求期间取消') as ctx: + download_album('123', option=object(), downloader=FailingDownloader) + self.assertIsInstance(ctx.exception.__cause__, RuntimeError) + self.assertEqual(str(ctx.exception.__cause__), '请求失败') + + async def test_async_downloader_preserves_client_failure_on_low_level_call(self): + control = DownloadControl() + downloader = object.__new__(JmAsyncDownloader) + downloader.client = Mock() + + async def fail_after_cancel(_album_id): + control.cancel('异步请求期间取消') + raise RuntimeError('请求失败') + + downloader.client.get_album_detail = fail_after_cancel + + with jm_task_context(control=control): + with self.assertRaisesRegex(RuntimeError, '请求失败'): + await downloader.download_album('123') + + async def test_async_api_converts_client_failure_to_cancellation(self): + control = DownloadControl() + + class FailingAsyncDownloader: + def __init__(self, _option): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + pass + + def add_features(self, _extra): + pass + + def raise_if_has_exception(self): + pass + + async def download_album(self, _album_id): + control.cancel('异步请求期间取消') + raise RuntimeError('请求失败') + + with jm_task_context(control=control): + with self.assertRaisesRegex(DownloadCancelledException, '异步请求期间取消') as ctx: + await download_album_async('123', option=object(), downloader=FailingAsyncDownloader) + self.assertIsInstance(ctx.exception.__cause__, RuntimeError) + self.assertEqual(str(ctx.exception.__cause__), '请求失败') + + async def test_control_cancellation_is_not_recorded_as_image_failure(self): + control = DownloadControl() + control.cancel('stop before image') + downloader = object.__new__(JmAsyncDownloader) + downloader.download_failed_image = [] + + async def cancelled(_image): + downloader.raise_if_cancelled() + + downloader._download_single_image = cancelled + + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException): + await downloader._safe_download_image(object()) + + self.assertEqual(downloader.download_failed_image, []) + + async def test_external_task_cancellation_is_not_swallowed(self): + downloader = object.__new__(JmAsyncDownloader) + + async def wait_forever(_image): + await asyncio.Event().wait() + + downloader._download_single_image = wait_forever + task = asyncio.create_task(downloader._safe_download_image(object())) + await asyncio.sleep(0) + task.cancel() + + with self.assertRaises(asyncio.CancelledError): + await task + + async def test_current_async_image_is_recorded_before_control_cancellation(self): + control = DownloadControl() + downloader = object.__new__(JmAsyncDownloader) + downloader.option = Mock() + downloader.client = Mock() + downloader.client.get_jm_image = AsyncMock() + downloader.client.get_jm_image.return_value.content = b'image' + downloader._image_semaphore = asyncio.Semaphore(1) + + async def run_inline(func, *args): + return func(*args) + + downloader._run_in_decode_pool = run_inline + downloader._save_raw = Mock(side_effect=lambda *_args: control.cancel('save finished')) + downloader.option.decide_image_filepath.return_value = 'image.jpg' + downloader.option.decide_download_cache.return_value = False + downloader.option.decide_download_image_decode.return_value = False + + image = Mock(spec=JmImageDetail) + image.skip = False + image.scramble_id = None + image.img_url = 'https://example.invalid/image.jpg' + image.download_url = 'https://example.invalid/image.jpg' + image.index = 1 + image.from_photo = Mock() + photo = image.from_photo + album = photo.from_album + photo.is_album.return_value = False + downloader.download_success_dict = {album: {photo: []}} + downloader.manifest_dict = {} + downloader.begin_manifest(photo) + downloader.before_image = AsyncMock() + + with patch('jmcomic.jm_async_downloader.os.path.exists', return_value=False): + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException): + await downloader._download_single_image(image) + + self.assertEqual(downloader.finish_manifest(photo).image_filepath_list, ['image.jpg']) + downloader._save_raw.assert_called_once() + downloader.option.call_all_plugin.assert_not_called() + + def test_sync_image_saved_during_cancellation_is_in_manifest(self): + control = DownloadControl() + downloader = object.__new__(JmDownloader) + downloader.option = Mock() + downloader.option.decide_image_filepath.return_value = 'image.jpg' + downloader.option.decide_download_cache.return_value = False + downloader.option.decide_download_image_decode.return_value = False + downloader.client = Mock() + downloader.client.download_by_image_detail.side_effect = ( + lambda *_args, **_kwargs: control.cancel('save finished') + ) + downloader.before_image = Mock() + image = Mock(spec=JmImageDetail) + image.skip = False + image.img_url = 'https://example.invalid/image.jpg' + image.index = 1 + image.from_photo = Mock() + photo = image.from_photo + album = photo.from_album + photo.is_album.return_value = False + downloader.download_success_dict = {album: {photo: []}} + downloader.manifest_dict = {} + downloader.begin_manifest(photo) + + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException): + downloader.download_by_image_detail(image) + + self.assertEqual(downloader.finish_manifest(photo).image_filepath_list, ['image.jpg']) + downloader.client.download_by_image_detail.assert_called_once() + downloader.option.call_all_plugin.assert_not_called() + + async def test_cancelled_decode_logs_worker_failure_and_preserves_cancellation(self): + future = Future() + submitted = asyncio.Event() + executor = Mock() + + def submit(*_args): + submitted.set() + return future + + executor.submit.side_effect = submit + runtime = JmAsyncRuntime(decode_workers=1) + downloader = object.__new__(JmAsyncDownloader) + downloader._decode_worker = 1 + error = OSError('save failed') + try: + with patch.object(runtime, 'executor', return_value=executor): + with patch('jmcomic.jm_async_downloader.jm_log') as log: + with jm_task_context(runtime=runtime): + task = asyncio.create_task(downloader._run_in_decode_pool(lambda: None)) + await asyncio.wait_for(submitted.wait(), timeout=1) + task.cancel() + await asyncio.sleep(0) + future.set_exception(error) + with self.assertRaises(asyncio.CancelledError): + await task + log.assert_called_once_with('dler.cancel.drain.exception', error) + finally: + runtime.close() + + async def test_image_cancelled_while_waiting_for_semaphore_never_requests(self): + control = DownloadControl() + downloader = object.__new__(JmAsyncDownloader) + downloader.option = Mock() + downloader.client = Mock() + downloader.client.get_jm_image = AsyncMock() + downloader._image_semaphore = asyncio.Semaphore(1) + await downloader._image_semaphore.acquire() + downloader.option.decide_image_filepath.return_value = 'image.jpg' + downloader.option.decide_download_cache.return_value = False + downloader.option.decide_download_image_decode.return_value = False + before_reached = asyncio.Event() + + async def before_image(_image, _path): + before_reached.set() + + downloader.before_image = before_image + downloader.after_image = AsyncMock() + image = SimpleNamespace( + skip=False, + scramble_id=None, + download_url='https://example.invalid/image.jpg', + ) + + with patch('jmcomic.jm_async_downloader.os.path.exists', return_value=False): + with jm_task_context(control=control): + task = asyncio.create_task(downloader._download_single_image(image)) + await asyncio.wait_for(before_reached.wait(), timeout=1) + await asyncio.sleep(0) + control.cancel('queued image') + downloader._image_semaphore.release() + with self.assertRaises(DownloadCancelledException): + await task + + downloader.client.get_jm_image.assert_not_awaited() + + async def test_photo_cancelled_while_waiting_for_semaphore_never_requests(self): + control = DownloadControl() + downloader = object.__new__(JmAsyncDownloader) + downloader.option = Mock() + downloader.option.decide_image_save_dir.return_value = 'photo' + downloader.client = Mock() + downloader.client.check_photo = AsyncMock() + downloader._photo_semaphore = asyncio.Semaphore(1) + await downloader._photo_semaphore.acquire() + photo = SimpleNamespace() + + with jm_task_context(control=control): + task = asyncio.create_task(downloader.download_by_photo_detail(photo)) + await asyncio.sleep(0) + control.cancel('queued photo') + downloader._photo_semaphore.release() + with self.assertRaises(DownloadCancelledException): + await task + + downloader.client.check_photo.assert_not_awaited() diff --git a/tests/test_jmcomic/test_jm_cli.py b/tests/test_jmcomic/test_jm_cli.py index ac692244c..3e6f23d26 100644 --- a/tests/test_jmcomic/test_jm_cli.py +++ b/tests/test_jmcomic/test_jm_cli.py @@ -1,10 +1,12 @@ +from concurrent.futures import ThreadPoolExecutor + from test_jmcomic import * from io import StringIO from types import SimpleNamespace from unittest.mock import MagicMock, patch from jmcomic.cli import JmcomicUI, JmViewUI -from jmcomic.jm_task_context import get_jm_task_context +from jmcomic import JmSyncRuntime, JTC, jm_task_context class Test_Cli(JmTestConfigurable): @@ -78,7 +80,7 @@ def parse_arg(): ui.option_path = None def create_default_option(): - self.assertTrue(get_jm_task_context().get('cli_no_progress')) + self.assertTrue(JTC.get_context().get('cli_no_progress')) return option with patch.object(ui, 'parse_arg', side_effect=parse_arg), \ @@ -88,7 +90,7 @@ def create_default_option(): patch('jmcomic.api.jm_log'): ui.main() - self.assertNotIn('cli_no_progress', get_jm_task_context()) + self.assertNotIn('cli_no_progress', JTC.get_context()) def test_jmcomic_falls_back_without_rich(self): ui = JmcomicUI() @@ -125,6 +127,44 @@ def test_jmcomic_parse_mixed(self): self.assertEqual(ui.album_id_list, [self.album_id]) self.assertEqual(ui.photo_id_list, [self.album_id]) + def test_jmcomic_mixed_threads_inherit_runtime(self): + ui = JmcomicUI() + ui.album_id_list = ['1'] + ui.photo_id_list = ['2'] + option = SimpleNamespace() + observed = [] + + def record(ids, actual_option): + observed.append(( + ids, + actual_option, + JTC.get_runtime(), + JTC.get_context(), + )) + + with ThreadPoolExecutor(max_workers=1) as executor: + runtime = JmSyncRuntime(album_executor=executor) + with patch('jmcomic.api.download_album', side_effect=record), \ + patch('jmcomic.api.download_photo', side_effect=record): + with jm_task_context(task_id='cli-job', runtime=runtime): + ui.run(option) + + self.assertCountEqual( + [(ids, actual_option) for ids, actual_option, _executor, _context in observed], + [(['1'], option), (['2'], option)], + ) + self.assertEqual( + [seen for _ids, _option, seen, _context in observed], + [runtime, runtime], + ) + self.assertEqual( + [context for _ids, _option, _executor, context in observed], + [ + {'task_id': 'cli-job', 'runtime': runtime}, + {'task_id': 'cli-job', 'runtime': runtime}, + ], + ) + def test_jmcomic_download_album(self): """jmcomic 真实下载 album 123""" JustDownloadSpecificCountImage.count = 5 diff --git a/tests/test_jmcomic/test_jm_client.py b/tests/test_jmcomic/test_jm_client.py index 0a15fce9c..abc32dbad 100644 --- a/tests/test_jmcomic/test_jm_client.py +++ b/tests/test_jmcomic/test_jm_client.py @@ -291,17 +291,9 @@ def search_and_test(case_name, params): raise AssertionError(elist) def test_comment_count(self): - aid = 'JM438516' + aid = '350234' album = self.client.get_album_detail(aid) self.assertGreater(album.comment_count, 0) - page = self.client.search_site('无修正') - for i in range(3): - aid, _atitle = page[i] - self.assertGreaterEqual( - self.client.get_album_detail(aid).comment_count, - 0, - aid, - ) def test_album_pagination(self): album_id = '438516' diff --git a/tests/test_jmcomic/test_jm_download_manifest.py b/tests/test_jmcomic/test_jm_download_manifest.py index bd7815856..461153141 100644 --- a/tests/test_jmcomic/test_jm_download_manifest.py +++ b/tests/test_jmcomic/test_jm_download_manifest.py @@ -59,7 +59,9 @@ def decide_image_save_dir(self, _photo): return os.path.join(self.base_dir, 'album', 'photo') def decide_image_filepath(self, image): - return os.path.join(self.decide_image_save_dir(image.from_photo), image.filename) + save_dir = self.decide_image_save_dir(image.from_photo) + os.makedirs(save_dir, exist_ok=True) + return os.path.join(save_dir, image.filename) def decide_download_cache(self, _image): return True @@ -75,7 +77,7 @@ def decide_image_batch_count(self, _photo): def call_all_plugin(self, group, **kwargs): self.plugin_event_list.append((group, kwargs)) - self.context_event_list.append((group, get_jm_task_context())) + self.context_event_list.append((group, JTC.get_context())) if group == 'after_image' and self.after_image_callback is not None: self.after_image_callback(kwargs['image']) @@ -122,7 +124,7 @@ def do_filter(self, detail): return list(self._contract_image_list) return detail - def execute_on_condition(self, iter_objs, apply, count_batch): + def execute_on_condition(self, iter_objs, apply, count_batch, level=None, **kwargs): for detail in self.do_filter(iter_objs): apply(detail) @@ -143,6 +145,10 @@ async def get_photo_detail(self, _photo_id): async def check_photo(self, _photo): return None + async def get_jm_image(self, _url): + self.image_download_count += 1 + return SimpleNamespace(content=b'image') + class ContractAsyncDownloader(JmAsyncDownloader): @@ -163,6 +169,132 @@ def do_filter(self, detail): class Test_Download_Manifest(unittest.TestCase): + def test_after_image_error_still_registers_saved_image_sync(self): + with TemporaryDirectory() as temp_dir: + album, photo, images = new_album_photo_images() + option = ContractOption(temp_dir) + downloader = ContractSyncDownloader(option, album, photo, images) + error = ValueError('image plugin failed') + + def fail(_image): + raise error + + option.after_image_callback = fail + with self.assertRaises(ValueError) as caught: + downloader.download_album(album.id) + + self.assertIs(caught.exception, error) + self.assertTrue(os.path.isfile(images[0].save_path)) + self.assertEqual( + downloader.download_success_dict[album][photo], + [(images[0].save_path, images[0])], + ) + self.assertEqual(downloader.download_failed_image, [(images[0], error)]) + self.assertEqual( + downloader.manifest_dict[album].image_filepath_list, + [images[0].save_path], + ) + + def test_after_image_error_still_registers_saved_image_async(self): + async def run_test(temp_dir): + album, photo, images = new_album_photo_images() + option = ContractOption(temp_dir) + downloader = ContractAsyncDownloader(option, album, photo, images) + error = ValueError('image plugin failed') + + def fail(_image): + raise error + + option.after_image_callback = fail + runtime = JmAsyncRuntime() + try: + with jm_task_context(runtime=runtime): + await downloader.download_album(album.id) + finally: + runtime.close() + + self.assertTrue(os.path.isfile(images[0].save_path)) + self.assertEqual( + downloader.download_success_dict[album][photo], + [(images[0].save_path, images[0])], + ) + self.assertEqual(downloader.download_failed_image, [(images[0], error)]) + self.assertEqual( + downloader.manifest_dict[album].image_filepath_list, + [images[0].save_path], + ) + + with TemporaryDirectory() as temp_dir: + asyncio.run(run_test(temp_dir)) + + def test_after_image_direct_cancellation_keeps_saved_image(self): + with TemporaryDirectory() as temp_dir: + album, photo, images = new_album_photo_images() + option = ContractOption(temp_dir) + downloader = ContractSyncDownloader(option, album, photo, images) + error = DownloadCancelledException('image plugin cancelled') + + def cancel(_image): + raise error + + option.after_image_callback = cancel + with self.assertRaises(DownloadCancelledException) as caught: + downloader.download_album(album.id) + + self.assertIs(caught.exception, error) + self.assertEqual(downloader.download_failed_image, []) + self.assertEqual( + downloader.manifest_dict[album].image_filepath_list, + [images[0].save_path], + ) + + def test_current_sync_image_is_recorded_before_cancellation(self): + with TemporaryDirectory() as temp_dir: + album, photo, image_list = new_album_photo_images() + option = ContractOption(temp_dir) + downloader = ContractSyncDownloader(option, album, photo, image_list) + control = DownloadControl() + option.after_image_callback = lambda image: control.cancel('stop after image') + + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException): + downloader.download_album(album.id) + + image = image_list[0] + self.assertTrue(os.path.isfile(image.save_path)) + self.assertEqual( + downloader.download_success_dict[album][photo], + [(image.save_path, image)], + ) + self.assertEqual(downloader.download_failed_image, []) + + def test_current_async_image_is_recorded_before_cancellation(self): + async def run_test(temp_dir): + album, photo, image_list = new_album_photo_images() + option = ContractOption(temp_dir) + downloader = ContractAsyncDownloader(option, album, photo, image_list) + control = DownloadControl() + option.after_image_callback = lambda image: control.cancel('stop after image') + os.makedirs(option.decide_image_save_dir(photo), exist_ok=True) + + runtime = JmAsyncRuntime() + try: + with jm_task_context(control=control, runtime=runtime): + with self.assertRaises(DownloadCancelledException): + await downloader.download_album(album.id) + finally: + runtime.close() + + image = image_list[0] + self.assertEqual( + downloader.download_success_dict[album][photo], + [(image.save_path, image)], + ) + self.assertEqual(downloader.download_failed_image, []) + + with TemporaryDirectory() as temp_dir: + asyncio.run(run_test(temp_dir)) + def test_downloadable_defaults(self): album, photo, image_list = new_album_photo_images() @@ -798,7 +930,7 @@ def test_entity_timing_contexts_are_nested_and_do_not_leak(self): [2, 2], [context['photo_started_at'] for context in context_by_group['before_image']], ) - self.assertEqual({}, get_jm_task_context()) + self.assertEqual({}, JTC.get_context()) def test_sync_download_album_duration_includes_detail_and_manifest(self): clock = {'now': 10.0} @@ -807,20 +939,20 @@ def test_sync_download_album_duration_includes_detail_and_manifest(self): downloader = object.__new__(JmDownloader) def get_album_detail(_album_id): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 20.0 return album def begin_manifest(_album): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 30.0 def download_by_album_detail(_album): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 40.0 def finish_manifest(_album): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 50.0 downloader.client = SimpleNamespace(get_album_detail=get_album_detail) @@ -834,7 +966,7 @@ def finish_manifest(_album): self.assertIs(result, album) self.assertEqual(40.0, album.duration) self.assertEqual([10.0] * 4, [context.get('album_started_at') for context in contexts]) - self.assertEqual({}, get_jm_task_context()) + self.assertEqual({}, JTC.get_context()) def test_sync_download_photo_duration_includes_detail_and_manifest(self): clock = {'now': 10.0} @@ -843,20 +975,20 @@ def test_sync_download_photo_duration_includes_detail_and_manifest(self): downloader = object.__new__(JmDownloader) def get_photo_detail(_photo_id): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 20.0 return photo def begin_manifest(_photo): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 30.0 def download_by_photo_detail(_photo): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 40.0 def finish_manifest(_photo): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 50.0 downloader.client = SimpleNamespace(get_photo_detail=get_photo_detail) @@ -870,7 +1002,7 @@ def finish_manifest(_photo): self.assertIs(result, photo) self.assertEqual(40.0, photo.duration) self.assertEqual([10.0] * 4, [context.get('photo_started_at') for context in contexts]) - self.assertEqual({}, get_jm_task_context()) + self.assertEqual({}, JTC.get_context()) def test_async_download_album_duration_includes_detail_and_manifest(self): async def run_test(): @@ -880,20 +1012,20 @@ async def run_test(): downloader = object.__new__(JmAsyncDownloader) async def get_album_detail(_album_id): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 20.0 return album def begin_manifest(_album): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 30.0 async def download_by_album_detail(_album): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 40.0 def finish_manifest(_album): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 50.0 downloader.client = SimpleNamespace(get_album_detail=get_album_detail) @@ -907,7 +1039,7 @@ def finish_manifest(_album): self.assertIs(result, album) self.assertEqual(40.0, album.duration) self.assertEqual([10.0] * 4, [context.get('album_started_at') for context in contexts]) - self.assertEqual({}, get_jm_task_context()) + self.assertEqual({}, JTC.get_context()) asyncio.run(run_test()) @@ -919,20 +1051,20 @@ async def run_test(): downloader = object.__new__(JmAsyncDownloader) async def get_photo_detail(_photo_id): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 20.0 return photo def begin_manifest(_photo): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 30.0 async def download_by_photo_detail(_photo): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 40.0 def finish_manifest(_photo): - contexts.append(get_jm_task_context()) + contexts.append(JTC.get_context()) clock['now'] = 50.0 downloader.client = SimpleNamespace(get_photo_detail=get_photo_detail) @@ -946,7 +1078,7 @@ def finish_manifest(_photo): self.assertIs(result, photo) self.assertEqual(40.0, photo.duration) self.assertEqual([10.0] * 4, [context.get('photo_started_at') for context in contexts]) - self.assertEqual({}, get_jm_task_context()) + self.assertEqual({}, JTC.get_context()) asyncio.run(run_test()) @@ -1036,21 +1168,23 @@ async def run_test(temp_dir): f.write(b'cached') downloader = ContractAsyncDownloader(option, album, photo, image_list) + runtime = JmAsyncRuntime() try: - await downloader.download_album(album.id) - - self.assertEqual(album.save_path, option.dir_rule.decide_album_root_dir(album)) - self.assertEqual(photo.save_path, option.decide_image_save_dir(photo)) - self.assertEqual(image.save_path, filepath) - self.assertIsInstance(album.duration, float) - self.assertIsInstance(photo.duration, float) - self.assertIsInstance(image.duration, float) - after_image_events = [event for event, _ in option.plugin_event_list if event == 'after_image'] - self.assertEqual(after_image_events, ['after_image']) - self.assertEqual(downloader.download_success_dict[album][photo], [(filepath, image)]) - self.assertEqual(downloader.manifest_dict[album].image_filepath_list, [filepath]) + with jm_task_context(runtime=runtime): + await downloader.download_album(album.id) finally: - downloader.shutdown() + runtime.close() + + self.assertEqual(album.save_path, option.dir_rule.decide_album_root_dir(album)) + self.assertEqual(photo.save_path, option.decide_image_save_dir(photo)) + self.assertEqual(image.save_path, filepath) + self.assertIsInstance(album.duration, float) + self.assertIsInstance(photo.duration, float) + self.assertIsInstance(image.duration, float) + after_image_events = [event for event, _ in option.plugin_event_list if event == 'after_image'] + self.assertEqual(after_image_events, ['after_image']) + self.assertEqual(downloader.download_success_dict[album][photo], [(filepath, image)]) + self.assertEqual(downloader.manifest_dict[album].image_filepath_list, [filepath]) with TemporaryDirectory() as temp_dir: asyncio.run(run_test(temp_dir)) diff --git a/tests/test_jmcomic/test_jm_download_progress.py b/tests/test_jmcomic/test_jm_download_progress.py index fe2894f5e..f83781ed6 100644 --- a/tests/test_jmcomic/test_jm_download_progress.py +++ b/tests/test_jmcomic/test_jm_download_progress.py @@ -3,7 +3,6 @@ import importlib.util import logging import os -import time import unittest from io import StringIO from pathlib import Path @@ -28,10 +27,6 @@ from jmcomic.jm_task_context import jm_task_context -PROJECT_DIR = Path(__file__).resolve().parents[2] -DOCUMENT_FILE = PROJECT_DIR / 'assets' / 'docs' / 'sources' / 'tutorial' / '15_download_progress.md' -PLUGIN_FILE = PROJECT_DIR / 'src' / 'jmcomic' / 'jm_plugin.py' -DOWNLOADER_FILE = PROJECT_DIR / 'src' / 'jmcomic' / 'jm_downloader.py' RICH_INSTALLED = importlib.util.find_spec('rich') is not None @@ -318,7 +313,7 @@ class AfterAsyncDownloader(JmAsyncDownloader): JmModuleConfig.CLASS_ASYNC_DOWNLOADER = original_downloader @unittest.skipUnless(RICH_INSTALLED, '需要安装 rich 才能测试彩色进度插件') - def test_sync_progress_is_rendered_before_download_finishes(self): + def test_sync_progress_updates_before_download_finishes(self): from rich.console import Console album = create_album() @@ -345,19 +340,24 @@ def test_sync_progress_is_rendered_before_download_finishes(self): ): downloader = ProgressDownloader(option) downloader.before_album(album) - rendered_before_wait = ui_output.getvalue() - time.sleep(0.2) - self.assertEqual(rendered_before_wait, ui_output.getvalue()) downloader.before_photo(photo) image.save_path = 'mock.jpg' downloader.after_image(image, image.save_path) - rendered_during_download = ui_output.getvalue() - self.assertIn('本子-JM123456', rendered_during_download) - self.assertIn('章节-JM101', rendered_during_download) - self.assertIn('1/2', rendered_during_download) - self.assertNotIn('✓ 本子-JM123456', rendered_during_download) + album_task = next( + task for task in downloader.progress.tasks + if '本子-JM123456' in task.description + ) + chapter_task = next( + task for task in downloader.progress.tasks + if '章节-JM101' in task.description + ) + self.assertEqual(0, album_task.completed) + self.assertEqual(1, chapter_task.completed) + self.assertEqual(2, chapter_task.total) + self.assertFalse(album_task.finished) + self.assertFalse(chapter_task.finished) finally: if downloader is not None: downloader.stop_progress() @@ -629,7 +629,7 @@ def fake_download_image(downloader, image): self.assertIn('检测到命令行参数 --no-progress', rendered) self.assertIn('当前 Option 已配置 download_progress', rendered) self.assertIn('JMComic Logs', rendered) - self.assertIn('album.before', rendered) + self.assertIn('album.after', rendered) self.assertIn('章节-JM101', rendered) self.assertIn('章节-JM102', rendered) self.assertIn('✓ 本子-JM123456', rendered) @@ -707,18 +707,11 @@ async def fake_download_image(downloader, image): self.assertIn('详细日志', rendered) self.assertIn('✓ 下载完成:本子-JM123456,章节 2/2,图片 5/5', rendered) - def test_plugin_is_registered_and_documentation_only_shows_usage(self): - document = DOCUMENT_FILE.read_text(encoding='utf-8') + def test_plugin_is_registered(self): self.assertIs( DownloadProgressPlugin, JmModuleConfig.REGISTRY_PLUGIN['download_progress'], ) - self.assertIn('plugin: download_progress', document) - self.assertIn('download_album_async', document) - self.assertNotIn('class ProgressDownloader', document) - self.assertNotIn('class DownloadProgressPlugin', document) - self.assertIn('class ProgressDownloader', PLUGIN_FILE.read_text(encoding='utf-8')) - self.assertNotIn('class ProgressDownloader', DOWNLOADER_FILE.read_text(encoding='utf-8')) if __name__ == '__main__': diff --git a/tests/test_jmcomic/test_jm_exception.py b/tests/test_jmcomic/test_jm_exception.py index cc4450a1f..5273bdba2 100644 --- a/tests/test_jmcomic/test_jm_exception.py +++ b/tests/test_jmcomic/test_jm_exception.py @@ -4,6 +4,50 @@ class Test_RequestRetryAllFailException(unittest.TestCase): + def test_require_true_keeps_replaced_exception_executor_compatible(self): + class IsolatedExceptionTool(ExceptionTool): + pass + + seen = [] + IsolatedExceptionTool.replace_old_exception_executor( + lambda old, msg, context: seen.append((old, msg, context)) + ) + + IsolatedExceptionTool.require_true(False, 'compatibility probe') + + self.assertEqual(len(seen), 1) + self.assertEqual(seen[0][1:], ('compatibility probe', {})) + + def test_replaced_exception_executor_preserves_requested_type(self): + class IsolatedExceptionTool(ExceptionTool): + pass + + IsolatedExceptionTool.replace_old_exception_executor( + lambda old, msg, context: old(msg=msg, context=context) + ) + + with self.assertRaises(DownloadCancelledException) as caught: + IsolatedExceptionTool.require_true( + False, 'cancel probe', etype=DownloadCancelledException, + ) + self.assertEqual(caught.exception.reason, 'cancel probe') + + def test_replaced_exception_executor_can_override_requested_type(self): + class IsolatedExceptionTool(ExceptionTool): + pass + + context = {'detail': 'override probe'} + IsolatedExceptionTool.replace_old_exception_executor( + lambda old, msg, context: old(msg, context, JmcomicException) + ) + + with self.assertRaises(JmcomicException) as caught: + IsolatedExceptionTool.raises( + 'override probe', context, DownloadCancelledException, + ) + self.assertIs(type(caught.exception), JmcomicException) + self.assertEqual(caught.exception.context, context) + def test_sync_client_collects_each_failed_request(self): client = object.__new__(AbstractJmClient) client.domain_list = ['api-one.example', 'api-two.example'] diff --git a/tests/test_jmcomic/test_jm_release.py b/tests/test_jmcomic/test_jm_release.py index 500f8d522..b46b2b678 100644 --- a/tests/test_jmcomic/test_jm_release.py +++ b/tests/test_jmcomic/test_jm_release.py @@ -114,45 +114,14 @@ def test_release_entry_count_uses_changelog_bullets(self): self.assertEqual(release.count_release_entries(body), 3) - def test_workflow_keeps_master_v_prefix_trigger_without_generated_notes(self): - workflow = (PROJECT_ROOT / ".github" / "workflows" / "release_auto.yml").read_text(encoding="utf-8") - - self.assertIn("branches:\n - master", workflow) - self.assertIn("startsWith(github.event.head_commit.message, 'v')", workflow) - self.assertIn('python .github/release.py "$commit_message"', workflow) - self.assertNotIn("generate_release_notes:", workflow) - - def test_manual_workflow_reads_source_version_from_master(self): - workflow = (PROJECT_ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") - - self.assertIn("workflow_dispatch:", workflow) - self.assertNotIn("types: [ published ]", workflow) - self.assertIn("github.ref_name == 'master'", workflow) - self.assertIn("python .github/release.py\n", workflow) - self.assertIn("softprops/action-gh-release@v2", workflow) - self.assertIn("pypa/gh-action-pypi-publish@release/v1", workflow) - - def test_release_workflows_build_before_creating_release(self): - for filename in ("release.yml", "release_auto.yml"): - with self.subTest(filename=filename): - workflow = (PROJECT_ROOT / ".github" / "workflows" / filename).read_text(encoding="utf-8") - - self.assertLess(workflow.index("- name: Build\n"), workflow.index("- name: Create Release\n")) - - def test_test_workflows_watch_development_requirements(self): - for filename in ("test_api.yml", "test_html.yml"): - with self.subTest(filename=filename): - workflow = (PROJECT_ROOT / ".github" / "workflows" / filename).read_text(encoding="utf-8") - - self.assertIn(" - '.github/requirements-dev.txt'", workflow) - - def test_contributing_allows_only_formal_release_prs_to_master(self): - contributing = (PROJECT_ROOT / ".github" / "CONTRIBUTING.md").read_text(encoding="utf-8") - - self.assertIn("普通 PR 禁止直飞 master", contributing) - self.assertIn("发版专线 (仅限版本发布)", contributing) - self.assertIn("任意一项缺失,都不得指向或合并到 `master`", contributing) - self.assertNotIn("本项目不接受任何直接指向 `master` 分支的 PR", contributing) + def test_python_39_remains_install_compatible(self): + pyproject = (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") + setup_py = (PROJECT_ROOT / "setup.py").read_text(encoding="utf-8") + + self.assertIn('requires-python = ">=3.9"', pyproject) + self.assertIn('python_requires=">=3.9"', setup_py) + self.assertIn("Programming Language :: Python :: 3.9", pyproject) + self.assertIn("Programming Language :: Python :: 3.9", setup_py) if __name__ == "__main__": diff --git a/tests/test_jmcomic/test_jm_runtime.py b/tests/test_jmcomic/test_jm_runtime.py new file mode 100644 index 000000000..6078571c4 --- /dev/null +++ b/tests/test_jmcomic/test_jm_runtime.py @@ -0,0 +1,281 @@ +import asyncio +import threading +import time +import unittest +from concurrent.futures import ThreadPoolExecutor + +from jmcomic import ( + DownloadCancelledException, + JmAsyncRuntime, + JmTempRuntime, + JmSyncRuntime, + download_batch, + download_batch_async, + JTC, + jm_task_context, +) + + +class Test_Jm_Runtime(unittest.TestCase): + + def test_launcher_expands_arguments_and_returns_done_futures(self): + runtime = JmSyncRuntime(album_workers=2) + try: + positional = runtime.multi_thread_launcher( + [(1, 2), (3, 4)], + lambda left, right: left + right, + level='album', + ) + keyword = runtime.multi_thread_launcher( + [{'left': 5, 'right': 6}], + lambda left, right: left + right, + level='album', + ) + + self.assertEqual([3, 7], [future.result() for future in positional]) + self.assertEqual([11], [future.result() for future in keyword]) + self.assertTrue(all(future.done() for future in positional + keyword)) + finally: + runtime.close() + + def test_launcher_can_return_without_waiting(self): + started = threading.Event() + release = threading.Event() + runtime = JmSyncRuntime(album_workers=1) + + def blocked(_item): + started.set() + release.wait(timeout=2) + + try: + futures = runtime.multi_thread_launcher( + [1], + blocked, + False, + level='album', + ) + self.assertTrue(started.wait(timeout=1)) + self.assertFalse(futures[0].done()) + finally: + release.set() + runtime.close() + + def test_launcher_waits_for_all_tasks_and_keeps_worker_error_in_future(self): + sibling_started = threading.Event() + release_sibling = threading.Event() + launcher_done = threading.Event() + futures = [] + runtime = JmSyncRuntime(album_workers=2) + + def work(item): + if item == 'error': + raise ValueError('worker failed') + sibling_started.set() + release_sibling.wait(timeout=2) + + def launch(): + try: + futures.extend(runtime.multi_thread_launcher( + ['error', 'sibling'], + work, + level='album', + )) + finally: + launcher_done.set() + + thread = threading.Thread(target=launch) + thread.start() + try: + self.assertTrue(sibling_started.wait(timeout=1)) + self.assertFalse(launcher_done.is_set()) + release_sibling.set() + self.assertTrue(launcher_done.wait(timeout=1)) + self.assertEqual(2, len(futures)) + with self.assertRaisesRegex(ValueError, 'worker failed'): + futures[0].result() + self.assertIsNone(futures[1].result()) + finally: + release_sibling.set() + thread.join(timeout=1) + runtime.close() + + def test_launcher_waits_for_submitted_tasks_when_submit_fails(self): + first_started = threading.Event() + release_first = threading.Event() + launcher_done = threading.Event() + raised = [] + + class FailSecondSubmitExecutor(ThreadPoolExecutor): + + def __init__(self): + super().__init__(max_workers=1) + self.submit_count = 0 + + def submit(self, fn, /, *args, **kwargs): + self.submit_count += 1 + if self.submit_count == 2: + raise RuntimeError('submit failed') + return super().submit(fn, *args, **kwargs) + + executor = FailSecondSubmitExecutor() + runtime = JmSyncRuntime(album_executor=executor) + + def blocked(_item): + first_started.set() + release_first.wait(timeout=2) + + def launch(): + try: + runtime.multi_thread_launcher( + [1, 2], + blocked, + level='album', + ) + except BaseException as error: + raised.append(error) + finally: + launcher_done.set() + + thread = threading.Thread(target=launch) + thread.start() + try: + self.assertTrue(first_started.wait(timeout=1)) + self.assertFalse(launcher_done.is_set()) + release_first.set() + self.assertTrue(launcher_done.wait(timeout=1)) + self.assertEqual(1, len(raised)) + self.assertIsInstance(raised[0], RuntimeError) + self.assertEqual('submit failed', str(raised[0])) + finally: + release_first.set() + thread.join(timeout=1) + runtime.close() + executor.shutdown(wait=True) + + def test_runtime_does_not_close_external_executor(self): + executor = ThreadPoolExecutor(max_workers=1) + try: + runtime = JmSyncRuntime(album_executor=executor) + futures = runtime.multi_thread_launcher( + [1, 2], + lambda item: item * 2, + level='album', + ) + self.assertEqual([2, 4], [future.result() for future in futures]) + + runtime.close() + self.assertEqual(9, executor.submit(lambda: 9).result(timeout=1)) + finally: + executor.shutdown(wait=True) + + def test_temp_runtime_uses_one_executor_without_level(self): + executor = ThreadPoolExecutor(max_workers=1) + try: + runtime = JmTempRuntime(executor=executor) + futures = runtime.multi_thread_launcher( + [1, 2], + lambda item: item * 2, + ) + self.assertEqual([2, 4], [future.result() for future in futures]) + + runtime.close() + self.assertEqual(9, executor.submit(lambda: 9).result(timeout=1)) + finally: + executor.shutdown(wait=True) + + def test_sync_batch_does_not_create_or_inject_runtime_by_default(self): + observed = [] + + def download_one(jmid, *_args, **_kwargs): + observed.append(JTC.get_runtime()) + return jmid + + self.assertEqual( + {'1'}, + set(download_batch(download_one, ['1'], option=object())), + ) + self.assertEqual(1, len(observed)) + self.assertIsNone(observed[0]) + + def test_async_batch_does_not_create_or_inject_runtime_by_default(self): + observed = [] + + async def download_one(jmid, *_args, **_kwargs): + observed.append(JTC.get_runtime()) + return jmid + + result = asyncio.run(download_batch_async( + download_one, + ['1'], + option=object(), + )) + + self.assertEqual({'1'}, set(result)) + self.assertEqual(1, len(observed)) + self.assertIsNone(observed[0]) + + def test_sync_batch_collects_failure_after_siblings_finish(self): + completed = [] + + def download_one(jmid, *_args, **_kwargs): + if jmid == '404': + raise ValueError('missing') + time.sleep(0.02) + completed.append(jmid) + return jmid + + result = download_batch( + download_one, + ['200', '404'], + option=object(), + ) + + self.assertEqual({'200'}, set(result)) + self.assertEqual(['200'], completed) + self.assertIsInstance(result.failed['404'], ValueError) + + def test_sync_batch_waits_for_siblings_before_raising_cancellation(self): + barrier = threading.Barrier(2) + completed = [] + + def download_one(jmid, *_args, **_kwargs): + barrier.wait(timeout=1) + if jmid == '1': + raise DownloadCancelledException('stop') + time.sleep(0.02) + completed.append(jmid) + return jmid + + with ThreadPoolExecutor(max_workers=2) as executor: + runtime = JmSyncRuntime(album_executor=executor) + with jm_task_context(runtime=runtime): + with self.assertRaisesRegex(DownloadCancelledException, 'stop'): + download_batch( + download_one, + ['1', '2'], + option=object(), + ) + + self.assertEqual(['2'], completed) + self.assertEqual(9, executor.submit(lambda: 9).result(timeout=1)) + + def test_sync_runtime_supports_album_naming_and_rejects_shared_executor_with_deadlock_hint(self): + runtime = JmSyncRuntime(album_workers=3, photo_workers=2, image_workers=10) + try: + self.assertEqual(3, runtime._workers['album']) + self.assertEqual(2, runtime._workers['photo']) + self.assertEqual(10, runtime._workers['image']) + self.assertEqual(3, runtime.executor('album')._max_workers) + # 严格拒绝过时的 'id' 层 + with self.assertRaises(ValueError): + runtime.executor('id') + finally: + runtime.close() + + with ThreadPoolExecutor(max_workers=2) as shared_executor: + with self.assertRaisesRegex(ValueError, 'starvation deadlock'): + JmSyncRuntime(photo_executor=shared_executor, image_executor=shared_executor) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_jmcomic/test_jm_task_context.py b/tests/test_jmcomic/test_jm_task_context.py index 89d585958..1d25a5a45 100644 --- a/tests/test_jmcomic/test_jm_task_context.py +++ b/tests/test_jmcomic/test_jm_task_context.py @@ -1,6 +1,7 @@ import asyncio import logging import threading +import time import unittest from concurrent.futures import ThreadPoolExecutor from queue import Queue @@ -8,8 +9,13 @@ from jmcomic import ( BaseDownloader, + BatchResult, + JmAsyncRuntime, DownloadManifest, + DownloadControl, + DownloadCancelledException, Feature, + JTC, JmAsyncDownloader, JmDownloader, JmModuleConfig, @@ -18,13 +24,14 @@ JM_TASK_CONTEXT, PhotoConcurrentFetcherProxy, PrettyFormatter, + JmTempRuntime, + JmSyncRuntime, bind_jm_task_context, default_jm_logging, download_album, download_batch, download_batch_async, download_photo_async, - get_jm_task_context, jm_log, jm_task_context, jm_logger, @@ -41,39 +48,234 @@ def emit(self, record): self.records.append(record) +class ExecutorProbeDownloader(JmDownloader): + + def __init__(self): + BaseDownloader.__init__(self, object()) + self.work = lambda _item: None + + def download_by_image_detail(self, image): + self.work(image) + + +class PhotoDetails(list): + + @staticmethod + def is_album(): + return False + + class Test_Jm_Task_Context(unittest.TestCase): + def test_context_exposes_runtime_and_borrows_executor(self): + with ThreadPoolExecutor(max_workers=1) as executor: + runtime = JmSyncRuntime(album_executor=executor) + with jm_task_context(task_id='job-42', runtime=runtime): + self.assertEqual( + JTC.get_context(), + {'task_id': 'job-42', 'runtime': runtime}, + ) + raw_context = JM_TASK_CONTEXT.get() + self.assertEqual(raw_context['task_id'], 'job-42') + self.assertIs(raw_context['runtime'], runtime) + with self.assertRaises(TypeError): + raw_context['mutable'] = True + self.assertIs(JTC.get_runtime(), runtime) + with jm_task_context(jm_id='123'): + self.assertEqual( + JTC.get_context(), + { + 'task_id': 'job-42', + 'runtime': runtime, + 'jm_id': '123', + }, + ) + + public_bound = executor.submit(bind_jm_task_context( + lambda: ( + JTC.get_context(), + JTC.get_runtime(), + ) + )) + self.assertEqual( + public_bound.result(timeout=1), + ({'task_id': 'job-42', 'runtime': runtime}, runtime), + ) + + self.assertEqual(executor.submit(lambda: 7).result(timeout=1), 7) + + def test_option_is_public_and_propagated(self): + option = object() + with ThreadPoolExecutor(max_workers=1) as executor: + with jm_task_context(option=option, task_id='job-42'): + self.assertEqual( + JTC.get_context(), + {'task_id': 'job-42', 'option': option}, + ) + self.assertIs(JTC.get_option(), option) + future = executor.submit(bind_jm_task_context(JTC.get_option)) + + self.assertIs(future.result(timeout=1), option) + + self.assertIsNone(JTC.get_option()) + + def test_none_option_runtime_and_nested_runtime_rules(self): + with jm_task_context(option=None, runtime=None, task_id='job-42'): + self.assertEqual(JTC.get_context(), {'task_id': 'job-42'}) + self.assertIsNone(JTC.get_option()) + self.assertIsNone(JTC.get_runtime()) + + outer = JmSyncRuntime(album_workers=1) + inner = JmSyncRuntime(album_workers=1) + try: + with jm_task_context(runtime=outer): + with jm_task_context(runtime=outer): + self.assertIs(JTC.get_runtime(), outer) + with self.assertRaisesRegex(RuntimeError, 'already active'): + with jm_task_context(runtime=inner): + self.fail('nested runtime must be rejected') + finally: + outer.close() + inner.close() + + runtime = JmSyncRuntime(album_workers=1) + with jm_task_context(runtime=runtime): + pass + futures = runtime.multi_thread_launcher( + [1], + lambda item: item, + level='album', + ) + self.assertEqual(1, futures[0].result()) + runtime.close() + with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'): + runtime.multi_thread_launcher( + [1], + lambda item: item, + level='album', + ) + + def test_runtime_configuration_is_explicit_and_validated(self): + with self.assertRaisesRegex(TypeError, 'runtime must be'): + with jm_task_context(runtime='metadata'): + self.fail('runtime must be a Runtime instance') + + with ThreadPoolExecutor(max_workers=1) as executor: + with self.assertRaisesRegex(ValueError, 'mutually exclusive'): + JmSyncRuntime(album_workers=1, album_executor=executor) + with self.assertRaisesRegex(ValueError, 'different executor'): + JmSyncRuntime(album_executor=executor, photo_executor=executor) + with self.assertRaisesRegex(ValueError, 'mutually exclusive'): + JmAsyncRuntime(decode_workers=1, decode_executor=executor) + with self.assertRaisesRegex(ValueError, 'mutually exclusive'): + JmTempRuntime(workers=1, executor=executor) + + with self.assertRaisesRegex(ValueError, 'positive integer'): + JmSyncRuntime(photo_workers=0) + with self.assertRaisesRegex(ValueError, 'positive integer'): + JmAsyncRuntime(decode_workers=True) + with self.assertRaisesRegex(ValueError, 'positive integer'): + JmTempRuntime(workers=0) + + def test_sync_runtime_reuses_one_album_pool_across_batches(self): + worker_threads = [] + + def fake_download(jmid, _option, _downloader, **_kwargs): + worker_threads.append(threading.get_ident()) + return str(jmid) + + runtime = JmSyncRuntime(album_workers=1, photo_workers=1, image_workers=1) + try: + with jm_task_context(runtime=runtime): + self.assertEqual( + {'1', '2'}, + set(download_batch(fake_download, ['1', '2'], option=object())), + ) + self.assertEqual( + {'3'}, + set(download_batch(fake_download, ['3'], option=object())), + ) + + self.assertEqual(1, len(set(worker_threads))) + finally: + runtime.close() + + def test_sync_runtime_accepts_call_site_worker_defaults(self): + with ThreadPoolExecutor(max_workers=1) as album_executor: + runtime = JmSyncRuntime(album_executor=album_executor) + try: + with jm_task_context(runtime=runtime): + photo_futures = runtime.multi_thread_launcher( + [1, 2], + lambda item: item * 2, + level='photo', + default_workers=2, + ) + self.assertEqual( + [future.result() for future in photo_futures], + [2, 4], + ) + image_futures = runtime.multi_thread_launcher( + [1], + lambda item: item + 1, + level='image', + default_workers=4, + ) + self.assertEqual( + [future.result() for future in image_futures], + [2], + ) + finally: + runtime.close() + + self.assertEqual(album_executor.submit(lambda: 5).result(timeout=1), 5) + + def test_download_control_is_propagated_by_public_binder(self): + control = DownloadControl() + + with ThreadPoolExecutor(max_workers=1) as executor: + with jm_task_context(control=control): + future = executor.submit(bind_jm_task_context(JTC.get_control)) + + self.assertIs(future.result(timeout=1), control) + self.assertIsNone(executor.submit(JTC.get_control).result(timeout=1)) + + self.assertTrue(control.cancel('user requested')) + self.assertFalse(control.cancel('ignored')) + self.assertTrue(control.is_cancelled) + self.assertEqual(control.reason, 'user requested') + def test_public_context_var_names_log_record_field(self): self.assertEqual('jm_task_context', JM_TASK_CONTEXT.name) def test_nested_context_restores_on_normal_and_exception_exit(self): - self.assertEqual({}, get_jm_task_context()) + self.assertEqual({}, JTC.get_context()) with jm_task_context(session_id='outer'): - self.assertEqual({'session_id': 'outer'}, get_jm_task_context()) + self.assertEqual({'session_id': 'outer'}, JTC.get_context()) with jm_task_context(session_id='inner', task_id='task'): self.assertEqual( {'session_id': 'inner', 'task_id': 'task'}, - get_jm_task_context(), + JTC.get_context(), ) - self.assertEqual({'session_id': 'outer'}, get_jm_task_context()) + self.assertEqual({'session_id': 'outer'}, JTC.get_context()) with self.assertRaisesRegex(RuntimeError, 'stop'): with jm_task_context(task_id='failed'): raise RuntimeError('stop') - self.assertEqual({'session_id': 'outer'}, get_jm_task_context()) + self.assertEqual({'session_id': 'outer'}, JTC.get_context()) - self.assertEqual({}, get_jm_task_context()) + self.assertEqual({}, JTC.get_context()) def test_bound_context_survives_thread_pool_and_does_not_leak(self): with ThreadPoolExecutor(max_workers=1) as executor: with jm_task_context(session_id='A'): - future_a = executor.submit(bind_jm_task_context(get_jm_task_context)) + future_a = executor.submit(bind_jm_task_context(JTC.get_context)) with jm_task_context(session_id='B'): - future_b = executor.submit(bind_jm_task_context(get_jm_task_context)) + future_b = executor.submit(bind_jm_task_context(JTC.get_context)) - empty = executor.submit(get_jm_task_context) + empty = executor.submit(JTC.get_context) self.assertEqual({'session_id': 'A'}, future_a.result()) self.assertEqual({'session_id': 'B'}, future_b.result()) @@ -104,7 +306,7 @@ def test_default_logger_and_custom_executors_can_read_context(self): captured = [] def executor_two_args(topic, msg): - captured.append((topic, msg, get_jm_task_context())) + captured.append((topic, msg, JTC.get_context())) JmModuleConfig.EXECUTOR_LOG = executor_two_args with jm_task_context(session_id='custom-2'): @@ -113,7 +315,7 @@ def executor_two_args(topic, msg): error = ValueError('failed') def executor_three_args(topic, msg, e): - captured.append((topic, msg, e, get_jm_task_context())) + captured.append((topic, msg, e, JTC.get_context())) JmModuleConfig.EXECUTOR_LOG = executor_three_args with jm_task_context(session_id='custom-3'): @@ -267,10 +469,14 @@ async def download_photo(self, _photo_id): ) self.assertIsInstance(album_context.get('task_started_at'), float) self.assertIsInstance(photo_context.get('task_started_at'), float) + self.assertNotIn('runtime', album_context) + self.assertNotIn('runtime', photo_context) def test_sync_result_duration_uses_task_context_and_finishes_after_downloader_exit(self): clock = {'now': 10.0} observed_contexts = [] + observed_options = [] + option = object() class Detail: duration = 99.0 @@ -291,19 +497,22 @@ def download_album(self, _album_id): detail = Detail() self.begin_manifest(detail) try: - observed_contexts.append(get_jm_task_context()) + observed_contexts.append(JTC.get_context()) + observed_options.append(JTC.get_option()) finally: self.finish_manifest(detail) return detail with patch('jmcomic.api.perf_counter', side_effect=lambda: clock['now'], create=True): - result = download_album('123', option=object(), downloader=FakeDownloader) + result = download_album('123', option=option, downloader=FakeDownloader) self.assertEqual(10.0, observed_contexts[0].get('task_started_at')) self.assertEqual(10.0, result.manifest.duration) self.assertEqual(10.0, result.duration) self.assertEqual(99.0, result.detail.duration) - self.assertEqual({}, get_jm_task_context()) + self.assertEqual([option], observed_options) + self.assertEqual({}, JTC.get_context()) + self.assertIsNone(JTC.get_option()) def test_async_result_duration_uses_task_context_and_finishes_after_downloader_exit(self): async def run_test(): @@ -321,7 +530,7 @@ def is_album(): class FakeDownloader(BaseDownloader): async def __aenter__(self): - observed_contexts.append(get_jm_task_context()) + observed_contexts.append(JTC.get_context()) return self async def __aexit__(self, *_args): @@ -344,7 +553,7 @@ async def download_photo(self, _photo_id): self.assertEqual(30.0, result.manifest.duration) self.assertEqual(30.0, result.duration) self.assertEqual(99.0, result.detail.duration) - self.assertEqual({}, get_jm_task_context()) + self.assertEqual({}, JTC.get_context()) def test_plugin_invocation_can_read_current_task_context(self): observed = [] @@ -440,7 +649,7 @@ def test_plugin_feature_uses_download_type_context(self): def test_sync_batch_binds_parent_and_item_context(self): def fake_download(jmid, _option, _downloader, **_kwargs): - context = get_jm_task_context() + context = JTC.get_context() return ( str(jmid), context['session_id'], @@ -453,19 +662,19 @@ def fake_download(jmid, _option, _downloader, **_kwargs): self.assertEqual( { - ('1', 'sync-batch', 'fake_download', '1'), - ('2', 'sync-batch', 'fake_download', '2'), + ('1', 'sync-batch', 'batch', '1'), + ('2', 'sync-batch', 'batch', '2'), }, set(result), ) - self.assertEqual({}, get_jm_task_context()) + self.assertEqual({}, JTC.get_context()) def test_concurrent_sync_sessions_do_not_cross(self): barrier = threading.Barrier(2) def fake_download(jmid, _option, _downloader, **_kwargs): barrier.wait(timeout=2) - context = get_jm_task_context() + context = JTC.get_context() return context['session_id'], context['jm_id'], str(jmid) def run_session(session_id, jmid): @@ -497,9 +706,10 @@ def test_batch_failure_log_keeps_item_context(self): def fail(jmid, _option, _downloader, **_kwargs): raise ValueError(f'failed-{jmid}') + option = object() try: with jm_task_context(session_id='failed-session'): - result = download_batch(fail, ['404'], option=object()) + result = download_batch(fail, ['404'], option=option) finally: jm_logger.handlers[:] = original_handlers @@ -510,42 +720,58 @@ def fail(jmid, _option, _downloader, **_kwargs): if getattr(record, 'topic', None) == 'batch.failed' ] self.assertEqual(1, len(batch_failure_records)) + log_context = batch_failure_records[0].jm_task_context self.assertEqual( { 'session_id': 'failed-session', - 'download_type': 'fail', + 'download_type': 'batch', 'jm_id': '404', }, - batch_failure_records[0].jm_task_context, + { + key: log_context[key] + for key in ('session_id', 'download_type', 'jm_id') + }, ) + self.assertIs(log_context['option'], option) + self.assertNotIn('runtime', log_context) - def test_sync_downloader_propagates_both_threading_branches(self): + def test_sync_downloader_uses_and_closes_temporary_temp_runtime(self): downloader = object.__new__(JmDownloader) BaseDownloader.__init__(downloader, object()) + created = [] + + class ProbeRuntime(JmTempRuntime): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + created.append(self) for count_batch in (3, 1): observed = Queue() - with jm_task_context(session_id=f'workers-{count_batch}'): - downloader.execute_on_condition( - iter_objs=[1, 2, 3], - apply=lambda _item: observed.put(get_jm_task_context()), - count_batch=count_batch, - ) + with patch('jmcomic.jm_downloader.JmTempRuntime', ProbeRuntime): + with jm_task_context(session_id=f'workers-{count_batch}'): + downloader.execute_on_condition( + iter_objs=[1, 2, 3], + apply=lambda _item: observed.put(JTC.get_context()), + count_batch=count_batch, + ) contexts = [observed.get_nowait() for _ in range(3)] self.assertEqual( - [{ - 'session_id': f'workers-{count_batch}', - }] * 3, - contexts, + [f'workers-{count_batch}'] * 3, + [context['session_id'] for context in contexts], ) + self.assertTrue(all('runtime' not in context for context in contexts)) + self.assertTrue(all('option' not in context for context in contexts)) + with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'): + created[-1].executor() def test_async_batch_and_decode_pool_propagate_context(self): async def run_test(): async def fake_download(jmid, _option, _downloader, **_kwargs): await asyncio.sleep(0) - context = get_jm_task_context() + context = JTC.get_context() return ( str(jmid), context['session_id'], @@ -560,21 +786,23 @@ async def fake_download(jmid, _option, _downloader, **_kwargs): option=object(), ) - downloader = object.__new__(JmAsyncDownloader) - downloader._decode_pool = ThreadPoolExecutor(max_workers=1) - try: + with ThreadPoolExecutor(max_workers=1) as decode_executor: + runtime = JmAsyncRuntime(decode_executor=decode_executor) with jm_task_context(session_id='decode-pool'): - executor_context = await downloader._run_in_decode_pool( - get_jm_task_context - ) + with jm_task_context(runtime=runtime): + future = runtime.executor( + 'decode', + 1, + ).submit( + bind_jm_task_context(JTC.get_context), + ) + executor_context = await asyncio.wrap_future(future) loop = asyncio.get_running_loop() leaked_context = await loop.run_in_executor( - downloader._decode_pool, - get_jm_task_context, + decode_executor, + JTC.get_context, ) - finally: - downloader.shutdown() return batch_result, executor_context, leaked_context @@ -582,12 +810,13 @@ async def fake_download(jmid, _option, _downloader, **_kwargs): self.assertEqual( { - ('1', 'async-batch', 'fake_download', '1'), - ('2', 'async-batch', 'fake_download', '2'), + ('1', 'async-batch', 'batch', '1'), + ('2', 'async-batch', 'batch', '2'), }, set(batch_result), ) - self.assertEqual({'session_id': 'decode-pool'}, executor_context) + self.assertEqual('decode-pool', executor_context['session_id']) + self.assertIsInstance(executor_context['runtime'], JmAsyncRuntime) self.assertEqual({}, leaked_context) def test_photo_concurrent_proxy_propagates_context(self): @@ -597,12 +826,308 @@ class FakeClient: proxy = PhotoConcurrentFetcherProxy(FakeClient(), max_workers=1) try: with jm_task_context(session_id='client-proxy'): - future = proxy.get_future('context', get_jm_task_context) + future = proxy.get_future('context', JTC.get_context) self.assertEqual({'session_id': 'client-proxy'}, future.result()) finally: proxy.executors.shutdown(wait=True) + def test_sync_runtime_executor_capacity_controls_image_concurrency(self): + downloader = ExecutorProbeDownloader() + lock = threading.Lock() + active = 0 + maximum = 0 + + def limited_work(_item): + nonlocal active, maximum + with lock: + active += 1 + maximum = max(maximum, active) + time.sleep(0.02) + with lock: + active -= 1 + + downloader.work = limited_work + downloader.do_filter = lambda details: list(details) + with ThreadPoolExecutor(max_workers=2) as executor: + runtime = JmSyncRuntime(image_executor=executor) + with jm_task_context(runtime=runtime): + downloader.execute_on_condition( + PhotoDetails(range(8)), + downloader.download_by_image_detail, + count_batch=8, + level='image', + ) + runtime.close() + self.assertEqual(executor.submit(lambda: 1).result(timeout=1), 1) + + self.assertEqual(maximum, 2) + + def test_async_runtime_borrows_shared_executor(self): + async def scenario(): + with ThreadPoolExecutor(max_workers=1) as executor: + runtime = JmAsyncRuntime(decode_executor=executor) + with jm_task_context(session_id='async-runtime', runtime=runtime): + future = runtime.executor( + 'decode', + 1, + ).submit( + bind_jm_task_context( + lambda: (8, JTC.get_context()['session_id']) + ), + ) + value = await asyncio.wrap_future(future) + + self.assertEqual(value, (8, 'async-runtime')) + self.assertEqual(executor.submit(lambda: 9).result(timeout=1), 9) + + asyncio.run(scenario()) + + def test_jtc_facade_getters(self): + # 根作用域无状态 + self.assertIsNone(JTC.get_runtime()) + self.assertIsNone(JTC.get_option()) + self.assertIsNone(JTC.get_control()) + self.assertEqual(JTC.get_context(), {}) + + runtime = JmSyncRuntime() + option = object() + control = DownloadControl() + + try: + with jm_task_context(runtime=runtime, option=option, control=control, custom_field='val'): + self.assertIs(JTC.get_runtime(), runtime) + self.assertIs(JTC.get_option(), option) + self.assertIs(JTC.get_control(), control) + ctx = JTC.get_context() + self.assertIs(ctx['runtime'], runtime) + self.assertIs(ctx['option'], option) + self.assertIs(ctx['control'], control) + self.assertEqual(ctx['custom_field'], 'val') + finally: + runtime.close() + + # 退出作用域后恢复 + self.assertIsNone(JTC.get_runtime()) + self.assertIsNone(JTC.get_option()) + self.assertIsNone(JTC.get_control()) + + def test_runtime_auto_bind_context_and_close_rules(self): + # 1. 验证 JmSyncRuntime 自动绑定上下文并在退出后关闭自建池 + self.assertIsNone(JTC.get_runtime()) + with JmSyncRuntime(album_workers=2) as runtime: + self.assertIs(JTC.get_runtime(), runtime) + self.assertEqual(JTC.get_context()['runtime'], runtime) + # 内部嵌套 jm_task_context(runtime=runtime) 不报错 + with jm_task_context(runtime=runtime): + self.assertIs(JTC.get_runtime(), runtime) + + # 退出后已解绑且 runtime 已关闭 + self.assertIsNone(JTC.get_runtime()) + self.assertTrue(runtime._closed) + + # 2. 验证外部 executor 不会被 close 关闭(谁创建谁关闭原则) + external_pool = ThreadPoolExecutor(max_workers=1) + try: + with JmSyncRuntime(album_executor=external_pool) as runtime: + self.assertIs(JTC.get_runtime(), runtime) + self.assertIsNone(JTC.get_runtime()) + # 外部池依然可用 + fut = external_pool.submit(lambda: 42) + self.assertEqual(fut.result(timeout=1), 42) + finally: + external_pool.shutdown(wait=True) + + # 3. 验证 JmTempRuntime 不会自动绑定上下文 + with JmTempRuntime(workers=2) as temp_runtime: + self.assertIsNone(JTC.get_runtime()) + self.assertTrue(temp_runtime._closed) + + # 4. 验证 JmAsyncRuntime 自动绑定 + async def check_async(): + with JmAsyncRuntime(decode_workers=2) as async_runtime: + self.assertIs(JTC.get_runtime(), async_runtime) + self.assertIsNone(JTC.get_runtime()) + self.assertTrue(async_runtime._closed) + + asyncio.run(check_async()) + + def test_api_control_param_sugar_sync_and_async(self): + # 1. 非 DownloadControl 参数抛出 TypeError + with self.assertRaises(TypeError): + download_album(123456, control="invalid_control") + + # 2. 验证 control 参数成功注入上下文 + control = DownloadControl() + captured_control = [] + + class ProbeDownloader: + def __init__(self, _option): + self.manifest_dict = {} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def add_features(self, _extra): + pass + + def raise_if_has_exception(self): + pass + + def download_album(self, album_id): + captured_control.append(JTC.get_control()) + detail = object() + self.manifest_dict[detail] = DownloadManifest() + return detail + + # 单本调用 + download_album(123456, option=object(), downloader=ProbeDownloader, control=control, check_exception=False) + self.assertEqual(len(captured_control), 1) + self.assertIs(captured_control[0], control) + + # 批量调用 + captured_control.clear() + download_album([123, 456], option=object(), downloader=ProbeDownloader, control=control, check_exception=False) + self.assertEqual(len(captured_control), 2) + self.assertIs(captured_control[0], control) + self.assertIs(captured_control[1], control) + + def test_task_context_named_control_and_batch_order_preservation(self): + control = DownloadControl() + + # 1. 验证 jm_task_context 具名参数 control + with jm_task_context(control=control): + self.assertIs(JTC.get_control(), control) + + # 2. 验证非 DownloadControl 抛出 TypeError + with self.assertRaises(TypeError): + with jm_task_context(control="invalid"): + pass + + # 3. 验证 download_batch 保序去重 + called_ids = [] + + def dummy_download(aid, *args, **kwargs): + called_ids.append(aid) + return aid + + input_ids = [456, 123, 456, 789, 123] + download_batch(dummy_download, input_ids) + self.assertEqual(called_ids, ['456', '123', '789']) + + def test_async_downloader_retains_client_instance_after_exit(self): + class DummyClient: + def __init__(self): + self.closed = False + + async def setup(self): + pass + + async def close(self): + self.closed = True + + class DummyOption: + download = type('Download', (), {'threading': type('Threading', (), {'image': 1, 'photo': 1})()})() + plugins = {} + + def new_jm_async_client(self, **kwargs): + return DummyClient() + + async def check(): + dler = JmAsyncDownloader(DummyOption()) + async with dler: + self.assertIsNotNone(dler.client) + self.assertFalse(dler.client.closed) + # 退出后 client 实例仍保留,但已执行 close + self.assertIsNotNone(dler.client) + self.assertTrue(dler.client.closed) + + asyncio.run(check()) + + def test_batch_result_contract_and_order(self): + self.assertFalse(issubclass(BatchResult, set)) + + result = BatchResult() + self.assertEqual(len(result), 0) + self.assertEqual(result.total, 0) + self.assertTrue(result.all_succeeded) + + result.add(('album1', 'dler1')) + result.add(('album2', 'dler2')) + result.add(('album3', 'dler3')) + + # 验证 FIFO 保序迭代与解包 + items = list(result) + self.assertEqual(items, [('album1', 'dler1'), ('album2', 'dler2'), ('album3', 'dler3')]) + unpacked = [(a, d) for a, d in result] + self.assertEqual(unpacked, items) + + # 验证包含与长度 + self.assertEqual(len(result), 3) + self.assertIn(('album2', 'dler2'), result) + self.assertNotIn(('album4', 'dler4'), result) + + # 验证失败项与统计 + result.failed['999'] = RuntimeError('err') + self.assertFalse(result.all_succeeded) + self.assertEqual(result.total, 4) + + # 验证按车号检索与映射语法糖 (1, 2, 3, 4) + class MockDetail: + def __init__(self, aid): + self.id = aid + + class MockResultItem: + def __init__(self, aid): + self.detail = MockDetail(aid) + + r1 = MockResultItem('111') + r2 = MockResultItem('222') + result.add(r1) + result.add(r2, aid=222) + + self.assertIn('111', result) + self.assertIn(111, result) + self.assertIn('222', result) + self.assertIn(222, result) + self.assertNotIn('333', result) + self.assertIs(result['111'], r1) + self.assertIs(result[111], r1) + self.assertIs(result.get('222'), r2) + self.assertIsNone(result.get('333')) + self.assertEqual(result.get('333', 'default'), 'default') + with self.assertRaises(KeyError): + _ = result['99999'] + + def test_jtc_cancellation_methods(self): + self.assertFalse(JTC.is_cancelled()) + JTC.raise_if_cancelled() # 未取消时无任何异常 + + control = DownloadControl() + with jm_task_context(control=control): + self.assertFalse(JTC.is_cancelled()) + JTC.raise_if_cancelled() + control.cancel('manual cancel') + self.assertTrue(JTC.is_cancelled()) + with self.assertRaises(DownloadCancelledException) as cm: + JTC.raise_if_cancelled() + self.assertIn('manual cancel', str(cm.exception)) + + def test_async_with_jm_task_context(self): + async def run(): + control = DownloadControl() + async with jm_task_context(session_id='async-ctx', control=control): + self.assertEqual(JTC.get_context_value('session_id'), 'async-ctx') + self.assertIs(JTC.get_control(), control) + self.assertIsNone(JTC.get_context_value('session_id')) + self.assertIsNone(JTC.get_control()) + + asyncio.run(run()) + if __name__ == '__main__': unittest.main() + From e662f7246a49eaa31cf2f16f7d5ab58b40328cd8 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:54:58 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CI=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=A4=B1=E8=B4=A5=E5=B9=B6=E6=94=B9=E8=BF=9B=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E8=B0=83=E5=BA=A6=E4=B8=8E=E4=BB=BB=E5=8A=A1=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/sources/tutorial/0_common_usage.md | 42 +++++++++++- .../sources/tutorial/16_shared_executors.md | 16 ++--- src/jmcomic/api.py | 14 ++-- src/jmcomic/jm_async_downloader.py | 6 +- src/jmcomic/jm_downloader.py | 25 +++---- src/jmcomic/jm_plugin.py | 10 +-- src/jmcomic/jm_runtime.py | 65 ++++++++++--------- src/jmcomic/jm_task_context.py | 15 ++--- tests/test_jmcomic/test_jm_async_api.py | 8 +-- tests/test_jmcomic/test_jm_async_custom.py | 33 +++------- tests/test_jmcomic/test_jm_client.py | 2 +- tests/test_jmcomic/test_jm_runtime.py | 42 ++++++++++++ tests/test_jmcomic/test_jm_task_context.py | 54 ++++++++++----- 13 files changed, 217 insertions(+), 115 deletions(-) diff --git a/assets/docs/sources/tutorial/0_common_usage.md b/assets/docs/sources/tutorial/0_common_usage.md index 216273987..2924ec087 100644 --- a/assets/docs/sources/tutorial/0_common_usage.md +++ b/assets/docs/sources/tutorial/0_common_usage.md @@ -586,6 +586,46 @@ my_control.cancel("不想要了,取消掉吧") t.join() ``` +### 进阶:连用取消控制、共享线程池与任务上下文 + +若需同时**控制取消**、**限制并发(共享线程池)**与**绑定任务ID(日志追踪)**,使用 Python 原生复合 `with` 语法即可: + +```python +from threading import Thread +from time import sleep +from jmcomic import ( + download_album, + DownloadCancelledException, + DownloadControl, + JmSyncRuntime, + jm_task_context, +) + +# 1. 创建取消控制器 +control = DownloadControl() + +def run_download(): + try: + # 2. 复合使用三项功能: + # - JmSyncRuntime: 管理并发线程池,退出 with 作用域自动关闭并回收所有线程 + # - jm_task_context: 绑定取消控制器 control 与业务追踪字段 task_id 到当前调用栈 + with JmSyncRuntime(image_workers=10) as rt, jm_task_context(control=control, task_id='task_001'): + download_album(['123456', '789012']) + except DownloadCancelledException as e: + print(f'下载已取消: {e.reason}') + print(f'已完成本子: {[res.album.id for res in e.completed_results]}') + print(f'未完成本子: {e.unfinished_ids}') + +# 在子线程中执行下载任务 +t = Thread(target=run_download) +t.start() + +# 模拟 2 秒后外部触发取消 +sleep(2) +control.cancel("用户主动取消下载") +t.join() +``` + ## 下载返回值 `download_album` 和 `download_photo` 下载完成后,单个 ID 返回 `DownloadResult`,多个 ID 返回 `BatchResult`。 @@ -701,7 +741,7 @@ from jmcomic import download_album, DownloadResult # 同时下载多个本子 batch_result = download_album(['123', '456', '789']) -# BatchResult 继承 set,直接遍历是只遍历成功结果 +# BatchResult 按输入顺序保存成功结果,直接遍历只遍历成功结果;也支持 batch_result['123'] 按车号检索 for result in batch_result: result: DownloadResult # result 的用法同上 diff --git a/assets/docs/sources/tutorial/16_shared_executors.md b/assets/docs/sources/tutorial/16_shared_executors.md index be23eeb63..6d688d771 100644 --- a/assets/docs/sources/tutorial/16_shared_executors.md +++ b/assets/docs/sources/tutorial/16_shared_executors.md @@ -24,8 +24,8 @@ jmcomic 对下载分为三层,本子层、章节层、图片层 * **章节层**:取option配置 `threading.photo`,不配默认是当前系统的 CPU 核心数 * 假设这个场景是跑在8核的cpu上,那就是 **8** -* **图片层**:取option配置 `threading.image`,不配默认固定为 **30** 个线程。 - * 这个场景假设没配,就是 **30** +* **图片层**:取option配置 `threading.image`,不配默认固定为 **20** 个线程。 + * 这个场景假设没配,就是 **20** 各层级联调度的真实结构如下: @@ -35,17 +35,17 @@ download_album([123, 456]) ──► 待下载 2 个本子 【本子层】同时跑 2 个本子 │ ├─► 本子 123 ── 各自独立创建 Photo 线程池 (默认 8 线程) - │ ├─► 章节 1 ── 各自独立创建 Image 线程池 (默认 30 线程) ──► 30 线程下载图片 - │ ├─► 章节 2 ── 各自独立创建 Image 线程池 (默认 30 线程) ──► 30 线程下载图片 + │ ├─► 章节 1 ── 各自独立创建 Image 线程池 (默认 20 线程) ──► 20 线程下载图片 + │ ├─► 章节 2 ── 各自独立创建 Image 线程池 (默认 20 线程) ──► 20 线程下载图片 │ └─► ... (最多同时跑 8 个章节) │ └─► 本子 456 ── 各自独立创建 Photo 线程池 (默认 8 线程) - ├─► 章节 1 ── 各自独立创建 Image 线程池 (默认 30 线程) ──► 30 线程下载图片 - ├─► 章节 2 ── 各自独立创建 Image 线程池 (默认 30 线程) ──► 30 线程下载图片 + ├─► 章节 1 ── 各自独立创建 Image 线程池 (默认 20 线程) ──► 20 线程下载图片 + ├─► 章节 2 ── 各自独立创建 Image 线程池 (默认 20 线程) ──► 20 线程下载图片 └─► ... (最多同时跑 8 个章节) ``` -如果 123,456这个本子的章节和图片非常多,就有可能拉满全部并发,峰值线程数会达到 $2 \text{ (本子)} \times 8 \text{ (章节)} \times 30 \text{ (图片)} = \mathbf{480}$ **个并发线程!** +如果 123,456这个本子的章节和图片非常多,就有可能拉满全部并发,峰值线程数会达到 $2 \text{ (本子)} \times 8 \text{ (章节)} \times 20 \text{ (图片)} = \mathbf{320}$ **个并发线程!** 这种**乘法级(连乘)级联膨胀**,会导致系统瞬时创建上千个系统线程,引起大量 CPU 上下文切换、内存占用飙升、网络连接数耗尽,甚至直接触发目标服务端的反爬限频或把自己的本地网络打瘫痪。 @@ -83,7 +83,7 @@ download_album([123, 456], option=option) $$2 \text{ (本子)} \times 2 \text{ (章节)} \times 5 \text{ (图片)} = \mathbf{20} \text{ 个并发线程}$$ -从 **480** 骤降到 **20**! +从 **320** 骤降到 **20**! ### 减少本子层并发 diff --git a/src/jmcomic/api.py b/src/jmcomic/api.py index 520d95968..300974ca2 100644 --- a/src/jmcomic/api.py +++ b/src/jmcomic/api.py @@ -3,7 +3,12 @@ from .jm_downloader import * from .jm_exception import DownloadCancelledException, ExceptionTool -from .jm_runtime import JmAsyncRuntime, JmSyncRuntime +from .jm_runtime import ( + JmAsyncRuntime, + JmRuntime, + JmSyncRuntime, + JmTempRuntime, +) from .jm_task_context import ( bind_jm_task_context, JTC, @@ -155,14 +160,13 @@ def _download_one(aid): cancellation_error = None worker = bind_jm_task_context(_download_one) - # 3. 调度本子层并发:外部有 JmSyncRuntime 则复用;没有则使用单层局部 Runtime 调度本子列表,不向下渗透污染子任务 - if runtime is not None: + # 3. 调度本子层并发:外部 JmSyncRuntime 显式配置了 album 层则复用;未配置则使用独立 JmTempRuntime,避免将首批数量固化为常驻池容量 + if runtime is not None and runtime.has_executor(JmRuntime.LEVEL_ALBUM): futures = runtime.multi_thread_launcher( iter_objs=jm_ids, apply_each_obj_func=worker, wait_finish=True, - level='album', - default_workers=len(jm_ids), + level=JmRuntime.LEVEL_ALBUM, ) else: with JmTempRuntime(workers=len(jm_ids)) as launcher: diff --git a/src/jmcomic/jm_async_downloader.py b/src/jmcomic/jm_async_downloader.py index 8cac5374e..a84b6d1f9 100644 --- a/src/jmcomic/jm_async_downloader.py +++ b/src/jmcomic/jm_async_downloader.py @@ -17,7 +17,7 @@ from .jm_toolkit import JmImageTool from .jm_config import JmModuleConfig, jm_log from .jm_exception import DownloadCancelledException -from .jm_runtime import JmAsyncRuntime +from .jm_runtime import JmAsyncRuntime, JmRuntime from .jm_task_context import ( bind_jm_task_context, JTC, @@ -88,7 +88,7 @@ def use(cls, *args, **kwargs): async def _run_in_decode_pool(self, func, *args): runtime = JTC.get_runtime() if isinstance(runtime, JmAsyncRuntime): - executor = runtime.executor('decode', self._decode_worker) + executor = runtime.executor(JmRuntime.LEVEL_DECODE) else: if self._decode_pool is None: self._decode_pool = ThreadPoolExecutor( @@ -368,8 +368,6 @@ async def after_image(self, image: JmImageDetail, img_save_path: str): DownloadCallback.after_image(self, image, img_save_path) self.raise_if_cancelled() await self._dispatch_plugin('after_image', image=image, downloader=self) - self.raise_if_cancelled() - await self._dispatch_feature('after_image', image=image, downloader=self) def shutdown(self): """关闭解密线程池""" diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py index a5cf20ea2..7dca91181 100644 --- a/src/jmcomic/jm_downloader.py +++ b/src/jmcomic/jm_downloader.py @@ -3,7 +3,11 @@ from time import perf_counter from .jm_option import * -from .jm_runtime import JmTempRuntime, JmSyncRuntime +from .jm_runtime import ( + JmRuntime, + JmSyncRuntime, + JmTempRuntime, +) from .jm_task_context import ( bind_jm_task_context, JTC, @@ -547,7 +551,7 @@ def download_by_album_detail(self, album: JmAlbumDetail): iter_objs=album, apply=self.download_by_photo_detail, count_batch=self.option.decide_photo_batch_count(album), - level='photo', + level=JmRuntime.LEVEL_PHOTO, ) self.after_album(album) @@ -575,7 +579,7 @@ def download_by_photo_detail(self, photo: JmPhotoDetail): iter_objs=photo, apply=self.download_by_image_detail, count_batch=self.option.decide_image_batch_count(photo), - level='image', + level=JmRuntime.LEVEL_IMAGE, ) self.after_photo(photo) @@ -623,19 +627,18 @@ def execute_on_condition(self, iter_objs, apply, count_batch, level=None): self.raise_if_cancelled() - if runtime is None: - with JmTempRuntime(workers=count_batch) as runtime: - runtime.multi_thread_launcher( - iter_objs=iter_objs, - apply_each_obj_func=bind_jm_task_context(apply), - ) - else: + if runtime is not None and level is not None and runtime.has_executor(level): runtime.multi_thread_launcher( iter_objs=iter_objs, apply_each_obj_func=bind_jm_task_context(apply), level=level, - default_workers=count_batch, ) + else: + with JmTempRuntime(workers=count_batch) as temp_launcher: + temp_launcher.multi_thread_launcher( + iter_objs=iter_objs, + apply_each_obj_func=bind_jm_task_context(apply), + ) # 批处理线程等待结束后,统一在此处检查取消状态 self.raise_if_cancelled() diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index 2eac8b09e..ed5e167c5 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -8,6 +8,7 @@ from .jm_option import * from .jm_async_downloader import JmAsyncDownloader from .jm_downloader import JmDownloader +from .jm_runtime import JmTempRuntime from .jm_task_context import bind_jm_task_context, JTC @@ -1413,10 +1414,11 @@ def main(self): folders.setdefault('0', '全部') # 一个收藏夹一个线程,导出收藏夹数据到文件 - multi_thread_launcher( - iter_objs=folders.items(), - apply_each_obj_func=bind_jm_task_context(self.handle_folder), - ) + with JmTempRuntime(workers=len(folders)) as launcher: + launcher.multi_thread_launcher( + iter_objs=folders.items(), + apply_each_obj_func=bind_jm_task_context(self.handle_folder), + ) # 压缩导出的文件(放在失败检查之前:即便有收藏夹失败, # 已经成功抓下来的那部分也应该照常打包,不能因为一个收藏夹失败就丢掉整批数据) diff --git a/src/jmcomic/jm_runtime.py b/src/jmcomic/jm_runtime.py index 71b1cadb2..077b8cf1c 100644 --- a/src/jmcomic/jm_runtime.py +++ b/src/jmcomic/jm_runtime.py @@ -65,6 +65,12 @@ class JmRuntime: 绝不会自动关闭。必须由调用方显式调用 runtime.close(),或作为长效单例常驻复用。 """ + LEVEL_ALBUM = 'album' + LEVEL_PHOTO = 'photo' + LEVEL_IMAGE = 'image' + LEVEL_DECODE = 'decode' + LEVEL_DEFAULT = 'default' + def __init__( self, configs: Dict[str, Tuple[Optional[int], Optional[Executor]]], @@ -82,23 +88,25 @@ def __init__( self._closed = False self._ctx_cm = None + def has_executor(self, level: Optional[str] = None) -> bool: + """ + 检查指定层级是否已由外部显式配置(workers 或 executor),或已初始化 Executor。 + """ + if level is None: + raise TypeError('level is required for this Runtime') + with self._lock: + return self._workers.get(level) is not None or self._executors.get(level) is not None + def executor( self, level: Optional[str] = None, - default_workers: Optional[int] = None, ) -> Executor: """ - 返回指定层级的 Executor;未配置时按调用点给出的默认并发数创建。 + 返回指定层级的 Executor;未显式配置 workers 时按系统默认并发数创建。 """ if level is None: raise TypeError('level is required for this Runtime') - if default_workers is not None: - default_workers = validate_jm_workers( - f'{level}_default_workers', - default_workers, - ) - with self._lock: if self._closed: raise RuntimeError('JmRuntime is closed') @@ -112,10 +120,6 @@ def executor( return executor workers = self._workers[level] - if workers is None: - workers = default_workers - self._workers[level] = workers - if workers is None: executor = ThreadPoolExecutor(thread_name_prefix=f'jm-{level}') else: @@ -135,12 +139,11 @@ def multi_thread_launcher( wait_finish=True, *, level: Optional[str] = None, - default_workers: Optional[int] = None, ): """ 使用指定层级的 Executor 批量提交任务,并按需等待完成。 """ - executor = self.executor(level, default_workers) + executor = self.executor(level) futures = [] try: @@ -209,16 +212,18 @@ def __init__( super().__init__( configs={ - 'default': (workers, executor), + JmRuntime.LEVEL_DEFAULT: (workers, executor), }, ) + def has_executor(self, level: Optional[str] = JmRuntime.LEVEL_DEFAULT) -> bool: + return super().has_executor(level or JmRuntime.LEVEL_DEFAULT) + def executor( self, - level: Optional[str] = 'default', - default_workers: Optional[int] = None, + level: Optional[str] = JmRuntime.LEVEL_DEFAULT, ) -> Executor: - return super().executor(level or 'default', default_workers) + return super().executor(level or JmRuntime.LEVEL_DEFAULT) def __enter__(self): return self @@ -267,15 +272,15 @@ def __init__( 'as sharing one executor across nested levels causes thread pool starvation deadlock' ) - album_config = normalize_jm_executor_config('album', album_workers, album_executor) + album_config = normalize_jm_executor_config(JmRuntime.LEVEL_ALBUM, album_workers, album_executor) super().__init__( configs={ - 'album': album_config, - 'photo': normalize_jm_executor_config( - 'photo', photo_workers, photo_executor + JmRuntime.LEVEL_ALBUM: album_config, + JmRuntime.LEVEL_PHOTO: normalize_jm_executor_config( + JmRuntime.LEVEL_PHOTO, photo_workers, photo_executor ), - 'image': normalize_jm_executor_config( - 'image', image_workers, image_executor + JmRuntime.LEVEL_IMAGE: normalize_jm_executor_config( + JmRuntime.LEVEL_IMAGE, image_workers, image_executor ), }, ) @@ -294,15 +299,17 @@ def __init__( ): super().__init__( configs={ - 'decode': normalize_jm_executor_config( - 'decode', decode_workers, decode_executor + JmRuntime.LEVEL_DECODE: normalize_jm_executor_config( + JmRuntime.LEVEL_DECODE, decode_workers, decode_executor ), }, ) + def has_executor(self, level: Optional[str] = JmRuntime.LEVEL_DECODE) -> bool: + return super().has_executor(level or JmRuntime.LEVEL_DECODE) + def executor( self, - level: Optional[str] = 'decode', - default_workers: Optional[int] = None, + level: Optional[str] = JmRuntime.LEVEL_DECODE, ) -> Executor: - return super().executor(level or 'decode', default_workers) + return super().executor(level or JmRuntime.LEVEL_DECODE) diff --git a/src/jmcomic/jm_task_context.py b/src/jmcomic/jm_task_context.py index 0a64c6bbd..382869f3e 100644 --- a/src/jmcomic/jm_task_context.py +++ b/src/jmcomic/jm_task_context.py @@ -11,6 +11,7 @@ 'JM_TASK_CONTEXT', 'DownloadControl', 'JTC', + 'JmTaskContext', 'get_jm_task_context', 'jm_task_context', 'bind_jm_task_context', @@ -129,8 +130,7 @@ def raise_if_cancelled(cls) -> None: # 兼容已发布的上下文查询入口,内部统一使用 JTC。 get_jm_task_context = JTC.get_context - -class jm_task_context: +class JmTaskContext: """ 临时绑定任务字段;只传播 Option/Runtime/Control,不管理资源生命周期。 @@ -146,16 +146,9 @@ def __init__(self, *, option=None, runtime=None, control=None, **fields): def __enter__(self): context = JTC.get_context() - parent_runtime = context.get('runtime') if self.runtime is not None and not isinstance(self.runtime, JmRuntime): raise TypeError(f'runtime must be JmRuntime or None, got {type(self.runtime)}') - if ( - self.runtime is not None - and parent_runtime is not None - and self.runtime is not parent_runtime - ): - raise RuntimeError('another JmRuntime is already active in this task context') if self.control is not None and not isinstance(self.control, DownloadControl): raise TypeError(f'control must be DownloadControl, got {type(self.control)}') @@ -183,6 +176,10 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): return self.__exit__(exc_type, exc_val, exc_tb) +# 保持向下兼容的小写别名 +jm_task_context = JmTaskContext + + def bind_jm_task_context(func: Callable, context: Optional[Mapping] = None) -> Callable: """ 把完整任务上下文快照绑定到同步可调用对象。 diff --git a/tests/test_jmcomic/test_jm_async_api.py b/tests/test_jmcomic/test_jm_async_api.py index 995b3663d..dee856065 100644 --- a/tests/test_jmcomic/test_jm_async_api.py +++ b/tests/test_jmcomic/test_jm_async_api.py @@ -32,8 +32,8 @@ def test_async_download_photo_by_id(self): self.assertIsInstance(async_dler, JmAsyncDownloader, 'downloader 必须是异步版本') self.assertIsInstance(async_photo, JmPhotoDetail, '返回值必须包含 photo') - self.assert_sync_async_equal(sync_photo.photo_id, async_photo.photo_id, 'photo.photo_id') - self.assertIsNone(async_dler.client, '顶层异步下载结束后 downloader client 应已关闭') + self.assertIsNotNone(async_dler.client, '顶层异步下载结束后 downloader client 实例应保留') + self.assertIsNone(async_dler.client._session, '顶层异步下载结束后 downloader client session 应已关闭') def test_async_download_album_by_id(self): """测试 download_album_async:验证返回值与同步版本保持一致""" @@ -49,8 +49,8 @@ def test_async_download_album_by_id(self): self.assertIsInstance(async_dler, JmAsyncDownloader, 'downloader 必须是异步版本') self.assertIsInstance(async_album, JmAlbumDetail, '返回值必须包含 album') - self.assert_album_equal(sync_album, async_album) - self.assertIsNone(async_dler.client, '顶层异步下载结束后 downloader client 应已关闭') + self.assertIsNotNone(async_dler.client, '顶层异步下载结束后 downloader client 实例应保留') + self.assertIsNone(async_dler.client._session, '顶层异步下载结束后 downloader client session 应已关闭') def test_async_batch(self): """测试 download_batch_async:验证返回集合大小与同步版本保持一致""" diff --git a/tests/test_jmcomic/test_jm_async_custom.py b/tests/test_jmcomic/test_jm_async_custom.py index 78631295d..702218254 100644 --- a/tests/test_jmcomic/test_jm_async_custom.py +++ b/tests/test_jmcomic/test_jm_async_custom.py @@ -7,8 +7,7 @@ from jmcomic.jm_async_client import AsyncJmApiClient from jmcomic.jm_async_downloader import JmAsyncDownloader from jmcomic.jm_client_interface import AsyncJmcomicClient -import asyncio -from unittest.mock import patch +from concurrent.futures import ThreadPoolExecutor class Test_Async_Custom(JmAsyncTestConfigurable): @@ -221,16 +220,9 @@ def test_async_downloader_cleanup_when_setup_fails(self): old_updated_domains = JmModuleConfig.DOMAIN_API_UPDATED_LIST old_setup_domain = AsyncJmApiClient._has_setup_domain - class ProbeAsyncRuntime(JmAsyncRuntime): - instances = [] - - def __init__(self): - super().__init__(decode_workers=1) - self.instances.append(self) - loop = asyncio.new_event_loop() downloader = None - runtime = None + fake_pool = None try: JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = False JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = True @@ -250,21 +242,16 @@ def __init__(self): photo_concurrency=1, decode_worker=1, ) - with patch( - 'jmcomic.jm_async_downloader.JmAsyncRuntime', - ProbeAsyncRuntime, - ): - with self.assertRaises(RequestRetryAllFailException): - loop.run_until_complete(downloader.__aenter__()) - - runtime = ProbeAsyncRuntime.instances[0] + fake_pool = downloader._decode_pool = ThreadPoolExecutor(max_workers=1) + with self.assertRaises(RequestRetryAllFailException): + loop.run_until_complete(downloader.__aenter__()) + self.assertIsNone(downloader.client) - self.assertIsNone(JTC.get_runtime()) - with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'): - runtime.executor('decode', 1) + self.assertIsNone(downloader._decode_pool) + self.assertTrue(fake_pool._shutdown) finally: - if runtime is not None: - runtime.close() + if fake_pool is not None and not fake_pool._shutdown: + fake_pool.shutdown(wait=False) loop.close() JmModuleConfig.FLAG_API_CLIENT_AUTO_UPDATE_DOMAIN = old_auto_update JmModuleConfig.FLAG_API_CLIENT_REQUIRE_COOKIES = old_require_cookies diff --git a/tests/test_jmcomic/test_jm_client.py b/tests/test_jmcomic/test_jm_client.py index abc32dbad..5fd433ae6 100644 --- a/tests/test_jmcomic/test_jm_client.py +++ b/tests/test_jmcomic/test_jm_client.py @@ -291,7 +291,7 @@ def search_and_test(case_name, params): raise AssertionError(elist) def test_comment_count(self): - aid = '350234' + aid = '438516' album = self.client.get_album_detail(aid) self.assertGreater(album.comment_count, 0) diff --git a/tests/test_jmcomic/test_jm_runtime.py b/tests/test_jmcomic/test_jm_runtime.py index 6078571c4..4ad199956 100644 --- a/tests/test_jmcomic/test_jm_runtime.py +++ b/tests/test_jmcomic/test_jm_runtime.py @@ -7,6 +7,7 @@ from jmcomic import ( DownloadCancelledException, JmAsyncRuntime, + JmRuntime, JmTempRuntime, JmSyncRuntime, download_batch, @@ -276,6 +277,47 @@ def test_sync_runtime_supports_album_naming_and_rejects_shared_executor_with_dea with self.assertRaisesRegex(ValueError, 'starvation deadlock'): JmSyncRuntime(photo_executor=shared_executor, image_executor=shared_executor) + def test_sync_runtime_unset_album_workers_does_not_fix_batch_pool(self): + """CR-3 / Mode B: 当 JmSyncRuntime 未配置 album 层时,download_batch 不会固化 album 线程池,各批次独立""" + runtime = JmSyncRuntime(image_workers=10) + try: + self.assertFalse(runtime.has_executor(JmRuntime.LEVEL_ALBUM)) + self.assertFalse(runtime.has_executor(JmRuntime.LEVEL_PHOTO)) + self.assertTrue(runtime.has_executor(JmRuntime.LEVEL_IMAGE)) + + with jm_task_context(runtime=runtime): + res1 = download_batch(lambda aid, *_: aid, ['1', '2'], option=object()) + self.assertEqual({'1', '2'}, set(res1)) + # 依然未配置 album executor,没有被污染 + self.assertFalse(runtime.has_executor(JmRuntime.LEVEL_ALBUM)) + + res2 = download_batch(lambda aid, *_: aid, ['3', '4', '5'], option=object()) + self.assertEqual({'3', '4', '5'}, set(res2)) + self.assertFalse(runtime.has_executor(JmRuntime.LEVEL_ALBUM)) + finally: + runtime.close() + + def test_runtime_level_constants_and_has_executor_behavior(self): + self.assertEqual('album', JmRuntime.LEVEL_ALBUM) + self.assertEqual('photo', JmRuntime.LEVEL_PHOTO) + self.assertEqual('image', JmRuntime.LEVEL_IMAGE) + self.assertEqual('decode', JmRuntime.LEVEL_DECODE) + self.assertEqual('default', JmRuntime.LEVEL_DEFAULT) + + temp = JmTempRuntime(workers=2) + try: + self.assertTrue(temp.has_executor()) + self.assertTrue(temp.has_executor(JmRuntime.LEVEL_DEFAULT)) + finally: + temp.close() + + async_rt = JmAsyncRuntime(decode_workers=4) + try: + self.assertTrue(async_rt.has_executor()) + self.assertTrue(async_rt.has_executor(JmRuntime.LEVEL_DECODE)) + finally: + async_rt.close() + if __name__ == '__main__': unittest.main() diff --git a/tests/test_jmcomic/test_jm_task_context.py b/tests/test_jmcomic/test_jm_task_context.py index 1d25a5a45..7d72a69b8 100644 --- a/tests/test_jmcomic/test_jm_task_context.py +++ b/tests/test_jmcomic/test_jm_task_context.py @@ -21,6 +21,7 @@ JmModuleConfig, JmOption, JmOptionPlugin, + JmRuntime, JM_TASK_CONTEXT, PhotoConcurrentFetcherProxy, PrettyFormatter, @@ -33,6 +34,7 @@ download_batch_async, download_photo_async, jm_log, + JmTaskContext, jm_task_context, jm_logger, ) @@ -129,11 +131,10 @@ def test_none_option_runtime_and_nested_runtime_rules(self): inner = JmSyncRuntime(album_workers=1) try: with jm_task_context(runtime=outer): - with jm_task_context(runtime=outer): - self.assertIs(JTC.get_runtime(), outer) - with self.assertRaisesRegex(RuntimeError, 'already active'): - with jm_task_context(runtime=inner): - self.fail('nested runtime must be rejected') + self.assertIs(JTC.get_runtime(), outer) + with jm_task_context(runtime=inner): + self.assertIs(JTC.get_runtime(), inner) + self.assertIs(JTC.get_runtime(), outer) finally: outer.close() inner.close() @@ -200,36 +201,57 @@ def fake_download(jmid, _option, _downloader, **_kwargs): finally: runtime.close() - def test_sync_runtime_accepts_call_site_worker_defaults(self): + def test_sync_runtime_has_executor_and_on_demand_initialization(self): with ThreadPoolExecutor(max_workers=1) as album_executor: runtime = JmSyncRuntime(album_executor=album_executor) try: + self.assertTrue(runtime.has_executor(JmRuntime.LEVEL_ALBUM)) + self.assertFalse(runtime.has_executor(JmRuntime.LEVEL_PHOTO)) + self.assertFalse(runtime.has_executor(JmRuntime.LEVEL_IMAGE)) + with jm_task_context(runtime=runtime): photo_futures = runtime.multi_thread_launcher( [1, 2], lambda item: item * 2, - level='photo', - default_workers=2, + level=JmRuntime.LEVEL_PHOTO, ) self.assertEqual( [future.result() for future in photo_futures], [2, 4], ) + self.assertTrue(runtime.has_executor(JmRuntime.LEVEL_PHOTO)) + image_futures = runtime.multi_thread_launcher( [1], lambda item: item + 1, - level='image', - default_workers=4, + level=JmRuntime.LEVEL_IMAGE, ) self.assertEqual( [future.result() for future in image_futures], [2], ) + self.assertTrue(runtime.has_executor(JmRuntime.LEVEL_IMAGE)) finally: runtime.close() self.assertEqual(album_executor.submit(lambda: 5).result(timeout=1), 5) + def test_task_context_class_alias_and_nested_runtime_shadowing(self): + self.assertIs(jm_task_context, JmTaskContext) + rt1 = JmSyncRuntime(album_workers=1) + rt2 = JmSyncRuntime(album_workers=2) + try: + self.assertIsNone(JTC.get_runtime()) + with JmTaskContext(runtime=rt1): + self.assertIs(JTC.get_runtime(), rt1) + with jm_task_context(runtime=rt2): + self.assertIs(JTC.get_runtime(), rt2) + self.assertIs(JTC.get_runtime(), rt1) + self.assertIsNone(JTC.get_runtime()) + finally: + rt1.close() + rt2.close() + def test_download_control_is_propagated_by_public_binder(self): control = DownloadControl() @@ -791,8 +813,7 @@ async def fake_download(jmid, _option, _downloader, **_kwargs): with jm_task_context(session_id='decode-pool'): with jm_task_context(runtime=runtime): future = runtime.executor( - 'decode', - 1, + JmRuntime.LEVEL_DECODE, ).submit( bind_jm_task_context(JTC.get_context), ) @@ -869,8 +890,7 @@ async def scenario(): runtime = JmAsyncRuntime(decode_executor=executor) with jm_task_context(session_id='async-runtime', runtime=runtime): future = runtime.executor( - 'decode', - 1, + JmRuntime.LEVEL_DECODE, ).submit( bind_jm_task_context( lambda: (8, JTC.get_context()['session_id']) @@ -1015,8 +1035,10 @@ def dummy_download(aid, *args, **kwargs): return aid input_ids = [456, 123, 456, 789, 123] - download_batch(dummy_download, input_ids) - self.assertEqual(called_ids, ['456', '123', '789']) + result = download_batch(dummy_download, input_ids) + self.assertEqual(list(result), ['456', '123', '789']) + self.assertEqual(set(called_ids), {'456', '123', '789'}) + self.assertEqual(len(called_ids), 3) def test_async_downloader_retains_client_instance_after_exit(self): class DummyClient: From dc3511b8db92f4f5a3caff3b73b73bc95c2481d8 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:57:51 +0800 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20=E8=BF=98=E5=8E=9F=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E4=B8=BA2.7.9=EF=BC=8C=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=9B=9E=E8=B0=83=E5=B9=B6=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=A4=9A=E4=BD=99=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/jmcomic/__init__.py | 2 +- src/jmcomic/jm_async_downloader.py | 9 +++-- src/jmcomic/jm_client_impl.py | 6 ---- src/jmcomic/jm_client_interface.py | 8 ++--- src/jmcomic/jm_downloader.py | 7 ++-- src/jmcomic/jm_exception.py | 12 +++---- tests/test_jmcomic/test_jm_exception.py | 44 ------------------------- 7 files changed, 19 insertions(+), 69 deletions(-) diff --git a/src/jmcomic/__init__.py b/src/jmcomic/__init__.py index 71cb50245..d719f4817 100644 --- a/src/jmcomic/__init__.py +++ b/src/jmcomic/__init__.py @@ -2,7 +2,7 @@ # 被依赖方 <--- 使用方 # config <--- entity <--- toolkit <--- client <--- option <--- downloader -__version__ = '2.7.10' +__version__ = '2.7.9' from .jm_exception import DownloadCancelledException from .jm_runtime import * diff --git a/src/jmcomic/jm_async_downloader.py b/src/jmcomic/jm_async_downloader.py index a84b6d1f9..b63f12b7c 100644 --- a/src/jmcomic/jm_async_downloader.py +++ b/src/jmcomic/jm_async_downloader.py @@ -360,13 +360,18 @@ async def before_image(self, image: JmImageDetail, img_save_path: str): self.raise_if_cancelled() async def after_image(self, image: JmImageDetail, img_save_path: str): + # 1. 记录下载成功图片 photo = image.from_photo album = photo.from_album - if album in self.download_success_dict: - self.download_success_dict[album].setdefault(photo, []).append((image.save_path, image)) + self.download_success_dict[album][photo].append((image.save_path, image)) + # 2. 日志回调 DownloadCallback.after_image(self, image, img_save_path) + + # 3. 检查取消 self.raise_if_cancelled() + + # 4. 插件回调 await self._dispatch_plugin('after_image', image=image, downloader=self) def shutdown(self): diff --git a/src/jmcomic/jm_client_impl.py b/src/jmcomic/jm_client_impl.py index 94538aa23..c124ddf63 100644 --- a/src/jmcomic/jm_client_impl.py +++ b/src/jmcomic/jm_client_impl.py @@ -275,12 +275,6 @@ def add_favorite_album(self, }, ) - if resp.status_code != 200: - self.check_special_http_code(resp) - self.raise_request_error(resp) - - self.require_resp_success_else_raise(resp, '/ajax/favorite_album') - res = resp.json() if res['status'] != 1: diff --git a/src/jmcomic/jm_client_interface.py b/src/jmcomic/jm_client_interface.py index 3f5cb6b15..1b78d6073 100644 --- a/src/jmcomic/jm_client_interface.py +++ b/src/jmcomic/jm_client_interface.py @@ -171,14 +171,14 @@ class JmDailyCheckinResp(JmResp): """ 每日签到打卡响应类 """ - CODE_SUCCESS = 0 # 签到成功 + CODE_SUCCESS = 0 # 签到成功 CODE_ALREADY_CHECKED_IN = 1 # 重复签到(今日已签) def __init__(self, resp, code: int, msg: str, raw_data: dict = None): super().__init__(resp) - self.code: int = code # 0=签到成功, 1=重复签到 - self.status: int = code # 兼容字段,值等同于 code - self.msg: str = msg # 透传服务端的提示信息或奖励内容 + self.code: int = code # 0=签到成功, 1=重复签到 + self.status: int = code # 兼容字段,值等同于 code + self.msg: str = msg # 透传服务端的提示信息或奖励内容 self.raw_data: dict = raw_data or {} def __repr__(self): diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py index 7dca91181..d9a45e883 100644 --- a/src/jmcomic/jm_downloader.py +++ b/src/jmcomic/jm_downloader.py @@ -275,16 +275,15 @@ def before_image(self, image: JmImageDetail, img_save_path): self.raise_if_cancelled() def after_image(self, image: JmImageDetail, img_save_path): - # 1. 图片已成功落盘(或命中本地缓存),第一时间登记入成功清单 + # 1. 记录下载成功图片 photo = image.from_photo album = photo.from_album - if album in self.download_success_dict: - self.download_success_dict[album].setdefault(photo, []).append((image.save_path, image)) + self.download_success_dict[album][photo].append((image.save_path, image)) # 2. 日志回调 super().after_image(image, img_save_path) - # 3. 取消检查 + # 3. 检查取消 self.raise_if_cancelled() # 4. 插件回调 diff --git a/src/jmcomic/jm_exception.py b/src/jmcomic/jm_exception.py index 36e27e30b..796187e3a 100644 --- a/src/jmcomic/jm_exception.py +++ b/src/jmcomic/jm_exception.py @@ -231,24 +231,20 @@ def raise_missing(cls, ) @classmethod - def require_true(cls, case: bool, msg: str, etype=None): + def require_true(cls, case: bool, msg: str): if case: return - cls.raises(msg, etype=etype) + cls.raises(msg) @classmethod def replace_old_exception_executor(cls, raises: Callable[[Callable, str, dict], None]): old = cls.raises - def new(msg, context=None, etype=None): + def new(msg, context=None, _etype=None): if context is None: context = {} - - def raise_original(msg, context=None, etype=etype): - return old(msg, context, etype) - - raises(raise_original, msg, context) + raises(old, msg, context) cls.raises = new diff --git a/tests/test_jmcomic/test_jm_exception.py b/tests/test_jmcomic/test_jm_exception.py index 5273bdba2..cc4450a1f 100644 --- a/tests/test_jmcomic/test_jm_exception.py +++ b/tests/test_jmcomic/test_jm_exception.py @@ -4,50 +4,6 @@ class Test_RequestRetryAllFailException(unittest.TestCase): - def test_require_true_keeps_replaced_exception_executor_compatible(self): - class IsolatedExceptionTool(ExceptionTool): - pass - - seen = [] - IsolatedExceptionTool.replace_old_exception_executor( - lambda old, msg, context: seen.append((old, msg, context)) - ) - - IsolatedExceptionTool.require_true(False, 'compatibility probe') - - self.assertEqual(len(seen), 1) - self.assertEqual(seen[0][1:], ('compatibility probe', {})) - - def test_replaced_exception_executor_preserves_requested_type(self): - class IsolatedExceptionTool(ExceptionTool): - pass - - IsolatedExceptionTool.replace_old_exception_executor( - lambda old, msg, context: old(msg=msg, context=context) - ) - - with self.assertRaises(DownloadCancelledException) as caught: - IsolatedExceptionTool.require_true( - False, 'cancel probe', etype=DownloadCancelledException, - ) - self.assertEqual(caught.exception.reason, 'cancel probe') - - def test_replaced_exception_executor_can_override_requested_type(self): - class IsolatedExceptionTool(ExceptionTool): - pass - - context = {'detail': 'override probe'} - IsolatedExceptionTool.replace_old_exception_executor( - lambda old, msg, context: old(msg, context, JmcomicException) - ) - - with self.assertRaises(JmcomicException) as caught: - IsolatedExceptionTool.raises( - 'override probe', context, DownloadCancelledException, - ) - self.assertIs(type(caught.exception), JmcomicException) - self.assertEqual(caught.exception.context, context) - def test_sync_client_collects_each_failed_request(self): client = object.__new__(AbstractJmClient) client.domain_list = ['api-one.example', 'api-two.example'] From ab6b98dab40ff59c36f822b73f6c6c7eb14fd660 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:57:38 +0800 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20=E5=90=88=E5=B9=B62.7.9=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=97=A5=E5=BF=97=E5=B9=B6=E8=A1=A5=E5=85=A8=E6=9C=AC?= =?UTF-8?q?=E6=AC=A1=E5=8F=91=E5=B8=83=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03df2a95d..ee2396925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,36 +5,32 @@ 条目分类参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 -## [2.7.10] - 2026-10-08 +## [2.7.9] - 2026-10-13 ### Summary -本次更新支持协作式取消下载与共享 Runtime 调度。 +本次更新支持基于 `DownloadControl` 的协作式取消下载与多层级共享 Runtime 调度,重构批量下载结果为保序检索集合,降低默认图片并发配置,并优化多任务进度界面。 ### Added + - 新增 `JmTempRuntime`、`JmSyncRuntime` 与 `JmAsyncRuntime`;裸同步 Downloader 的局部调度使用临时单池 Runtime,顶层同步 API 或自定义同步调度可复用 `album/photo/image` 三层线程池,异步下载可复用 `decode` 线程池。 - 新增 `DownloadControl` 和 `DownloadCancelledException`,支持通过任务上下文协作式取消同步与异步下载;`DownloadCancelledException` 支持通过 `completed_results` 与 `unfinished_ids` 读取批量取消时的已完成成果与未完成 ID。 +- `BatchResult` 重构为独立类,保持输入 FIFO 原序并支持车号检索与下标访问,不再继承 `set`。 - `DownloadResult` 新增 `album` 和 `photo` 快捷实体访问属性。 - 新增 `JTC` (Jm Task Context) 门面类,统一提供上下文、Runtime、Option 及取消控制器的快捷读取方法。 +- `jmcomic --dry-run` 使用随机模拟任务预览多任务进度和重试日志,不请求网络、不下载图片、不写日志文件,也不加载 `--option` 配置;不能与 `--no-progress` 同用。 ### Changed + +- 默认图片层下载并发配置 `download.threading.image` 从 30 调整为 20。 - 下载调度统一使用标准库 Executor;顶层 API 和裸同步 Downloader 的临时调度显式关闭自己创建的 Runtime,`jm_task_context` 只传播字段;外部 Runtime 和 Executor 仍由调用方关闭。 - 顶层下载会把 Runtime 和 Option 作为公开字段直接放入任务上下文;Runtime 不依赖 Context 或 Option,未配置的层级由实际调用点传入默认 worker 数。 -## [2.7.9] - 2026-10-08 - -### Summary - -修复并发下载时多个进度界面和日志区相互干扰的问题,并提供离线进度预览命令。 - ### Fixed - 同步和异步下载器共用一个动态进度界面与日志区,已结束任务保留到最后一个活动下载器退出,再统一关闭界面。 - 界面启动或渲染失败时清理注册状态;界面退出失败时仍执行下载器资源释放,避免影响后续下载。 -### Added - -- `jmcomic --dry-run` 使用随机模拟任务预览多任务进度和重试日志,不请求网络、不下载图片、不写日志文件,也不加载 `--option` 配置;不能与 `--no-progress` 同用。 ## [2.7.8] - 2026-10-07 From 8cde206e6d7fae3e9cdb74c8f06610d4af13f47f Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:01:59 +0800 Subject: [PATCH 6/7] =?UTF-8?q?test:=20=E8=A7=84=E8=8C=83=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=20DownloadControl=20=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_jmcomic/test_jm_cancellation.py | 13 +++++++++---- tests/test_jmcomic/test_jm_download_manifest.py | 14 ++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/tests/test_jmcomic/test_jm_cancellation.py b/tests/test_jmcomic/test_jm_cancellation.py index bdb11f4bc..286bd3f61 100644 --- a/tests/test_jmcomic/test_jm_cancellation.py +++ b/tests/test_jmcomic/test_jm_cancellation.py @@ -137,12 +137,17 @@ def cancel_first(jmid, *_args, **_kwargs): with self.assertRaises(DownloadCancelledException): download_batch(cancel_first, ['123', '456'], option=object()) - def test_sync_batch_propagates_manual_cancellation_without_control(self): + def test_sync_batch_cancellation_with_control(self): + control = DownloadControl() + def stop(_jmid, *_args, **_kwargs): - raise DownloadCancelledException('manual stop') + control.cancel('manual stop') + BaseDownloader.raise_if_cancelled() + + with jm_task_context(control=control): + with self.assertRaisesRegex(DownloadCancelledException, 'manual stop'): + download_batch(stop, ['123'], option=object()) - with self.assertRaisesRegex(DownloadCancelledException, 'manual stop'): - download_batch(stop, ['123'], option=object()) def test_sync_batch_cancellation_exposes_completed_results_and_unfinished_ids(self): control = DownloadControl() diff --git a/tests/test_jmcomic/test_jm_download_manifest.py b/tests/test_jmcomic/test_jm_download_manifest.py index 461153141..c650da4cb 100644 --- a/tests/test_jmcomic/test_jm_download_manifest.py +++ b/tests/test_jmcomic/test_jm_download_manifest.py @@ -227,27 +227,29 @@ def fail(_image): with TemporaryDirectory() as temp_dir: asyncio.run(run_test(temp_dir)) - def test_after_image_direct_cancellation_keeps_saved_image(self): + def test_after_image_cancellation_keeps_saved_image(self): with TemporaryDirectory() as temp_dir: album, photo, images = new_album_photo_images() option = ContractOption(temp_dir) downloader = ContractSyncDownloader(option, album, photo, images) - error = DownloadCancelledException('image plugin cancelled') + control = DownloadControl() def cancel(_image): - raise error + control.cancel('image plugin cancelled') option.after_image_callback = cancel - with self.assertRaises(DownloadCancelledException) as caught: - downloader.download_album(album.id) + with jm_task_context(control=control): + with self.assertRaises(DownloadCancelledException) as caught: + downloader.download_album(album.id) - self.assertIs(caught.exception, error) + self.assertEqual(caught.exception.reason, 'image plugin cancelled') self.assertEqual(downloader.download_failed_image, []) self.assertEqual( downloader.manifest_dict[album].image_filepath_list, [images[0].save_path], ) + def test_current_sync_image_is_recorded_before_cancellation(self): with TemporaryDirectory() as temp_dir: album, photo, image_list = new_album_photo_images() From badc04ab8a68d9eb6f0d26b9c16e6302b3b21746 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:24:18 +0800 Subject: [PATCH 7/7] =?UTF-8?q?ci:=20=E8=BF=98=E5=8E=9F=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81Action=E7=89=88=E6=9C=AC=E4=B8=8Emaster=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E7=A1=AE=E4=BF=9D=E5=8F=91=E5=B8=83=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/benchmark.yml | 6 +++--- .github/workflows/close_specific_pr.yml | 4 ++-- .github/workflows/export_favorites.yml | 8 +++----- .github/workflows/release.yml | 8 +++----- .github/workflows/release_auto.yml | 8 +++----- .github/workflows/test_api.yml | 4 ++-- .github/workflows/test_html.yml | 4 ++-- 7 files changed, 18 insertions(+), 24 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index dd928be5e..e534db734 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -19,10 +19,10 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v7 + uses: actions/setup-python@v5 with: python-version: '3.14' @@ -49,7 +49,7 @@ jobs: - name: Upload Benchmark Report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: benchmark-report path: PERFORMANCE_REPORT.md diff --git a/.github/workflows/close_specific_pr.yml b/.github/workflows/close_specific_pr.yml index ce890e337..8ed49d3eb 100644 --- a/.github/workflows/close_specific_pr.yml +++ b/.github/workflows/close_specific_pr.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Close PR run: | @@ -36,4 +36,4 @@ jobs: gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body '${{ env.comment }}' gh pr close ${{ github.event.pull_request.number }} --repo ${{ github.repository }} env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/export_favorites.yml b/.github/workflows/export_favorites.yml index 4375e3bc7..a00cdf64c 100644 --- a/.github/workflows/export_favorites.yml +++ b/.github/workflows/export_favorites.yml @@ -48,11 +48,9 @@ jobs: ZIP_FP: /home/runner/work/jmcomic/download/export.7z steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false + - uses: actions/checkout@v4 - name: Set up Python 3.11 - uses: actions/setup-python@v7 + uses: actions/setup-python@v5 with: python-version: "3.11" @@ -73,7 +71,7 @@ jobs: python workflow_export_favorites.py - name: 上传结果 - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: '导出的收藏夹' path: ${{ env.ZIP_FP }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9458b5856..8f17f7722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,10 @@ jobs: id-token: write contents: write steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false + - uses: actions/checkout@v4 - name: Set up Python 3.11 - uses: actions/setup-python@v7 + uses: actions/setup-python@v5 with: python-version: "3.11" @@ -33,7 +31,7 @@ jobs: python -m build - name: Create Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/release_auto.yml b/.github/workflows/release_auto.yml index c3829d119..0ef2d6cb1 100644 --- a/.github/workflows/release_auto.yml +++ b/.github/workflows/release_auto.yml @@ -14,12 +14,10 @@ jobs: contents: write if: startsWith(github.event.head_commit.message, 'v') steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false + - uses: actions/checkout@v4 - name: Set up Python 3.11 - uses: actions/setup-python@v7 + uses: actions/setup-python@v5 with: python-version: "3.11" @@ -35,7 +33,7 @@ jobs: python -m build - name: Create Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/test_api.yml b/.github/workflows/test_api.yml index 35245d21f..e0e5d7558 100644 --- a/.github/workflows/test_api.yml +++ b/.github/workflows/test_api.yml @@ -27,10 +27,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/test_html.yml b/.github/workflows/test_html.yml index f8a45b50c..8f44d7c9b 100644 --- a/.github/workflows/test_html.yml +++ b/.github/workflows/test_html.yml @@ -27,10 +27,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }}