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 的依赖提示给出了可安装的源码来源。')