From 7b5b8c65892d161a0df3f04ee9e2cd847eb71028 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Tue, 1 Sep 2026 10:15:01 +0800
Subject: [PATCH 1/8] =?UTF-8?q?=E5=85=B1=E4=BA=AB=E7=BA=BF=E7=A8=8B?=
=?UTF-8?q?=E6=B1=A0=E5=92=8C=E5=8D=8F=E4=BD=9C=E5=BC=8F=E5=8F=96=E6=B6=88?=
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/download.yml | 6 +-
.github/workflows/download_dispatch.yml | 6 +-
.github/workflows/export_favorites.yml | 6 +-
.github/workflows/release.yml | 6 +-
.github/workflows/release_auto.yml | 6 +-
.github/workflows/test_api.yml | 4 +-
.github/workflows/test_html.yml | 4 +-
CHANGELOG.md | 11 +
assets/docs/mkdocs.yml | 1 +
assets/docs/sources/api/download.md | 44 +++
.../docs/sources/tutorial/0_common_usage.md | 56 +++
.../docs/sources/tutorial/14_async_usage.md | 9 +-
.../sources/tutorial/16_shared_executors.md | 109 ++++++
pyproject.toml | 2 +-
src/jmcomic/__init__.py | 2 +
src/jmcomic/api.py | 332 +++++++++++-----
src/jmcomic/cli.py | 5 +-
src/jmcomic/jm_async_client.py | 7 +-
src/jmcomic/jm_async_downloader.py | 112 ++++--
src/jmcomic/jm_client_impl.py | 23 +-
src/jmcomic/jm_downloader.py | 123 ++++--
src/jmcomic/jm_exception.py | 25 +-
src/jmcomic/jm_runtime.py | 236 ++++++++++++
src/jmcomic/jm_task_context.py | 87 ++++-
tests/test_jmcomic/test_jm_api.py | 3 +
tests/test_jmcomic/test_jm_async_custom.py | 30 +-
tests/test_jmcomic/test_jm_cancellation.py | 353 ++++++++++++++++++
tests/test_jmcomic/test_jm_cli.py | 41 ++
tests/test_jmcomic/test_jm_client.py | 31 ++
.../test_jmcomic/test_jm_download_manifest.py | 83 +++-
.../test_jmcomic/test_jm_download_progress.py | 39 +-
tests/test_jmcomic/test_jm_exception.py | 14 +
tests/test_jmcomic/test_jm_favorite.py | 39 ++
tests/test_jmcomic/test_jm_release.py | 47 +--
tests/test_jmcomic/test_jm_runtime.py | 267 +++++++++++++
tests/test_jmcomic/test_jm_task_context.py | 331 ++++++++++++++--
38 files changed, 2208 insertions(+), 302 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_favorite.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/download.yml b/.github/workflows/download.yml
index 380d4bc03..9ea493025 100644
--- a/.github/workflows/download.yml
+++ b/.github/workflows/download.yml
@@ -29,9 +29,9 @@ jobs:
UPLOAD_NAME: 'Click me to download'
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python 3.11
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -69,7 +69,7 @@ jobs:
mv "../$ZIP_NAME" .
- name: 上传结果
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: ${{ env.UPLOAD_NAME }}
path: ${{ env.JM_DOWNLOAD_DIR }}/${{ env.ZIP_NAME }}
diff --git a/.github/workflows/download_dispatch.yml b/.github/workflows/download_dispatch.yml
index ae02fb8a0..a1c1596c4 100644
--- a/.github/workflows/download_dispatch.yml
+++ b/.github/workflows/download_dispatch.yml
@@ -108,9 +108,9 @@ jobs:
JM_DOWNLOAD_DIR: /home/runner/work/jmcomic/download/
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python 3.11
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -153,7 +153,7 @@ jobs:
mv "../$ZIP_NAME" .
- name: 上传结果
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: ${{ env.UPLOAD_NAME }}
path: ${{ env.JM_DOWNLOAD_DIR }}/${{ env.ZIP_NAME }}
diff --git a/.github/workflows/export_favorites.yml b/.github/workflows/export_favorites.yml
index a00cdf64c..b653deb79 100644
--- a/.github/workflows/export_favorites.yml
+++ b/.github/workflows/export_favorites.yml
@@ -48,9 +48,9 @@ jobs:
ZIP_FP: /home/runner/work/jmcomic/download/export.7z
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python 3.11
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -71,7 +71,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..61f62a939 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,10 +13,10 @@ jobs:
id-token: write
contents: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python 3.11
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -31,7 +31,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..d0fcf4aec 100644
--- a/.github/workflows/release_auto.yml
+++ b/.github/workflows/release_auto.yml
@@ -14,10 +14,10 @@ jobs:
contents: write
if: startsWith(github.event.head_commit.message, 'v')
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python 3.11
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -33,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/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 67b9680fc..11b84e2ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,17 @@
条目分类参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
+## [Unreleased]
+
+### Added
+- 新增 `JmSimpleRuntime`、`JmSyncRuntime` 与 `JmAsyncRuntime`;裸同步 Downloader 的局部调度使用单池 Runtime,顶层同步 API 或自定义同步调度可复用 `id/photo/image` 三层线程池,异步下载可复用 `blocking` 线程池。
+- 新增 `DownloadControl` 和 `DownloadCancelledException`,支持通过任务上下文协作式取消同步与异步下载。
+
+### Changed
+- Python 3.9 保留安装兼容,但不再纳入 CI。
+- 下载调度统一使用标准库 Executor;顶层 API 和裸同步 Downloader 的临时调度显式关闭自己创建的 Runtime,`jm_task_context` 只传播字段;外部 Runtime 和 Executor 仍由调用方关闭。
+- 顶层下载会把 Runtime 和 Option 作为公开字段直接放入任务上下文;Runtime 不依赖 Context 或 Option,未配置的层级由实际调用点传入默认 worker 数。
+
## [2.7.5] - 2026-08-25
### Summary
diff --git a/assets/docs/mkdocs.yml b/assets/docs/mkdocs.yml
index 003684fcb..182055fa5 100644
--- a/assets/docs/mkdocs.yml
+++ b/assets/docs/mkdocs.yml
@@ -58,6 +58,7 @@ nav:
- tutorial/12_domain_strategy.md
- tutorial/13_export_and_feature.md
- tutorial/14_async_usage.md
+ - tutorial/16_shared_executors.md
- tutorial/15_download_progress.md
plugins:
diff --git a/assets/docs/sources/api/download.md b/assets/docs/sources/api/download.md
index ce7d19654..92817233a 100644
--- a/assets/docs/sources/api/download.md
+++ b/assets/docs/sources/api/download.md
@@ -17,3 +17,47 @@
options:
members:
- JmAsyncDownloader
+
+::: jmcomic.jm_downloader
+ options:
+ members:
+ - BaseDownloader
+ - JmDownloader
+
+## 下载 Runtime 与取消控制
+
+::: jmcomic.jm_runtime
+ options:
+ members:
+ - JmRuntime
+ - JmSimpleRuntime
+ - JmSyncRuntime
+ - JmAsyncRuntime
+
+::: jmcomic.jm_exception
+ options:
+ members:
+ - DownloadCancelledException
+
+::: jmcomic.jm_task_context
+ options:
+ members:
+ - DownloadControl
+ - jm_task_context
+ - bind_jm_task_context
+ - get_jm_task_context
+ - get_current_control
+ - get_jm_runtime
+ - get_current_option
+
+同步顶层 API 默认创建 `JmSyncRuntime`,并在调用结束时显式关闭。需要让多个顶层调用复用线程池时,先创建 `JmSyncRuntime`,通过 `jm_task_context(runtime=runtime)` 传播,并在任务完成后显式调用 `runtime.close()`;异步调用改用 `JmAsyncRuntime`。裸同步 Downloader 没有 Runtime 时,会为每次局部调度创建并关闭只有一个线程池的 `JmSimpleRuntime`。完整示例见[复用下载 Runtime](../tutorial/16_shared_executors.md)。
+
+Runtime 只负责 Executor 的配置、调度和生命周期。`JmSimpleRuntime.multi_thread_launcher()` 不要求下载层级;`JmSyncRuntime` 的 launcher 使用 `id/photo/image` 层级。未显式配置容量时,由下载调用点把 Option 或 Downloader 已解析出的默认 worker 数传给 Runtime。
+
+使用 `get_jm_runtime()` 可以读取当前任务激活的 Runtime;没有激活 Runtime 时返回 `None`。
+
+Runtime 和 Option 以公开字段 `runtime`、`option` 直接保存在 `JM_TASK_CONTEXT` 中。`get_jm_task_context()` 返回包含这两个字段的完整副本;`get_jm_runtime()` 和 `get_current_option()` 是读取它们的便捷方法。自定义日志处理器也可以从 LogRecord 的任务上下文中访问这两个公开字段,默认文本日志不会展开对象内容。
+
+`jm_task_context` 只负责字段传播与作用域恢复,不会关闭 Runtime。谁创建 Runtime,谁显式调用 `runtime.close()`;Runtime 只会关闭自身创建的 Executor,不会关闭调用方注入的 Executor。
+
+取消检查统一由 `BaseDownloader.raise_if_cancelled()` 这个 `classmethod` 执行。自定义 Downloader 可以重写它来调整检查策略;Client 和顶层 API 不直接读取取消状态。
diff --git a/assets/docs/sources/tutorial/0_common_usage.md b/assets/docs/sources/tutorial/0_common_usage.md
index 2093e91e5..dcb07a627 100644
--- a/assets/docs/sources/tutorial/0_common_usage.md
+++ b/assets/docs/sources/tutorial/0_common_usage.md
@@ -574,6 +574,62 @@ print('是否全部成功:', batch_result.all_succeeded)
下载单个 ID 时,请求本子失败会直接抛出异常;如果只有部分章节或图片失败,会在任务结束后汇总抛出 `PartialDownloadFailedException`,此时不会返回 `DownloadResult`。批量下载则继续执行其他任务,并把失败项集中放进 `batch_result.failed`。
+### 取消下载
+
+下载开始后,可以从按钮、定时器或其他线程调用 `DownloadControl.cancel()` 请求停止。JMComic 收到请求后不会再开始下载新的章节和图片,但会先把当前正在处理的图片完整保存,避免留下损坏文件;随后下载会抛出 `DownloadCancelledException`,调用方可以据此提示用户任务已经取消。
+
+下面的同步示例启动一个下载线程,并在两秒后从主线程请求取消:
+
+```python
+from threading import Thread
+from time import sleep
+
+from jmcomic import DownloadCancelledException, DownloadControl, download_album, jm_task_context
+
+control = DownloadControl()
+
+def run_download():
+ try:
+ # ContextVar 不会自动进入用户创建的新线程,
+ # 因此 context 必须在实际调用下载的线程内建立。
+ with jm_task_context(control=control):
+ download_album('123')
+ except DownloadCancelledException as e:
+ print('取消原因:', e.reason)
+
+thread = Thread(target=run_download)
+thread.start()
+
+# GUI 按钮、请求处理器或其他线程调用
+sleep(2)
+control.cancel('用户取消')
+thread.join()
+```
+
+异步 API 使用完全相同的 `DownloadControl` 和 context。`asyncio.create_task()` 会自动复制当前 Context,因此 Task 会看到同一个 `control`。`DownloadControl` 的协作式取消路径不会调用 `asyncio.Task.cancel()`;如果要保留“当前图片完整写入后再停止”的保证,只调用 `control.cancel()`。如果调用方直接取消外层异步批量 Task,批量 API 会取消并等待其内部子 Task 完成清理,再透传 `CancelledError`;这条外部取消路径不保证当前图片完整写入。
+
+```python
+import asyncio
+
+from jmcomic import DownloadCancelledException, DownloadControl, download_album_async, jm_task_context
+
+async def main():
+ control = DownloadControl()
+ with jm_task_context(control=control):
+ task = asyncio.create_task(download_album_async('123'))
+
+ await asyncio.sleep(2)
+ control.cancel('用户取消')
+ try:
+ await task
+ except DownloadCancelledException as e:
+ print('取消原因:', e.reason)
+
+asyncio.run(main())
+```
+
+取消不会删除已经完整写入的图片。同步 HTTP 请求以及已经运行的解密或写盘不能被强制中断,因此取消会在当前不可中断操作完成或超时后生效。取消后不会执行整章、整本完成回调及 PDF/ZIP 等导出 Feature。
+
### 速查表
| 你的需求 | 推荐写法 |
diff --git a/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md
index dfac607f9..007cd7f80 100644
--- a/assets/docs/sources/tutorial/14_async_usage.md
+++ b/assets/docs/sources/tutorial/14_async_usage.md
@@ -19,9 +19,12 @@ import jmcomic
async def main():
# 异步下载单个本子
- album, downloader = await jmcomic.download_album_async('438696')
+ result = await jmcomic.download_album_async('438696')
+ album = result.detail
+ downloader = result.downloader
- # 返回的 downloader 已释放网络连接和线程池,只用于读取下载结果
+ # 返回时该 Downloader 的网络 client 已关闭;如果传入共享 blocking
+ # 执行器,它仍由创建它的调用方管理
print(downloader.download_failed_image)
# 异步下载单章节
@@ -52,6 +55,8 @@ async def main():
asyncio.run(main())
```
+异步下载的网络 I/O 仍由 event loop 和 Semaphore 控制;解密、PIL、写盘及同步 hook 使用标准线程池。每个 `JmAsyncDownloader` 仍独立创建并关闭自己的 client、session 和并发控制;需要显式复用线程池时,请参阅[复用下载 Runtime](16_shared_executors.md)。
+
## 3. 异步获取实体类,并发请求
### 💡 关于 async with 和自动初始化
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..763b8f71f
--- /dev/null
+++ b/assets/docs/sources/tutorial/16_shared_executors.md
@@ -0,0 +1,109 @@
+# 复用下载 Runtime
+
+普通下载不需要手动创建 Runtime。同步顶层 API 使用临时 `JmSyncRuntime` 并在返回前显式关闭;裸同步 Downloader 没有 Runtime 时,每次局部调度使用一个临时 `JmSimpleRuntime`。只有当你希望多个下载调用复用同一组线程池,或想明确控制各层并发数时,才需要 `jm_task_context`:
+
+```python
+from jmcomic import JmSyncRuntime, download_album, jm_task_context
+
+
+runtime = JmSyncRuntime(
+ id_workers=2,
+ photo_workers=3,
+ image_workers=8,
+)
+try:
+ with jm_task_context(runtime=runtime):
+ download_album(['123', '456'])
+finally:
+ runtime.close()
+```
+
+这就是分层同步 Runtime 的完整公开用法。`JmSyncRuntime` 负责 `id`、`photo` 和 `image` 三层调度;任务上下文只传播 Runtime,不管理资源,创建 Runtime 的代码负责显式关闭它。
+
+如果只需要直接调度一层独立任务,也可以使用 `JmSimpleRuntime(workers=...)` 或 `JmSimpleRuntime(executor=...)`。它的 `multi_thread_launcher()` 不接收 `level`。不要把同一个单池 Runtime 用于 album 的 photo/image 两层同步嵌套调度,否则外层 worker 等待内层 worker 时可能耗尽线程。
+
+## Runtime 默认容量从哪里来
+
+Runtime 本身不读取 Option,也不保存下载任务状态。它只管理 Executor 的配置、创建、调度和关闭。
+
+如果你没有给某一层配置 `*_workers`,真正发起调度的调用点会把已经解析好的默认值传给 Runtime:`photo` 和 `image` 使用 Option 中的 `download.threading.photo/image`,批量 ID 使用本次 ID 数量,异步阻塞池使用 Downloader 的 `decode_worker`。这样 Runtime 不需要反向依赖 Context 或 Option。
+
+你也可以直接使用公开的 `multi_thread_launcher()`,但普通下载通常不需要这样做。`wait_finish=True` 会等待所有 Future 完成;worker 异常保存在对应 Future 中,由需要结果的调用方通过 `future.result()` 读取:
+
+```python
+from jmcomic import JmSyncRuntime, jm_task_context
+
+
+runtime = JmSyncRuntime(id_workers=2)
+try:
+ with jm_task_context(runtime=runtime):
+ futures = runtime.multi_thread_launcher(
+ [123, 456],
+ str,
+ level='id',
+ )
+ print([future.result() for future in futures])
+finally:
+ runtime.close()
+```
+
+## 借用已有线程池
+
+如果应用已经管理自己的 `ThreadPoolExecutor`,可以把它交给 Runtime。Runtime 只借用外部 Executor,不会替你关闭:
+
+```python
+from concurrent.futures import ThreadPoolExecutor
+
+from jmcomic import JmSyncRuntime, download_album, jm_task_context
+
+
+with ThreadPoolExecutor(max_workers=2) as id_executor, \
+ ThreadPoolExecutor(max_workers=3) as photo_executor, \
+ ThreadPoolExecutor(max_workers=8) as image_executor:
+ runtime = JmSyncRuntime(
+ id_executor=id_executor,
+ photo_executor=photo_executor,
+ image_executor=image_executor,
+ )
+ try:
+ with jm_task_context(runtime=runtime):
+ download_album(['123', '456'])
+ finally:
+ runtime.close()
+```
+
+三个同步层级会互相等待,因此必须使用不同的 Executor。任务上下文需要在线程间传播,所以不支持 `ProcessPoolExecutor`。
+
+## 异步下载
+
+异步网络请求由 event loop 和 Semaphore 并发;图片解密、PIL、写盘和同步 hook 交给 `blocking` Executor:
+
+```python
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+
+from jmcomic import JmAsyncRuntime, download_album_async, jm_task_context
+
+
+async def main():
+ with ThreadPoolExecutor(max_workers=4) as blocking_executor:
+ runtime = JmAsyncRuntime(blocking_executor=blocking_executor)
+ try:
+ with jm_task_context(runtime=runtime):
+ await download_album_async('123')
+ finally:
+ runtime.close()
+
+
+asyncio.run(main())
+```
+
+共享 `blocking` Executor 不会共享 Downloader、网络 Client、Session 或 Semaphore。每个顶层调用仍有自己的 Downloader 和 Manifest。
+
+## Context 中保存什么
+
+项目只有一个 `JM_TASK_CONTEXT`。Runtime 和 Option 直接使用公开字段 `runtime`、`option` 保存,和 `download_type`、`jm_id` 以及调用方附加字段处于同一个 context mapping 中。`get_jm_task_context()` 返回包含所有字段的完整副本。
+
+可以直接从 context 副本读取 `runtime`、`option`,也可以使用便捷方法 `get_jm_runtime()`、`get_current_option()`。`bind_jm_task_context()` 会把完整上下文快照传播到工作线程。默认文本日志不会展开 Runtime 或 Option,但自定义日志处理器可以读取这两个公开字段。
+
+嵌套上下文中的 `runtime=None` 和 `option=None` 表示继承父上下文。同步下载 API 只接受 `JmSyncRuntime`,异步 API 只接受 `JmAsyncRuntime`;同一任务作用域不能替换成另一个 Runtime。`JmSimpleRuntime` 用于独立的单层调度,不作为分层同步下载的共享 context Runtime。
diff --git a/pyproject.toml b/pyproject.toml
index 007c68baa..70922cf53 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,4 +41,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 fdf04d29a..080f708da 100644
--- a/src/jmcomic/__init__.py
+++ b/src/jmcomic/__init__.py
@@ -4,6 +4,8 @@
__version__ = '2.7.5'
+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..a5c252241 100644
--- a/src/jmcomic/api.py
+++ b/src/jmcomic/api.py
@@ -2,7 +2,13 @@
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
+from .jm_runtime import JmAsyncRuntime, JmSyncRuntime
+from .jm_task_context import (
+ bind_jm_task_context,
+ get_jm_runtime,
+ jm_task_context,
+)
__DOWNLOAD_API_RET = DownloadResult
@@ -22,12 +28,27 @@ def _finish_download_result(detail, dler, 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 _resolve_runtime(runtime_type, error_message):
+ runtime = get_jm_runtime()
+ if runtime is None:
+ runtime = runtime_type()
+ return runtime, True
+ if not isinstance(runtime, runtime_type):
+ raise TypeError(error_message)
+ return runtime, False
+
+
+def download_batch(
+ download_api,
+ jm_id_iter: Union[Iterable, Generator],
+ option=None,
+ downloader=None,
+ **kwargs,
+) -> BatchResult:
"""
批量下载 album / photo
@@ -40,35 +61,66 @@ def download_batch(download_api,
:param option: 下载选项,所有的jmid共用一个option
:param downloader: 下载器类
"""
- from common import multi_thread_launcher
-
- if option is None:
- option = JmModuleConfig.option_class().default()
-
+ option = _ensure_option(option)
+ jm_ids = list({
+ JmcomicText.parse_to_jm_id(jmid)
+ for jmid in jm_id_iter
+ })
result = BatchResult()
-
+ if len(jm_ids) == 0:
+ return result
download_type = _download_type(download_api)
- def _safe_download(aid):
- """batch 内部的单任务包装:确保异常被收集而非静默丢失"""
+ def _download_one(aid):
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
+ item = download_api(aid, option, downloader, **kwargs)
+ return aid, item, None
+ except Exception as error:
+ return aid, None, error
+
+ runtime, runtime_created_here = _resolve_runtime(
+ JmSyncRuntime,
+ 'sync batch API requires JmSyncRuntime',
)
- return result
+ cancellation_error = None
+ try:
+ with jm_task_context(option=option, runtime=runtime):
+ worker = bind_jm_task_context(_download_one)
+ futures = runtime.multi_thread_launcher(
+ iter_objs=jm_ids,
+ apply_each_obj_func=worker,
+ wait_finish=True,
+ level='id',
+ default_workers=len(jm_ids),
+ )
+
+ for future in futures:
+ aid, item, error = future.result()
+ if error is None:
+ result.add(item)
+ continue
+
+ if isinstance(error, DownloadCancelledException):
+ if cancellation_error is None:
+ cancellation_error = error
+ continue
+
+ 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
+
+ if cancellation_error is not None:
+ raise cancellation_error
+ return result
+ finally:
+ if runtime_created_here:
+ runtime.close()
def download_album(jm_album_id,
@@ -96,17 +148,31 @@ def download_album(jm_album_id,
if not isinstance(jm_album_id, (str, int)):
return download_batch(download_album, jm_album_id, option, downloader, extra=extra)
+ option = _ensure_option(option)
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()
+ runtime, runtime_created_here = _resolve_runtime(
+ JmSyncRuntime,
+ 'sync API requires JmSyncRuntime',
+ )
- return _finish_download_result(album, dler, task_started_at)
+ try:
+ with jm_task_context(
+ option=option,
+ runtime=runtime,
+ download_type='album',
+ jm_id=str(jm_album_id),
+ task_started_at=task_started_at,
+ ):
+ with new_downloader(option, downloader) as dler:
+ 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)
+ finally:
+ if runtime_created_here:
+ runtime.close()
def download_photo(jm_photo_id,
@@ -125,17 +191,31 @@ def download_photo(jm_photo_id,
if not isinstance(jm_photo_id, (str, int)):
return download_batch(download_photo, jm_photo_id, option, downloader, extra=extra)
+ option = _ensure_option(option)
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()
+ runtime, runtime_created_here = _resolve_runtime(
+ JmSyncRuntime,
+ 'sync API requires JmSyncRuntime',
+ )
- return _finish_download_result(photo, dler, task_started_at)
+ try:
+ with jm_task_context(
+ option=option,
+ runtime=runtime,
+ download_type='photo',
+ jm_id=str(jm_photo_id),
+ task_started_at=task_started_at,
+ ):
+ with new_downloader(option, downloader) as dler:
+ 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)
+ finally:
+ if runtime_created_here:
+ runtime.close()
def new_downloader(option=None, downloader=None) -> JmDownloader:
@@ -188,13 +268,15 @@ async def download_album_async(jm_album_id,
check_exception=True,
extra=None,
):
- """
- 异步下载一个本子(album),包含其所有的章节(photo)。
+ """异步下载一个本子及其全部章节。
+
+ 当 ``jm_album_id`` 是可迭代对象时使用批量 API。单个 ID 返回
+ ``DownloadResult``;其中 Downloader 的网络 client 已关闭,阻塞工作也已
+ 收敛。通过 ``jm_task_context(runtime=JmAsyncRuntime(...))`` 提供的 blocking
+ 执行器由调用方负责关闭。
- - 支持批量下载(当 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,
@@ -204,16 +286,31 @@ async def download_album_async(jm_album_id,
extra=extra
)
+ option = _ensure_option(option)
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()
+ runtime, runtime_created_here = _resolve_runtime(
+ JmAsyncRuntime,
+ 'async API requires JmAsyncRuntime',
+ )
- return _finish_download_result(album, dler, task_started_at)
+ try:
+ with jm_task_context(
+ option=option,
+ runtime=runtime,
+ 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)
+ finally:
+ if runtime_created_here:
+ runtime.close()
async def download_photo_async(jm_photo_id,
@@ -223,11 +320,13 @@ async def download_photo_async(jm_photo_id,
check_exception=True,
extra=None,
):
- """
- 异步下载一个章节(photo)。
- 返回的 downloader 已关闭网络和线程池资源,仅用于读取下载结果。
- check_exception 仅当 jm_photo_id 是单个 ID 时生效。多 ID 场景请检查
- BatchResult.failed,或自行封装 download_batch_async 处理批量异常。
+ """异步下载一个章节。
+
+ 返回的 Downloader 已关闭网络 client,阻塞工作也已收敛。通过
+ ``jm_task_context(runtime=JmAsyncRuntime(...))`` 提供的 blocking 执行器由调用方负责关闭。
+
+ ``check_exception`` 只对单个 ID 生效。批量场景应检查
+ ``BatchResult.failed``,或按需封装 ``download_batch_async``。
"""
if not isinstance(jm_photo_id, (str, int)):
return await download_batch_async(download_photo_async,
@@ -237,52 +336,87 @@ async def download_photo_async(jm_photo_id,
extra=extra
)
+ option = _ensure_option(option)
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)
-
+ runtime, runtime_created_here = _resolve_runtime(
+ JmAsyncRuntime,
+ 'async API requires JmAsyncRuntime',
+ )
-async def download_batch_async(download_api,
- jm_id_iter,
- option=None,
- downloader=None,
- **kwargs,
- ) -> BatchResult:
+ try:
+ with jm_task_context(
+ option=option,
+ runtime=runtime,
+ 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)
+ finally:
+ if runtime_created_here:
+ runtime.close()
+
+
+async def download_batch_async(
+ download_api,
+ jm_id_iter,
+ option=None,
+ downloader=None,
+ **kwargs,
+) -> BatchResult:
"""
异步批量下载 album / photo。
- 容错机制:单个 album/photo 失败不会中止整批,也不会丢失其它已完成结果。
- 返回 BatchResult(set),失败项收集在 result.failed 中。
"""
- 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))
+ result = BatchResult()
+ if len(jm_ids) == 0:
+ return result
download_type = _download_type(download_api)
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,
+ runtime, runtime_created_here = _resolve_runtime(
+ JmAsyncRuntime,
+ 'async API requires JmAsyncRuntime',
)
- # 失败不抛出,但要记录到 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
+ try:
+ with jm_task_context(option=option, runtime=runtime):
+ tasks = [asyncio.create_task(_download_one(jmid)) for jmid in jm_ids]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ for item in results:
+ if isinstance(item, DownloadCancelledException):
+ raise item
+
+ 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
+ finally:
+ if runtime_created_here:
+ runtime.close()
diff --git a/src/jmcomic/cli.py b/src/jmcomic/cli.py
index ad1871db8..ffea99858 100644
--- a/src/jmcomic/cli.py
+++ b/src/jmcomic/cli.py
@@ -135,6 +135,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:
@@ -146,11 +147,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 d7ac2b27e..a558448d3 100644
--- a/src/jmcomic/jm_async_client.py
+++ b/src/jmcomic/jm_async_client.py
@@ -681,8 +681,11 @@ async def add_favorite_album(self, album_id, folder_id='0'):
将指定图集加入用户的收藏夹。
注意:移动端没有提供 folder_id 参数。
"""
- # 服务端实现上使用带 body 的 GET 请求方式
- resp = await self.req_api('/favorite', data={'aid': album_id})
+ resp = await self.req_api(
+ self.API_FAVORITE,
+ get=False,
+ data={'aid': album_id},
+ )
data = resp.model_data
if data.status != 'ok':
ExceptionTool.raises_resp(data.msg, resp)
diff --git a/src/jmcomic/jm_async_downloader.py b/src/jmcomic/jm_async_downloader.py
index 3f89e24e0..e6eb4449d 100644
--- a/src/jmcomic/jm_async_downloader.py
+++ b/src/jmcomic/jm_async_downloader.py
@@ -10,13 +10,19 @@
import asyncio
import os
-from concurrent.futures import ThreadPoolExecutor
+from contextlib import ExitStack
from .jm_downloader import BaseDownloader, 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,
+ get_jm_runtime,
+ jm_task_context,
+)
from .jm_option import JmOption
@@ -50,8 +56,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._runtime_context = ExitStack()
@classmethod
def use(cls, *args, **kwargs):
@@ -70,17 +87,35 @@ 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 = get_jm_runtime()
+ if not isinstance(runtime, JmAsyncRuntime):
+ raise RuntimeError(
+ 'async downloader requires jm_task_context('
+ 'runtime=JmAsyncRuntime(...))'
+ )
+ worker = bind_jm_task_context(func)
+ executor = runtime.executor('blocking', self._decode_worker)
+ future = executor.submit(worker, *args)
+ waiter = asyncio.wrap_future(future)
+ try:
+ return await asyncio.shield(waiter)
+ except asyncio.CancelledError:
+ # 线程任务无法可靠中断,协程取消后仍需等待写盘等操作收尾。
+ try:
+ await waiter
+ except BaseException:
+ pass
+ raise
@record_download_duration('album_started_at')
async def download_album(self, album_id) -> JmAlbumDetail:
"""对齐 sync JmDownloader.download_album"""
- album = await self.client.get_album_detail(album_id)
+ self.raise_if_cancelled()
+ try:
+ album = await self.client.get_album_detail(album_id)
+ except Exception:
+ self.raise_if_cancelled()
+ raise
self.begin_manifest(album)
try:
await self.download_by_album_detail(album)
@@ -106,7 +141,10 @@ 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)
+ results = await asyncio.gather(*photo_tasks, return_exceptions=True)
+ for item in results:
+ if isinstance(item, BaseException):
+ raise item
await self.after_album(album)
@@ -114,14 +152,22 @@ 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:
+ self.raise_if_cancelled()
jm_log('photo.failed', f'章节下载失败: [{photo.id}], 异常: [{e}]', e)
self.download_failed_photo.append((photo, e))
@record_download_duration('photo_started_at')
async def download_photo(self, photo_id) -> JmPhotoDetail:
"""对齐 sync JmDownloader.download_photo"""
- photo = await self.client.get_photo_detail(photo_id)
+ self.raise_if_cancelled()
+ try:
+ photo = await self.client.get_photo_detail(photo_id)
+ except Exception:
+ self.raise_if_cancelled()
+ raise
self.begin_manifest(photo)
try:
await self.download_by_photo_detail(photo)
@@ -135,11 +181,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 +205,10 @@ async def download_by_photo_detail(self, photo: JmPhotoDetail):
self._safe_download_image(image)
for image in image_list
]
- await asyncio.gather(*download_tasks)
+ results = await asyncio.gather(*download_tasks, return_exceptions=True)
+ for item in results:
+ if isinstance(item, BaseException):
+ raise item
await self.after_photo(photo)
@@ -167,7 +219,10 @@ async def _safe_download_image(self, image: JmImageDetail):
"""
try:
await self._download_single_image(image)
+ except DownloadCancelledException:
+ raise
except Exception as e:
+ self.raise_if_cancelled()
jm_log('image.failed', f'图片下载失败: [{image.download_url}], 异常: [{e}]', e)
self.download_failed_image.append((image, e))
@@ -188,12 +243,15 @@ async def _download_single_image(self, image: JmImageDetail):
if image.cache and image.exists:
await self.after_image(image, img_save_path)
+ self.raise_if_cancelled()
return
decode_image = self.option.decide_download_image_decode(image)
# 异步下载图片(受 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
@@ -225,6 +283,7 @@ async def _download_single_image(self, image: JmImageDetail):
)
await self.after_image(image, img_save_path)
+ self.raise_if_cancelled()
# ======================================================================
# 磁盘写入(在线程池中执行)
@@ -278,17 +337,30 @@ async def after_image(self, image: JmImageDetail, img_save_path: str):
await self._run_in_decode_pool(super().after_image, image, img_save_path)
def shutdown(self):
- """关闭解密线程池"""
- self._decode_pool.shutdown(wait=False)
+ """关闭 __aenter__ 建立的任务上下文和自建 Runtime。"""
+ self._runtime_context.close()
async def __aenter__(self):
- # 创建并独占一个 async client(含 AsyncSession)。
- self.client = self.option.new_jm_async_client(max_clients=self._image_concurrency)
+ runtime = get_jm_runtime()
+ if runtime is None:
+ runtime = JmAsyncRuntime()
+ self._runtime_context.callback(runtime.close)
+ self._runtime_context.enter_context(
+ jm_task_context(runtime=runtime)
+ )
+ if not isinstance(runtime, JmAsyncRuntime):
+ raise TypeError('async downloader requires JmAsyncRuntime')
+
try:
+ # 创建并独占一个 async client(含 AsyncSession)。
+ self.client = self.option.new_jm_async_client(
+ max_clients=self._image_concurrency
+ )
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,8 +371,6 @@ 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()
diff --git a/src/jmcomic/jm_client_impl.py b/src/jmcomic/jm_client_impl.py
index c183a2624..449fd46c1 100644
--- a/src/jmcomic/jm_client_impl.py
+++ b/src/jmcomic/jm_client_impl.py
@@ -268,15 +268,29 @@ def add_favorite_album(self,
folder_id='0',
):
data = {
- 'album_id': album_id,
- 'fid': folder_id,
+ 'album_id': str(album_id),
+ 'fid': str(folder_id),
}
- resp = self.get_jm_html(
+ resp = self.post(
'/ajax/favorite_album',
data=data,
+ headers={
+ 'accept': 'application/json, text/javascript, */*; q=0.01',
+ 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
+ 'sec-fetch-dest': 'empty',
+ 'sec-fetch-mode': 'cors',
+ 'sec-fetch-site': 'same-origin',
+ 'x-requested-with': 'XMLHttpRequest',
+ },
)
+ 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:
@@ -967,7 +981,8 @@ def add_favorite_album(self,
移动端没有提供folder_id参数
"""
resp = self.req_api(
- '/favorite',
+ self.API_FAVORITE,
+ get=False,
data={
'aid': album_id,
},
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index 2846e547b..41925276f 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -5,7 +5,14 @@
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 JmSimpleRuntime, JmSyncRuntime
+from .jm_task_context import (
+ bind_jm_task_context,
+ get_current_control,
+ get_jm_runtime,
+ get_jm_task_context,
+ jm_task_context,
+)
def record_download_duration(context_key: str, clock=None):
@@ -79,9 +86,13 @@ 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
+ self.raise_if_cancelled()
detail: JmBaseEntity = args[0]
if detail.is_image():
detail: JmImageDetail
@@ -196,9 +207,25 @@ def all_success(self) -> bool:
def has_download_failures(self):
return len(self.download_failed_image) != 0 or len(self.download_failed_photo) != 0
+ def is_cancelled(self) -> bool:
+ control = get_current_control()
+ return control is not None and control.is_cancelled
+
+ @classmethod
+ def raise_if_cancelled(cls) -> None:
+ """在当前下载作用域已取消时抛出异常,子类可重写该检查点。"""
+ control = get_current_control()
+ if control is None or not control.is_cancelled:
+ return
+ raise DownloadCancelledException(
+ control.reason,
+ {'control': control, 'reason': control.reason},
+ )
+
# 下面是回调方法
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 +233,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,26 +257,32 @@ 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):
+ self.raise_if_cancelled()
super().after_image(image, img_save_path)
self.option.call_all_plugin(
'after_image',
@@ -255,6 +292,7 @@ def after_image(self, image: JmImageDetail, img_save_path):
photo = image.from_photo
album = photo.from_album
self.download_success_dict.get(album).get(photo).append((image.save_path, image))
+ self.raise_if_cancelled()
def begin_manifest(self, detail: DetailEntity) -> DownloadManifest:
manifest = DownloadManifest()
@@ -355,12 +393,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:
@@ -438,7 +481,12 @@ def create_client(self):
@record_download_duration('album_started_at')
def download_album(self, album_id):
- album = self.client.get_album_detail(album_id)
+ self.raise_if_cancelled()
+ try:
+ album = self.client.get_album_detail(album_id)
+ except Exception:
+ self.raise_if_cancelled()
+ raise
self.begin_manifest(album)
try:
self.download_by_album_detail(album)
@@ -455,13 +503,18 @@ 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),
)
self.after_album(album)
@record_download_duration('photo_started_at')
def download_photo(self, photo_id):
- photo = self.client.get_photo_detail(photo_id)
+ self.raise_if_cancelled()
+ try:
+ photo = self.client.get_photo_detail(photo_id)
+ except Exception:
+ self.raise_if_cancelled()
+ raise
self.begin_manifest(photo)
try:
self.download_by_photo_detail(photo)
@@ -480,7 +533,7 @@ 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),
)
self.after_photo(photo)
@@ -498,6 +551,7 @@ def download_by_image_detail(self, image: JmImageDetail):
if image.cache and image.exists:
self.after_image(image, img_save_path)
+ self.raise_if_cancelled()
return
decode_image = self.option.decide_download_image_decode(image)
@@ -508,36 +562,43 @@ def download_by_image_detail(self, image: JmImageDetail):
)
self.after_image(image, img_save_path)
+ self.raise_if_cancelled()
+
+ def execute_on_condition(self, iter_objs, apply, count_batch):
+ """使用当前 Runtime 调度本子或章节的下载。"""
+ runtime = get_jm_runtime()
+ if runtime is not None and not isinstance(runtime, JmSyncRuntime):
+ raise TypeError('sync downloader requires JmSyncRuntime')
+ level = None if runtime is None else (
+ 'photo' if iter_objs.is_album() else 'image'
+ )
- 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)
-
- if count_real == 0:
+ if len(iter_objs) == 0:
return
- apply = bind_jm_task_context(apply)
+ if isinstance(count_batch, bool) or not isinstance(count_batch, int) or count_batch <= 0:
+ raise ValueError(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,
- )
- else:
- # 创建batch个线程的线程池
- thread_pool_executor(
- iter_objs=iter_objs,
- apply_each_obj_func=apply,
- max_workers=count_batch,
- )
+ worker = bind_jm_task_context(apply)
+
+ if runtime is None:
+ runtime = JmSimpleRuntime(workers=count_batch)
+ try:
+ runtime.multi_thread_launcher(
+ iter_objs=iter_objs,
+ apply_each_obj_func=worker,
+ )
+ finally:
+ runtime.close()
+ return
+
+ runtime.multi_thread_launcher(
+ iter_objs=iter_objs,
+ apply_each_obj_func=worker,
+ level=level,
+ default_workers=count_batch,
+ )
# 下面是对with语法的支持
diff --git a/src/jmcomic/jm_exception.py b/src/jmcomic/jm_exception.py
index f88392e44..56c164d39 100644
--- a/src/jmcomic/jm_exception.py
+++ b/src/jmcomic/jm_exception.py
@@ -94,6 +94,25 @@ 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('reason', msg)
+ super().__init__(msg, context)
+
+ @property
+ def control(self):
+ return self.from_context('control')
+
+ @property
+ def reason(self) -> str:
+ return str(self.context.get('reason', 'download cancelled'))
+
+
class ExceptionTool:
"""
抛异常的工具
@@ -198,17 +217,17 @@ 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)
diff --git a/src/jmcomic/jm_runtime.py b/src/jmcomic/jm_runtime.py
new file mode 100644
index 000000000..fc9b664e7
--- /dev/null
+++ b/src/jmcomic/jm_runtime.py
@@ -0,0 +1,236 @@
+import concurrent.futures
+from concurrent.futures import Executor, ProcessPoolExecutor, ThreadPoolExecutor
+from threading import RLock
+from typing import Callable, Dict, Optional, Set, Tuple
+
+from common import process_single_arg_to_args_and_kwargs
+
+
+__all__ = (
+ 'JmRuntime',
+ 'JmSimpleRuntime',
+ '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 的配置、创建、调度和关闭。"""
+
+ _default_level = None
+
+ 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
+
+ def executor(
+ self,
+ level: Optional[str] = None,
+ default_workers: Optional[int] = None,
+ ) -> Executor:
+ """返回指定层级的 Executor;未配置时按调用点给出的默认并发数创建。"""
+ if level is None:
+ level = self._default_level
+ 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))
+ except BaseException:
+ if wait_finish:
+ concurrent.futures.wait(futures)
+ raise
+
+ 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)
+
+
+class JmSimpleRuntime(JmRuntime):
+ """单 Executor Runtime:用于一次同步下载中的局部并发。"""
+
+ _default_level = 'default'
+
+ 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),
+ },
+ )
+
+
+class JmSyncRuntime(JmRuntime):
+ """同步下载 Runtime:分别管理 id、photo、image 三层 Executor。"""
+
+ def __init__(
+ self,
+ *,
+ id_workers: Optional[int] = None,
+ id_executor: Optional[Executor] = None,
+ photo_workers: Optional[int] = None,
+ photo_executor: Optional[Executor] = None,
+ image_workers: Optional[int] = None,
+ image_executor: Optional[Executor] = None,
+ ):
+ external_executors = [
+ executor
+ for executor in (id_executor, photo_executor, image_executor)
+ if executor is not None
+ ]
+ if len({id(executor) for executor in external_executors}) != len(external_executors):
+ raise ValueError(
+ 'id/photo/image levels must use different executor objects'
+ )
+
+ super().__init__(
+ configs={
+ 'id': normalize_jm_executor_config('id', id_workers, id_executor),
+ '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 的 blocking Executor。"""
+
+ def __init__(
+ self,
+ *,
+ blocking_workers: Optional[int] = None,
+ blocking_executor: Optional[Executor] = None,
+ ):
+ super().__init__(
+ configs={
+ 'blocking': normalize_jm_executor_config(
+ 'blocking', blocking_workers, blocking_executor
+ ),
+ },
+ )
diff --git a/src/jmcomic/jm_task_context.py b/src/jmcomic/jm_task_context.py
index 57a5e9e1d..8a1d94ad2 100644
--- a/src/jmcomic/jm_task_context.py
+++ b/src/jmcomic/jm_task_context.py
@@ -2,14 +2,21 @@
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',
+ 'DownloadControl',
'jm_task_context',
'get_jm_task_context',
+ 'get_current_control',
+ 'get_jm_runtime',
+ 'get_current_option',
'bind_jm_task_context',
)
@@ -21,16 +28,82 @@
)
+class DownloadControl:
+ """在线程间共享、线程安全且幂等的下载取消信号。"""
+
+ def __init__(self):
+ self._event = Event()
+ self._lock = Lock()
+ self._reason = 'download cancelled'
+
+ @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) -> str:
+ with self._lock:
+ return self._reason
+
+
def get_jm_task_context() -> dict:
- """Return a mutable snapshot of the current JM task context."""
+ """返回当前 JM 任务上下文的可变快照。"""
return dict(JM_TASK_CONTEXT.get())
+def get_current_control() -> Optional[DownloadControl]:
+ """返回当前 JM 任务上下文中的取消控制器。"""
+ control = get_jm_task_context().get('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
+
+
+def get_jm_runtime() -> Optional[JmRuntime]:
+ """返回当前任务的 Runtime;没有活动 Runtime 时返回 None。"""
+ return get_jm_task_context().get('runtime')
+
+
+def get_current_option():
+ """返回当前任务的 Option;没有活动 Option 时返回 None。"""
+ return get_jm_task_context().get('option')
+
+
@contextmanager
-def jm_task_context(**fields):
- """Temporarily add fields to the current JM task context."""
+def jm_task_context(*, option=None, runtime=None, **fields):
+ """临时绑定任务字段;只传播 Option/Runtime,不管理资源生命周期。"""
context = get_jm_task_context()
+ parent_runtime = context.get('runtime')
+
+ if runtime is not None and not isinstance(runtime, JmRuntime):
+ raise TypeError('runtime must be JmSyncRuntime, JmAsyncRuntime, or None')
+ if (
+ runtime is not None
+ and parent_runtime is not None
+ and runtime is not parent_runtime
+ ):
+ raise RuntimeError('another JmRuntime is already active in this task context')
+
context.update(fields)
+ if runtime is not None:
+ context['runtime'] = runtime
+ if option is not None:
+ context['option'] = option
+
token = JM_TASK_CONTEXT.set(MappingProxyType(context))
try:
yield
@@ -39,9 +112,11 @@ def jm_task_context(**fields):
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(
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..3c4dc2843 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__(blocking_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(get_jm_runtime())
+ with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
+ runtime.executor('blocking', 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..8f395128e
--- /dev/null
+++ b/tests/test_jmcomic/test_jm_cancellation.py
@@ -0,0 +1,353 @@
+import asyncio
+from types import SimpleNamespace
+from threading import Thread
+import unittest
+from unittest.mock import AsyncMock, Mock, patch
+
+from jmcomic import (
+ BaseDownloader,
+ DownloadCancelledException,
+ DownloadControl,
+ JmImageDetail,
+ JmAsyncDownloader,
+ JmDownloader,
+ bind_jm_task_context,
+ download_album,
+ download_batch,
+ download_batch_async,
+ get_current_control,
+ 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(get_current_control())
+ )
+ thread = Thread(target=worker)
+ thread.start()
+ thread.join()
+
+ self.assertEqual(seen, [control])
+ self.assertFalse(control.is_cancelled)
+ 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,
+ {'control': control, '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',
+ {'control': control, '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(get_current_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())
+
+ 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(
+ download_batch(lambda *_args: None, [], option=object()),
+ set(),
+ )
+ self.assertEqual(
+ 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_converts_client_failure_after_cancellation(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(DownloadCancelledException, '请求期间取消'):
+ downloader.download_album('123')
+
+ async def test_async_downloader_converts_client_failure_after_cancellation(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(DownloadCancelledException, '异步请求期间取消'):
+ await downloader.download_album('123')
+
+ 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)
+ downloader._run_in_decode_pool = AsyncMock()
+ 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.download_url = 'https://example.invalid/image.jpg'
+ recorded = []
+
+ async def after_image(current_image, _path):
+ recorded.append(current_image)
+ control.cancel('stop after current image')
+
+ downloader.before_image = AsyncMock()
+ downloader.after_image = after_image
+
+ 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(recorded, [image])
+ downloader._run_in_decode_pool.assert_awaited_once()
+
+ 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 d20f16c1d..9dd1beb58 100644
--- a/tests/test_jmcomic/test_jm_cli.py
+++ b/tests/test_jmcomic/test_jm_cli.py
@@ -1,9 +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 import JmSyncRuntime, get_jm_runtime, jm_task_context
from jmcomic.jm_task_context import get_jm_task_context
@@ -107,6 +110,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,
+ get_jm_runtime(),
+ get_jm_task_context(),
+ ))
+
+ with ThreadPoolExecutor(max_workers=1) as executor:
+ runtime = JmSyncRuntime(id_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 350234"""
JustDownloadSpecificCountImage.count = 5
diff --git a/tests/test_jmcomic/test_jm_client.py b/tests/test_jmcomic/test_jm_client.py
index aefe69467..85abb6d89 100644
--- a/tests/test_jmcomic/test_jm_client.py
+++ b/tests/test_jmcomic/test_jm_client.py
@@ -1,11 +1,42 @@
import asyncio
from types import SimpleNamespace
+from unittest.mock import Mock
from test_jmcomic import *
class Test_Client(JmTestConfigurable):
+ def test_add_favorite_album_uses_ajax_form_post(self):
+ client = object.__new__(JmHtmlClient)
+ response = SimpleNamespace(
+ status_code=200,
+ url='https://example.com/ajax/favorite_album',
+ redirect_count=0,
+ text='{"status": 1}',
+ json=lambda: {'status': 1},
+ )
+ client.post = Mock(return_value=response)
+
+ result = JmHtmlClient.add_favorite_album(client, 21, 123)
+
+ self.assertIs(result, response)
+ client.post.assert_called_once_with(
+ '/ajax/favorite_album',
+ data={
+ 'album_id': '21',
+ 'fid': '123',
+ },
+ headers={
+ 'accept': 'application/json, text/javascript, */*; q=0.01',
+ 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
+ 'sec-fetch-dest': 'empty',
+ 'sec-fetch-mode': 'cors',
+ 'sec-fetch-site': 'same-origin',
+ 'x-requested-with': 'XMLHttpRequest',
+ },
+ )
+
def test_download_image(self):
jm_photo_id = 'JM438516'
photo = self.client.get_photo_detail(jm_photo_id)
diff --git a/tests/test_jmcomic/test_jm_download_manifest.py b/tests/test_jmcomic/test_jm_download_manifest.py
index 48037c20b..cdb156d04 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
@@ -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,53 @@ def do_filter(self, detail):
class Test_Download_Manifest(unittest.TestCase):
+ 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()
@@ -1032,21 +1085,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 57b151e4d..6e81b30de 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
@@ -159,7 +154,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()
@@ -186,19 +181,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()
@@ -470,7 +470,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)
@@ -548,18 +548,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..4d2e2834f 100644
--- a/tests/test_jmcomic/test_jm_exception.py
+++ b/tests/test_jmcomic/test_jm_exception.py
@@ -4,6 +4,20 @@
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_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_favorite.py b/tests/test_jmcomic/test_jm_favorite.py
new file mode 100644
index 000000000..134656e09
--- /dev/null
+++ b/tests/test_jmcomic/test_jm_favorite.py
@@ -0,0 +1,39 @@
+import asyncio
+import unittest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+from jmcomic import AsyncJmApiClient, JmApiClient
+
+
+class Test_Favorite(unittest.TestCase):
+
+ def test_api_add_favorite_album_uses_post(self):
+ client = object.__new__(JmApiClient)
+ response = SimpleNamespace(model_data=SimpleNamespace(status='ok'))
+ client.req_api = Mock(return_value=response)
+ client.require_resp_status_ok = Mock()
+
+ result = JmApiClient.add_favorite_album(client, 21)
+
+ self.assertIs(result, response)
+ client.req_api.assert_called_once_with(
+ client.API_FAVORITE,
+ get=False,
+ data={'aid': 21},
+ )
+ client.require_resp_status_ok.assert_called_once_with(response)
+
+ def test_async_api_add_favorite_album_uses_post(self):
+ client = object.__new__(AsyncJmApiClient)
+ response = SimpleNamespace(model_data=SimpleNamespace(status='ok'))
+ client.req_api = AsyncMock(return_value=response)
+
+ result = asyncio.run(AsyncJmApiClient.add_favorite_album(client, 21))
+
+ self.assertIs(result, response)
+ client.req_api.assert_awaited_once_with(
+ client.API_FAVORITE,
+ get=False,
+ data={'aid': 21},
+ )
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..6851be639
--- /dev/null
+++ b/tests/test_jmcomic/test_jm_runtime.py
@@ -0,0 +1,267 @@
+import asyncio
+import threading
+import time
+import unittest
+from concurrent.futures import ThreadPoolExecutor
+
+from jmcomic import (
+ DownloadCancelledException,
+ JmAsyncRuntime,
+ JmSimpleRuntime,
+ JmSyncRuntime,
+ download_batch,
+ download_batch_async,
+ get_jm_runtime,
+ jm_task_context,
+)
+
+
+class Test_Jm_Runtime(unittest.TestCase):
+
+ def test_launcher_expands_arguments_and_returns_done_futures(self):
+ runtime = JmSyncRuntime(id_workers=2)
+ try:
+ positional = runtime.multi_thread_launcher(
+ [(1, 2), (3, 4)],
+ lambda left, right: left + right,
+ level='id',
+ )
+ keyword = runtime.multi_thread_launcher(
+ [{'left': 5, 'right': 6}],
+ lambda left, right: left + right,
+ level='id',
+ )
+
+ 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(id_workers=1)
+
+ def blocked(_item):
+ started.set()
+ release.wait(timeout=2)
+
+ try:
+ futures = runtime.multi_thread_launcher(
+ [1],
+ blocked,
+ False,
+ level='id',
+ )
+ 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(id_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='id',
+ ))
+ 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(id_executor=executor)
+
+ def blocked(_item):
+ first_started.set()
+ release_first.wait(timeout=2)
+
+ def launch():
+ try:
+ runtime.multi_thread_launcher(
+ [1, 2],
+ blocked,
+ level='id',
+ )
+ 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(id_executor=executor)
+ futures = runtime.multi_thread_launcher(
+ [1, 2],
+ lambda item: item * 2,
+ level='id',
+ )
+ 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_simple_runtime_uses_one_executor_without_level(self):
+ executor = ThreadPoolExecutor(max_workers=1)
+ try:
+ runtime = JmSimpleRuntime(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_explicitly_closes_runtime_created_by_api(self):
+ observed = []
+
+ def download_one(jmid, *_args, **_kwargs):
+ observed.append(get_jm_runtime())
+ return jmid
+
+ self.assertEqual(
+ {'1'},
+ set(download_batch(download_one, ['1'], option=object())),
+ )
+ self.assertEqual(1, len(observed))
+ with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
+ observed[0].executor('id', 1)
+
+ def test_async_batch_explicitly_closes_runtime_created_by_api(self):
+ observed = []
+
+ async def download_one(jmid, *_args, **_kwargs):
+ observed.append(get_jm_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.assertIsInstance(observed[0], JmAsyncRuntime)
+ with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
+ observed[0].executor('blocking', 1)
+
+ 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(id_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))
+
+
+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..a5cea3111 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,7 +9,9 @@
from jmcomic import (
BaseDownloader,
+ JmAsyncRuntime,
DownloadManifest,
+ DownloadControl,
Feature,
JmAsyncDownloader,
JmDownloader,
@@ -18,12 +21,17 @@
JM_TASK_CONTEXT,
PhotoConcurrentFetcherProxy,
PrettyFormatter,
+ JmSimpleRuntime,
+ JmSyncRuntime,
bind_jm_task_context,
default_jm_logging,
download_album,
download_batch,
download_batch_async,
download_photo_async,
+ get_current_control,
+ get_current_option,
+ get_jm_runtime,
get_jm_task_context,
jm_log,
jm_task_context,
@@ -41,8 +49,203 @@ 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(id_executor=executor)
+ with jm_task_context(task_id='job-42', runtime=runtime):
+ self.assertEqual(
+ get_jm_task_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(get_jm_runtime(), runtime)
+ with jm_task_context(jm_id='123'):
+ self.assertEqual(
+ get_jm_task_context(),
+ {
+ 'task_id': 'job-42',
+ 'runtime': runtime,
+ 'jm_id': '123',
+ },
+ )
+
+ public_bound = executor.submit(bind_jm_task_context(
+ lambda: (
+ get_jm_task_context(),
+ get_jm_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(
+ get_jm_task_context(),
+ {'task_id': 'job-42', 'option': option},
+ )
+ self.assertIs(get_current_option(), option)
+ future = executor.submit(bind_jm_task_context(get_current_option))
+
+ self.assertIs(future.result(timeout=1), option)
+
+ self.assertIsNone(get_current_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(get_jm_task_context(), {'task_id': 'job-42'})
+ self.assertIsNone(get_current_option())
+ self.assertIsNone(get_jm_runtime())
+
+ outer = JmSyncRuntime(id_workers=1)
+ inner = JmSyncRuntime(id_workers=1)
+ try:
+ with jm_task_context(runtime=outer):
+ with jm_task_context(runtime=outer):
+ self.assertIs(get_jm_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(id_workers=1)
+ with jm_task_context(runtime=runtime):
+ pass
+ futures = runtime.multi_thread_launcher(
+ [1],
+ lambda item: item,
+ level='id',
+ )
+ self.assertEqual(1, futures[0].result())
+ runtime.close()
+ with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
+ runtime.multi_thread_launcher(
+ [1],
+ lambda item: item,
+ level='id',
+ )
+
+ 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(id_workers=1, id_executor=executor)
+ with self.assertRaisesRegex(ValueError, 'different executor'):
+ JmSyncRuntime(id_executor=executor, photo_executor=executor)
+ with self.assertRaisesRegex(ValueError, 'mutually exclusive'):
+ JmAsyncRuntime(blocking_workers=1, blocking_executor=executor)
+ with self.assertRaisesRegex(ValueError, 'mutually exclusive'):
+ JmSimpleRuntime(workers=1, executor=executor)
+
+ with self.assertRaisesRegex(ValueError, 'positive integer'):
+ JmSyncRuntime(photo_workers=0)
+ with self.assertRaisesRegex(ValueError, 'positive integer'):
+ JmAsyncRuntime(blocking_workers=True)
+ with self.assertRaisesRegex(ValueError, 'positive integer'):
+ JmSimpleRuntime(workers=0)
+
+ def test_sync_runtime_reuses_one_id_pool_across_batches(self):
+ worker_threads = []
+
+ def fake_download(jmid, _option, _downloader, **_kwargs):
+ worker_threads.append(threading.get_ident())
+ return str(jmid)
+
+ runtime = JmSyncRuntime(id_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 id_executor:
+ runtime = JmSyncRuntime(id_executor=id_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(id_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(get_current_control))
+
+ self.assertIs(future.result(timeout=1), control)
+ self.assertIsNone(executor.submit(get_current_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)
@@ -267,10 +470,17 @@ 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.assertIsInstance(album_context['runtime'], JmSyncRuntime)
+ with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
+ album_context['runtime'].executor('photo', 1)
+ with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
+ photo_context['runtime'].executor('blocking', 1)
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
@@ -292,18 +502,21 @@ def download_album(self, _album_id):
self.begin_manifest(detail)
try:
observed_contexts.append(get_jm_task_context())
+ observed_options.append(get_current_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([option], observed_options)
self.assertEqual({}, get_jm_task_context())
+ self.assertIsNone(get_current_option())
def test_async_result_duration_uses_task_context_and_finishes_after_downloader_exit(self):
async def run_test():
@@ -497,9 +710,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,36 +724,52 @@ 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',
'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.assertIsInstance(log_context['runtime'], JmSyncRuntime)
- def test_sync_downloader_propagates_both_threading_branches(self):
+ def test_sync_downloader_uses_and_closes_temporary_simple_runtime(self):
downloader = object.__new__(JmDownloader)
BaseDownloader.__init__(downloader, object())
+ created = []
+
+ class ProbeRuntime(JmSimpleRuntime):
+
+ 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.JmSimpleRuntime', 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(get_jm_task_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():
@@ -560,21 +790,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 blocking_executor:
+ runtime = JmAsyncRuntime(blocking_executor=blocking_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(
+ 'blocking',
+ 1,
+ ).submit(
+ bind_jm_task_context(get_jm_task_context),
+ )
+ executor_context = await asyncio.wrap_future(future)
loop = asyncio.get_running_loop()
leaked_context = await loop.run_in_executor(
- downloader._decode_pool,
+ blocking_executor,
get_jm_task_context,
)
- finally:
- downloader.shutdown()
return batch_result, executor_context, leaked_context
@@ -587,7 +819,8 @@ async def fake_download(jmid, _option, _downloader, **_kwargs):
},
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):
@@ -603,6 +836,56 @@ class FakeClient:
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,
+ )
+ 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(blocking_executor=executor)
+ with jm_task_context(session_id='async-runtime', runtime=runtime):
+ future = runtime.executor(
+ 'blocking',
+ 1,
+ ).submit(
+ bind_jm_task_context(
+ lambda: (8, get_jm_task_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())
+
if __name__ == '__main__':
unittest.main()
From 97e55bb289f8c94afe1910afde671de191fefc4b Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Thu, 3 Sep 2026 01:14:07 +0800
Subject: [PATCH 2/8] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20JTC=20=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E4=B8=8A=E4=B8=8B=E6=96=87=E9=97=A8=E9=9D=A2=EF=BC=8C?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=BC=82=E6=AD=A5=20decode=20=E5=91=BD?=
=?UTF-8?q?=E5=90=8D=EF=BC=8C=E5=AE=8C=E5=96=84=E6=95=99=E7=A8=8B=E6=96=87?=
=?UTF-8?q?=E6=A1=A3=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC=E8=87=B3?=
=?UTF-8?q?=20v2.7.6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 3 +-
assets/docs/sources/api/download.md | 13 +-
.../docs/sources/tutorial/0_common_usage.md | 95 +++++++----
.../docs/sources/tutorial/14_async_usage.md | 45 +++++
.../sources/tutorial/16_shared_executors.md | 155 ++++++++++++------
src/jmcomic/__init__.py | 2 +-
src/jmcomic/api.py | 4 +-
src/jmcomic/jm_async_downloader.py | 2 +-
src/jmcomic/jm_exception.py | 4 +-
src/jmcomic/jm_runtime.py | 36 ++--
src/jmcomic/jm_task_context.py | 63 ++++++-
tests/test_jmcomic/test_jm_async_custom.py | 4 +-
tests/test_jmcomic/test_jm_runtime.py | 2 +-
tests/test_jmcomic/test_jm_task_context.py | 48 +++++-
14 files changed, 346 insertions(+), 130 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11b84e2ec..37dc1badd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,8 +8,9 @@
## [Unreleased]
### Added
-- 新增 `JmSimpleRuntime`、`JmSyncRuntime` 与 `JmAsyncRuntime`;裸同步 Downloader 的局部调度使用单池 Runtime,顶层同步 API 或自定义同步调度可复用 `id/photo/image` 三层线程池,异步下载可复用 `blocking` 线程池。
+- 新增 `JmSimpleRuntime`、`JmSyncRuntime` 与 `JmAsyncRuntime`;裸同步 Downloader 的局部调度使用单池 Runtime,顶层同步 API 或自定义同步调度可复用 `id/photo/image` 三层线程池,异步下载可复用 `decode` 线程池。
- 新增 `DownloadControl` 和 `DownloadCancelledException`,支持通过任务上下文协作式取消同步与异步下载。
+- 新增 `JTC` (Jm Task Context) 门面类,统一提供上下文、Runtime、Option 及取消控制器的快捷读取方法。
### Changed
- Python 3.9 保留安装兼容,但不再纳入 CI。
diff --git a/assets/docs/sources/api/download.md b/assets/docs/sources/api/download.md
index 92817233a..19a460782 100644
--- a/assets/docs/sources/api/download.md
+++ b/assets/docs/sources/api/download.md
@@ -43,21 +43,10 @@
options:
members:
- DownloadControl
+ - JTC
- jm_task_context
- bind_jm_task_context
- get_jm_task_context
- get_current_control
- get_jm_runtime
- get_current_option
-
-同步顶层 API 默认创建 `JmSyncRuntime`,并在调用结束时显式关闭。需要让多个顶层调用复用线程池时,先创建 `JmSyncRuntime`,通过 `jm_task_context(runtime=runtime)` 传播,并在任务完成后显式调用 `runtime.close()`;异步调用改用 `JmAsyncRuntime`。裸同步 Downloader 没有 Runtime 时,会为每次局部调度创建并关闭只有一个线程池的 `JmSimpleRuntime`。完整示例见[复用下载 Runtime](../tutorial/16_shared_executors.md)。
-
-Runtime 只负责 Executor 的配置、调度和生命周期。`JmSimpleRuntime.multi_thread_launcher()` 不要求下载层级;`JmSyncRuntime` 的 launcher 使用 `id/photo/image` 层级。未显式配置容量时,由下载调用点把 Option 或 Downloader 已解析出的默认 worker 数传给 Runtime。
-
-使用 `get_jm_runtime()` 可以读取当前任务激活的 Runtime;没有激活 Runtime 时返回 `None`。
-
-Runtime 和 Option 以公开字段 `runtime`、`option` 直接保存在 `JM_TASK_CONTEXT` 中。`get_jm_task_context()` 返回包含这两个字段的完整副本;`get_jm_runtime()` 和 `get_current_option()` 是读取它们的便捷方法。自定义日志处理器也可以从 LogRecord 的任务上下文中访问这两个公开字段,默认文本日志不会展开对象内容。
-
-`jm_task_context` 只负责字段传播与作用域恢复,不会关闭 Runtime。谁创建 Runtime,谁显式调用 `runtime.close()`;Runtime 只会关闭自身创建的 Executor,不会关闭调用方注入的 Executor。
-
-取消检查统一由 `BaseDownloader.raise_if_cancelled()` 这个 `classmethod` 执行。自定义 Downloader 可以重写它来调整检查策略;Client 和顶层 API 不直接读取取消状态。
diff --git a/assets/docs/sources/tutorial/0_common_usage.md b/assets/docs/sources/tutorial/0_common_usage.md
index dcb07a627..49a89dbaf 100644
--- a/assets/docs/sources/tutorial/0_common_usage.md
+++ b/assets/docs/sources/tutorial/0_common_usage.md
@@ -572,77 +572,112 @@ print('是否全部成功:', batch_result.all_succeeded)
-下载单个 ID 时,请求本子失败会直接抛出异常;如果只有部分章节或图片失败,会在任务结束后汇总抛出 `PartialDownloadFailedException`,此时不会返回 `DownloadResult`。批量下载则继续执行其他任务,并把失败项集中放进 `batch_result.failed`。
+下载单个 ID 时,请求本子失败会直接抛出异常;如果只有部分章节或图片失败,会在任务结束后汇总抛出 `PartialDownloadFailedException`,此时不会返回 `DownloadResult`。
+
+批量下载(如传入 ID 列表)具备**自动容错机制**,单个本子下载失败不会中断其他任务,所有失败项会被完整收集在 `batch_result.failed` 字典中,键为本子 ID,值为对应的异常对象:
+
+```python
+from jmcomic import download_album
+
+result = download_album(['123456', '99999999']) # 假设后者不存在
+
+print(f'全部成功? {result.all_succeeded}') # False
+print(f'成功数量: {len(result)}, 失败数量: {len(result.failed)}')
+
+# 逐一排查失败项
+for aid, err in result.failed.items():
+ print(f'本子 [{aid}] 下载失败: {err}')
+```
+
+---
### 取消下载
-下载开始后,可以从按钮、定时器或其他线程调用 `DownloadControl.cancel()` 请求停止。JMComic 收到请求后不会再开始下载新的章节和图片,但会先把当前正在处理的图片完整保存,避免留下损坏文件;随后下载会抛出 `DownloadCancelledException`,调用方可以据此提示用户任务已经取消。
+自 `v2.7.6` 起,JMComic 新增了 `DownloadControl` 控制器。如果在 GUI 界面(如 PyQt/Tkinter)、Web 接口或后台脚本中需要支持中途停止下载,可以通过它实现优雅取消。
+
+> [!NOTE]
+> `DownloadControl` 采用协作式退出设计:收到取消请求后,下载器不会再开始新的章节和图片下载;当前正在处理的那张图片会正常保存完毕后再退出,避免磁盘上留下损坏文件,随后抛出 `DownloadCancelledException`。
+
+#### 基本用法
+
+取消下载的逻辑分为三步:
+1. **创建控制器**:`control = DownloadControl()`;
+2. **绑定任务上下文**:使用 `with jm_task_context(control=control):` 包裹下载调用,将控制器传给当前下载任务;
+3. **请求停止**:在其他线程或协程中调用 `control.cancel('取消原因')`。
-下面的同步示例启动一个下载线程,并在两秒后从主线程请求取消:
+#### 1. 同步下载取消示例
+
+如果下载运行在独立的子线程(例如 GUI 点击下载按钮后启动线程),可以在主线程或其他线程中调用 `control.cancel()`:
```python
from threading import Thread
from time import sleep
-
from jmcomic import DownloadCancelledException, DownloadControl, download_album, jm_task_context
+# 1. 创建控制器
control = DownloadControl()
def run_download():
try:
- # ContextVar 不会自动进入用户创建的新线程,
- # 因此 context 必须在实际调用下载的线程内建立。
+ # 2. 通过 jm_task_context 将 control 绑定给当前下载任务
+ # (由于任务上下文是与线程绑定的,请将 with 写在实际调用下载的线程内部)
with jm_task_context(control=control):
- download_album('123')
+ download_album('123456')
except DownloadCancelledException as e:
- print('取消原因:', e.reason)
+ print(f'下载已取消: {e.reason}')
-thread = Thread(target=run_download)
-thread.start()
+t = Thread(target=run_download)
+t.start()
-# GUI 按钮、请求处理器或其他线程调用
+# 模拟用户在界面上点击了取消按钮
sleep(2)
-control.cancel('用户取消')
-thread.join()
+control.cancel('用户主动取消')
+t.join()
```
-异步 API 使用完全相同的 `DownloadControl` 和 context。`asyncio.create_task()` 会自动复制当前 Context,因此 Task 会看到同一个 `control`。`DownloadControl` 的协作式取消路径不会调用 `asyncio.Task.cancel()`;如果要保留“当前图片完整写入后再停止”的保证,只调用 `control.cancel()`。如果调用方直接取消外层异步批量 Task,批量 API 会取消并等待其内部子 Task 完成清理,再透传 `CancelledError`;这条外部取消路径不保证当前图片完整写入。
+#### 2. 异步下载取消示例
+
+异步调用的方式完全一致。在异步代码中,`asyncio.create_task` 会自动传播任务上下文,子任务会自动继承 `control`:
```python
import asyncio
-
from jmcomic import DownloadCancelledException, DownloadControl, download_album_async, jm_task_context
async def main():
control = DownloadControl()
+
+ # 绑定 control 并启动下载任务
with jm_task_context(control=control):
- task = asyncio.create_task(download_album_async('123'))
+ task = asyncio.create_task(download_album_async('123456'))
await asyncio.sleep(2)
- control.cancel('用户取消')
+ # 请求取消
+ control.cancel('任务超时取消')
+
try:
await task
except DownloadCancelledException as e:
- print('取消原因:', e.reason)
+ print(f'异步任务已取消: {e.reason}')
asyncio.run(main())
```
-取消不会删除已经完整写入的图片。同步 HTTP 请求以及已经运行的解密或写盘不能被强制中断,因此取消会在当前不可中断操作完成或超时后生效。取消后不会执行整章、整本完成回调及 PDF/ZIP 等导出 Feature。
+取消生效后,已下载完成的图片会保留在本地;未完成的章节不会触发整章或整本完成回调,也不会执行 PDF/ZIP 等导出特性。
+
+---
### 速查表
-| 你的需求 | 推荐写法 |
-| --- | --- |
-| 查看本子或章节信息 | `result.detail` |
-| 查看本子或章节目录 | `result.detail.save_path` |
-| 查看单张图片路径和状态 | 遍历实体后读取 `image.save_path` 等字段 |
-| 查看图片或章节失败原因 | 捕获 `PartialDownloadFailedException` 后,读取 `e.downloader.download_failed_image` / `download_failed_photo`;列表元素为 `(实体, 异常)` |
-| 获取本次成功图片路径列表 | `result.manifest.image_filepath_list` |
-| 获取已登记的导出文件路径 | `result.manifest.get_export_filepath_list('后缀')` |
-| 查看单个 ID 下载的完整时间 | `result.duration` |
-| 定位本子、章节或图片的内部处理慢点 | 对应实体的 `duration` |
-| 检查批量下载失败项 | `batch_result.failed` |
+| 你的需求 | 推荐写法 | 说明 |
+| :--- | :--- | :--- |
+| 查看本子或章节信息 | `result.detail` | 访问实体类所有属性(标题、作者、标签等) |
+| 查看本子或章节目录 | `result.detail.save_path` | 获取下载保存的目标文件夹路径 |
+| 查看单张图片路径和状态 | 遍历实体读取 `image.save_path` | 获取具体图片文件路径 |
+| 获取本次成功图片路径清单 | `result.manifest.image_filepath_list` | 包含所有下载成功的图片绝对路径 |
+| 获取已登记的导出文件路径 | `result.manifest.get_export_filepath_list('zip')` | 获取导出插件生成的压缩包或 PDF 路径 |
+| 查看单个 ID 下载的完整时间 | `result.duration` | 秒数,顶层完整下载耗时 |
+| 检查批量下载失败项 | `batch_result.failed` | 字典结构:`{aid: error}` |
+| 获取当前任务上下文信息 | `JTC.get_runtime()` / `get_option()` / `get_control()` | 通过 `JTC` 统一门面快捷自省 |
兼容旧版本的返回值解包写法
diff --git a/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md
index 007cd7f80..4ae7c47dd 100644
--- a/assets/docs/sources/tutorial/14_async_usage.md
+++ b/assets/docs/sources/tutorial/14_async_usage.md
@@ -270,3 +270,48 @@ asyncio.run(main())
| `image.duration` | 处理这张图片花了多久,包含检查缓存、下载、解密和保存 |
下载器可能同时处理多个章节或多张图片,所以把它们的耗时全部相加,不会得到本子的耗时,这是正常现象。同步下载中的这些字段含义相同。
+
+## 10. 异步解码池复用与性能调优 (Async Runtime)
+
+禁漫的图片下载后需要进行分块拼接、反混淆解密并存盘。异步下载时,网络请求由 `asyncio` 协程高效处理,而图片解码与写盘等阻塞操作则由后台线程池负责。
+
+默认情况下,每次调用 `download_album_async` 会自动管理解码池,任务结束后自动释放,通常不需要手动配置。
+
+自 `v2.7.6` 起,如果你在并发下载多个本子时希望控制解码并发数,或者希望复用外部线程池,可以使用 `JmAsyncRuntime`:
+
+```python
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+from jmcomic import JmAsyncRuntime, download_album_async, jm_task_context, JTC
+
+
+async def main():
+ # 场景 1:指定解码并发线程数为 4
+ runtime = JmAsyncRuntime(decode_workers=4)
+ try:
+ with jm_task_context(runtime=runtime):
+ # 两个本子的图片统一使用这 4 个解码线程处理
+ await asyncio.gather(
+ download_album_async('123456'),
+ download_album_async('789012'),
+ )
+
+ # 下载过程中可以通过 JTC 查看当前生效的 Runtime
+ print('当前 Runtime:', JTC.get_runtime())
+ finally:
+ runtime.close()
+
+ # 场景 2:复用外部已有的线程池(生命周期由外部管理,JMComic 不会主动关闭它)
+ with ThreadPoolExecutor(max_workers=4) as my_pool:
+ runtime = JmAsyncRuntime(decode_executor=my_pool)
+ try:
+ with jm_task_context(runtime=runtime):
+ await download_album_async('123456')
+ finally:
+ runtime.close()
+
+
+asyncio.run(main())
+```
+
+关于 Runtime 与任务上下文的更多进阶机制(如同步三层拓扑、JTC 门面类、外部 Executor 生命周期),请参考 [复用下载 Runtime 与共享线程池](16_shared_executors.md)。
diff --git a/assets/docs/sources/tutorial/16_shared_executors.md b/assets/docs/sources/tutorial/16_shared_executors.md
index 763b8f71f..45fbfd6be 100644
--- a/assets/docs/sources/tutorial/16_shared_executors.md
+++ b/assets/docs/sources/tutorial/16_shared_executors.md
@@ -1,109 +1,160 @@
-# 复用下载 Runtime
+# 复用下载 Runtime 与共享线程池
-普通下载不需要手动创建 Runtime。同步顶层 API 使用临时 `JmSyncRuntime` 并在返回前显式关闭;裸同步 Downloader 没有 Runtime 时,每次局部调度使用一个临时 `JmSimpleRuntime`。只有当你希望多个下载调用复用同一组线程池,或想明确控制各层并发数时,才需要 `jm_task_context`:
+日常使用 JMComic 下载时,通常不需要手动配置 Runtime。顶层下载 API 会自动管理线程资源,并在任务结束后自动释放。
-```python
-from jmcomic import JmSyncRuntime, download_album, jm_task_context
+自 `v2.7.6` 起,如果你需要在批量下载时控制并发数,或者希望复用已有程序中的线程池,可以通过 `JmSyncRuntime` 或 `JmAsyncRuntime` 进行定制。
+---
-runtime = JmSyncRuntime(
- id_workers=2,
- photo_workers=3,
- image_workers=8,
-)
-try:
- with jm_task_context(runtime=runtime):
- download_album(['123', '456'])
-finally:
- runtime.close()
+## 1. 同步下载的分层并发机制
+
+同步下载漫画时,内部通常是分层调度的:
+
+```text
+本子层 (id_executor) -> 同时下载几个本子 (Album)
+ └── 章节层 (photo_executor) -> 每个本子同时下载几个章节 (Photo)
+ └── 图片层 (image_executor) -> 每个章节同时下载几张图片 (Image)
```
-这就是分层同步 Runtime 的完整公开用法。`JmSyncRuntime` 负责 `id`、`photo` 和 `image` 三层调度;任务上下文只传播 Runtime,不管理资源,创建 Runtime 的代码负责显式关闭它。
+因为外层任务(下载本子)需要等待内层任务(下载章节和图片)完成,所以这三层需要分别使用不同的线程池。如果三层混用同一个线程池,当外层任务占满线程时,内层任务可能无法获得线程执行,导致任务互相等待。
-如果只需要直接调度一层独立任务,也可以使用 `JmSimpleRuntime(workers=...)` 或 `JmSimpleRuntime(executor=...)`。它的 `multi_thread_launcher()` 不接收 `level`。不要把同一个单池 Runtime 用于 album 的 photo/image 两层同步嵌套调度,否则外层 worker 等待内层 worker 时可能耗尽线程。
+`JmSyncRuntime` 负责集中配置和管理这三层的线程池。
-## Runtime 默认容量从哪里来
+---
-Runtime 本身不读取 Option,也不保存下载任务状态。它只管理 Executor 的配置、创建、调度和关闭。
+## 2. 同步下载:使用 JmSyncRuntime
-如果你没有给某一层配置 `*_workers`,真正发起调度的调用点会把已经解析好的默认值传给 Runtime:`photo` 和 `image` 使用 Option 中的 `download.threading.photo/image`,批量 ID 使用本次 ID 数量,异步阻塞池使用 Downloader 的 `decode_worker`。这样 Runtime 不需要反向依赖 Context 或 Option。
+### 场景 A:自定义各层并发数
-你也可以直接使用公开的 `multi_thread_launcher()`,但普通下载通常不需要这样做。`wait_finish=True` 会等待所有 Future 完成;worker 异常保存在对应 Future 中,由需要结果的调用方通过 `future.result()` 读取:
+通过 `JmSyncRuntime`,可以分别设置每一层的并发线程数,并通过 `jm_task_context` 传给下载方法:
```python
-from jmcomic import JmSyncRuntime, jm_task_context
+from jmcomic import JmSyncRuntime, download_album, jm_task_context
+# 1. 创建 Runtime,指定本子、章节和图片的并发数
+runtime = JmSyncRuntime(
+ id_workers=2, # 同时下载 2 个本子
+ photo_workers=3, # 每个本子同时下载 3 个章节
+ image_workers=8, # 每个章节同时下载 8 张图片
+)
-runtime = JmSyncRuntime(id_workers=2)
try:
+ # 2. 绑定到任务上下文并执行下载
with jm_task_context(runtime=runtime):
- futures = runtime.multi_thread_launcher(
- [123, 456],
- str,
- level='id',
- )
- print([future.result() for future in futures])
+ download_album(['123456', '789012'])
finally:
+ # 3. 释放 Runtime 创建的线程池
runtime.close()
```
-## 借用已有线程池
+如果某一层没有指定 `*_workers`,下载器会按 Option 里的默认配置建池。
+
+### 场景 B:复用已有线程池
-如果应用已经管理自己的 `ThreadPoolExecutor`,可以把它交给 Runtime。Runtime 只借用外部 Executor,不会替你关闭:
+如果你的程序本身已经维护了 `ThreadPoolExecutor`,可以直接传给 `JmSyncRuntime` 复用:
```python
from concurrent.futures import ThreadPoolExecutor
-
from jmcomic import JmSyncRuntime, download_album, jm_task_context
+with ThreadPoolExecutor(max_workers=2) as id_pool, \
+ ThreadPoolExecutor(max_workers=3) as photo_pool, \
+ ThreadPoolExecutor(max_workers=8) as image_pool:
-with ThreadPoolExecutor(max_workers=2) as id_executor, \
- ThreadPoolExecutor(max_workers=3) as photo_executor, \
- ThreadPoolExecutor(max_workers=8) as image_executor:
runtime = JmSyncRuntime(
- id_executor=id_executor,
- photo_executor=photo_executor,
- image_executor=image_executor,
+ id_executor=id_pool,
+ photo_executor=photo_pool,
+ image_executor=image_pool,
)
try:
with jm_task_context(runtime=runtime):
- download_album(['123', '456'])
+ download_album(['123456', '789012'])
finally:
runtime.close()
```
-三个同步层级会互相等待,因此必须使用不同的 Executor。任务上下文需要在线程间传播,所以不支持 `ProcessPoolExecutor`。
+关于外部线程池的关闭:
+- **不影响外部线程池**:JMComic 遵循“谁创建、谁关闭”的原则,外部传入的线程池仍由外部上下文管理,`runtime.close()` 不会调用外部池的 `shutdown()`;
+- **状态注销与防泄漏**:`runtime.close()` 会将 Runtime 标记为已结束;如果部分层级使用了外部池、另一部分是内部自建的,它会自动关闭自建的那部分线程池,避免资源泄漏。
+
+---
-## 异步下载
+## 3. 异步下载:使用 JmAsyncRuntime
-异步网络请求由 event loop 和 Semaphore 并发;图片解密、PIL、写盘和同步 hook 交给 `blocking` Executor:
+异步下载与同步下载的并发分工不同:
+- **网络请求**:完全由 Python 的 `asyncio` 事件循环和协程处理,无需占用线程池;
+- **图片解密与处理**:禁漫的图片下载后需要进行分块拼接、反混淆解密和写盘,这部分属于 CPU 计算与文件写入,交给后台线程池处理。
+
+`JmAsyncRuntime` 负责管理这层解码线程池:
```python
import asyncio
from concurrent.futures import ThreadPoolExecutor
-
from jmcomic import JmAsyncRuntime, download_album_async, jm_task_context
-
async def main():
- with ThreadPoolExecutor(max_workers=4) as blocking_executor:
- runtime = JmAsyncRuntime(blocking_executor=blocking_executor)
+ # 方式 1:指定解码线程数(任务完成后 close 释放)
+ runtime = JmAsyncRuntime(decode_workers=4)
+ try:
+ with jm_task_context(runtime=runtime):
+ await asyncio.gather(
+ download_album_async('123456'),
+ download_album_async('789012'),
+ )
+ finally:
+ runtime.close()
+
+ # 方式 2:直接复用外部已有线程池
+ with ThreadPoolExecutor(max_workers=4) as my_decode_pool:
+ runtime = JmAsyncRuntime(decode_executor=my_decode_pool)
try:
with jm_task_context(runtime=runtime):
- await download_album_async('123')
+ await download_album_async('123456')
finally:
runtime.close()
-
asyncio.run(main())
```
-共享 `blocking` Executor 不会共享 Downloader、网络 Client、Session 或 Semaphore。每个顶层调用仍有自己的 Downloader 和 Manifest。
+---
+
+## 4. 任务上下文统一门面:JTC
+
+在下载过程中,自定义插件(Plugin)、Feature、回调函数或主逻辑常常需要查询当前任务状态。
+
+JMComic 提供了门面类 **`JTC` (Jm Task Context)**,方便统一读取上下文信息:
+
+```python
+from jmcomic import JTC, jm_task_context, JmSyncRuntime, JmOption, DownloadControl
+
+runtime = JmSyncRuntime(id_workers=2)
+option = JmOption.default()
+control = DownloadControl()
+
+with jm_task_context(runtime=runtime, option=option, control=control, custom_tag='v1'):
+ # 1. 获取当前生效的 Runtime
+ cur_runtime = JTC.get_runtime()
+
+ # 2. 获取当前生效的 Option 配置
+ cur_option = JTC.get_option()
+
+ # 3. 获取当前的取消控制器
+ cur_control = JTC.get_control()
+
+ # 4. 获取完整任务上下文快照
+ ctx_dict = JTC.get_context()
+ print('自定义字段:', ctx_dict.get('custom_tag'))
+```
-## Context 中保存什么
+在未进入 `jm_task_context` 的代码区域调用 `JTC.get_runtime()` 等方法,会安全返回 `None`。
-项目只有一个 `JM_TASK_CONTEXT`。Runtime 和 Option 直接使用公开字段 `runtime`、`option` 保存,和 `download_type`、`jm_id` 以及调用方附加字段处于同一个 context mapping 中。`get_jm_task_context()` 返回包含所有字段的完整副本。
+---
-可以直接从 context 副本读取 `runtime`、`option`,也可以使用便捷方法 `get_jm_runtime()`、`get_current_option()`。`bind_jm_task_context()` 会把完整上下文快照传播到工作线程。默认文本日志不会展开 Runtime 或 Option,但自定义日志处理器可以读取这两个公开字段。
+## 5. 核心规则与速查
-嵌套上下文中的 `runtime=None` 和 `option=None` 表示继承父上下文。同步下载 API 只接受 `JmSyncRuntime`,异步 API 只接受 `JmAsyncRuntime`;同一任务作用域不能替换成另一个 Runtime。`JmSimpleRuntime` 用于独立的单层调度,不作为分层同步下载的共享 context Runtime。
+| 概念 | 核心职责 | 推荐使用方式 |
+| :--- | :--- | :--- |
+| **`JmSyncRuntime`** | 统一管理同步的 `id`、`photo`、`image` 三层线程池 | 批量下载多本漫画且需控制并发时使用,通过 `try...finally: runtime.close()` 回收 |
+| **`JmAsyncRuntime`** | 统一管理异步中的 CPU 解密与文件写入池 (`decode`) | 多个异步任务并发、需要控制解密并发时使用 |
+| **`JTC`** | 任务上下文统一访问门面 | 随时读取 `JTC.get_runtime()` / `get_option()` / `get_control()` / `get_context()` |
+| **外部 Executor** | 复用已有线程池 | 传入 `*_executor` 参数;Runtime 不会代关,由调用方自行管理生命周期 |
diff --git a/src/jmcomic/__init__.py b/src/jmcomic/__init__.py
index 080f708da..4235342e0 100644
--- a/src/jmcomic/__init__.py
+++ b/src/jmcomic/__init__.py
@@ -2,7 +2,7 @@
# 被依赖方 <--- 使用方
# config <--- entity <--- toolkit <--- client <--- option <--- downloader
-__version__ = '2.7.5'
+__version__ = '2.7.6'
from .jm_exception import DownloadCancelledException
from .jm_runtime import *
diff --git a/src/jmcomic/api.py b/src/jmcomic/api.py
index a5c252241..628c5b8af 100644
--- a/src/jmcomic/api.py
+++ b/src/jmcomic/api.py
@@ -272,7 +272,7 @@ async def download_album_async(jm_album_id,
当 ``jm_album_id`` 是可迭代对象时使用批量 API。单个 ID 返回
``DownloadResult``;其中 Downloader 的网络 client 已关闭,阻塞工作也已
- 收敛。通过 ``jm_task_context(runtime=JmAsyncRuntime(...))`` 提供的 blocking
+ 收敛。通过 ``jm_task_context(runtime=JmAsyncRuntime(...))`` 提供的 decode
执行器由调用方负责关闭。
``check_exception`` 只对单个 ID 生效。批量场景应检查
@@ -323,7 +323,7 @@ async def download_photo_async(jm_photo_id,
"""异步下载一个章节。
返回的 Downloader 已关闭网络 client,阻塞工作也已收敛。通过
- ``jm_task_context(runtime=JmAsyncRuntime(...))`` 提供的 blocking 执行器由调用方负责关闭。
+ ``jm_task_context(runtime=JmAsyncRuntime(...))`` 提供的 decode 执行器由调用方负责关闭。
``check_exception`` 只对单个 ID 生效。批量场景应检查
``BatchResult.failed``,或按需封装 ``download_batch_async``。
diff --git a/src/jmcomic/jm_async_downloader.py b/src/jmcomic/jm_async_downloader.py
index e6eb4449d..4b4a49d5d 100644
--- a/src/jmcomic/jm_async_downloader.py
+++ b/src/jmcomic/jm_async_downloader.py
@@ -94,7 +94,7 @@ async def _run_in_decode_pool(self, func, *args):
'runtime=JmAsyncRuntime(...))'
)
worker = bind_jm_task_context(func)
- executor = runtime.executor('blocking', self._decode_worker)
+ executor = runtime.executor('decode', self._decode_worker)
future = executor.submit(worker, *args)
waiter = asyncio.wrap_future(future)
try:
diff --git a/src/jmcomic/jm_exception.py b/src/jmcomic/jm_exception.py
index 56c164d39..156916cb6 100644
--- a/src/jmcomic/jm_exception.py
+++ b/src/jmcomic/jm_exception.py
@@ -98,7 +98,9 @@ class DownloadCancelledException(JmcomicException):
description = '下载任务已取消'
def __init__(self, msg='download cancelled', context=None):
- """按 JMComic 异常的统一结构创建下载取消异常。"""
+ """
+ 按 JMComic 异常的统一结构创建下载取消异常。
+ """
msg = str(msg or 'download cancelled')
context = dict(context or {})
context.setdefault('reason', msg)
diff --git a/src/jmcomic/jm_runtime.py b/src/jmcomic/jm_runtime.py
index fc9b664e7..8488ac6c2 100644
--- a/src/jmcomic/jm_runtime.py
+++ b/src/jmcomic/jm_runtime.py
@@ -51,7 +51,9 @@ def normalize_jm_executor_config(
class JmRuntime:
- """轻量 Runtime:管理 Executor 的配置、创建、调度和关闭。"""
+ """
+ 轻量 Runtime:管理 Executor 的配置、创建、调度和关闭。
+ """
_default_level = None
@@ -76,7 +78,9 @@ def executor(
level: Optional[str] = None,
default_workers: Optional[int] = None,
) -> Executor:
- """返回指定层级的 Executor;未配置时按调用点给出的默认并发数创建。"""
+ """
+ 返回指定层级的 Executor;未配置时按调用点给出的默认并发数创建。
+ """
if level is None:
level = self._default_level
if level is None:
@@ -126,7 +130,9 @@ def multi_thread_launcher(
level: Optional[str] = None,
default_workers: Optional[int] = None,
):
- """使用指定层级的 Executor 批量提交任务,并按需等待完成。"""
+ """
+ 使用指定层级的 Executor 批量提交任务,并按需等待完成。
+ """
executor = self.executor(level, default_workers)
futures = []
@@ -145,7 +151,9 @@ def multi_thread_launcher(
return futures
def close(self) -> None:
- """关闭 Runtime 自建的 Executor;调用方传入的 Executor 保持可用。"""
+ """
+ 关闭 Runtime 自建的 Executor;调用方传入的 Executor 保持可用。
+ """
with self._lock:
if self._closed:
return
@@ -158,7 +166,9 @@ def close(self) -> None:
class JmSimpleRuntime(JmRuntime):
- """单 Executor Runtime:用于一次同步下载中的局部并发。"""
+ """
+ 单 Executor Runtime:用于一次同步下载中的局部并发。
+ """
_default_level = 'default'
@@ -183,7 +193,9 @@ def __init__(
class JmSyncRuntime(JmRuntime):
- """同步下载 Runtime:分别管理 id、photo、image 三层 Executor。"""
+ """
+ 同步下载 Runtime:分别管理 id、photo、image 三层 Executor。
+ """
def __init__(
self,
@@ -219,18 +231,20 @@ def __init__(
class JmAsyncRuntime(JmRuntime):
- """异步下载 Runtime:管理解密、图片处理和同步 hook 的 blocking Executor。"""
+ """
+ 异步下载 Runtime:管理图片解密、处理和同步 hook 的 decode Executor。
+ """
def __init__(
self,
*,
- blocking_workers: Optional[int] = None,
- blocking_executor: Optional[Executor] = None,
+ decode_workers: Optional[int] = None,
+ decode_executor: Optional[Executor] = None,
):
super().__init__(
configs={
- 'blocking': normalize_jm_executor_config(
- 'blocking', blocking_workers, blocking_executor
+ 'decode': normalize_jm_executor_config(
+ 'decode', decode_workers, decode_executor
),
},
)
diff --git a/src/jmcomic/jm_task_context.py b/src/jmcomic/jm_task_context.py
index 8a1d94ad2..f36087aad 100644
--- a/src/jmcomic/jm_task_context.py
+++ b/src/jmcomic/jm_task_context.py
@@ -12,6 +12,7 @@
__all__ = (
'JM_TASK_CONTEXT',
'DownloadControl',
+ 'JTC',
'jm_task_context',
'get_jm_task_context',
'get_current_control',
@@ -29,7 +30,9 @@
class DownloadControl:
- """在线程间共享、线程安全且幂等的下载取消信号。"""
+ """
+ 在线程间共享、线程安全且幂等的下载取消信号。
+ """
def __init__(self):
self._event = Event()
@@ -56,12 +59,16 @@ def reason(self) -> str:
def get_jm_task_context() -> dict:
- """返回当前 JM 任务上下文的可变快照。"""
+ """
+ 返回当前 JM 任务上下文的可变快照。
+ """
return dict(JM_TASK_CONTEXT.get())
def get_current_control() -> Optional[DownloadControl]:
- """返回当前 JM 任务上下文中的取消控制器。"""
+ """
+ 返回当前 JM 任务上下文中的取消控制器。
+ """
control = get_jm_task_context().get('control')
if control is None:
return None
@@ -74,18 +81,58 @@ def get_current_control() -> Optional[DownloadControl]:
def get_jm_runtime() -> Optional[JmRuntime]:
- """返回当前任务的 Runtime;没有活动 Runtime 时返回 None。"""
+ """
+ 返回当前任务的 Runtime;没有活动 Runtime 时返回 None。
+ """
return get_jm_task_context().get('runtime')
def get_current_option():
- """返回当前任务的 Option;没有活动 Option 时返回 None。"""
+ """
+ 返回当前任务的 Option;没有活动 Option 时返回 None。
+ """
return get_jm_task_context().get('option')
+class JTC:
+ """
+ JMComic 任务上下文统一门面 (Jm Task Context)。
+ """
+
+ @classmethod
+ def get_context(cls) -> dict:
+ """
+ 返回当前 JM 任务上下文的可变快照。
+ """
+ return get_jm_task_context()
+
+ @classmethod
+ def get_runtime(cls) -> Optional[JmRuntime]:
+ """
+ 返回当前任务绑定的 JmRuntime;没有活动 Runtime 时返回 None。
+ """
+ return get_jm_runtime()
+
+ @classmethod
+ def get_option(cls):
+ """
+ 返回当前任务绑定的 JmOption;没有活动 Option 时返回 None。
+ """
+ return get_current_option()
+
+ @classmethod
+ def get_control(cls) -> Optional[DownloadControl]:
+ """
+ 返回当前任务绑定的取消控制器 DownloadControl;未设置时返回 None。
+ """
+ return get_current_control()
+
+
@contextmanager
def jm_task_context(*, option=None, runtime=None, **fields):
- """临时绑定任务字段;只传播 Option/Runtime,不管理资源生命周期。"""
+ """
+ 临时绑定任务字段;只传播 Option/Runtime,不管理资源生命周期。
+ """
context = get_jm_task_context()
parent_runtime = context.get('runtime')
@@ -112,7 +159,9 @@ def jm_task_context(*, option=None, runtime=None, **fields):
def bind_jm_task_context(func: Callable, context: Optional[Mapping] = None) -> Callable:
- """把完整任务上下文快照绑定到同步可调用对象。"""
+ """
+ 把完整任务上下文快照绑定到同步可调用对象。
+ """
if (
inspect.iscoroutinefunction(func)
or inspect.iscoroutinefunction(getattr(func, '__call__', None))
diff --git a/tests/test_jmcomic/test_jm_async_custom.py b/tests/test_jmcomic/test_jm_async_custom.py
index 3c4dc2843..a7cb22a59 100644
--- a/tests/test_jmcomic/test_jm_async_custom.py
+++ b/tests/test_jmcomic/test_jm_async_custom.py
@@ -225,7 +225,7 @@ class ProbeAsyncRuntime(JmAsyncRuntime):
instances = []
def __init__(self):
- super().__init__(blocking_workers=1)
+ super().__init__(decode_workers=1)
self.instances.append(self)
loop = asyncio.new_event_loop()
@@ -261,7 +261,7 @@ def __init__(self):
self.assertIsNone(downloader.client)
self.assertIsNone(get_jm_runtime())
with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
- runtime.executor('blocking', 1)
+ runtime.executor('decode', 1)
finally:
if runtime is not None:
runtime.close()
diff --git a/tests/test_jmcomic/test_jm_runtime.py b/tests/test_jmcomic/test_jm_runtime.py
index 6851be639..f2ebb7d93 100644
--- a/tests/test_jmcomic/test_jm_runtime.py
+++ b/tests/test_jmcomic/test_jm_runtime.py
@@ -215,7 +215,7 @@ async def download_one(jmid, *_args, **_kwargs):
self.assertEqual(1, len(observed))
self.assertIsInstance(observed[0], JmAsyncRuntime)
with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
- observed[0].executor('blocking', 1)
+ observed[0].executor('decode', 1)
def test_sync_batch_collects_failure_after_siblings_finish(self):
completed = []
diff --git a/tests/test_jmcomic/test_jm_task_context.py b/tests/test_jmcomic/test_jm_task_context.py
index a5cea3111..13f474c99 100644
--- a/tests/test_jmcomic/test_jm_task_context.py
+++ b/tests/test_jmcomic/test_jm_task_context.py
@@ -13,6 +13,7 @@
DownloadManifest,
DownloadControl,
Feature,
+ JTC,
JmAsyncDownloader,
JmDownloader,
JmModuleConfig,
@@ -167,14 +168,14 @@ def test_runtime_configuration_is_explicit_and_validated(self):
with self.assertRaisesRegex(ValueError, 'different executor'):
JmSyncRuntime(id_executor=executor, photo_executor=executor)
with self.assertRaisesRegex(ValueError, 'mutually exclusive'):
- JmAsyncRuntime(blocking_workers=1, blocking_executor=executor)
+ JmAsyncRuntime(decode_workers=1, decode_executor=executor)
with self.assertRaisesRegex(ValueError, 'mutually exclusive'):
JmSimpleRuntime(workers=1, executor=executor)
with self.assertRaisesRegex(ValueError, 'positive integer'):
JmSyncRuntime(photo_workers=0)
with self.assertRaisesRegex(ValueError, 'positive integer'):
- JmAsyncRuntime(blocking_workers=True)
+ JmAsyncRuntime(decode_workers=True)
with self.assertRaisesRegex(ValueError, 'positive integer'):
JmSimpleRuntime(workers=0)
@@ -474,7 +475,7 @@ async def download_photo(self, _photo_id):
with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
album_context['runtime'].executor('photo', 1)
with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
- photo_context['runtime'].executor('blocking', 1)
+ photo_context['runtime'].executor('decode', 1)
def test_sync_result_duration_uses_task_context_and_finishes_after_downloader_exit(self):
clock = {'now': 10.0}
@@ -790,12 +791,12 @@ async def fake_download(jmid, _option, _downloader, **_kwargs):
option=object(),
)
- with ThreadPoolExecutor(max_workers=1) as blocking_executor:
- runtime = JmAsyncRuntime(blocking_executor=blocking_executor)
+ with ThreadPoolExecutor(max_workers=1) as decode_executor:
+ runtime = JmAsyncRuntime(decode_executor=decode_executor)
with jm_task_context(session_id='decode-pool'):
with jm_task_context(runtime=runtime):
future = runtime.executor(
- 'blocking',
+ 'decode',
1,
).submit(
bind_jm_task_context(get_jm_task_context),
@@ -804,7 +805,7 @@ async def fake_download(jmid, _option, _downloader, **_kwargs):
loop = asyncio.get_running_loop()
leaked_context = await loop.run_in_executor(
- blocking_executor,
+ decode_executor,
get_jm_task_context,
)
@@ -869,10 +870,10 @@ def limited_work(_item):
def test_async_runtime_borrows_shared_executor(self):
async def scenario():
with ThreadPoolExecutor(max_workers=1) as executor:
- runtime = JmAsyncRuntime(blocking_executor=executor)
+ runtime = JmAsyncRuntime(decode_executor=executor)
with jm_task_context(session_id='async-runtime', runtime=runtime):
future = runtime.executor(
- 'blocking',
+ 'decode',
1,
).submit(
bind_jm_task_context(
@@ -886,6 +887,35 @@ async def scenario():
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())
+
if __name__ == '__main__':
unittest.main()
From 77a4b042830f8aacc18cad67dd498c4bc169d555 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Sun, 6 Sep 2026 01:06:51 +0800
Subject: [PATCH 3/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=96=E6=B6=88?=
=?UTF-8?q?=E6=94=B6=E5=B0=BE=E4=B8=8E=E5=BC=82=E5=B8=B8=E5=85=BC=E5=AE=B9?=
=?UTF-8?q?=EF=BC=8C=E5=AE=8C=E5=96=84=E5=85=B1=E4=BA=AB=E8=B5=84=E6=BA=90?=
=?UTF-8?q?=E7=A4=BA=E4=BE=8B=E5=92=8C=E5=8F=91=E5=B8=83=E9=85=8D=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/download_dispatch.yml | 2 +
.github/workflows/export_favorites.yml | 2 +
.github/workflows/release.yml | 2 +
.github/workflows/release_auto.yml | 2 +
CHANGELOG.md | 2 +-
.../docs/sources/tutorial/14_async_usage.md | 19 ++--
.../sources/tutorial/16_shared_executors.md | 25 +++---
src/jmcomic/jm_async_downloader.py | 4 +-
src/jmcomic/jm_downloader.py | 23 ++---
src/jmcomic/jm_exception.py | 6 +-
src/jmcomic/jm_runtime.py | 6 +-
tests/test_jmcomic/test_jm_cancellation.py | 89 ++++++++++++++++---
tests/test_jmcomic/test_jm_exception.py | 30 +++++++
13 files changed, 167 insertions(+), 45 deletions(-)
diff --git a/.github/workflows/download_dispatch.yml b/.github/workflows/download_dispatch.yml
index a1c1596c4..897920702 100644
--- a/.github/workflows/download_dispatch.yml
+++ b/.github/workflows/download_dispatch.yml
@@ -109,6 +109,8 @@ jobs:
steps:
- uses: actions/checkout@v7
+ with:
+ persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v7
with:
diff --git a/.github/workflows/export_favorites.yml b/.github/workflows/export_favorites.yml
index b653deb79..4375e3bc7 100644
--- a/.github/workflows/export_favorites.yml
+++ b/.github/workflows/export_favorites.yml
@@ -49,6 +49,8 @@ jobs:
steps:
- uses: actions/checkout@v7
+ with:
+ persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v7
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 61f62a939..9458b5856 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,6 +14,8 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v7
+ with:
+ persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v7
diff --git a/.github/workflows/release_auto.yml b/.github/workflows/release_auto.yml
index d0fcf4aec..c3829d119 100644
--- a/.github/workflows/release_auto.yml
+++ b/.github/workflows/release_auto.yml
@@ -15,6 +15,8 @@ jobs:
if: startsWith(github.event.head_commit.message, 'v')
steps:
- uses: actions/checkout@v7
+ with:
+ persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v7
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37dc1badd..b592147bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@
条目分类参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
-## [Unreleased]
+## [2.7.6] - 2026-09-06
### Added
- 新增 `JmSimpleRuntime`、`JmSyncRuntime` 与 `JmAsyncRuntime`;裸同步 Downloader 的局部调度使用单池 Runtime,顶层同步 API 或自定义同步调度可复用 `id/photo/image` 三层线程池,异步下载可复用 `decode` 线程池。
diff --git a/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md
index 4ae7c47dd..99ea54a22 100644
--- a/assets/docs/sources/tutorial/14_async_usage.md
+++ b/assets/docs/sources/tutorial/14_async_usage.md
@@ -23,7 +23,7 @@ async def main():
album = result.detail
downloader = result.downloader
- # 返回时该 Downloader 的网络 client 已关闭;如果传入共享 blocking
+ # 返回时该 Downloader 的网络 client 已关闭;如果传入共享 decode
# 执行器,它仍由创建它的调用方管理
print(downloader.download_failed_image)
@@ -107,11 +107,17 @@ async def main():
# 示例:使用 async 并发获取本子详情
album_id_list = [123, 456]
album_list = await asyncio.gather(
- *(cl.get_album_detail(aid) for aid in album_id_list)
+ *(cl.get_album_detail(aid) for aid in album_id_list),
+ return_exceptions=True, # 等所有请求结束后再关闭共享 client
)
# 打印结果
for aid, album in zip(album_id_list, album_list):
+ if isinstance(album, asyncio.CancelledError):
+ raise album
+ if isinstance(album, Exception):
+ print(f'[JM{aid}] 查询失败: {album}')
+ continue
print(f'[JM{aid}] 本子详情: {album}')
asyncio.run(main())
@@ -291,10 +297,9 @@ async def main():
try:
with jm_task_context(runtime=runtime):
# 两个本子的图片统一使用这 4 个解码线程处理
- await asyncio.gather(
- download_album_async('123456'),
- download_album_async('789012'),
- )
+ results = await download_album_async(['123456', '789012'])
+ for album_id, error in results.failed.items():
+ print(f'本子 {album_id} 下载任务失败: {error}')
# 下载过程中可以通过 JTC 查看当前生效的 Runtime
print('当前 Runtime:', JTC.get_runtime())
@@ -314,4 +319,6 @@ async def main():
asyncio.run(main())
```
+> 批量 API 会等两个本子的任务都结束,再返回 `BatchResult`。其中某个任务抛出普通异常时,会记录到 `results.failed`,不会提前关闭另一个任务正在使用的 Runtime;取消仍会向调用方抛出。
+
关于 Runtime 与任务上下文的更多进阶机制(如同步三层拓扑、JTC 门面类、外部 Executor 生命周期),请参考 [复用下载 Runtime 与共享线程池](16_shared_executors.md)。
diff --git a/assets/docs/sources/tutorial/16_shared_executors.md b/assets/docs/sources/tutorial/16_shared_executors.md
index 45fbfd6be..b9611a28c 100644
--- a/assets/docs/sources/tutorial/16_shared_executors.md
+++ b/assets/docs/sources/tutorial/16_shared_executors.md
@@ -11,9 +11,9 @@
同步下载漫画时,内部通常是分层调度的:
```text
-本子层 (id_executor) -> 同时下载几个本子 (Album)
- └── 章节层 (photo_executor) -> 每个本子同时下载几个章节 (Photo)
- └── 图片层 (image_executor) -> 每个章节同时下载几张图片 (Image)
+本子层 (id_executor) -> 共享此 Runtime 的本子任务并发上限
+ └── 章节层 (photo_executor) -> 这些本子的章节任务共用一个并发上限
+ └── 图片层 (image_executor) -> 这些章节的图片任务共用一个并发上限
```
因为外层任务(下载本子)需要等待内层任务(下载章节和图片)完成,所以这三层需要分别使用不同的线程池。如果三层混用同一个线程池,当外层任务占满线程时,内层任务可能无法获得线程执行,导致任务互相等待。
@@ -33,9 +33,9 @@ from jmcomic import JmSyncRuntime, download_album, jm_task_context
# 1. 创建 Runtime,指定本子、章节和图片的并发数
runtime = JmSyncRuntime(
- id_workers=2, # 同时下载 2 个本子
- photo_workers=3, # 每个本子同时下载 3 个章节
- image_workers=8, # 每个章节同时下载 8 张图片
+ id_workers=2, # 整个 Runtime 最多同时处理 2 个本子任务
+ photo_workers=3, # 所有本子合计最多同时处理 3 个章节任务
+ image_workers=8, # 所有章节合计最多同时处理 8 个图片任务
)
try:
@@ -47,7 +47,9 @@ finally:
runtime.close()
```
-如果某一层没有指定 `*_workers`,下载器会按 Option 里的默认配置建池。
+> 上例中的两个本子共用 3 个章节线程,所有章节共用 8 个图片线程。不会因为增加本子或章节数量,就为每个本子、每个章节分别创建一套线程池。
+
+如果某一层没有指定 `*_workers`,下载器会在首次使用该层时按调用点传入的默认并发数建池,后续任务复用该池;章节、图片层的默认值来自 Option。
### 场景 B:复用已有线程池
@@ -97,10 +99,9 @@ async def main():
runtime = JmAsyncRuntime(decode_workers=4)
try:
with jm_task_context(runtime=runtime):
- await asyncio.gather(
- download_album_async('123456'),
- download_album_async('789012'),
- )
+ results = await download_album_async(['123456', '789012'])
+ for album_id, error in results.failed.items():
+ print(f'本子 {album_id} 下载任务失败: {error}')
finally:
runtime.close()
@@ -116,6 +117,8 @@ async def main():
asyncio.run(main())
```
+> 批量 API 会等两个本子的任务都结束,再返回 `BatchResult`。普通任务异常收集在 `results.failed` 中,取消仍会向调用方抛出,因此需要检查失败项。不要直接用默认的 `asyncio.gather` 包住两个单本下载后立即关闭 Runtime:一个任务报错时,另一个任务可能还在使用解码池。
+
---
## 4. 任务上下文统一门面:JTC
diff --git a/src/jmcomic/jm_async_downloader.py b/src/jmcomic/jm_async_downloader.py
index 4b4a49d5d..d2fc6ee0f 100644
--- a/src/jmcomic/jm_async_downloader.py
+++ b/src/jmcomic/jm_async_downloader.py
@@ -103,8 +103,10 @@ async def _run_in_decode_pool(self, func, *args):
# 线程任务无法可靠中断,协程取消后仍需等待写盘等操作收尾。
try:
await waiter
- except BaseException:
+ 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')
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index 41925276f..a4a138edc 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -282,16 +282,19 @@ def before_image(self, image: JmImageDetail, img_save_path):
self.raise_if_cancelled()
def after_image(self, image: JmImageDetail, img_save_path):
- self.raise_if_cancelled()
- super().after_image(image, img_save_path)
- 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))
+ try:
+ self.raise_if_cancelled()
+ super().after_image(image, img_save_path)
+ self.option.call_all_plugin(
+ 'after_image',
+ image=image,
+ downloader=self,
+ )
+ finally:
+ # 图片已经保存或命中缓存;取消时也登记,正常完成时保留插件更新后的路径。
+ photo = image.from_photo
+ album = photo.from_album
+ self.download_success_dict.get(album).get(photo).append((image.save_path, image))
self.raise_if_cancelled()
def begin_manifest(self, detail: DetailEntity) -> DownloadManifest:
diff --git a/src/jmcomic/jm_exception.py b/src/jmcomic/jm_exception.py
index 156916cb6..7f1ba4f16 100644
--- a/src/jmcomic/jm_exception.py
+++ b/src/jmcomic/jm_exception.py
@@ -232,7 +232,11 @@ def replace_old_exception_executor(cls, raises: Callable[[Callable, str, dict],
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_runtime.py b/src/jmcomic/jm_runtime.py
index 8488ac6c2..c6e89654d 100644
--- a/src/jmcomic/jm_runtime.py
+++ b/src/jmcomic/jm_runtime.py
@@ -140,13 +140,9 @@ def multi_thread_launcher(
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))
- except BaseException:
+ finally:
if wait_finish:
concurrent.futures.wait(futures)
- raise
-
- if wait_finish:
- concurrent.futures.wait(futures)
return futures
diff --git a/tests/test_jmcomic/test_jm_cancellation.py b/tests/test_jmcomic/test_jm_cancellation.py
index 8f395128e..a2e76c2d9 100644
--- a/tests/test_jmcomic/test_jm_cancellation.py
+++ b/tests/test_jmcomic/test_jm_cancellation.py
@@ -1,4 +1,5 @@
import asyncio
+from concurrent.futures import Future
from types import SimpleNamespace
from threading import Thread
import unittest
@@ -10,6 +11,7 @@
DownloadControl,
JmImageDetail,
JmAsyncDownloader,
+ JmAsyncRuntime,
JmDownloader,
bind_jm_task_context,
download_album,
@@ -269,7 +271,12 @@ async def test_current_async_image_is_recorded_before_control_cancellation(self)
downloader.client.get_jm_image = AsyncMock()
downloader.client.get_jm_image.return_value.content = b'image'
downloader._image_semaphore = asyncio.Semaphore(1)
- downloader._run_in_decode_pool = AsyncMock()
+
+ 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
@@ -278,22 +285,84 @@ async def test_current_async_image_is_recorded_before_control_cancellation(self)
image.skip = False
image.scramble_id = None
image.download_url = 'https://example.invalid/image.jpg'
- recorded = []
-
- async def after_image(current_image, _path):
- recorded.append(current_image)
- control.cancel('stop after current image')
-
+ 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()
- downloader.after_image = after_image
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(recorded, [image])
- downloader._run_in_decode_pool.assert_awaited_once()
+ 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.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()
diff --git a/tests/test_jmcomic/test_jm_exception.py b/tests/test_jmcomic/test_jm_exception.py
index 4d2e2834f..5273bdba2 100644
--- a/tests/test_jmcomic/test_jm_exception.py
+++ b/tests/test_jmcomic/test_jm_exception.py
@@ -18,6 +18,36 @@ class IsolatedExceptionTool(ExceptionTool):
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 0265a6d6cdf60ab18184ed7c358a910ff5f7e8df Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Sun, 6 Sep 2026 02:10:33 +0800
Subject: [PATCH 4/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?=
=?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=A4=B1=E8=B4=A5=E6=97=B6=E8=AF=AF=E7=99=BB?=
=?UTF-8?q?=E8=AE=B0=E4=B8=8B=E8=BD=BD=E6=88=90=E5=8A=9F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/jmcomic/jm_downloader.py | 15 +++--
.../test_jmcomic/test_jm_download_manifest.py | 67 +++++++++++++++++++
2 files changed, 77 insertions(+), 5 deletions(-)
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index a4a138edc..9b2d529ea 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -282,6 +282,7 @@ def before_image(self, image: JmImageDetail, img_save_path):
self.raise_if_cancelled()
def after_image(self, image: JmImageDetail, img_save_path):
+ cancellation_error = None
try:
self.raise_if_cancelled()
super().after_image(image, img_save_path)
@@ -290,11 +291,15 @@ def after_image(self, image: JmImageDetail, img_save_path):
image=image,
downloader=self,
)
- finally:
- # 图片已经保存或命中缓存;取消时也登记,正常完成时保留插件更新后的路径。
- photo = image.from_photo
- album = photo.from_album
- self.download_success_dict.get(album).get(photo).append((image.save_path, image))
+ except DownloadCancelledException as error:
+ cancellation_error = error
+
+ # 正常完成或取消时登记;普通插件异常直接向外传播,不进入成功清单。
+ photo = image.from_photo
+ album = photo.from_album
+ self.download_success_dict.get(album).get(photo).append((image.save_path, image))
+ if cancellation_error is not None:
+ raise cancellation_error
self.raise_if_cancelled()
def begin_manifest(self, detail: DetailEntity) -> DownloadManifest:
diff --git a/tests/test_jmcomic/test_jm_download_manifest.py b/tests/test_jmcomic/test_jm_download_manifest.py
index cdb156d04..aa05eb1bc 100644
--- a/tests/test_jmcomic/test_jm_download_manifest.py
+++ b/tests/test_jmcomic/test_jm_download_manifest.py
@@ -169,6 +169,73 @@ def do_filter(self, detail):
class Test_Download_Manifest(unittest.TestCase):
+ def test_after_image_error_does_not_register_sync_success(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], [])
+ self.assertEqual(downloader.download_failed_image, [(images[0], error)])
+ self.assertEqual(downloader.manifest_dict[album].image_filepath_list, [])
+
+ def test_after_image_error_does_not_register_async_success(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], [])
+ self.assertEqual(downloader.download_failed_image, [(images[0], error)])
+ self.assertEqual(downloader.manifest_dict[album].image_filepath_list, [])
+
+ 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()
From 09c696d9d1f5b9f774a6c38eed077e3bc009f9e3 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Sun, 6 Sep 2026 14:30:55 +0800
Subject: [PATCH 5/8] =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E8=AF=84=E8=AE=BA?=
=?UTF-8?q?=E6=95=B0=E6=B5=8B=E8=AF=95=E6=9C=AC=E5=AD=90=EF=BC=8C=E7=A7=BB?=
=?UTF-8?q?=E9=99=A4=E5=AE=9E=E6=97=B6=E6=90=9C=E7=B4=A2=E4=BE=9D=E8=B5=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tests/test_jmcomic/test_jm_client.py | 10 +---------
1 file changed, 1 insertion(+), 9 deletions(-)
diff --git a/tests/test_jmcomic/test_jm_client.py b/tests/test_jmcomic/test_jm_client.py
index 85abb6d89..42de3a6a0 100644
--- a/tests/test_jmcomic/test_jm_client.py
+++ b/tests/test_jmcomic/test_jm_client.py
@@ -244,17 +244,9 @@ def search_and_test(expected_result, 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 = '302820'
From c83f9babaa4d0272e35c71f6cd4cfa0b51dcb109 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Mon, 7 Sep 2026 01:02:23 +0800
Subject: [PATCH 6/8] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BD=BF=E7=94=A8=20JTC?=
=?UTF-8?q?=20=E6=9F=A5=E8=AF=A2=E4=BB=BB=E5=8A=A1=E4=B8=8A=E4=B8=8B?=
=?UTF-8?q?=E6=96=87=EF=BC=8C=E7=B2=BE=E7=AE=80=E5=8F=96=E6=B6=88=E4=B8=8B?=
=?UTF-8?q?=E8=BD=BD=E7=A4=BA=E4=BE=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
assets/docs/sources/api/download.md | 13 ++-
.../docs/sources/tutorial/0_common_usage.md | 107 ++++++------------
.../docs/sources/tutorial/14_async_usage.md | 28 +++++
.../sources/tutorial/16_shared_executors.md | 30 +++++
src/jmcomic/api.py | 4 +-
src/jmcomic/jm_async_downloader.py | 6 +-
src/jmcomic/jm_config.py | 4 +-
src/jmcomic/jm_downloader.py | 16 ++-
src/jmcomic/jm_feature.py | 4 +-
src/jmcomic/jm_plugin.py | 6 +-
src/jmcomic/jm_task_context.py | 65 ++++-------
tests/test_jmcomic/test_jm_async_custom.py | 2 +-
tests/test_jmcomic/test_jm_cancellation.py | 6 +-
tests/test_jmcomic/test_jm_cli.py | 11 +-
.../test_jmcomic/test_jm_download_manifest.py | 44 +++----
tests/test_jmcomic/test_jm_runtime.py | 6 +-
tests/test_jmcomic/test_jm_task_context.py | 86 +++++++-------
17 files changed, 211 insertions(+), 227 deletions(-)
diff --git a/assets/docs/sources/api/download.md b/assets/docs/sources/api/download.md
index 19a460782..1d2305e08 100644
--- a/assets/docs/sources/api/download.md
+++ b/assets/docs/sources/api/download.md
@@ -43,10 +43,13 @@
options:
members:
- DownloadControl
- - JTC
- jm_task_context
- bind_jm_task_context
- - get_jm_task_context
- - get_current_control
- - get_jm_runtime
- - get_current_option
+
+::: 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 49a89dbaf..f4e6af11e 100644
--- a/assets/docs/sources/tutorial/0_common_usage.md
+++ b/assets/docs/sources/tutorial/0_common_usage.md
@@ -591,81 +591,6 @@ for aid, err in result.failed.items():
---
-### 取消下载
-
-自 `v2.7.6` 起,JMComic 新增了 `DownloadControl` 控制器。如果在 GUI 界面(如 PyQt/Tkinter)、Web 接口或后台脚本中需要支持中途停止下载,可以通过它实现优雅取消。
-
-> [!NOTE]
-> `DownloadControl` 采用协作式退出设计:收到取消请求后,下载器不会再开始新的章节和图片下载;当前正在处理的那张图片会正常保存完毕后再退出,避免磁盘上留下损坏文件,随后抛出 `DownloadCancelledException`。
-
-#### 基本用法
-
-取消下载的逻辑分为三步:
-1. **创建控制器**:`control = DownloadControl()`;
-2. **绑定任务上下文**:使用 `with jm_task_context(control=control):` 包裹下载调用,将控制器传给当前下载任务;
-3. **请求停止**:在其他线程或协程中调用 `control.cancel('取消原因')`。
-
-#### 1. 同步下载取消示例
-
-如果下载运行在独立的子线程(例如 GUI 点击下载按钮后启动线程),可以在主线程或其他线程中调用 `control.cancel()`:
-
-```python
-from threading import Thread
-from time import sleep
-from jmcomic import DownloadCancelledException, DownloadControl, download_album, jm_task_context
-
-# 1. 创建控制器
-control = DownloadControl()
-
-def run_download():
- try:
- # 2. 通过 jm_task_context 将 control 绑定给当前下载任务
- # (由于任务上下文是与线程绑定的,请将 with 写在实际调用下载的线程内部)
- with jm_task_context(control=control):
- download_album('123456')
- except DownloadCancelledException as e:
- print(f'下载已取消: {e.reason}')
-
-t = Thread(target=run_download)
-t.start()
-
-# 模拟用户在界面上点击了取消按钮
-sleep(2)
-control.cancel('用户主动取消')
-t.join()
-```
-
-#### 2. 异步下载取消示例
-
-异步调用的方式完全一致。在异步代码中,`asyncio.create_task` 会自动传播任务上下文,子任务会自动继承 `control`:
-
-```python
-import asyncio
-from jmcomic import DownloadCancelledException, DownloadControl, download_album_async, jm_task_context
-
-async def main():
- control = DownloadControl()
-
- # 绑定 control 并启动下载任务
- with jm_task_context(control=control):
- task = asyncio.create_task(download_album_async('123456'))
-
- await asyncio.sleep(2)
- # 请求取消
- control.cancel('任务超时取消')
-
- try:
- await task
- except DownloadCancelledException as e:
- print(f'异步任务已取消: {e.reason}')
-
-asyncio.run(main())
-```
-
-取消生效后,已下载完成的图片会保留在本地;未完成的章节不会触发整章或整本完成回调,也不会执行 PDF/ZIP 等导出特性。
-
----
-
### 速查表
| 你的需求 | 推荐写法 | 说明 |
@@ -697,3 +622,35 @@ assert album is result.detail
```
+
+---
+
+### 取消下载
+
+如果需要中途停止下载,可以使用 `DownloadControl`(需 `v2.7.6` 及以上版本)。
+
+```python
+from threading import Thread
+from time import sleep
+from jmcomic import DownloadCancelledException, DownloadControl, download_album, jm_task_context
+
+# 创建取消控制器
+control = DownloadControl()
+
+def run_download():
+ try:
+ # 在下载的线程里绑定 control
+ with jm_task_context(control=control):
+ download_album('123456')
+ except DownloadCancelledException as e:
+ print(f'下载已取消: {e.reason}')
+
+t = Thread(target=run_download)
+t.start()
+
+# 模拟一段时间后需要取消下载
+sleep(2)
+control.cancel() # 可以传入取消原因,下载线程可通过上面的 e.reason 获取
+# 等待下载收尾
+t.join()
+```
diff --git a/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md
index 99ea54a22..24b6e5858 100644
--- a/assets/docs/sources/tutorial/14_async_usage.md
+++ b/assets/docs/sources/tutorial/14_async_usage.md
@@ -322,3 +322,31 @@ asyncio.run(main())
> 批量 API 会等两个本子的任务都结束,再返回 `BatchResult`。其中某个任务抛出普通异常时,会记录到 `results.failed`,不会提前关闭另一个任务正在使用的 Runtime;取消仍会向调用方抛出。
关于 Runtime 与任务上下文的更多进阶机制(如同步三层拓扑、JTC 门面类、外部 Executor 生命周期),请参考 [复用下载 Runtime 与共享线程池](16_shared_executors.md)。
+
+## 11. 取消下载
+
+如果需要中途停止异步下载,可以使用 `DownloadControl`(需 `v2.7.6` 及以上版本)。
+
+```python
+import asyncio
+from jmcomic import DownloadCancelledException, DownloadControl, download_album_async, jm_task_context
+
+async def main():
+ control = DownloadControl()
+
+ # 绑定 control 并启动下载任务
+ with jm_task_context(control=control):
+ task = asyncio.create_task(download_album_async('123456'))
+
+ # 模拟一段时间后需要取消下载
+ await asyncio.sleep(2)
+ control.cancel() # 可以传入取消原因,下载协程可通过下面的 e.reason 获取
+
+ try:
+ # 等待下载收尾
+ await task
+ except DownloadCancelledException as e:
+ print(f'异步任务已取消: {e.reason}')
+
+asyncio.run(main())
+```
diff --git a/assets/docs/sources/tutorial/16_shared_executors.md b/assets/docs/sources/tutorial/16_shared_executors.md
index b9611a28c..ddf21f43d 100644
--- a/assets/docs/sources/tutorial/16_shared_executors.md
+++ b/assets/docs/sources/tutorial/16_shared_executors.md
@@ -161,3 +161,33 @@ with jm_task_context(runtime=runtime, option=option, control=control, custom_tag
| **`JmAsyncRuntime`** | 统一管理异步中的 CPU 解密与文件写入池 (`decode`) | 多个异步任务并发、需要控制解密并发时使用 |
| **`JTC`** | 任务上下文统一访问门面 | 随时读取 `JTC.get_runtime()` / `get_option()` / `get_control()` / `get_context()` |
| **外部 Executor** | 复用已有线程池 | 传入 `*_executor` 参数;Runtime 不会代关,由调用方自行管理生命周期 |
+
+## Runtime 与 Control 架构图
+
+```text
+下载调用
+ |
+ +-- jm_task_context:为任务绑定 Runtime 和 Control
+ | |
+ | +-- Runtime:管理下载使用的线程池
+ | `-- Control:保存取消信号和原因
+ |
+ `-- 下载 API -> Downloader(下载器)
+ |
+ +-- 同步:JmSyncRuntime
+ | ID 池(批量时)-> 章节池 -> 图片池
+ | `-- 请求、解码、保存
+ |
+ +-- 异步:JmAsyncRuntime
+ | 协程 + 信号量 -> 网络请求
+ | 解码池 -> 图片解码、保存、同步回调
+ |
+ `-- 取消检查点 <--- Control.cancel() <--- 用户点击停止
+ 已取消 -> 当前图片工作收尾,保留已保存文件
+ 抛出 DownloadCancelledException
+
+资源关闭
+ API 自动创建的 Runtime -> API 在任务结束后关闭
+ 调用方提供的 Runtime -> 调用方等待所有任务结束后关闭
+ 外部传入的线程池 -> 由外部管理,Runtime 不代为关闭
+```
diff --git a/src/jmcomic/api.py b/src/jmcomic/api.py
index 628c5b8af..e5f3c3a4d 100644
--- a/src/jmcomic/api.py
+++ b/src/jmcomic/api.py
@@ -6,7 +6,7 @@
from .jm_runtime import JmAsyncRuntime, JmSyncRuntime
from .jm_task_context import (
bind_jm_task_context,
- get_jm_runtime,
+ JTC,
jm_task_context,
)
@@ -33,7 +33,7 @@ def _ensure_option(option):
def _resolve_runtime(runtime_type, error_message):
- runtime = get_jm_runtime()
+ runtime = JTC.get_runtime()
if runtime is None:
runtime = runtime_type()
return runtime, True
diff --git a/src/jmcomic/jm_async_downloader.py b/src/jmcomic/jm_async_downloader.py
index d2fc6ee0f..f277eeb18 100644
--- a/src/jmcomic/jm_async_downloader.py
+++ b/src/jmcomic/jm_async_downloader.py
@@ -20,7 +20,7 @@
from .jm_runtime import JmAsyncRuntime
from .jm_task_context import (
bind_jm_task_context,
- get_jm_runtime,
+ JTC,
jm_task_context,
)
from .jm_option import JmOption
@@ -87,7 +87,7 @@ def use(cls, *args, **kwargs):
# ======================================================================
async def _run_in_decode_pool(self, func, *args):
- runtime = get_jm_runtime()
+ runtime = JTC.get_runtime()
if not isinstance(runtime, JmAsyncRuntime):
raise RuntimeError(
'async downloader requires jm_task_context('
@@ -343,7 +343,7 @@ def shutdown(self):
self._runtime_context.close()
async def __aenter__(self):
- runtime = get_jm_runtime()
+ runtime = JTC.get_runtime()
if runtime is None:
runtime = JmAsyncRuntime()
self._runtime_context.callback(runtime.close)
diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py
index 05380d4df..2efa78374 100644
--- a/src/jmcomic/jm_config.py
+++ b/src/jmcomic/jm_config.py
@@ -4,7 +4,7 @@
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 +68,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)
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index 9b2d529ea..86a26b07b 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -8,9 +8,7 @@
from .jm_runtime import JmSimpleRuntime, JmSyncRuntime
from .jm_task_context import (
bind_jm_task_context,
- get_current_control,
- get_jm_runtime,
- get_jm_task_context,
+ JTC,
jm_task_context,
)
@@ -35,7 +33,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().get(context_key) is not None:
return await func(*args, **kwargs)
started_at = get_time()
@@ -53,7 +51,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().get(context_key) is not None:
return func(*args, **kwargs)
started_at = get_time()
@@ -208,13 +206,13 @@ def has_download_failures(self):
return len(self.download_failed_image) != 0 or len(self.download_failed_photo) != 0
def is_cancelled(self) -> bool:
- control = get_current_control()
+ control = JTC.get_control()
return control is not None and control.is_cancelled
@classmethod
def raise_if_cancelled(cls) -> None:
"""在当前下载作用域已取消时抛出异常,子类可重写该检查点。"""
- control = get_current_control()
+ control = JTC.get_control()
if control is None or not control.is_cancelled:
return
raise DownloadCancelledException(
@@ -356,7 +354,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().get('download_type')
ExceptionTool.require_true(
download_type in ('album', 'photo'),
'Feature 注册与执行必须位于下载任务上下文中,请使用 '
@@ -574,7 +572,7 @@ def download_by_image_detail(self, image: JmImageDetail):
def execute_on_condition(self, iter_objs, apply, count_batch):
"""使用当前 Runtime 调度本子或章节的下载。"""
- runtime = get_jm_runtime()
+ runtime = JTC.get_runtime()
if runtime is not None and not isinstance(runtime, JmSyncRuntime):
raise TypeError('sync downloader requires JmSyncRuntime')
level = None if runtime is None else (
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_plugin.py b/src/jmcomic/jm_plugin.py
index ad2442424..40c3b5eff 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):
@@ -36,7 +36,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':
@@ -930,7 +930,7 @@ class DownloadProgressPlugin(JmOptionPlugin):
@staticmethod
def cli_no_progress_notice():
- if not get_jm_task_context().get('cli_no_progress'):
+ if not JTC.get_context().get('cli_no_progress'):
return ''
return (
diff --git a/src/jmcomic/jm_task_context.py b/src/jmcomic/jm_task_context.py
index f36087aad..1b3022dbe 100644
--- a/src/jmcomic/jm_task_context.py
+++ b/src/jmcomic/jm_task_context.py
@@ -13,11 +13,8 @@
'JM_TASK_CONTEXT',
'DownloadControl',
'JTC',
- 'jm_task_context',
'get_jm_task_context',
- 'get_current_control',
- 'get_jm_runtime',
- 'get_current_option',
+ 'jm_task_context',
'bind_jm_task_context',
)
@@ -58,42 +55,6 @@ def reason(self) -> str:
return self._reason
-def get_jm_task_context() -> dict:
- """
- 返回当前 JM 任务上下文的可变快照。
- """
- return dict(JM_TASK_CONTEXT.get())
-
-
-def get_current_control() -> Optional[DownloadControl]:
- """
- 返回当前 JM 任务上下文中的取消控制器。
- """
- control = get_jm_task_context().get('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
-
-
-def get_jm_runtime() -> Optional[JmRuntime]:
- """
- 返回当前任务的 Runtime;没有活动 Runtime 时返回 None。
- """
- return get_jm_task_context().get('runtime')
-
-
-def get_current_option():
- """
- 返回当前任务的 Option;没有活动 Option 时返回 None。
- """
- return get_jm_task_context().get('option')
-
-
class JTC:
"""
JMComic 任务上下文统一门面 (Jm Task Context)。
@@ -104,28 +65,40 @@ def get_context(cls) -> dict:
"""
返回当前 JM 任务上下文的可变快照。
"""
- return get_jm_task_context()
+ return dict(JM_TASK_CONTEXT.get())
@classmethod
def get_runtime(cls) -> Optional[JmRuntime]:
"""
返回当前任务绑定的 JmRuntime;没有活动 Runtime 时返回 None。
"""
- return get_jm_runtime()
+ return cls.get_context().get('runtime')
@classmethod
def get_option(cls):
"""
返回当前任务绑定的 JmOption;没有活动 Option 时返回 None。
"""
- return get_current_option()
+ return cls.get_context().get('option')
@classmethod
def get_control(cls) -> Optional[DownloadControl]:
"""
返回当前任务绑定的取消控制器 DownloadControl;未设置时返回 None。
"""
- return get_current_control()
+ control = cls.get_context().get('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
+
+
+# 兼容已发布的上下文查询入口,内部统一使用 JTC。
+get_jm_task_context = JTC.get_context
@contextmanager
@@ -133,7 +106,7 @@ def jm_task_context(*, option=None, runtime=None, **fields):
"""
临时绑定任务字段;只传播 Option/Runtime,不管理资源生命周期。
"""
- context = get_jm_task_context()
+ context = JTC.get_context()
parent_runtime = context.get('runtime')
if runtime is not None and not isinstance(runtime, JmRuntime):
@@ -169,7 +142,7 @@ def bind_jm_task_context(func: Callable, context: Optional[Mapping] = None) -> C
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/tests/test_jmcomic/test_jm_async_custom.py b/tests/test_jmcomic/test_jm_async_custom.py
index a7cb22a59..78631295d 100644
--- a/tests/test_jmcomic/test_jm_async_custom.py
+++ b/tests/test_jmcomic/test_jm_async_custom.py
@@ -259,7 +259,7 @@ def __init__(self):
runtime = ProbeAsyncRuntime.instances[0]
self.assertIsNone(downloader.client)
- self.assertIsNone(get_jm_runtime())
+ self.assertIsNone(JTC.get_runtime())
with self.assertRaisesRegex(RuntimeError, 'JmRuntime is closed'):
runtime.executor('decode', 1)
finally:
diff --git a/tests/test_jmcomic/test_jm_cancellation.py b/tests/test_jmcomic/test_jm_cancellation.py
index a2e76c2d9..04e0313cd 100644
--- a/tests/test_jmcomic/test_jm_cancellation.py
+++ b/tests/test_jmcomic/test_jm_cancellation.py
@@ -17,7 +17,7 @@
download_album,
download_batch,
download_batch_async,
- get_current_control,
+ JTC,
jm_task_context,
)
@@ -30,7 +30,7 @@ def test_control_is_idempotent_and_visible_across_threads(self):
with jm_task_context(control=control):
worker = bind_jm_task_context(
- lambda: seen.append(get_current_control())
+ lambda: seen.append(JTC.get_control())
)
thread = Thread(target=worker)
thread.start()
@@ -104,7 +104,7 @@ def add_features(self, _extra):
pass
def download_album(self, _album_id):
- seen.append(get_current_control())
+ seen.append(JTC.get_control())
control.cancel('api probe')
BaseDownloader.raise_if_cancelled()
diff --git a/tests/test_jmcomic/test_jm_cli.py b/tests/test_jmcomic/test_jm_cli.py
index 9dd1beb58..40c4cd7ba 100644
--- a/tests/test_jmcomic/test_jm_cli.py
+++ b/tests/test_jmcomic/test_jm_cli.py
@@ -6,8 +6,7 @@
from unittest.mock import MagicMock, patch
from jmcomic.cli import JmcomicUI, JmViewUI
-from jmcomic import JmSyncRuntime, get_jm_runtime, jm_task_context
-from jmcomic.jm_task_context import get_jm_task_context
+from jmcomic import JmSyncRuntime, JTC, jm_task_context
class Test_Cli(JmTestConfigurable):
@@ -63,7 +62,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), \
@@ -73,7 +72,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()
@@ -121,8 +120,8 @@ def record(ids, actual_option):
observed.append((
ids,
actual_option,
- get_jm_runtime(),
- get_jm_task_context(),
+ JTC.get_runtime(),
+ JTC.get_context(),
))
with ThreadPoolExecutor(max_workers=1) as executor:
diff --git a/tests/test_jmcomic/test_jm_download_manifest.py b/tests/test_jmcomic/test_jm_download_manifest.py
index aa05eb1bc..0bb0f7a79 100644
--- a/tests/test_jmcomic/test_jm_download_manifest.py
+++ b/tests/test_jmcomic/test_jm_download_manifest.py
@@ -77,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'])
@@ -914,7 +914,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}
@@ -923,20 +923,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)
@@ -950,7 +950,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}
@@ -959,20 +959,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)
@@ -986,7 +986,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():
@@ -996,20 +996,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)
@@ -1023,7 +1023,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())
@@ -1035,20 +1035,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)
@@ -1062,7 +1062,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())
diff --git a/tests/test_jmcomic/test_jm_runtime.py b/tests/test_jmcomic/test_jm_runtime.py
index f2ebb7d93..bb2ede3d3 100644
--- a/tests/test_jmcomic/test_jm_runtime.py
+++ b/tests/test_jmcomic/test_jm_runtime.py
@@ -11,7 +11,7 @@
JmSyncRuntime,
download_batch,
download_batch_async,
- get_jm_runtime,
+ JTC,
jm_task_context,
)
@@ -187,7 +187,7 @@ def test_sync_batch_explicitly_closes_runtime_created_by_api(self):
observed = []
def download_one(jmid, *_args, **_kwargs):
- observed.append(get_jm_runtime())
+ observed.append(JTC.get_runtime())
return jmid
self.assertEqual(
@@ -202,7 +202,7 @@ def test_async_batch_explicitly_closes_runtime_created_by_api(self):
observed = []
async def download_one(jmid, *_args, **_kwargs):
- observed.append(get_jm_runtime())
+ observed.append(JTC.get_runtime())
return jmid
result = asyncio.run(download_batch_async(
diff --git a/tests/test_jmcomic/test_jm_task_context.py b/tests/test_jmcomic/test_jm_task_context.py
index 13f474c99..298d803f5 100644
--- a/tests/test_jmcomic/test_jm_task_context.py
+++ b/tests/test_jmcomic/test_jm_task_context.py
@@ -30,10 +30,6 @@
download_batch,
download_batch_async,
download_photo_async,
- get_current_control,
- get_current_option,
- get_jm_runtime,
- get_jm_task_context,
jm_log,
jm_task_context,
jm_logger,
@@ -74,7 +70,7 @@ def test_context_exposes_runtime_and_borrows_executor(self):
runtime = JmSyncRuntime(id_executor=executor)
with jm_task_context(task_id='job-42', runtime=runtime):
self.assertEqual(
- get_jm_task_context(),
+ JTC.get_context(),
{'task_id': 'job-42', 'runtime': runtime},
)
raw_context = JM_TASK_CONTEXT.get()
@@ -82,10 +78,10 @@ def test_context_exposes_runtime_and_borrows_executor(self):
self.assertIs(raw_context['runtime'], runtime)
with self.assertRaises(TypeError):
raw_context['mutable'] = True
- self.assertIs(get_jm_runtime(), runtime)
+ self.assertIs(JTC.get_runtime(), runtime)
with jm_task_context(jm_id='123'):
self.assertEqual(
- get_jm_task_context(),
+ JTC.get_context(),
{
'task_id': 'job-42',
'runtime': runtime,
@@ -95,8 +91,8 @@ def test_context_exposes_runtime_and_borrows_executor(self):
public_bound = executor.submit(bind_jm_task_context(
lambda: (
- get_jm_task_context(),
- get_jm_runtime(),
+ JTC.get_context(),
+ JTC.get_runtime(),
)
))
self.assertEqual(
@@ -111,28 +107,28 @@ def test_option_is_public_and_propagated(self):
with ThreadPoolExecutor(max_workers=1) as executor:
with jm_task_context(option=option, task_id='job-42'):
self.assertEqual(
- get_jm_task_context(),
+ JTC.get_context(),
{'task_id': 'job-42', 'option': option},
)
- self.assertIs(get_current_option(), option)
- future = executor.submit(bind_jm_task_context(get_current_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(get_current_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(get_jm_task_context(), {'task_id': 'job-42'})
- self.assertIsNone(get_current_option())
- self.assertIsNone(get_jm_runtime())
+ self.assertEqual(JTC.get_context(), {'task_id': 'job-42'})
+ self.assertIsNone(JTC.get_option())
+ self.assertIsNone(JTC.get_runtime())
outer = JmSyncRuntime(id_workers=1)
inner = JmSyncRuntime(id_workers=1)
try:
with jm_task_context(runtime=outer):
with jm_task_context(runtime=outer):
- self.assertIs(get_jm_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')
@@ -237,10 +233,10 @@ def test_download_control_is_propagated_by_public_binder(self):
with ThreadPoolExecutor(max_workers=1) as executor:
with jm_task_context(control=control):
- future = executor.submit(bind_jm_task_context(get_current_control))
+ future = executor.submit(bind_jm_task_context(JTC.get_control))
self.assertIs(future.result(timeout=1), control)
- self.assertIsNone(executor.submit(get_current_control).result(timeout=1))
+ self.assertIsNone(executor.submit(JTC.get_control).result(timeout=1))
self.assertTrue(control.cancel('user requested'))
self.assertFalse(control.cancel('ignored'))
@@ -251,33 +247,33 @@ 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())
@@ -308,7 +304,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'):
@@ -317,7 +313,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'):
@@ -502,8 +498,8 @@ def download_album(self, _album_id):
detail = Detail()
self.begin_manifest(detail)
try:
- observed_contexts.append(get_jm_task_context())
- observed_options.append(get_current_option())
+ observed_contexts.append(JTC.get_context())
+ observed_options.append(JTC.get_option())
finally:
self.finish_manifest(detail)
return detail
@@ -516,8 +512,8 @@ def download_album(self, _album_id):
self.assertEqual(10.0, result.duration)
self.assertEqual(99.0, result.detail.duration)
self.assertEqual([option], observed_options)
- self.assertEqual({}, get_jm_task_context())
- self.assertIsNone(get_current_option())
+ 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():
@@ -535,7 +531,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):
@@ -558,7 +554,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 = []
@@ -654,7 +650,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'],
@@ -672,14 +668,14 @@ def fake_download(jmid, _option, _downloader, **_kwargs):
},
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):
@@ -758,7 +754,7 @@ def __init__(self, **kwargs):
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()),
+ apply=lambda _item: observed.put(JTC.get_context()),
count_batch=count_batch,
)
@@ -776,7 +772,7 @@ 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'],
@@ -799,14 +795,14 @@ async def fake_download(jmid, _option, _downloader, **_kwargs):
'decode',
1,
).submit(
- bind_jm_task_context(get_jm_task_context),
+ 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(
decode_executor,
- get_jm_task_context,
+ JTC.get_context,
)
return batch_result, executor_context, leaked_context
@@ -831,7 +827,7 @@ 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:
@@ -877,7 +873,7 @@ async def scenario():
1,
).submit(
bind_jm_task_context(
- lambda: (8, get_jm_task_context()['session_id'])
+ lambda: (8, JTC.get_context()['session_id'])
),
)
value = await asyncio.wrap_future(future)
From bdc9465f98ea95b52da171bdeab6771df96b14b1 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Wed, 9 Sep 2026 18:02:48 +0800
Subject: [PATCH 7/8] =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
assets/docs/sources/api/download.md | 2 -
.../docs/sources/tutorial/0_common_usage.md | 135 +++++++++---------
.../docs/sources/tutorial/14_async_usage.md | 129 ++++++-----------
3 files changed, 113 insertions(+), 153 deletions(-)
diff --git a/assets/docs/sources/api/download.md b/assets/docs/sources/api/download.md
index 1d2305e08..ee37905ba 100644
--- a/assets/docs/sources/api/download.md
+++ b/assets/docs/sources/api/download.md
@@ -24,8 +24,6 @@
- BaseDownloader
- JmDownloader
-## 下载 Runtime 与取消控制
-
::: jmcomic.jm_runtime
options:
members:
diff --git a/assets/docs/sources/tutorial/0_common_usage.md b/assets/docs/sources/tutorial/0_common_usage.md
index f4e6af11e..072869a7c 100644
--- a/assets/docs/sources/tutorial/0_common_usage.md
+++ b/assets/docs/sources/tutorial/0_common_usage.md
@@ -440,11 +440,61 @@ 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(xxx),都会统一被取消,因为属于同一个 任务上下文
+ download_album('123456')
+ except DownloadCancelledException as e:
+ print(f'下载已取消: {e.reason}')
+
+# 使用单独的下载线程执行下载
+# 主线程负责取消
+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
@@ -544,24 +594,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}')
@@ -572,37 +624,22 @@ print('是否全部成功:', batch_result.all_succeeded)
-下载单个 ID 时,请求本子失败会直接抛出异常;如果只有部分章节或图片失败,会在任务结束后汇总抛出 `PartialDownloadFailedException`,此时不会返回 `DownloadResult`。
-
-批量下载(如传入 ID 列表)具备**自动容错机制**,单个本子下载失败不会中断其他任务,所有失败项会被完整收集在 `batch_result.failed` 字典中,键为本子 ID,值为对应的异常对象:
-
-```python
-from jmcomic import download_album
-
-result = download_album(['123456', '99999999']) # 假设后者不存在
-
-print(f'全部成功? {result.all_succeeded}') # False
-print(f'成功数量: {len(result)}, 失败数量: {len(result.failed)}')
-
-# 逐一排查失败项
-for aid, err in result.failed.items():
- print(f'本子 [{aid}] 下载失败: {err}')
-```
-
---
+
### 速查表
-| 你的需求 | 推荐写法 | 说明 |
-| :--- | :--- | :--- |
-| 查看本子或章节信息 | `result.detail` | 访问实体类所有属性(标题、作者、标签等) |
-| 查看本子或章节目录 | `result.detail.save_path` | 获取下载保存的目标文件夹路径 |
-| 查看单张图片路径和状态 | 遍历实体读取 `image.save_path` | 获取具体图片文件路径 |
-| 获取本次成功图片路径清单 | `result.manifest.image_filepath_list` | 包含所有下载成功的图片绝对路径 |
-| 获取已登记的导出文件路径 | `result.manifest.get_export_filepath_list('zip')` | 获取导出插件生成的压缩包或 PDF 路径 |
-| 查看单个 ID 下载的完整时间 | `result.duration` | 秒数,顶层完整下载耗时 |
-| 检查批量下载失败项 | `batch_result.failed` | 字典结构:`{aid: error}` |
-| 获取当前任务上下文信息 | `JTC.get_runtime()` / `get_option()` / `get_control()` | 通过 `JTC` 统一门面快捷自省 |
+| 你的需求 | 推荐写法 |
+| --- | --- |
+| 查看本子或章节信息 | `result.detail` |
+| 查看本子或章节目录 | `result.detail.save_path` |
+| 查看单张图片路径和状态 | 遍历实体后读取 `image.save_path` 等字段 |
+| 查看图片或章节失败原因 | 捕获 `PartialDownloadFailedException` 后,读取 `e.downloader.download_failed_image` / `download_failed_photo`;列表元素为 `(实体, 异常)` |
+| 获取本次成功图片路径列表 | `result.manifest.image_filepath_list` |
+| 获取已登记的导出文件路径 | `result.manifest.get_export_filepath_list('后缀')` |
+| 查看单个 ID 下载的完整时间 | `result.duration` |
+| 定位本子、章节或图片的内部处理慢点 | 对应实体的 `duration` |
+| 检查批量下载失败项 | `batch_result.failed` |
兼容旧版本的返回值解包写法
@@ -622,35 +659,3 @@ assert album is result.detail
```
-
----
-
-### 取消下载
-
-如果需要中途停止下载,可以使用 `DownloadControl`(需 `v2.7.6` 及以上版本)。
-
-```python
-from threading import Thread
-from time import sleep
-from jmcomic import DownloadCancelledException, DownloadControl, download_album, jm_task_context
-
-# 创建取消控制器
-control = DownloadControl()
-
-def run_download():
- try:
- # 在下载的线程里绑定 control
- with jm_task_context(control=control):
- download_album('123456')
- except DownloadCancelledException as e:
- print(f'下载已取消: {e.reason}')
-
-t = Thread(target=run_download)
-t.start()
-
-# 模拟一段时间后需要取消下载
-sleep(2)
-control.cancel() # 可以传入取消原因,下载线程可通过上面的 e.reason 获取
-# 等待下载收尾
-t.join()
-```
diff --git a/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md
index 24b6e5858..c72787367 100644
--- a/assets/docs/sources/tutorial/14_async_usage.md
+++ b/assets/docs/sources/tutorial/14_async_usage.md
@@ -19,12 +19,9 @@ import jmcomic
async def main():
# 异步下载单个本子
- result = await jmcomic.download_album_async('438696')
- album = result.detail
- downloader = result.downloader
+ album, downloader = await jmcomic.download_album_async('438696')
- # 返回时该 Downloader 的网络 client 已关闭;如果传入共享 decode
- # 执行器,它仍由创建它的调用方管理
+ # 返回的 downloader 已释放网络连接和线程池,只用于读取下载结果
print(downloader.download_failed_image)
# 异步下载单章节
@@ -55,8 +52,6 @@ async def main():
asyncio.run(main())
```
-异步下载的网络 I/O 仍由 event loop 和 Semaphore 控制;解密、PIL、写盘及同步 hook 使用标准线程池。每个 `JmAsyncDownloader` 仍独立创建并关闭自己的 client、session 和并发控制;需要显式复用线程池时,请参阅[复用下载 Runtime](16_shared_executors.md)。
-
## 3. 异步获取实体类,并发请求
### 💡 关于 async with 和自动初始化
@@ -107,17 +102,11 @@ async def main():
# 示例:使用 async 并发获取本子详情
album_id_list = [123, 456]
album_list = await asyncio.gather(
- *(cl.get_album_detail(aid) for aid in album_id_list),
- return_exceptions=True, # 等所有请求结束后再关闭共享 client
+ *(cl.get_album_detail(aid) for aid in album_id_list)
)
# 打印结果
for aid, album in zip(album_id_list, album_list):
- if isinstance(album, asyncio.CancelledError):
- raise album
- if isinstance(album, Exception):
- print(f'[JM{aid}] 查询失败: {album}')
- continue
print(f'[JM{aid}] 本子详情: {album}')
asyncio.run(main())
@@ -241,9 +230,9 @@ client:
async_impl: async_api
```
-## 9. 查看下载耗时
+## 9. 下载返回值
-异步下载完成后,可以直接查看自己总共等了多久,也可以继续查看具体是哪个本子、章节或图片比较慢。所有 `duration` 的单位都是秒。
+用法与同步版本完全一致,返回的 `DownloadResult` 包含了本子实体、保存路径、耗时以及下载清单(`manifest`)等专属字段:
```python
import asyncio
@@ -254,99 +243,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` | 处理这张图片花了多久,包含检查缓存、下载、解密和保存 |
-
-下载器可能同时处理多个章节或多张图片,所以把它们的耗时全部相加,不会得到本子的耗时,这是正常现象。同步下载中的这些字段含义相同。
+## 10. 取消下载
-## 10. 异步解码池复用与性能调优 (Async Runtime)
-
-禁漫的图片下载后需要进行分块拼接、反混淆解密并存盘。异步下载时,网络请求由 `asyncio` 协程高效处理,而图片解码与写盘等阻塞操作则由后台线程池负责。
-
-默认情况下,每次调用 `download_album_async` 会自动管理解码池,任务结束后自动释放,通常不需要手动配置。
-
-自 `v2.7.6` 起,如果你在并发下载多个本子时希望控制解码并发数,或者希望复用外部线程池,可以使用 `JmAsyncRuntime`:
+下面示例代码模拟了一个 GUI 场景中,有两个按钮,一个开始下载,一个取消下载,基于 `DownloadControl` 实现。
```python
import asyncio
-from concurrent.futures import ThreadPoolExecutor
-from jmcomic import JmAsyncRuntime, download_album_async, jm_task_context, JTC
-
+from jmcomic import DownloadCancelledException, DownloadControl, download_album_async, jm_task_context
-async def main():
- # 场景 1:指定解码并发线程数为 4
- runtime = JmAsyncRuntime(decode_workers=4)
- try:
- with jm_task_context(runtime=runtime):
- # 两个本子的图片统一使用这 4 个解码线程处理
- results = await download_album_async(['123456', '789012'])
- for album_id, error in results.failed.items():
- print(f'本子 {album_id} 下载任务失败: {error}')
-
- # 下载过程中可以通过 JTC 查看当前生效的 Runtime
- print('当前 Runtime:', JTC.get_runtime())
- finally:
- runtime.close()
-
- # 场景 2:复用外部已有的线程池(生命周期由外部管理,JMComic 不会主动关闭它)
- with ThreadPoolExecutor(max_workers=4) as my_pool:
- runtime = JmAsyncRuntime(decode_executor=my_pool)
- try:
- with jm_task_context(runtime=runtime):
- await download_album_async('123456')
- finally:
- runtime.close()
+# 界面持有的当前下载控制器与任务
+current_control = None
+current_task = None
-asyncio.run(main())
-```
+# 按钮 1:点击【开始下载】
+def on_btn_download_click(album_id: str) -> asyncio.Task:
+ global current_control, current_task
+ current_control = DownloadControl()
-> 批量 API 会等两个本子的任务都结束,再返回 `BatchResult`。其中某个任务抛出普通异常时,会记录到 `results.failed`,不会提前关闭另一个任务正在使用的 Runtime;取消仍会向调用方抛出。
+ 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}')
-关于 Runtime 与任务上下文的更多进阶机制(如同步三层拓扑、JTC 门面类、外部 Executor 生命周期),请参考 [复用下载 Runtime 与共享线程池](16_shared_executors.md)。
+ current_task = asyncio.create_task(_run())
+ return current_task
-## 11. 取消下载
-如果需要中途停止异步下载,可以使用 `DownloadControl`(需 `v2.7.6` 及以上版本)。
+# 按钮 2:点击【取消下载】
+def on_btn_cancel_click():
+ if current_control:
+ current_control.cancel("用户点击了界面的取消按钮")
-```python
-import asyncio
-from jmcomic import DownloadCancelledException, DownloadControl, download_album_async, jm_task_context
async def main():
- control = DownloadControl()
+ # 模拟用户点击【开始下载】
+ task = on_btn_download_click('123456')
- # 绑定 control 并启动下载任务
- with jm_task_context(control=control):
- task = asyncio.create_task(download_album_async('123456'))
-
- # 模拟一段时间后需要取消下载
+ # 模拟用户在界面等待一段时间后,点击了【取消下载】
await asyncio.sleep(2)
- control.cancel() # 可以传入取消原因,下载协程可通过下面的 e.reason 获取
+ on_btn_cancel_click()
+
+ # 等待下载任务响应取消并安全收尾
+ await task
- try:
- # 等待下载收尾
- await task
- except DownloadCancelledException as e:
- print(f'异步任务已取消: {e.reason}')
asyncio.run(main())
```
From 92e2ae8f4239c89875b3eb19ff79bce20afe9f13 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Wed, 9 Sep 2026 20:06:33 +0800
Subject: [PATCH 8/8] =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../sources/tutorial/16_shared_executors.md | 177 +++++++-----------
1 file changed, 68 insertions(+), 109 deletions(-)
diff --git a/assets/docs/sources/tutorial/16_shared_executors.md b/assets/docs/sources/tutorial/16_shared_executors.md
index ddf21f43d..c4153f698 100644
--- a/assets/docs/sources/tutorial/16_shared_executors.md
+++ b/assets/docs/sources/tutorial/16_shared_executors.md
@@ -1,64 +1,78 @@
# 复用下载 Runtime 与共享线程池
-日常使用 JMComic 下载时,通常不需要手动配置 Runtime。顶层下载 API 会自动管理线程资源,并在任务结束后自动释放。
-
-自 `v2.7.6` 起,如果你需要在批量下载时控制并发数,或者希望复用已有程序中的线程池,可以通过 `JmSyncRuntime` 或 `JmAsyncRuntime` 进行定制。
+> 日常使用 JMComic 下载时,通常不需要手动配置线程池,顶层下载 API 会自动管理并在任务结束后安全释放。
+>
+> 但在以下两种场景下,你可以使用 `Runtime` 来精细控制:
+>
+> 1. **批量下载时防卡死**:一次性下载几十上百个本子,希望严格限制并发(例如“最多同时下 2 个本子,所有图片最多 8 个线程”),避免把带宽或电脑卡爆。
+> 2. **复用已有的线程池**:你的程序(如 Web 服务、后台调度器)本身已经维护了全局线程池,希望 JMComic 直接复用,避免重复创建和销毁线程。
---
-## 1. 同步下载的分层并发机制
+## 什么是 Runtime?
-同步下载漫画时,内部通常是分层调度的:
+平时我们直接调用 `download_album('123456')` 时,完全不需要传任何 Runtime 参数。
-```text
-本子层 (id_executor) -> 共享此 Runtime 的本子任务并发上限
- └── 章节层 (photo_executor) -> 这些本子的章节任务共用一个并发上限
- └── 图片层 (image_executor) -> 这些章节的图片任务共用一个并发上限
-```
+这是因为 JMComic 已经在后台自动创建了一个默认的 Runtime(下载运行时),负责拉起线程池,并在下载结束后自动释放。
+
+**Runtime 的定位很简单:它就是专门负责管理下载过程中“线程池与并发调度”的管家。**
-因为外层任务(下载本子)需要等待内层任务(下载章节和图片)完成,所以这三层需要分别使用不同的线程池。如果三层混用同一个线程池,当外层任务占满线程时,内层任务可能无法获得线程执行,导致任务互相等待。
+当你需要**自定义线程数**,或者希望**复用已有线程池**时,就需要显式创建一个 Runtime,并通过 `jm_task_context` 传递给下载任务:
+
+```python
+from jmcomic import JmSyncRuntime, download_album, jm_task_context
-`JmSyncRuntime` 负责集中配置和管理这三层的线程池。
+# 1. 创建你定制的 Runtime
+runtime = JmSyncRuntime(...)
+
+# 2. 通过 jm_task_context 注入给下载任务
+with jm_task_context(runtime=runtime):
+ download_album(...)
+```
+
+接下来分别介绍同步与异步的具体用法。
---
-## 2. 同步下载:使用 JmSyncRuntime
+## 1. 同步下载:使用 JmSyncRuntime
+
+同步下载时,任务分为三个层级:本子、章节、图片。`JmSyncRuntime` 允许你分别控制这三层的并发线程数。
### 场景 A:自定义各层并发数
-通过 `JmSyncRuntime`,可以分别设置每一层的并发线程数,并通过 `jm_task_context` 传给下载方法:
+通过 `JmSyncRuntime` 指定每一层的最大线程数,并通过 `jm_task_context` 传入下载任务:
```python
from jmcomic import JmSyncRuntime, download_album, jm_task_context
-# 1. 创建 Runtime,指定本子、章节和图片的并发数
+# 1. 创建 Runtime,指定各层并发上限
runtime = JmSyncRuntime(
- id_workers=2, # 整个 Runtime 最多同时处理 2 个本子任务
- photo_workers=3, # 所有本子合计最多同时处理 3 个章节任务
- image_workers=8, # 所有章节合计最多同时处理 8 个图片任务
+ id_workers=2, # 最多同时下载 2 个本子
+ photo_workers=3, # 所有本子合计最多同时处理 3 个章节
+ image_workers=8, # 所有章节合计最多同时下载 8 张图片
)
try:
# 2. 绑定到任务上下文并执行下载
with jm_task_context(runtime=runtime):
- download_album(['123456', '789012'])
+ download_album(['123456', '789012', '345678'])
finally:
- # 3. 释放 Runtime 创建的线程池
+ # 3. 任务完成后释放线程池
runtime.close()
```
-> 上例中的两个本子共用 3 个章节线程,所有章节共用 8 个图片线程。不会因为增加本子或章节数量,就为每个本子、每个章节分别创建一套线程池。
-
-如果某一层没有指定 `*_workers`,下载器会在首次使用该层时按调用点传入的默认并发数建池,后续任务复用该池;章节、图片层的默认值来自 Option。
+> [!TIP]
+> 如果某一层不传参数(例如不传 `photo_workers`),该层会使用系统默认值。
-### 场景 B:复用已有线程池
+### 场景 B:复用外部已有的线程池
-如果你的程序本身已经维护了 `ThreadPoolExecutor`,可以直接传给 `JmSyncRuntime` 复用:
+如果你的程序已经有现成的 `ThreadPoolExecutor`,可以直接传给 Runtime 复用:
```python
from concurrent.futures import ThreadPoolExecutor
from jmcomic import JmSyncRuntime, download_album, jm_task_context
+# 假设项目中已有维护好的线程池
with ThreadPoolExecutor(max_workers=2) as id_pool, \
ThreadPoolExecutor(max_workers=3) as photo_pool, \
ThreadPoolExecutor(max_workers=8) as image_pool:
@@ -72,122 +86,67 @@ with ThreadPoolExecutor(max_workers=2) as id_pool, \
with jm_task_context(runtime=runtime):
download_album(['123456', '789012'])
finally:
+ # 释放 Runtime。遵循“谁创建谁关闭”原则,外部传入的线程池不会被关闭
runtime.close()
```
-关于外部线程池的关闭:
-- **不影响外部线程池**:JMComic 遵循“谁创建、谁关闭”的原则,外部传入的线程池仍由外部上下文管理,`runtime.close()` 不会调用外部池的 `shutdown()`;
-- **状态注销与防泄漏**:`runtime.close()` 会将 Runtime 标记为已结束;如果部分层级使用了外部池、另一部分是内部自建的,它会自动关闭自建的那部分线程池,避免资源泄漏。
-
---
-## 3. 异步下载:使用 JmAsyncRuntime
+## 2. 异步下载:使用 JmAsyncRuntime
+
+异步下载与同步不同:网络请求完全由 `asyncio` 协程高效处理,不占线程;**只有图片的解密、反混淆拼接与写盘(CPU 计算与文件 I/O)需要用到后台线程池**。
-异步下载与同步下载的并发分工不同:
-- **网络请求**:完全由 Python 的 `asyncio` 事件循环和协程处理,无需占用线程池;
-- **图片解密与处理**:禁漫的图片下载后需要进行分块拼接、反混淆解密和写盘,这部分属于 CPU 计算与文件写入,交给后台线程池处理。
+因此,`JmAsyncRuntime` 只需要管理一个 `decode`(图片解码)线程池,配置更加简单。
-`JmAsyncRuntime` 负责管理这层解码线程池:
+### 场景 A:指定解码并发线程数
```python
import asyncio
-from concurrent.futures import ThreadPoolExecutor
from jmcomic import JmAsyncRuntime, download_album_async, jm_task_context
+
async def main():
- # 方式 1:指定解码线程数(任务完成后 close 释放)
+ # 限制最多同时使用 4 个线程进行图片解密与保存
runtime = JmAsyncRuntime(decode_workers=4)
try:
with jm_task_context(runtime=runtime):
- results = await download_album_async(['123456', '789012'])
- for album_id, error in results.failed.items():
- print(f'本子 {album_id} 下载任务失败: {error}')
+ await download_album_async(['123456', '789012'])
finally:
runtime.close()
- # 方式 2:直接复用外部已有线程池
- with ThreadPoolExecutor(max_workers=4) as my_decode_pool:
- runtime = JmAsyncRuntime(decode_executor=my_decode_pool)
- try:
- with jm_task_context(runtime=runtime):
- await download_album_async('123456')
- finally:
- runtime.close()
asyncio.run(main())
```
-> 批量 API 会等两个本子的任务都结束,再返回 `BatchResult`。普通任务异常收集在 `results.failed` 中,取消仍会向调用方抛出,因此需要检查失败项。不要直接用默认的 `asyncio.gather` 包住两个单本下载后立即关闭 Runtime:一个任务报错时,另一个任务可能还在使用解码池。
-
----
-
-## 4. 任务上下文统一门面:JTC
-
-在下载过程中,自定义插件(Plugin)、Feature、回调函数或主逻辑常常需要查询当前任务状态。
-
-JMComic 提供了门面类 **`JTC` (Jm Task Context)**,方便统一读取上下文信息:
+### 场景 B:复用外部已有的解码线程池
```python
-from jmcomic import JTC, jm_task_context, JmSyncRuntime, JmOption, DownloadControl
-
-runtime = JmSyncRuntime(id_workers=2)
-option = JmOption.default()
-control = DownloadControl()
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+from jmcomic import JmAsyncRuntime, download_album_async, jm_task_context
-with jm_task_context(runtime=runtime, option=option, control=control, custom_tag='v1'):
- # 1. 获取当前生效的 Runtime
- cur_runtime = JTC.get_runtime()
- # 2. 获取当前生效的 Option 配置
- cur_option = JTC.get_option()
+async def main():
+ # 借用现成的外部线程池
+ with ThreadPoolExecutor(max_workers=4) as my_decode_pool:
+ runtime = JmAsyncRuntime(decode_executor=my_decode_pool)
+ try:
+ with jm_task_context(runtime=runtime):
+ await download_album_async('123456')
+ finally:
+ runtime.close()
- # 3. 获取当前的取消控制器
- cur_control = JTC.get_control()
- # 4. 获取完整任务上下文快照
- ctx_dict = JTC.get_context()
- print('自定义字段:', ctx_dict.get('custom_tag'))
+asyncio.run(main())
```
-在未进入 `jm_task_context` 的代码区域调用 `JTC.get_runtime()` 等方法,会安全返回 `None`。
-
---
-## 5. 核心规则与速查
+## 3. 常见问题与速查
-| 概念 | 核心职责 | 推荐使用方式 |
+| 需求 | 推荐做法 | 说明 |
| :--- | :--- | :--- |
-| **`JmSyncRuntime`** | 统一管理同步的 `id`、`photo`、`image` 三层线程池 | 批量下载多本漫画且需控制并发时使用,通过 `try...finally: runtime.close()` 回收 |
-| **`JmAsyncRuntime`** | 统一管理异步中的 CPU 解密与文件写入池 (`decode`) | 多个异步任务并发、需要控制解密并发时使用 |
-| **`JTC`** | 任务上下文统一访问门面 | 随时读取 `JTC.get_runtime()` / `get_option()` / `get_control()` / `get_context()` |
-| **外部 Executor** | 复用已有线程池 | 传入 `*_executor` 参数;Runtime 不会代关,由调用方自行管理生命周期 |
-
-## Runtime 与 Control 架构图
-
-```text
-下载调用
- |
- +-- jm_task_context:为任务绑定 Runtime 和 Control
- | |
- | +-- Runtime:管理下载使用的线程池
- | `-- Control:保存取消信号和原因
- |
- `-- 下载 API -> Downloader(下载器)
- |
- +-- 同步:JmSyncRuntime
- | ID 池(批量时)-> 章节池 -> 图片池
- | `-- 请求、解码、保存
- |
- +-- 异步:JmAsyncRuntime
- | 协程 + 信号量 -> 网络请求
- | 解码池 -> 图片解码、保存、同步回调
- |
- `-- 取消检查点 <--- Control.cancel() <--- 用户点击停止
- 已取消 -> 当前图片工作收尾,保留已保存文件
- 抛出 DownloadCancelledException
-
-资源关闭
- API 自动创建的 Runtime -> API 在任务结束后关闭
- 调用方提供的 Runtime -> 调用方等待所有任务结束后关闭
- 外部传入的线程池 -> 由外部管理,Runtime 不代为关闭
-```
+| **同步批量控制并发** | `JmSyncRuntime(id_workers=..., image_workers=...)` | 避免一次性下几十个本子把资源占满 |
+| **异步控制解密并发** | `JmAsyncRuntime(decode_workers=...)` | 仅限制图片解密写盘线程数,网络请求仍走协程 |
+| **复用已有线程池** | 传入 `*_executor=你的线程池` | 遵循“谁创建谁关闭”,`runtime.close()` 不会关闭外部线程池 |
+| **自省当前 Runtime** | `JTC.get_runtime()` | 在插件或上下文内部随时自省当前生效的 Runtime 对象 |