From eabc3dcaacdbf743aa12ad090c59d8d23b93dec7 Mon Sep 17 00:00:00 2001 From: check <137591557+yifenliwu@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:40:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20=E7=BC=BA=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E5=AE=89=E8=A3=85=E6=8C=87=E5=BC=95=E6=94=B9?= =?UTF-8?q?=E6=88=90=E5=8F=AF=E7=94=A8=E7=9A=84=E6=BA=90=E7=A0=81=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=EF=BC=88jmcomic-calibre=20=E4=B8=8D=E5=9C=A8=20PyPI?= =?UTF-8?q?=20=E4=B8=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #579 的 review 里 CodeRabbit 提了两次同一件事,都落在 `calibre_metadata` 上:这插件声明了 `jmcomic_calibre`,但 `jmcomic-calibre` 根本没发到 PyPI(https://pypi.org/pypi/jmcomic-calibre/json 是 404)。 后果是默认的 failed-fast 策略会给出 `pip install jmcomic-calibre`,照着敲必然装不上;配 `auto-install` 的话 pip 报找不到包然后直接抛错。等于这插件现在启用了也用不了。 改动: - 依赖规格换成 pip 的直接引用 `jmcomic-calibre @ git+https://github.com/yifenliwu/jmcomic-calibre.git`,failed-fast 的提示和 auto-install 实际执行的命令都会带上这个来源。 - 这种规格带空格,拼进命令行会被 shell 拆成多个参数,所以加了 `format_pip_install_cmd()`,遇到带空格的规格整体加引号。原来直接 `'pip install ' + ' '.join(...)` 拼字符串的三处都换成它。 - 插件 docstring 和 `option_file_syntax.md` 里补了同一条安装命令。 - `invoke()` 里兜底的 `warning_lib_not_install()` 复用同一份规格,不再手写包名。 `pyproject.toml` 的 `[plugins]` extra 我没动。往 extra 里放一个 git 直接引用,会让 `pip install jmcomic[plugins]` 从 GitHub 拉源码,不装 calibre 的人也要多依赖 git;等包发到 PyPI 再补那一行更稳妥。 --- 这次重新推了一版:原来那个分支 base 在 489a76a,之后 #581 先合了(改了同一个文件),GitHub 上已经变成冲突状态。现在把同样的改动重新打在 dev 当前 tip(cf4657c)上,`jm_plugin.py` 那块因为 #581 动了 `zip_with_password` 和依赖预检,行号有偏移,但内容没有实质冲突;`tests/test_jmcomic/test_jm_plugin.py` 是唯一真冲突——#581 在文件末尾追加了三条测试,原来的 patch 锚在旧文件尾,这里手工把两条测试接在后面。 本地验证:17 passed(含 #581 的 4 条)。只把 `src/jmcomic/jm_plugin.py` 回滚到 2.7.7 原版、测试保留,会红 4 条(我这两条 + #581 那两条),说明测试确实在守这两处行为。 --- assets/docs/sources/option_file_syntax.md | 4 ++ src/jmcomic/jm_plugin.py | 45 ++++++++++++++--- tests/test_jmcomic/test_jm_plugin.py | 59 +++++++++++++++++++++++ 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/assets/docs/sources/option_file_syntax.md b/assets/docs/sources/option_file_syntax.md index 548498c1..6ebbb9e8 100644 --- a/assets/docs/sources/option_file_syntax.md +++ b/assets/docs/sources/option_file_syntax.md @@ -153,6 +153,10 @@ dir_rule: > pip install jmcomic[plugins] > ``` > - **关注环境体积 / 仅需特定功能的用户**:无需安装全家桶,只需在用到具体插件时按需安装对应依赖即可。也可通过下面的 `plugins.dependencies_strategy` 配置处理策略。 +> - **`calibre_metadata` 插件的依赖不在全家桶里**:`jmcomic-calibre` 暂未发布到 PyPI,需要单独从源码安装: +> ```bash +> pip install "jmcomic-calibre @ git+https://github.com/yifenliwu/jmcomic-calibre.git" +> ``` ```yaml # 插件的配置示例 diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index 4bf990a8..6d6a49d3 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -11,6 +11,19 @@ from .jm_task_context import bind_jm_task_context, get_jm_task_context +def format_pip_install_cmd(pip_specs) -> str: + """ + 拼接可直接复制执行的 pip 安装命令。 + + pip 支持 `包名 @ 来源` 形式的直接引用(例如 + jmcomic-calibre @ git+https://github.com/yifenliwu/jmcomic-calibre.git), + 这种规格本身含空格,直接拼进命令行会被 shell 拆成多个参数,所以要整体加引号。 + """ + return 'pip install ' + ' '.join( + f'"{spec}"' if ' ' in spec else spec for spec in pip_specs + ) + + class PluginValidationException(Exception): def __init__(self, plugin: 'JmOptionPlugin', msg: str): @@ -80,7 +93,7 @@ def check_plugin_dependency(cls, kwargs: dict, strategy: str = 'failed-fast') -> missing_import_names = [m[0] for m in missing] missing_pip_names = [m[1] for m in missing] import_names_str = ', '.join(missing_import_names) - pip_install_cmd = 'pip install ' + ' '.join(missing_pip_names) + pip_install_cmd = format_pip_install_cmd(missing_pip_names) # 4. 根据策略分发处理: if strategy == 'auto-install': @@ -138,7 +151,7 @@ def install_missing_dependencies(cls, pip_packages: List[str]) -> None: f'插件 [{cls.plugin_key}] 自动安装依赖 [{" ".join(pip_packages)}] 失败。\n' f'执行命令: {" ".join(cmd)}\n' f'错误详情: {err_output}\n' - f'请排查网络/权限问题,或手动执行: pip install {" ".join(pip_packages)}' + f'请排查网络/权限问题,或手动执行: {format_pip_install_cmd(pip_packages)}' ) def __init__(self, option: JmOption): @@ -187,9 +200,17 @@ def require_param(self, case: Any, msg: str): raise PluginValidationException(self, msg) - def warning_lib_not_install(self, lib: str, throw=False): + def warning_lib_not_install(self, lib: str, throw=False, pip_spec: str = None): + """ + 依赖库缺失时的统一提示。 + + :param lib: 依赖库名,用于展示 + :param throw: 为 True 时把提示升级成异常 + :param pip_spec: 该库在 pip 中的安装规格,缺省与 lib 相同。 + 当包没有发布到 PyPI 时,可传入直接引用(如 git+https://...) + """ msg = (f'插件`{self.plugin_key}`依赖库: {lib},请先安装{lib}再使用。' - f'安装命令: [pip install {lib}]') + f'安装命令: [{format_pip_install_cmd([pip_spec or lib])}]') import warnings warnings.warn(msg) self.require_param(throw, msg) @@ -2096,6 +2117,11 @@ class CalibreMetadataPlugin(JmOptionPlugin): OPF 的生成逻辑由 jmcomic-calibre 提供, 避免同一份 XML 拼接逻辑在两处各维护一份。 + jmcomic-calibre 暂未发布到 PyPI,需要从源码安装: + + ``` + pip install "jmcomic-calibre @ git+https://github.com/yifenliwu/jmcomic-calibre.git" + ``` 配置示例: @@ -2118,7 +2144,13 @@ class CalibreMetadataPlugin(JmOptionPlugin): - include_cover 依赖 downloader(after_album 阶段自动传入) """ plugin_key = 'calibre_metadata' - plugin_dependencies = (('jmcomic_calibre', 'jmcomic-calibre'),) + # jmcomic-calibre 还没上 PyPI。这里声明 pip 的直接引用(PEP 508), + # 否则 failed-fast 策略给出的 `pip install jmcomic-calibre` 必然装不上, + # auto-install 策略也会因为找不到包而直接抛错。 + plugin_dependencies = (( + 'jmcomic_calibre', + 'jmcomic-calibre @ git+https://github.com/yifenliwu/jmcomic-calibre.git', + ),) def invoke(self, dir_rule: dict, @@ -2133,7 +2165,8 @@ def invoke(self, try: import jmcomic_calibre except ImportError: - self.warning_lib_not_install('jmcomic-calibre') + self.warning_lib_not_install( + 'jmcomic-calibre', pip_spec=self.plugin_dependencies[0][1]) return opf_path = self.decide_filepath(album, photo, None, None, None, dir_rule) diff --git a/tests/test_jmcomic/test_jm_plugin.py b/tests/test_jmcomic/test_jm_plugin.py index ccd649bd..077bd4a0 100644 --- a/tests/test_jmcomic/test_jm_plugin.py +++ b/tests/test_jmcomic/test_jm_plugin.py @@ -658,3 +658,62 @@ def test_dependencies_reject_non_mapping_kwargs(self): else: self.fail('非 mapping 的 kwargs 应当抛配置错误,实际构建成功') print('✅ non-mapping kwargs rejected with a readable config error.') + + + def test_dependency_install_hint_supports_direct_reference(self): + """ + 依赖不在 PyPI 上时,安装指引要给出可用的直接引用(PEP 508), + 并且含空格的规格要整体加引号,否则复制到 shell 里会被拆成多个参数。 + """ + from unittest import mock + + from jmcomic import JmOption, JmModuleConfig, JmcomicException + from jmcomic.jm_plugin import format_pip_install_cmd + + # 纯函数:普通包名不加引号,直接引用加引号 + self.assertEqual('pip install a b', format_pip_install_cmd(['a', 'b'])) + self.assertEqual( + 'pip install "a @ git+https://example.com/a.git"', + format_pip_install_cmd(['a @ git+https://example.com/a.git']), + ) + + pclass = JmModuleConfig.REGISTRY_PLUGIN['img2pdf'] + origin_deps = pclass.plugin_dependencies + spec = 'jmcomic-calibre @ git+https://example.com/jmcomic-calibre.git' + pclass.plugin_dependencies = (('__lib_not_exists__', spec),) + try: + dic = {'plugins': {'after_album': [{'plugin': 'img2pdf'}]}} + with self.assertRaises(JmcomicException) as ctx: + JmOption.construct(dic) + err_text = str(ctx.exception) + self.assertIn(spec, err_text) + self.assertIn(f'pip install "{spec}"', err_text) + print('✅ 直接引用规格在安装指引里被完整引用。') + finally: + pclass.plugin_dependencies = origin_deps + + def test_calibre_metadata_declares_usable_install_source(self): + """ + calibre_metadata 声明的 pip 规格必须指向真实存在的来源: + jmcomic-calibre 没上 PyPI,写成裸包名会让 failed-fast 的指引必然装不上。 + """ + from unittest import mock + + from jmcomic import JmModuleConfig, JmcomicException + from jmcomic.jm_plugin import format_pip_install_cmd + + pclass = JmModuleConfig.REGISTRY_PLUGIN['calibre_metadata'] + import_name, pip_spec = pclass.parse_dependency_spec(pclass.plugin_dependencies[0]) + + self.assertEqual('jmcomic_calibre', import_name) + self.assertIn('git+https://github.com/yifenliwu/jmcomic-calibre', pip_spec) + + # 缺库时按 failed-fast 给出的命令必须带上来源 + with mock.patch('importlib.util.find_spec', return_value=None): + with self.assertRaises(JmcomicException) as ctx: + pclass.check_plugin_dependency({}, strategy='failed-fast') + + err_text = str(ctx.exception) + self.assertIn(format_pip_install_cmd([pip_spec]), err_text) + self.assertIn(f'pip install "{pip_spec}"', err_text) + print('✅ calibre_metadata 的依赖提示给出了可安装的源码来源。')