11import asyncio
22import importlib
3- from collections .abc import Iterable
3+ from collections .abc import Generator , Iterable
44from contextlib import suppress
55from functools import lru_cache
66from importlib .metadata import Distribution , PackageNotFoundError , distribution
7- from typing import Optional
7+ from pathlib import Path
88
99from cookit .loguru import warning_suppress
10- from cookit .pyd import type_validate_python
10+ from cookit .pyd import type_validate_json , type_validate_python
1111from nonebot import logger
1212from nonebot .plugin import Plugin
1313
14+ from ..config import external_infos_dir , pm_menus_dir
1415from ..utils import normalize_plugin_name
15- from .mixin import chain_mixins , plugin_collect_mixins
16- from .models import PMNData , PMNPluginExtra , PMNPluginInfo
16+ from .mixin import PluginCollectMixinNext , chain_mixins , plugin_collect_mixins
17+ from .models import ExternalPluginInfo , PMNData , PMNPluginExtra , PMNPluginInfo
1718
1819
1920def normalize_metadata_user (info : str , allow_multi : bool = False ) -> str :
@@ -24,7 +25,7 @@ def normalize_metadata_user(info: str, allow_multi: bool = False) -> str:
2425
2526
2627@lru_cache
27- def get_dist (module_name : str ) -> Optional [ Distribution ] :
28+ def get_dist (module_name : str ) -> Distribution | None :
2829 with warning_suppress (f"Unexpected error happened when getting info of package { module_name } " ),\
2930 suppress (PackageNotFoundError ): # fmt: skip
3031 return distribution (module_name )
@@ -35,7 +36,7 @@ def get_dist(module_name: str) -> Optional[Distribution]:
3536
3637
3738@lru_cache
38- def get_version_attr (module_name : str ) -> Optional [ str ] :
39+ def get_version_attr (module_name : str ) -> str | None :
3940 with warning_suppress (f"Unexpected error happened when importing { module_name } " ),\
4041 suppress (ImportError ): # fmt: skip
4142 m = importlib .import_module (module_name )
@@ -49,7 +50,7 @@ def get_version_attr(module_name: str) -> Optional[str]:
4950
5051async def get_info_from_plugin (plugin : Plugin ) -> PMNPluginInfo :
5152 meta = plugin .metadata
52- extra : Optional [ PMNPluginExtra ] = None
53+ extra : PMNPluginExtra | None = None
5354 if meta :
5455 with warning_suppress (f"Failed to parse plugin metadata of { plugin .id_ } " ):
5556 extra = type_validate_python (PMNPluginExtra , meta .extra )
@@ -68,19 +69,25 @@ async def get_info_from_plugin(plugin: Plugin) -> PMNPluginInfo:
6869 else None
6970 )
7071 if not author and (dist := get_dist (plugin .module_name )):
71- if author := dist .metadata .get ("Author" ) or dist .metadata .get ("Maintainer" ):
72+ if (("Author" in dist .metadata ) and (author := dist .metadata ["Author" ])) or (
73+ ("Maintainer" in dist .metadata ) and (author := dist .metadata ["Maintainer" ])
74+ ):
7275 author = normalize_metadata_user (author )
73- elif author := dist .metadata .get ("Author-Email" ) or dist .metadata .get (
74- "Maintainer-Email" ,
76+ elif (
77+ ("Author-Email" in dist .metadata )
78+ and (author := dist .metadata ["Author-Email" ])
79+ ) or (
80+ ("Maintainer-Email" in dist .metadata )
81+ and (author := dist .metadata ["Maintainer-Email" ])
7582 ):
7683 author = normalize_metadata_user (author , allow_multi = True )
7784
7885 description = (
7986 meta .description
8087 if meta
8188 else (
82- dist .metadata . get ( "Summary" )
83- if (dist := get_dist (plugin .module_name ))
89+ dist .metadata [ "Summary" ]
90+ if (dist := get_dist (plugin .module_name )) and "Summary" in dist . metadata
8491 else None
8592 )
8693 )
@@ -102,6 +109,107 @@ async def get_info_from_plugin(plugin: Plugin) -> PMNPluginInfo:
102109 )
103110
104111
112+ def scan_path (path : Path , suffixes : Iterable [str ] | None = None ) -> Generator [Path ]:
113+ for child in path .iterdir ():
114+ if child .is_dir ():
115+ yield from scan_path (child , suffixes )
116+ elif suffixes and child .suffix in suffixes :
117+ yield child
118+
119+
120+ async def collect_menus ():
121+ yaml = None
122+
123+ supported_suffixes = {".json" , ".yml" , ".yaml" , ".toml" }
124+
125+ def _load_file (path : Path ) -> ExternalPluginInfo :
126+ nonlocal yaml
127+
128+ if path .suffix == ".json" :
129+ return type_validate_json (ExternalPluginInfo , path .read_text ("u8" ))
130+
131+ if path .suffix in {".yml" , ".yaml" }:
132+ if not yaml :
133+ try :
134+ from ruamel .yaml import YAML
135+ except ImportError as e :
136+ raise ImportError (
137+ "Missing dependency for parsing yaml files, please install using"
138+ " `pip install nonebot-plugin-picmenu-next[yaml]`" ,
139+ ) from e
140+ yaml = YAML ()
141+ return type_validate_python (
142+ ExternalPluginInfo ,
143+ yaml .load (path .read_text ("u8" )),
144+ )
145+
146+ if path .suffix == ".toml" :
147+ try :
148+ import tomllib
149+ except ImportError :
150+ try :
151+ import tomli as tomllib
152+ except ImportError as e :
153+ raise ImportError (
154+ "Missing dependency for parsing toml files, please install using"
155+ " `pip install nonebot-plugin-picmenu-next[toml]`" ,
156+ ) from e
157+ return type_validate_python (
158+ ExternalPluginInfo ,
159+ tomllib .loads (path .read_text ("u8" )),
160+ )
161+
162+ raise ValueError ("Unsupported file type" )
163+
164+ infos : dict [str , ExternalPluginInfo ] = {}
165+
166+ def _load_to_infos (path : Path ):
167+ if path .name in infos :
168+ logger .warning (
169+ f"Find file with duplicated name `{ path .name } `! Skip loading {{path}}" ,
170+ )
171+ return
172+ with warning_suppress (f"Failed to load file { path } " ):
173+ infos [path .name ] = _load_file (path )
174+
175+ def _load_all (path : Path ):
176+ for x in scan_path (path , supported_suffixes ):
177+ _load_to_infos (x )
178+
179+ if pm_menus_dir .exists ():
180+ logger .warning (
181+ "Old PicMenu menus dir is deprecated"
182+ ", recommended to migrate to PicMenu Next config dir" ,
183+ )
184+ _load_all (pm_menus_dir )
185+
186+ _load_all (external_infos_dir )
187+
188+ return infos
189+
190+
191+ @plugin_collect_mixins (priority = 1 )
192+ async def load_user_custom_infos_mixin (
193+ next_mixin : PluginCollectMixinNext ,
194+ infos : list [PMNPluginInfo ],
195+ ) -> list [PMNPluginInfo ]:
196+ external_infos = await collect_menus ()
197+ if not external_infos :
198+ return await next_mixin (infos )
199+ logger .info (f"Collected { len (external_infos )} external infos" )
200+
201+ infos_map = {x .plugin_id : x for x in infos if x .plugin_id }
202+ for k , v in external_infos .items ():
203+ if k in infos_map :
204+ logger .debug (f"Found `{ k } ` in infos, will merge to original" )
205+ v .merge_to (infos_map [k ], plugin_id = k , copy = False )
206+ else :
207+ logger .debug (f"Not found `{ k } ` in infos, will add into" )
208+ infos .append (v .to_plugin_info (k ))
209+
210+ return await next_mixin (infos )
211+
212+
105213async def collect_plugin_infos (plugins : Iterable [Plugin ]):
106214 async def _get (p : Plugin ):
107215 with warning_suppress (f"Failed to get plugin info of { p .id_ } " ):
0 commit comments