diff --git a/README.md b/README.md
index 5fbb57113..f7b60c242 100644
--- a/README.md
+++ b/README.md
@@ -238,7 +238,7 @@ jmv 350234 -y
等
- **可扩展性强**
- - 支持自定义本子/章节/图片下载前后的回调函数
+ - 支持自定义本子/章节/图片下载前后事件的回调函数
- 支持自定义类:`Downloader(负责调度)` `Option(负责配置)` `Client(负责请求)` `实体类`等
- 支持自定义日志、异常监听器
- **支持Plugin插件,可以方便地扩展功能,以及使用别人的插件,目前核心内置插件有**:
diff --git a/assets/docs/sources/api/command-line.md b/assets/docs/sources/api/command-line.md
index 6f44be8ec..5e2d93044 100644
--- a/assets/docs/sources/api/command-line.md
+++ b/assets/docs/sources/api/command-line.md
@@ -1,6 +1,6 @@
# command-line
-::: jmcomic.cl
+::: jmcomic.cli
options:
members:
- JmcomicUI
diff --git a/assets/docs/sources/index.md b/assets/docs/sources/index.md
index 89d4ce8ea..8d06270fd 100644
--- a/assets/docs/sources/index.md
+++ b/assets/docs/sources/index.md
@@ -65,11 +65,11 @@
-
+
-
-
-
+
+
+
|
@@ -83,4 +83,4 @@
-
\ No newline at end of file
+
diff --git a/assets/docs/sources/option_file_syntax.md b/assets/docs/sources/option_file_syntax.md
index e676e94f4..49e0975fd 100644
--- a/assets/docs/sources/option_file_syntax.md
+++ b/assets/docs/sources/option_file_syntax.md
@@ -186,8 +186,8 @@ plugins:
# v2.5.0 引入的插件
# 可以启动一个服务器,可以在浏览器上查看本子
- # 基于flask框架,需要安装额外库: [pip install plugin_jm_server]
- # 源码:https://github.com/hect0x7/plugin-jm-server
+ # 基于flask框架,需要安装额外库: [pip install jm-view-server]
+ # 源码:https://github.com/hect0x7/jm-view-server
- plugin: jm_server
kwargs:
password: '3333' # 服务器访问密码
@@ -227,10 +227,10 @@ plugins:
rule: '{Atitle}/{Aid}_cover.jpg'
- after_album: # 钩子(插件被调用时机)
+ after_album: # 事件(插件被调用的时机)
- plugin: zip # 压缩文件插件
kwargs:
- # 压缩文件插件,配在不同钩子下面,效果不一样。可以选择配在 after_album 或者 after_photo 下
+ # 压缩文件插件,配在不同事件下面,效果不一样。可以选择配在 after_album 或者 after_photo 下
# 配置在 after_album 下 → 整个本子合并为一个压缩文件
# 配置在 after_photo 下 → 每个章节各一个压缩文件
# (旧的 level 配置已废弃,如果你配置过level,比如level=photo, 请直接改用after_photo)
@@ -239,7 +239,7 @@ plugins:
suffix: zip #压缩包后缀名,默认值为zip,可以指定为zip或者7z
filename_rule: Atitle # 压缩文件的命名规则
# 请注意⚠ [https://github.com/hect0x7/JMComic-Crawler-Python/issues/223#issuecomment-2045227527]
- # filename_rule和所在钩子有对应关系
+ # filename_rule和所在事件有对应关系
# 如果配置在 after_photo 下, filename_rule 可以写 Pxxx 和Axxx
# 如果配置在 after_album 下, filename_rule 只能写 Axxx,不能写 Pxxx
diff --git a/assets/docs/sources/tutorial/0_common_usage.md b/assets/docs/sources/tutorial/0_common_usage.md
index d6268a413..1101cbce3 100644
--- a/assets/docs/sources/tutorial/0_common_usage.md
+++ b/assets/docs/sources/tutorial/0_common_usage.md
@@ -109,7 +109,7 @@ except JmcomicException as e:
# 而JmDownloader有对应字段记录了这些线程发生的异常
# 使用check_exception=True参数可以使downloader主动检查是否存在下载异常
# 如果有,则当前线程会主动上抛一个PartialDownloadFailedException异常
-# 该参数主要用于主动检查部分下载失败的情况,
+# 该参数主要用于主动检查部分下载失败的情况,(仅对单个本子/章节 ID 生效,传入多个 ID 时不生效。多个 ID 的场景见下)
# 因为非当前线程抛出的异常(比如下载章节的线程和下载图片的线程),这些线程如果抛出异常,
# 当前线程是感知不到的,try-catch下载方法download_album不能捕获到其他线程发生的异常。
try:
@@ -117,6 +117,12 @@ try:
except PartialDownloadFailedException as e:
downloader: JmDownloader = e.downloader
print(f'下载出现部分失败, 下载失败的章节: {downloader.download_failed_photo}, 下载失败的图片: {downloader.download_failed_image}')
+
+# 多 ID 下载不会因为某一项失败而中断,请检查 BatchResult.failed。
+# 如果需要在批量失败时抛异常或重试,建议自行封装 download_batch。
+result = download_album([123, 456, 789])
+for album_id, error in result.failed.items():
+ print(f'本子 {album_id} 下载失败: {error}')
```
@@ -168,6 +174,28 @@ download_album(aid_list, option)
可参考discussions: https://github.com/hect0x7/JMComic-Crawler-Python/discussions/235
+### 一键导出全部收藏夹
+
+下面的代码不会下载图片,只会把帐号中的全部收藏夹导出为 CSV,并生成 `favorites.zip`:
+
+```python
+from jmcomic import JmOption, FavoriteFolderExportPlugin
+
+USERNAME = '你的禁漫帐号'
+PASSWORD = '你的禁漫密码'
+
+option = JmOption.default()
+option.build_jm_client().login(USERNAME, PASSWORD)
+
+FavoriteFolderExportPlugin(option).invoke(
+ save_dir='./',
+ zip_enable=True,
+ zip_filepath='./favorites.zip',
+)
+```
+
+### 获取并遍历收藏夹
+
```python
from jmcomic import *
diff --git a/assets/docs/sources/tutorial/11_log_custom.md b/assets/docs/sources/tutorial/11_log_custom.md
index 66aefb5d6..9b6e893a0 100644
--- a/assets/docs/sources/tutorial/11_log_custom.md
+++ b/assets/docs/sources/tutorial/11_log_custom.md
@@ -21,23 +21,45 @@ from jmcomic import disable_jm_log
disable_jm_log()
```
-## 2. 日志过滤,只保留特定topic
+## 2. 日志过滤,只保留特定 topic
-使用插件配置
+最简单的方式是使用内置插件配置:
```yaml
log: true
plugins:
after_init:
- - plugin: log_topic_filter # 日志topic过滤插件
+ - plugin: log_topic_filter # 日志 topic 过滤插件
kwargs:
- whitelist: [ # 只保留api和html,这两个是Client发请求时会打的日志topic
+ whitelist: [ # 只保留 api 和 html,这两个是 Client 发请求时会打印的日志 topic
'api',
'html',
]
```
+这个插件底层使用标准 `logging.Filter`。jmcomic 将每条日志的 topic 放在 `LogRecord.topic` 中,需要通过代码自定义时可以直接使用 logging API:
+
+```python
+import logging
+
+from jmcomic import jm_logger
+
+
+class TopicFilter(logging.Filter):
+ def __init__(self, whitelist):
+ super().__init__()
+ self.whitelist = set(whitelist)
+
+ def filter(self, record):
+ return getattr(record, 'topic', None) in self.whitelist
+
+
+jm_logger.addFilter(TopicFilter({'api', 'html'}))
+```
+
+Filter 加在 `jm_logger` 上会作用于它的所有 Handler;如果只想过滤某个输出目标,也可以将 Filter 加到对应的 Handler 上。
+
## 3. 屏蔽插件的日志
给插件配置加上一个`log`配置项即可
@@ -52,7 +74,76 @@ plugins:
whitelist: [ api, ]
```
-## 4. 深度自定义:两类不同的拦截手段
+## 4. 并发下载的日志上下文
+
+场景:当你在同时运行多个下载任务时,你希望区分哪些日志属于哪些任务,或者你希望结构化展示下载任务 对应的 下载日志。
+
+首先,你需要设定一个任务id,这一步是在定义你的**任务维度**。下载一个本子还是多个本子都可以算作一个任务。
+
+```python
+from jmcomic import download_album, jm_task_context
+
+with jm_task_context(task_id='task-1'): # 设置这个下载本子123的任务id为 task-1
+ download_album(123)
+
+with jm_task_context(task_id='task-1'): # 设置这三个本子的任务id为 task-1
+ download_album([123, 456, 789])
+```
+
+设置了task_id以后,整个 download_album 内的日志打印都会具有这个task_id标识。
+
+> jmcomic 使用 [ContextVar](https://docs.python.org/3/library/contextvars.html) 把上下文传播到库内部创建的下载线程或async task里。你设置的task_id其实就是上下文里的一个字段
+
+最直接的效果是,默认终端日志会自动显示任务 ID:
+
+```text
+[2026-01-01 12:00:00] [MainThread]:[task_id=task-1; album=123] 【album.after】本子下载完成: [123]
+```
+
+接下来你可以实现更高级的功能,比如收集任务对应的日志:
+
+给 `jm_logger` 添加一个 Handler。每当 jmcomic 产生一条日志,Handler 都可以从 `record.jm_task_context` 中取出它所属的 `task_id`,从而按任务收集日志。完整代码如下:
+
+```python
+import logging
+from collections import defaultdict
+
+from jmcomic import jm_logger, download_album, jm_task_context
+
+task_logs_dict = defaultdict(list) # 收集任务日志,任务id -> 日志列表
+
+
+class TaskLogHandler(logging.Handler):
+ def emit(self, record):
+ context: dict = getattr(record, 'jm_task_context', None) or {} # 通过 jm_task_context 字段取出任务上下文
+ task_id = context.get('task_id') # 任务上下文里的task_id,就是你上面自定义的 task-1
+ if task_id is not None:
+ task_logs_dict[task_id].append(self.format(record)) # 收集日志
+
+
+handler = TaskLogHandler()
+handler.setFormatter(jm_logger.handlers[0].formatter) # 复用jmcomic默认 Handler 的日志格式,你也可以自定义日志格式
+jm_logger.addHandler(handler)
+
+# 自定义handler后,再正常使用下载方法
+task_id = 'task-1'
+with jm_task_context(task_id=task_id): # 任务id
+ download_album(123)
+```
+
+任务上下文里,常用字段如下:
+
+| 字段 | 类型 | 谁来设置 | 含义 | 示例 |
+| --- |--------| --- |-----------------------------------------------------------------------| --- |
+| `task_id` | 你传入的类型;本例需可哈希,建议使用 `str` | 由你通过 `jm_task_context` 设置 | 你的一次下载任务标识 | `task-1` |
+| `download_type` | `str` | jmcomic 自动设置 | 你使用的download入口函数类型,例如 download_album -> album | `album` / `photo` |
+| `jm_id` | `str` | jmcomic 自动设置 | 你使用的download入口函数的入参,例如 download_album(123) -> 123。传入多个 ID 时,每个 ID 都在各自隔离的上下文中记录对应的 `jm_id`,并继承同一个 `task_id`。 | `123` |
+
+> 默认终端日志仅在 `task_id` 有值时显示任务上下文,并一同显示 `download_type` 和 `jm_id`。未设置 `task_id` 时,上下文仍会正常传递,但不会显示在默认日志中。
+
+你也可以放入其他对象到任务上下文里,比如放入一个局部queue用来收集日志。在 jm_task_context 方法里传入即可 `jm_task_context(**fields)`
+
+## 5. 深度自定义:两类不同的拦截手段
根据你的需求复杂度,你可以选择以下方式:
@@ -64,4 +155,4 @@ plugins:
适用于:需要完全重塑日志的分发逻辑,或者将日志直接桥接到不符合标准 logging 协议的第三方系统。
-详细参考文档:[模块自定义](./4_module_custom.md#自定义log)
+代码示例:[模块自定义-自定义log](./4_module_custom.md#自定义log)
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index 99a63855b..3829c5da9 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -155,12 +155,12 @@ Feature (基类)
└── 你的自定义 Feature ← 继承 Feature,实现任意逻辑
```
-- **Feature 基类**:通用的附加行为抽象,不绑定任何具体实现。默认在所有生命周期钩子中执行。
+- **Feature 基类**:通用的附加行为抽象,不绑定任何具体实现。默认在所有生命周期事件中执行。
- **PluginFeature**:Feature 的子类,专门封装 jmcomic 插件。除了调用插件之外,还会根据调用来源动态适配 `filename_rule` 参数;ZIP 的打包粒度则由插件在运行时根据上下文自动推导。
### 执行流程
-Feature **自然嵌入到 downloader 的生命周期钩子**中自动触发:
+Feature **自然嵌入到 downloader 的生命周期事件**中自动触发:
```text
api.download_album(extra=Feature.export_pdf)
@@ -187,7 +187,7 @@ api.download_album(extra=Feature.export_pdf)
> 💡 **关键点**:
>
-> - **执行时机**:`PluginFeature` 根据注册来源自动推导(`download_album` → `after_album`,`download_photo` → `after_photo`)。自定义 Feature 默认在所有钩子都会执行,你可以覆写 `should_invoke` 来控制。
+> - **执行时机**:`PluginFeature` 根据注册来源自动推导(`download_album` → `after_album`,`download_photo` → `after_photo`)。自定义 Feature 默认在所有事件都会执行,你可以覆写 `should_invoke` 来控制。
> - **参数自适应**:`PluginFeature` 的 `filename_rule` 前缀(A/P)会根据来源动态适配。ZIP 的打包粒度由插件根据上下文自动推导。用户显式传入的参数不会被覆盖。
### 自定义 Feature
@@ -207,4 +207,3 @@ class NotifyFeature(Feature):
# 使用
download_album('123', option, extra=NotifyFeature())
```
-
diff --git a/assets/docs/sources/tutorial/4_module_custom.md b/assets/docs/sources/tutorial/4_module_custom.md
index 06274c6fd..d804702cc 100644
--- a/assets/docs/sources/tutorial/4_module_custom.md
+++ b/assets/docs/sources/tutorial/4_module_custom.md
@@ -2,19 +2,21 @@
-## 自定义下载前后的回调函数
+## 自定义下载事件的回调函数
```python
-def custom_download_callback():
+def custom_download_event_callback():
"""
- 该函数演示自定义下载时的回调函数
+ 该函数演示如何自定义下载事件的回调函数。
+ 公开下载 API 不接收 callback 参数;需要响应下载事件时,请自定义 Downloader。
"""
# jmcomic的下载功能由 JmModuleConfig.CLASS_DOWNLOADER 这个类来负责执行
- # 这个类默认是 JmDownloader,继承了DownloadCallback
- # 你可以写一个自定义类,继承JmDownloader,覆盖属于DownloadCallback的方法,来实现自定义回调
+ # 这个类默认是 JmDownloader,继承了 DownloadCallback
+ # DownloadCallback 定义了 Downloader 内部各类下载事件的回调方法
+ # 你可以继承 JmDownloader 并覆盖相应方法来响应事件
class MyDownloader(JmDownloader):
- # 覆盖 album 下载完成后的回调
+ # 覆盖 album 下载完成事件的回调函数
def after_album(self, album: JmAlbumDetail):
print(f'album下载完毕: {album}')
pass
@@ -192,4 +194,4 @@ def custom_exception_listener():
# 注册监听器/回调
# 这个异常类(或者这个异常的子类)的实例将要被raise前,你的listener方法会被调用
JmModuleConfig.register_exception_listener(etype, listener)
-```
\ No newline at end of file
+```
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 07ed2aa11..f079b387c 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -5,6 +5,6 @@ Pillow
psutil
pycryptodome
requests
-plugin_jm_server
+jm-view-server
zhconv
-img2pdf
\ No newline at end of file
+img2pdf
diff --git a/src/jmcomic/__init__.py b/src/jmcomic/__init__.py
index 121313b69..516de4e4a 100644
--- a/src/jmcomic/__init__.py
+++ b/src/jmcomic/__init__.py
@@ -2,8 +2,9 @@
# 被依赖方 <--- 使用方
# config <--- entity <--- toolkit <--- client <--- option <--- downloader
-__version__ = '2.7.2'
+__version__ = '2.7.3'
+from .jm_task_context import *
from .api import *
from .jm_plugin import *
from .jm_feature import *
diff --git a/src/jmcomic/api.py b/src/jmcomic/api.py
index 722d47834..b1d8ab838 100644
--- a/src/jmcomic/api.py
+++ b/src/jmcomic/api.py
@@ -1,26 +1,18 @@
import asyncio
-import inspect
from .jm_downloader import *
+from .jm_task_context import bind_jm_task_context, jm_task_context
__DOWNLOAD_API_RET = DownloadResult
-async def _invoke_async_callback(callback, entity, downloader):
- if callback is None:
- return None
-
- is_async_callback = (
- inspect.iscoroutinefunction(callback)
- or inspect.iscoroutinefunction(getattr(callback, '__call__', None))
- )
- if is_async_callback:
- return await callback(entity, downloader)
-
- result = await asyncio.to_thread(callback, entity, downloader)
- if inspect.isawaitable(result):
- return await result
- return result
+def _download_type(download_api) -> str:
+ name = getattr(download_api, '__name__', download_api.__class__.__name__)
+ if name.endswith('_async'):
+ name = name[:-6]
+ if name.startswith('download_'):
+ name = name[9:]
+ return name
def download_batch(download_api,
@@ -48,21 +40,24 @@ def download_batch(download_api,
result = BatchResult()
+ download_type = _download_type(download_api)
+
def _safe_download(aid):
"""batch 内部的单任务包装:确保异常被收集而非静默丢失"""
- 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
+ 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=_safe_download,
+ apply_each_obj_func=bind_jm_task_context(_safe_download),
wait_finish=True
)
@@ -72,7 +67,7 @@ def _safe_download(aid):
def download_album(jm_album_id,
option=None,
downloader=None,
- callback=None,
+ *,
check_exception=True,
extra=None,
) -> Union[__DOWNLOAD_API_RET, Set[__DOWNLOAD_API_RET]]:
@@ -84,8 +79,9 @@ def download_album(jm_album_id,
:param jm_album_id: 本子的禁漫车号
:param option: 下载选项
:param downloader: 下载器类
- :param callback: 返回值回调函数,可以拿到 album 和 downloader
- :param check_exception: 是否检查异常, 如果为True,会检查downloader是否有下载异常,并上抛PartialDownloadFailedException
+ :param check_exception: 仅当 jm_album_id 是单个 ID 时生效。为 True 时检查 downloader 中的部分下载失败,
+ 并上抛 PartialDownloadFailedException。多 ID 调用会转交 download_batch,此参数不生效;
+ 请检查 BatchResult.failed,或自行封装 download_batch 实现所需的批量异常策略。
:param extra: 下载特性(Feature),下载时动态挂载的附加行为上下文。会自动根据上下文(如 album/photo 来源)自适应参数行为。支持单个 Feature、FeatureChain、或列表
:return: 对于的本子实体类,下载器(如果是上述的批量情况,返回值为download_batch的返回值)
"""
@@ -93,41 +89,42 @@ 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)
- with new_downloader(option, downloader) as dler:
- # 注册 Feature 及来源,由 downloader 在 after_album 钩子中自动执行
- dler.add_features(extra, 'download_album')
- album = dler.download_album(jm_album_id)
+ with jm_task_context(download_type='album', jm_id=str(jm_album_id)):
+ with new_downloader(option, downloader) as dler:
+ # 注册 Feature 及来源,由 downloader 在 after_album 钩子中自动执行
+ dler.add_features(extra, 'download_album')
+ album = dler.download_album(jm_album_id)
- if callback is not None:
- callback(album, dler)
- if check_exception:
- dler.raise_if_has_exception()
- return DownloadResult(album, dler)
+ if check_exception:
+ dler.raise_if_has_exception()
+ return DownloadResult(album, dler)
def download_photo(jm_photo_id,
option=None,
downloader=None,
- callback=None,
+ *,
check_exception=True,
extra=None,
):
"""
- 下载一个章节(photo),参数同 download_album
+ 下载一个章节(photo),参数同 download_album。
+
+ check_exception 仅当 jm_photo_id 是单个 ID 时生效。多 ID 场景请检查
+ BatchResult.failed,或自行封装 download_batch 处理批量异常。
"""
if not isinstance(jm_photo_id, (str, int)):
return download_batch(download_photo, jm_photo_id, option, downloader, extra=extra)
- with new_downloader(option, downloader) as dler:
- # 注册 Feature 及来源,由 downloader 在 after_photo 钩子中自动执行
- dler.add_features(extra, 'download_photo')
- photo = dler.download_photo(jm_photo_id)
+ with jm_task_context(download_type='photo', jm_id=str(jm_photo_id)):
+ with new_downloader(option, downloader) as dler:
+ # 注册 Feature 及来源,由 downloader 在 after_photo 钩子中自动执行
+ dler.add_features(extra, 'download_photo')
+ photo = dler.download_photo(jm_photo_id)
- if callback is not None:
- callback(photo, dler)
- if check_exception:
- dler.raise_if_has_exception()
- return DownloadResult(photo, dler)
+ if check_exception:
+ dler.raise_if_has_exception()
+ return DownloadResult(photo, dler)
def new_downloader(option=None, downloader=None) -> JmDownloader:
@@ -177,7 +174,7 @@ def new_async_downloader(option=None, downloader=None):
async def download_album_async(jm_album_id,
option=None,
downloader=None,
- callback=None,
+ *,
check_exception=True,
extra=None,
):
@@ -185,8 +182,9 @@ async def download_album_async(jm_album_id,
异步下载一个本子(album),包含其所有的章节(photo)。
- 支持批量下载(当 jm_album_id 为可迭代对象时)
- - callback 支持同步函数和异步函数
- 返回 (album, downloader) 元组,其中 downloader 的网络和线程池资源已关闭,仅用于读取下载结果
+ - check_exception 仅当 jm_album_id 是单个 ID 时生效。多 ID 场景请检查 BatchResult.failed,
+ 或自行封装 download_batch_async 处理批量异常
"""
if not isinstance(jm_album_id, (str, int)):
return await download_batch_async(download_album_async,
@@ -196,28 +194,29 @@ async def download_album_async(jm_album_id,
extra=extra
)
- async with new_async_downloader(option, downloader) as dler:
- dler.add_features(extra, 'download_album')
- album = await dler.download_album(jm_album_id)
+ with jm_task_context(download_type='album', jm_id=str(jm_album_id)):
+ async with new_async_downloader(option, downloader) as dler:
+ dler.add_features(extra, 'download_album')
+ album = await dler.download_album(jm_album_id)
- await _invoke_async_callback(callback, album, dler)
- if check_exception:
- dler.raise_if_has_exception()
+ if check_exception:
+ dler.raise_if_has_exception()
- return DownloadResult(album, dler)
+ return DownloadResult(album, dler)
async def download_photo_async(jm_photo_id,
option=None,
downloader=None,
- callback=None,
+ *,
check_exception=True,
extra=None,
):
"""
异步下载一个章节(photo)。
- callback 支持同步函数和异步函数。
返回的 downloader 已关闭网络和线程池资源,仅用于读取下载结果。
+ check_exception 仅当 jm_photo_id 是单个 ID 时生效。多 ID 场景请检查
+ BatchResult.failed,或自行封装 download_batch_async 处理批量异常。
"""
if not isinstance(jm_photo_id, (str, int)):
return await download_batch_async(download_photo_async,
@@ -227,15 +226,15 @@ async def download_photo_async(jm_photo_id,
extra=extra
)
- async with new_async_downloader(option, downloader) as dler:
- dler.add_features(extra, 'download_photo')
- photo = await dler.download_photo(jm_photo_id)
+ with jm_task_context(download_type='photo', jm_id=str(jm_photo_id)):
+ async with new_async_downloader(option, downloader) as dler:
+ dler.add_features(extra, 'download_photo')
+ photo = await dler.download_photo(jm_photo_id)
- await _invoke_async_callback(callback, photo, dler)
- if check_exception:
- dler.raise_if_has_exception()
+ if check_exception:
+ dler.raise_if_has_exception()
- return DownloadResult(photo, dler)
+ return DownloadResult(photo, dler)
async def download_batch_async(download_api,
@@ -253,9 +252,14 @@ async def download_batch_async(download_api,
option = JmModuleConfig.option_class().default()
jm_ids = list(dict.fromkeys(JmcomicText.parse_to_jm_id(jmid) for jmid in jm_id_iter))
+ 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_api(jmid, option, downloader, **kwargs) for jmid in jm_ids),
+ *(_download_one(jmid) for jmid in jm_ids),
return_exceptions=True,
)
@@ -263,7 +267,8 @@ async def download_batch_async(download_api,
result = BatchResult()
for jmid, r in zip(jm_ids, results):
if isinstance(r, BaseException):
- jm_log('async.batch.failed', f'批量下载失败: [{jmid}], 异常: [{r}]', r)
+ 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)
diff --git a/src/jmcomic/cl.py b/src/jmcomic/cl.py
index 82b8868ea..c5dbf3113 100644
--- a/src/jmcomic/cl.py
+++ b/src/jmcomic/cl.py
@@ -1,9 +1,11 @@
import warnings
+
from .cli import *
+
warnings.warn(
"The 'jmcomic.cl' module is deprecated and renamed to 'jmcomic.cli'. "
- "Please update your imports. It will be removed in a future version.",
+ "Please update your imports. It will be removed in version 2.7.4.",
DeprecationWarning,
- stacklevel=2
+ stacklevel=2,
)
diff --git a/src/jmcomic/jm_async_downloader.py b/src/jmcomic/jm_async_downloader.py
index b2ff905bb..ee0bef025 100644
--- a/src/jmcomic/jm_async_downloader.py
+++ b/src/jmcomic/jm_async_downloader.py
@@ -16,6 +16,7 @@
from .jm_entity import JmAlbumDetail, JmPhotoDetail, JmImageDetail
from .jm_toolkit import JmImageTool
from .jm_config import jm_log
+from .jm_task_context import bind_jm_task_context
from .jm_option import JmOption
@@ -56,6 +57,14 @@ def __init__(self,
# 核心下载流程 — 对齐 sync JmDownloader
# ======================================================================
+ 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,
+ )
+
async def download_album(self, album_id) -> JmAlbumDetail:
"""对齐 sync JmDownloader.download_album"""
album = await self.client.get_album_detail(album_id)
@@ -162,10 +171,8 @@ async def _download_single_image(self, image: JmImageDetail):
img_bytes = img_resp.content
# 提交到线程池解密并保存
- loop = asyncio.get_running_loop()
if decode_image and image.scramble_id:
- await loop.run_in_executor(
- self._decode_pool,
+ await self._run_in_decode_pool(
self._decode_and_save,
img_bytes,
int(image.scramble_id),
@@ -183,8 +190,7 @@ async def _download_single_image(self, image: JmImageDetail):
if qi != -1:
img_url = img_url[:qi]
need_convert = suffix_not_equal(img_url, img_save_path)
- await loop.run_in_executor(
- self._decode_pool,
+ await self._run_in_decode_pool(
self._save_raw,
img_bytes,
img_save_path,
@@ -227,28 +233,22 @@ def _save_raw(image_bytes, save_path, need_convert=False):
# ======================================================================
async def before_album(self, album: JmAlbumDetail):
- loop = asyncio.get_running_loop()
- await loop.run_in_executor(self._decode_pool, super().before_album, album)
+ await self._run_in_decode_pool(super().before_album, album)
async def after_album(self, album: JmAlbumDetail):
- loop = asyncio.get_running_loop()
- await loop.run_in_executor(self._decode_pool, super().after_album, album)
+ await self._run_in_decode_pool(super().after_album, album)
async def before_photo(self, photo: JmPhotoDetail):
- loop = asyncio.get_running_loop()
- await loop.run_in_executor(self._decode_pool, super().before_photo, photo)
+ await self._run_in_decode_pool(super().before_photo, photo)
async def after_photo(self, photo: JmPhotoDetail):
- loop = asyncio.get_running_loop()
- await loop.run_in_executor(self._decode_pool, super().after_photo, photo)
+ await self._run_in_decode_pool(super().after_photo, photo)
async def before_image(self, image: JmImageDetail, img_save_path: str):
- loop = asyncio.get_running_loop()
- await loop.run_in_executor(self._decode_pool, super().before_image, image, img_save_path)
+ await self._run_in_decode_pool(super().before_image, image, img_save_path)
async def after_image(self, image: JmImageDetail, img_save_path: str):
- loop = asyncio.get_running_loop()
- await loop.run_in_executor(self._decode_pool, super().after_image, image, img_save_path)
+ await self._run_in_decode_pool(super().after_image, image, img_save_path)
def shutdown(self):
"""关闭解密线程池"""
diff --git a/src/jmcomic/jm_client_impl.py b/src/jmcomic/jm_client_impl.py
index 8db3932d4..f0410f42b 100644
--- a/src/jmcomic/jm_client_impl.py
+++ b/src/jmcomic/jm_client_impl.py
@@ -1,5 +1,7 @@
from threading import Lock
+from .jm_task_context import bind_jm_task_context
+
from .jm_client_interface import *
@@ -1159,7 +1161,7 @@ def get_future(self, cache_key, task):
# after future done, remove it from future_dict.
# cache depends on self.client instead of self.future_dict
- future = self.FutureWrapper(self.executors.submit(task),
+ future = self.FutureWrapper(self.executors.submit(bind_jm_task_context(task)),
after_done_callback=lambda: self.future_dict.pop(cache_key, None)
)
diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py
index 6079d8c87..a1ad3ed50 100644
--- a/src/jmcomic/jm_config.py
+++ b/src/jmcomic/jm_config.py
@@ -1,11 +1,47 @@
from __future__ import annotations
import logging
+
from common import time_stamp, field_cache, ProxyBuilder
+from .jm_task_context import JM_TASK_CONTEXT, get_jm_task_context
+
jm_logger = logging.getLogger('jmcomic')
+class JmLogFormatter(logging.Formatter):
+ """Format the stable correlation fields from a JM task context."""
+
+ @staticmethod
+ def _task_context_prefix(context) -> str:
+ if not isinstance(context, dict):
+ return ''
+
+ fields = []
+ task_id = context.get('task_id')
+ if task_id is None:
+ # 没有 task_id 时,默认不打印 task_context,保持日志简洁
+ return ''
+ fields.append(f'task_id={task_id}')
+
+ # 内置字段
+ download_type = context.get('download_type')
+ jm_id = context.get('jm_id')
+ if download_type is not None and jm_id is not None:
+ fields.append(f'{download_type}={jm_id}')
+ elif download_type is not None:
+ fields.append(f'download_type={download_type}')
+ elif jm_id is not None:
+ fields.append(f'jm_id={jm_id}')
+
+ return f'[{"; ".join(fields)}] ' if fields else ''
+
+ def format(self, record):
+ # noinspection PyTypeChecker
+ record.jm_task_context_prefix = self._task_context_prefix(getattr(record, JM_TASK_CONTEXT.name, None))
+ return super().format(record)
+
+
def shuffled(lines):
from random import shuffle
from common import str_to_list
@@ -19,7 +55,7 @@ def setup_default_jm_logger():
if not jm_logger.handlers:
import sys
handler = logging.StreamHandler(sys.stdout)
- formatter = logging.Formatter('[%(asctime)s] [%(threadName)s]:【%(topic)s】%(message)s', datefmt='%Y-%m-%d %H:%M:%S')
+ formatter = JmLogFormatter(JmModuleConfig.VAR_LOG_FMT, datefmt='%Y-%m-%d %H:%M:%S')
handler.setFormatter(formatter)
jm_logger.addHandler(handler)
jm_logger.setLevel(logging.INFO)
@@ -30,7 +66,10 @@ def default_jm_logging(topic: str, msg, e: BaseException | None = None):
if isinstance(msg, BaseException):
e = msg
msg = str(msg)
- extra = {'topic': topic}
+ extra = {
+ 'topic': topic,
+ JM_TASK_CONTEXT.name: get_jm_task_context(),
+ }
if e is not None:
jm_logger.error(msg, extra=extra, exc_info=e)
else:
@@ -103,7 +142,7 @@ class JmMagicConstants:
APP_TOKEN_SECRET_2 = '18comicAPPContent'
APP_DATA_SECRET = '185Hcomic3PAPP7R'
API_DOMAIN_SERVER_SECRET = 'diosfjckwpqpdfjkvnqQjsik'
- APP_VERSION = '2.0.28'
+ APP_VERSION = '2.0.30'
# 模块级别共用配置
@@ -259,6 +298,8 @@ class JmModuleConfig:
# 当发生 oserror: [Errno 36] File name too long 时,
# 把文件名限制在指定个字符以内
VAR_FILE_NAME_LENGTH_LIMIT = 100
+ # 日志格式
+ VAR_LOG_FMT = '[%(asctime)s] [%(threadName)s]:%(jm_task_context_prefix)s【%(topic)s】%(message)s' # 默认不打印
@classmethod
def downloader_class(cls):
@@ -565,8 +606,8 @@ def register_exception_listener(cls, etype, listener):
disable_jm_log = JmModuleConfig.disable_jm_log
-class PrettyFormatter(logging.Formatter):
- """带 ANSI 颜色的日志格式化器,按 topic 前缀分配颜色"""
+class PrettyFormatter(JmLogFormatter):
+ """带 ANSI 颜色的日志格式化器,按 topic 前缀分配颜色。"""
TOPIC_COLORS = {
'album': '\033[1;36m', # 青色加粗 — 本子级别
@@ -581,7 +622,10 @@ class PrettyFormatter(logging.Formatter):
RESET = '\033[0m'
def __init__(self):
- super().__init__(fmt='[%(asctime)s] %(message)s', datefmt='%H:%M:%S')
+ super().__init__(
+ fmt='[%(asctime)s] %(jm_task_context_prefix)s%(message)s',
+ datefmt='%H:%M:%S',
+ )
def format(self, record):
topic = getattr(record, 'topic', '')
@@ -590,7 +634,6 @@ def format(self, record):
elif record.levelno >= logging.WARNING:
color = self.WARN_COLOR
else:
- # 按 topic 前缀匹配颜色
color = next(
(c for prefix, c in self.TOPIC_COLORS.items()
if topic.startswith(prefix)),
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index 986196a7d..f760d5724 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -1,6 +1,7 @@
from typing import NamedTuple
from .jm_option import *
+from .jm_task_context import bind_jm_task_context
def catch_exception(func):
@@ -366,6 +367,8 @@ def execute_on_condition(self,
if count_real == 0:
return
+ apply = bind_jm_task_context(apply)
+
if count_batch >= count_real:
# 一个图/章节 对应 一个线程
multi_thread_launcher(
diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py
index 9021d113e..16d464efa 100644
--- a/src/jmcomic/jm_feature.py
+++ b/src/jmcomic/jm_feature.py
@@ -17,6 +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
class Feature:
@@ -33,6 +34,11 @@ class Feature:
export_zip: 'PluginFeature'
export_long_img: 'PluginFeature'
+ @property
+ def jm_task_context(self) -> dict:
+ """Return the current invocation's isolated task-context snapshot."""
+ return get_jm_task_context()
+
def should_invoke(self, feature_from: str, when: str) -> bool:
"""
判断在当前钩子(when)下,根据来源(feature_from),是否应该执行。
diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py
index 0373e762a..93721e8ca 100644
--- a/src/jmcomic/jm_plugin.py
+++ b/src/jmcomic/jm_plugin.py
@@ -3,6 +3,7 @@
"""
from .jm_option import *
+from .jm_task_context import bind_jm_task_context, get_jm_task_context
class PluginValidationException(Exception):
@@ -27,6 +28,11 @@ def invoke(self, **kwargs) -> None:
"""
raise NotImplementedError
+ @property
+ def jm_task_context(self) -> dict:
+ """Return the current invocation's isolated task-context snapshot."""
+ return get_jm_task_context()
+
@classmethod
def build(cls, option: JmOption) -> 'JmOptionPlugin':
"""
@@ -672,7 +678,7 @@ def main(self):
# 一个收藏夹一个线程,导出收藏夹数据到文件
multi_thread_launcher(
iter_objs=folders.items(),
- apply_each_obj_func=self.handle_folder,
+ apply_each_obj_func=bind_jm_task_context(self.handle_folder),
)
if not self.zip_enable:
@@ -992,21 +998,34 @@ def invoke(self,
if self.running:
return
- # 服务器的代码位于一个独立库:plugin_jm_server,需要独立安装
- # 源代码仓库:https://github.com/hect0x7/plugin-jm-server
+ # 服务器的代码位于独立库 jm-view-server,需要独立安装
+ # 源代码仓库:https://github.com/hect0x7/jm-view-server
try:
# noinspection PyUnresolvedReferences
- import plugin_jm_server
- self.log(f'当前使用plugin_jm_server版本: {plugin_jm_server.__version__}')
- except ImportError:
- self.warning_lib_not_install('plugin_jm_server')
- return
+ import jm_view_server as jm_server_lib
+ except ModuleNotFoundError as e:
+ if e.name != 'jm_view_server':
+ raise
+
+ try:
+ # 兼容尚未迁移的 plugin_jm_server <= 0.2.3
+ # noinspection PyUnresolvedReferences
+ import plugin_jm_server as jm_server_lib
+ except ModuleNotFoundError as legacy_error:
+ if legacy_error.name != 'plugin_jm_server':
+ raise
+ self.warning_lib_not_install('jm-view-server')
+ return
+
+ self.log('检测到旧包 plugin_jm_server,建议升级到 jm-view-server', 'warning')
+
+ self.log(f'当前使用 jm-view-server 版本: {jm_server_lib.__version__}')
# 核心函数,启动服务器,会阻塞当前线程
def blocking_run_server():
self.server_thread = current_thread()
self.enter_wait_list()
- server = plugin_jm_server.JmServer(base_dir, password, **kwargs)
+ server = jm_server_lib.JmServer(base_dir, password, **kwargs)
# run方法会阻塞当前线程直到flask退出
server.run(**run)
diff --git a/src/jmcomic/jm_task_context.py b/src/jmcomic/jm_task_context.py
new file mode 100644
index 000000000..57a5e9e1d
--- /dev/null
+++ b/src/jmcomic/jm_task_context.py
@@ -0,0 +1,59 @@
+import inspect
+from contextlib import contextmanager
+from contextvars import ContextVar
+from functools import wraps
+from types import MappingProxyType
+from typing import Callable, Mapping, Optional
+
+
+__all__ = (
+ 'JM_TASK_CONTEXT',
+ 'jm_task_context',
+ 'get_jm_task_context',
+ 'bind_jm_task_context',
+)
+
+
+_EMPTY_TASK_CONTEXT = MappingProxyType({})
+JM_TASK_CONTEXT: ContextVar[Mapping] = ContextVar(
+ 'jm_task_context',
+ default=_EMPTY_TASK_CONTEXT,
+)
+
+
+def get_jm_task_context() -> dict:
+ """Return a mutable snapshot of the current JM task context."""
+ return dict(JM_TASK_CONTEXT.get())
+
+
+@contextmanager
+def jm_task_context(**fields):
+ """Temporarily add fields to the current JM task context."""
+ context = get_jm_task_context()
+ context.update(fields)
+ token = JM_TASK_CONTEXT.set(MappingProxyType(context))
+ try:
+ yield
+ finally:
+ JM_TASK_CONTEXT.reset(token)
+
+
+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))):
+ raise TypeError('bind_jm_task_context only supports synchronous callables')
+
+ snapshot = MappingProxyType(dict(
+ get_jm_task_context() if context is None else context
+ ))
+
+ @wraps(func)
+ def wrapped(*args, **kwargs):
+ token = JM_TASK_CONTEXT.set(snapshot)
+ try:
+ return func(*args, **kwargs)
+ finally:
+ JM_TASK_CONTEXT.reset(token)
+
+ return wrapped
diff --git a/tests/test_jmcomic/test_jm_api.py b/tests/test_jmcomic/test_jm_api.py
index d9af3553a..bf46f99cc 100644
--- a/tests/test_jmcomic/test_jm_api.py
+++ b/tests/test_jmcomic/test_jm_api.py
@@ -1,8 +1,44 @@
+import asyncio
+import inspect
+from unittest.mock import AsyncMock, patch
+
from test_jmcomic import *
class Test_Api(JmTestConfigurable):
+ def test_callback_is_not_public_download_api(self):
+ for download_api in (
+ download_album,
+ download_photo,
+ download_album_async,
+ download_photo_async,
+ ):
+ parameters = inspect.signature(download_api).parameters
+ self.assertNotIn('callback', parameters)
+ self.assertEqual(
+ parameters['check_exception'].kind,
+ inspect.Parameter.KEYWORD_ONLY,
+ )
+
+ def test_multi_id_shortcuts_do_not_forward_check_exception(self):
+ for download_api in (download_album, download_photo):
+ expected = BatchResult()
+ with patch('jmcomic.api.download_batch', return_value=expected) as batch:
+ actual = download_api(['123', '456'], check_exception=False)
+
+ self.assertIs(actual, expected)
+ self.assertNotIn('check_exception', batch.call_args.kwargs)
+
+ for download_api in (download_album_async, download_photo_async):
+ expected = BatchResult()
+ batch = AsyncMock(return_value=expected)
+ with patch('jmcomic.api.download_batch_async', new=batch):
+ actual = asyncio.run(download_api(['123', '456'], check_exception=False))
+
+ self.assertIs(actual, expected)
+ self.assertNotIn('check_exception', batch.call_args.kwargs)
+
def test_download_photo_by_id(self):
"""
测试jmcomic模块的api的使用
diff --git a/tests/test_jmcomic/test_jm_async_api.py b/tests/test_jmcomic/test_jm_async_api.py
index 47aa774a1..995b3663d 100644
--- a/tests/test_jmcomic/test_jm_async_api.py
+++ b/tests/test_jmcomic/test_jm_async_api.py
@@ -6,7 +6,6 @@
"""
import asyncio
-import threading
from test_jmcomic import *
from jmcomic import (
download_album_async, download_photo_async, download_batch_async,
@@ -22,13 +21,6 @@ class Test_Async_Api(JmAsyncTestConfigurable):
def test_async_download_photo_by_id(self):
"""测试 download_photo_async:验证返回值与同步版本保持一致"""
photo_id = '438516'
- callback_result = {}
- caller_thread = threading.get_ident()
-
- def callback(photo, downloader):
- callback_result['photo'] = photo
- callback_result['downloader'] = downloader
- callback_result['thread'] = threading.get_ident()
# sync
sync_photo, sync_dler = download_photo(photo_id, self.option)
@@ -36,26 +28,16 @@ def callback(photo, downloader):
async_photo, async_dler = asyncio.run(download_photo_async(
photo_id,
self.option,
- callback=callback,
))
self.assertIsInstance(async_dler, JmAsyncDownloader, 'downloader 必须是异步版本')
self.assertIsInstance(async_photo, JmPhotoDetail, '返回值必须包含 photo')
self.assert_sync_async_equal(sync_photo.photo_id, async_photo.photo_id, 'photo.photo_id')
- self.assertIs(callback_result['photo'], async_photo)
- self.assertIs(callback_result['downloader'], async_dler)
- self.assertNotEqual(callback_result['thread'], caller_thread, '同步 callback 不应阻塞事件循环线程')
self.assertIsNone(async_dler.client, '顶层异步下载结束后 downloader client 应已关闭')
def test_async_download_album_by_id(self):
"""测试 download_album_async:验证返回值与同步版本保持一致"""
album_id = '438516'
- callback_result = {}
-
- async def callback(album, downloader):
- await asyncio.sleep(0)
- callback_result['album'] = album
- callback_result['downloader'] = downloader
# sync
sync_album, sync_dler = download_album(album_id, self.option)
@@ -63,14 +45,11 @@ async def callback(album, downloader):
async_album, async_dler = asyncio.run(download_album_async(
album_id,
self.option,
- callback=callback,
))
self.assertIsInstance(async_dler, JmAsyncDownloader, 'downloader 必须是异步版本')
self.assertIsInstance(async_album, JmAlbumDetail, '返回值必须包含 album')
self.assert_album_equal(sync_album, async_album)
- self.assertIs(callback_result['album'], async_album)
- self.assertIs(callback_result['downloader'], async_dler)
self.assertIsNone(async_dler.client, '顶层异步下载结束后 downloader client 应已关闭')
def test_async_batch(self):
diff --git a/tests/test_jmcomic/test_jm_cli.py b/tests/test_jmcomic/test_jm_cli.py
index 3db1b2370..bdbd913de 100644
--- a/tests/test_jmcomic/test_jm_cli.py
+++ b/tests/test_jmcomic/test_jm_cli.py
@@ -2,7 +2,7 @@
from io import StringIO
from unittest.mock import patch
-from jmcomic.cl import JmcomicUI, JmViewUI
+from jmcomic.cli import JmcomicUI, JmViewUI
class Test_Cli(JmTestConfigurable):
@@ -10,6 +10,12 @@ class Test_Cli(JmTestConfigurable):
album_id = '350234'
+ def test_cl_deprecated(self):
+ with self.assertWarnsRegex(DeprecationWarning, r'removed in version 2\.7\.4'):
+ from jmcomic.cl import JmcomicUI as DeprecatedJmcomicUI
+
+ self.assertIs(DeprecatedJmcomicUI, JmcomicUI)
+
# ========== jmcomic 命令测试 ==========
def test_jmcomic_parse_album_id(self):
diff --git a/tests/test_jmcomic/test_jm_task_context.py b/tests/test_jmcomic/test_jm_task_context.py
new file mode 100644
index 000000000..e052640f5
--- /dev/null
+++ b/tests/test_jmcomic/test_jm_task_context.py
@@ -0,0 +1,482 @@
+import asyncio
+import logging
+import threading
+import unittest
+from concurrent.futures import ThreadPoolExecutor
+from queue import Queue
+
+from jmcomic import (
+ BaseDownloader,
+ Feature,
+ JmAsyncDownloader,
+ JmDownloader,
+ JmModuleConfig,
+ JmOption,
+ JmOptionPlugin,
+ JM_TASK_CONTEXT,
+ PhotoConcurrentFetcherProxy,
+ PrettyFormatter,
+ bind_jm_task_context,
+ default_jm_logging,
+ download_album,
+ download_batch,
+ download_batch_async,
+ download_photo_async,
+ get_jm_task_context,
+ jm_log,
+ jm_task_context,
+ jm_logger,
+)
+
+
+class ListHandler(logging.Handler):
+
+ def __init__(self):
+ super().__init__()
+ self.records = []
+
+ def emit(self, record):
+ self.records.append(record)
+
+
+class Test_Jm_Task_Context(unittest.TestCase):
+
+ 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())
+
+ with jm_task_context(session_id='outer'):
+ self.assertEqual({'session_id': 'outer'}, get_jm_task_context())
+ with jm_task_context(session_id='inner', task_id='task'):
+ self.assertEqual(
+ {'session_id': 'inner', 'task_id': 'task'},
+ get_jm_task_context(),
+ )
+ self.assertEqual({'session_id': 'outer'}, get_jm_task_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({}, get_jm_task_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))
+ with jm_task_context(session_id='B'):
+ future_b = executor.submit(bind_jm_task_context(get_jm_task_context))
+
+ empty = executor.submit(get_jm_task_context)
+
+ self.assertEqual({'session_id': 'A'}, future_a.result())
+ self.assertEqual({'session_id': 'B'}, future_b.result())
+ self.assertEqual({}, empty.result())
+
+ def test_bind_rejects_async_callable(self):
+ async def async_work():
+ return None
+
+ with self.assertRaisesRegex(TypeError, 'synchronous callables'):
+ bind_jm_task_context(async_work)
+
+ def test_default_logger_and_custom_executors_can_read_context(self):
+ handler = ListHandler()
+ original_handlers = jm_logger.handlers[:]
+ original_executor = JmModuleConfig.EXECUTOR_LOG
+ jm_logger.handlers[:] = [handler]
+
+ try:
+ with jm_task_context(session_id='logger'):
+ default_jm_logging('test.context', 'message')
+
+ self.assertEqual(
+ {'session_id': 'logger'},
+ getattr(handler.records[0], JM_TASK_CONTEXT.name),
+ )
+
+ captured = []
+
+ def executor_two_args(topic, msg):
+ captured.append((topic, msg, get_jm_task_context()))
+
+ JmModuleConfig.EXECUTOR_LOG = executor_two_args
+ with jm_task_context(session_id='custom-2'):
+ jm_log('test.custom', 'message')
+
+ error = ValueError('failed')
+
+ def executor_three_args(topic, msg, e):
+ captured.append((topic, msg, e, get_jm_task_context()))
+
+ JmModuleConfig.EXECUTOR_LOG = executor_three_args
+ with jm_task_context(session_id='custom-3'):
+ jm_log('test.custom.error', 'message', error)
+
+ self.assertEqual(
+ ('test.custom', 'message', {'session_id': 'custom-2'}),
+ captured[0],
+ )
+ self.assertEqual(
+ ('test.custom.error', 'message', error, {'session_id': 'custom-3'}),
+ captured[1],
+ )
+ finally:
+ JmModuleConfig.EXECUTOR_LOG = original_executor
+ jm_logger.handlers[:] = original_handlers
+
+ def test_pretty_formatter_uses_topic_colors(self):
+ formatter = PrettyFormatter()
+
+ def make_record(topic, context, level=logging.INFO):
+ record = logging.LogRecord(
+ name='jmcomic',
+ level=level,
+ pathname=__file__,
+ lineno=1,
+ msg=topic,
+ args=(),
+ exc_info=None,
+ )
+ record.topic = topic
+ record.jm_task_context = context
+ return record
+
+ task_context = {
+ 'task_id': 'task-A',
+ 'download_type': 'album',
+ 'jm_id': '1',
+ }
+ self.assertTrue(formatter.format(
+ make_record('album.before', task_context)
+ ).startswith(formatter.TOPIC_COLORS['album']))
+ self.assertTrue(formatter.format(
+ make_record('image.before', task_context)
+ ).startswith(formatter.TOPIC_COLORS['image']))
+
+ self.assertTrue(formatter.format(
+ make_record('image.failed', task_context, logging.ERROR)
+ ).startswith(formatter.ERROR_COLOR))
+ self.assertTrue(formatter.format(
+ make_record('image.warning', task_context, logging.WARNING)
+ ).startswith(formatter.WARN_COLOR))
+
+ def test_public_downloads_add_task_context_to_downloader_logs(self):
+ class FakeSyncDownloader:
+
+ def __init__(self, _option):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ pass
+
+ def add_features(self, *_args):
+ pass
+
+ def download_album(self, album_id):
+ jm_log('album.before', 'message')
+ return album_id
+
+ def raise_if_has_exception(self):
+ pass
+
+ class FakeAsyncDownloader:
+
+ def __init__(self, _option):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_args):
+ pass
+
+ def add_features(self, *_args):
+ pass
+
+ async def download_photo(self, photo_id):
+ jm_log('photo.before', 'message')
+ return photo_id
+
+ def raise_if_has_exception(self):
+ pass
+
+ handler = ListHandler()
+ original_handlers = jm_logger.handlers[:]
+ jm_logger.handlers[:] = [handler]
+ try:
+ jm_log('plugin.usage_log.log', 'unrelated background log')
+ with jm_task_context(session_id='session-1', task_id='task-1'):
+ download_album(
+ '123',
+ option=object(),
+ downloader=FakeSyncDownloader,
+ )
+ asyncio.run(download_photo_async(
+ '456',
+ option=object(),
+ downloader=FakeAsyncDownloader,
+ ))
+ finally:
+ jm_logger.handlers[:] = original_handlers
+
+ task_records = [
+ record
+ for record in handler.records
+ if (getattr(record, JM_TASK_CONTEXT.name, None) or {}).get('session_id') == 'session-1'
+ ]
+ self.assertEqual(
+ ['album.before', 'photo.before'],
+ [record.topic for record in task_records],
+ )
+ self.assertEqual(
+ {
+ 'session_id': 'session-1',
+ 'task_id': 'task-1',
+ 'download_type': 'album',
+ 'jm_id': '123',
+ },
+ task_records[0].jm_task_context,
+ )
+ self.assertEqual(
+ {
+ 'session_id': 'session-1',
+ 'task_id': 'task-1',
+ 'download_type': 'photo',
+ 'jm_id': '456',
+ },
+ task_records[1].jm_task_context,
+ )
+
+ def test_plugin_invocation_can_read_current_task_context(self):
+ observed = []
+
+ class TaskAwarePlugin(JmOptionPlugin):
+ plugin_key = 'test_task_aware'
+
+ def invoke(self):
+ observed.append(self.jm_task_context)
+ self.log('plugin-message')
+
+ option = object.__new__(JmOption)
+ handler = ListHandler()
+ original_handlers = jm_logger.handlers[:]
+ jm_logger.handlers[:] = [handler]
+ try:
+ with jm_task_context(session_id='plugin-session', task_id='plugin-task'):
+ option.invoke_plugin(TaskAwarePlugin, None, {}, {})
+ finally:
+ jm_logger.handlers[:] = original_handlers
+
+ self.assertEqual([{
+ 'session_id': 'plugin-session',
+ 'task_id': 'plugin-task',
+ }], observed)
+ self.assertEqual(
+ [
+ 'plugin.invoke',
+ 'plugin.test_task_aware',
+ ],
+ [record.topic for record in handler.records],
+ )
+ self.assertEqual(
+ [{
+ 'session_id': 'plugin-session',
+ 'task_id': 'plugin-task',
+ }] * 2,
+ [record.jm_task_context for record in handler.records],
+ )
+
+ def test_feature_invocation_can_read_current_task_context(self):
+ observed = []
+
+ class TaskAwareFeature(Feature):
+
+ def invoke(self, _option, feature_from, when, **_kwargs):
+ observed.append((feature_from, when, self.jm_task_context))
+
+ downloader = BaseDownloader(object())
+ downloader.add_features(TaskAwareFeature(), 'download_album')
+
+ with jm_task_context(session_id='feature-session', task_id='feature-task'):
+ downloader._invoke_features_for('after_album')
+
+ self.assertEqual([(
+ 'download_album',
+ 'after_album',
+ {
+ 'session_id': 'feature-session',
+ 'task_id': 'feature-task',
+ },
+ )], observed)
+
+ def test_sync_batch_binds_parent_and_item_context(self):
+ def fake_download(jmid, _option, _downloader, **_kwargs):
+ context = get_jm_task_context()
+ return (
+ str(jmid),
+ context['session_id'],
+ context['download_type'],
+ context['jm_id'],
+ )
+
+ with jm_task_context(session_id='sync-batch'):
+ result = download_batch(fake_download, ['1', '2'], option=object())
+
+ self.assertEqual(
+ {
+ ('1', 'sync-batch', 'fake_download', '1'),
+ ('2', 'sync-batch', 'fake_download', '2'),
+ },
+ set(result),
+ )
+ self.assertEqual({}, get_jm_task_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()
+ return context['session_id'], context['jm_id'], str(jmid)
+
+ def run_session(session_id, jmid):
+ with jm_task_context(session_id=session_id):
+ return download_batch(
+ fake_download,
+ [jmid],
+ option=object(),
+ )
+
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ future_a = executor.submit(run_session, 'session-A', '1')
+ future_b = executor.submit(run_session, 'session-B', '2')
+
+ self.assertEqual(
+ {('session-A', '1', '1')},
+ set(future_a.result()),
+ )
+ self.assertEqual(
+ {('session-B', '2', '2')},
+ set(future_b.result()),
+ )
+
+ def test_batch_failure_log_keeps_item_context(self):
+ handler = ListHandler()
+ original_handlers = jm_logger.handlers[:]
+ jm_logger.handlers[:] = [handler]
+
+ def fail(jmid, _option, _downloader, **_kwargs):
+ raise ValueError(f'failed-{jmid}')
+
+ try:
+ with jm_task_context(session_id='failed-session'):
+ result = download_batch(fail, ['404'], option=object())
+ finally:
+ jm_logger.handlers[:] = original_handlers
+
+ self.assertIn('404', result.failed)
+ self.assertEqual(1, len(handler.records))
+ self.assertEqual(
+ {
+ 'session_id': 'failed-session',
+ 'download_type': 'fail',
+ 'jm_id': '404',
+ },
+ handler.records[0].jm_task_context,
+ )
+
+ def test_sync_downloader_propagates_both_threading_branches(self):
+ downloader = object.__new__(JmDownloader)
+ BaseDownloader.__init__(downloader, object())
+
+ 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,
+ )
+
+ contexts = [observed.get_nowait() for _ in range(3)]
+ self.assertEqual(
+ [{
+ 'session_id': f'workers-{count_batch}',
+ }] * 3,
+ contexts,
+ )
+
+ 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()
+ return (
+ str(jmid),
+ context['session_id'],
+ context['download_type'],
+ context['jm_id'],
+ )
+
+ with jm_task_context(session_id='async-batch'):
+ batch_result = await download_batch_async(
+ fake_download,
+ ['1', '2'],
+ option=object(),
+ )
+
+ downloader = object.__new__(JmAsyncDownloader)
+ downloader._decode_pool = ThreadPoolExecutor(max_workers=1)
+ try:
+ with jm_task_context(session_id='decode-pool'):
+ executor_context = await downloader._run_in_decode_pool(
+ get_jm_task_context
+ )
+
+ loop = asyncio.get_running_loop()
+ leaked_context = await loop.run_in_executor(
+ downloader._decode_pool,
+ get_jm_task_context,
+ )
+ finally:
+ downloader.shutdown()
+
+ return batch_result, executor_context, leaked_context
+
+ batch_result, executor_context, leaked_context = asyncio.run(run_test())
+
+ self.assertEqual(
+ {
+ ('1', 'async-batch', 'fake_download', '1'),
+ ('2', 'async-batch', 'fake_download', '2'),
+ },
+ set(batch_result),
+ )
+ self.assertEqual({'session_id': 'decode-pool'}, executor_context)
+ self.assertEqual({}, leaked_context)
+
+ def test_photo_concurrent_proxy_propagates_context(self):
+ class FakeClient:
+ pass
+
+ proxy = PhotoConcurrentFetcherProxy(FakeClient(), max_workers=1)
+ try:
+ with jm_task_context(session_id='client-proxy'):
+ future = proxy.get_future('context', get_jm_task_context)
+
+ self.assertEqual({'session_id': 'client-proxy'}, future.result())
+ finally:
+ proxy.executors.shutdown(wait=True)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/usage/workflow_download.py b/usage/workflow_download.py
index 4f8121225..22a117a31 100644
--- a/usage/workflow_download.py
+++ b/usage/workflow_download.py
@@ -1,5 +1,5 @@
from jmcomic import *
-from jmcomic.cl import JmcomicUI
+from jmcomic.cli import JmcomicUI
# 下方填入你要下载的本子的id,一行一个,每行的首尾可以有空白字符
jm_albums = '''