refactor backend module architecture

This commit is contained in:
jxxghp
2026-08-14 15:45:38 +08:00
parent 557cc0e2e3
commit 7b3444c366
716 changed files with 10378 additions and 7709 deletions
+1
View File
@@ -0,0 +1 @@
"""不依赖 MoviePilot 业务与运行配置的通用基础能力。"""
+213
View File
@@ -0,0 +1,213 @@
import base64
import hashlib
from hashlib import md5
from typing import Union, Optional, Tuple
from Crypto import Random
from Crypto.Cipher import AES
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding, rsa
class RSAUtils:
"""提供 RSA 密钥生成、校验和加解密辅助能力。"""
@staticmethod
def generate_rsa_key_pair(key_size: int = 2048) -> Tuple[str, str]:
"""
生成RSA密钥对
:return: 私钥和公钥(Base64 编码,无标识符)
"""
# 生成RSA密钥对
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=key_size,
)
public_key = private_key.public_key()
# 导出私钥为DER格式
private_key_der = private_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
# 导出公钥为DER格式
public_key_der = public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# 将DER格式的密钥编码为Base64
private_key_b64 = base64.b64encode(private_key_der).decode("utf-8")
public_key_b64 = base64.b64encode(public_key_der).decode("utf-8")
return private_key_b64, public_key_b64
@staticmethod
def verify_rsa_keys(private_key: Optional[str], public_key: Optional[str]) -> bool:
"""
使用 RSA 验证私钥和公钥是否匹配
:param private_key: 私钥字符串 (Base64 编码,无标识符)
:param public_key: 公钥字符串 (Base64 编码,无标识符)
:return: 如果匹配则返回 True,否则返回 False
"""
if not private_key or not public_key:
return False
try:
# 解码 Base64 编码的公钥和私钥
public_key_bytes = base64.b64decode(public_key)
private_key_bytes = base64.b64decode(private_key)
# 加载公钥
public_key = serialization.load_der_public_key(public_key_bytes, backend=default_backend())
# 加载私钥
private_key = serialization.load_der_private_key(private_key_bytes, password=None,
backend=default_backend())
# 测试加解密
message = b'test'
encrypted_message = public_key.encrypt(
message,
asym_padding.OAEP(
mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
decrypted_message = private_key.decrypt(
encrypted_message,
asym_padding.OAEP(
mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
return message == decrypted_message
except Exception as e:
print(f"RSA 密钥验证失败: {e}")
return False
class HashUtils:
"""提供常用摘要算法的统一字符串和字节接口。"""
@staticmethod
def md5(data: Union[str, bytes], encoding: str = "utf-8") -> str:
"""
生成数据的MD5哈希值,并以字符串形式返回
:param data: 输入的数据,类型为字符串
:param encoding: 字符串编码类型,默认使用UTF-8
:return: 生成的MD5哈希字符串
"""
if isinstance(data, str):
data = data.encode(encoding)
return hashlib.md5(data).hexdigest()
@staticmethod
def sha1(data: Union[str, bytes], encoding: str = "utf-8") -> str:
"""
生成数据的SHA-1哈希值,并以字符串形式返回
:param data: 输入的数据,类型为字符串或字节
:param encoding: 字符串编码类型,默认使用UTF-8
:return: 生成的SHA-1哈希字符串
"""
if isinstance(data, str):
data = data.encode(encoding)
return hashlib.sha1(data).hexdigest()
@staticmethod
def md5_bytes(data: Union[str, bytes], encoding: str = "utf-8") -> bytes:
"""
生成数据的MD5哈希值,并以字节形式返回
:param data: 输入的数据,类型为字符串
:param encoding: 字符串编码类型,默认使用UTF-8
:return: 生成的MD5哈希二进制数据
"""
if isinstance(data, str):
data = data.encode(encoding)
return hashlib.md5(data).digest()
class CryptoJsUtils:
"""兼容 CryptoJS OpenSSL 格式的 AES 加解密工具。"""
@staticmethod
def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes:
"""
生成加密/解密所需的密钥和初始化向量 (IV)
"""
# extended from https://gist.github.com/gsakkis/4546068
assert len(salt) == 8, len(salt)
data += salt
key = md5(data).digest()
final_key = key
while len(final_key) < output:
key = md5(key + data).digest()
final_key += key
return final_key[:output]
@staticmethod
def encrypt(message: bytes, passphrase: bytes) -> bytes:
"""
使用 CryptoJS 兼容的加密策略对消息进行加密
"""
# This is a modified copy of https://stackoverflow.com/questions/36762098/how-to-decrypt-password-from-javascript-cryptojs-aes-encryptpassword-passphras
# 生成8字节的随机盐值
salt = Random.new().read(8)
# 通过密码短语和盐值生成密钥和IV
key_iv = CryptoJsUtils.bytes_to_key(passphrase, salt, 32 + 16)
key = key_iv[:32]
iv = key_iv[32:]
# 创建AES加密器(CBC模式)
aes = AES.new(key, AES.MODE_CBC, iv)
# 应用PKCS#7填充
padding_length = 16 - (len(message) % 16)
padding = bytes([padding_length] * padding_length)
padded_message = message + padding
# 加密消息
encrypted = aes.encrypt(padded_message)
# 构建加密数据格式:b"Salted__" + salt + encrypted_message
salted_encrypted = b"Salted__" + salt + encrypted
# 返回Base64编码的加密数据
return base64.b64encode(salted_encrypted)
@staticmethod
def decrypt(encrypted: Union[str, bytes], passphrase: bytes) -> bytes:
"""
使用 CryptoJS 兼容的解密策略对加密消息进行解密
"""
# 确保输入是字节类型
if isinstance(encrypted, str):
encrypted = encrypted.encode("utf-8")
# Base64 解码
encrypted = base64.b64decode(encrypted)
# 检查前8字节是否为 "Salted__"
assert encrypted.startswith(b"Salted__"), "Invalid encrypted data format"
# 提取盐值
salt = encrypted[8:16]
# 通过密码短语和盐值生成密钥和IV
key_iv = CryptoJsUtils.bytes_to_key(passphrase, salt, 32 + 16)
key = key_iv[:32]
iv = key_iv[32:]
# 创建AES解密器(CBC模式)
aes = AES.new(key, AES.MODE_CBC, iv)
# 解密加密部分
decrypted_padded = aes.decrypt(encrypted[16:])
# 移除PKCS#7填充
padding_length = decrypted_padded[-1]
if isinstance(padding_length, str):
padding_length = ord(padding_length)
decrypted = decrypted_padded[:-padding_length]
return decrypted
+34
View File
@@ -0,0 +1,34 @@
from typing import Union
class DomUtils:
"""提供 XML DOM 节点读取和创建辅助能力。"""
@staticmethod
def tag_value(tag_item, tag_name: str, attname: str = "", default: Union[str, int] = None):
"""
解析XML标签值
"""
tagNames = tag_item.getElementsByTagName(tag_name)
if tagNames:
if attname:
attvalue = tagNames[0].getAttribute(attname)
if attvalue:
return attvalue
else:
firstChild = tagNames[0].firstChild
if firstChild:
return firstChild.data
return default
@staticmethod
def add_node(doc, parent, name: str, value: str = None):
"""
添加一个DOM节点
"""
node = doc.createElement(name)
parent.appendChild(node)
if value is not None:
text = doc.createTextNode(str(value))
node.appendChild(text)
return node
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
from typing import Optional, Union
# 后台任务会话使用的内部占位用户ID。
# 它只用于在 agent/memory/session 侧标识“系统触发的任务”,
# 不能直接作为真实消息接收人下发到 Telegram/企业微信 等通知渠道。
SYSTEM_INTERNAL_USER_ID = "system"
def is_internal_user_id(userid: Optional[Union[str, int]]) -> bool:
"""
判断是否为系统内部占位用户ID。
"""
return (
isinstance(userid, str)
and userid.strip().lower() == SYSTEM_INTERNAL_USER_ID
)
def normalize_internal_user_id(
userid: Optional[Union[str, int]]
) -> Optional[Union[str, int]]:
"""
将系统内部占位用户ID归一化为 None,避免被通知渠道误认为真实接收人。
"""
if is_internal_user_id(userid):
return None
return userid
+10
View File
@@ -0,0 +1,10 @@
"""中文分词工具。"""
from jieba_next import cut as jieba_next_cut
def cut(text: str, HMM: bool = True, cut_all: bool = False) -> list[str]:
"""
使用 jieba-next 执行中文分词,并兼容 jieba.cut 的常用参数名。
"""
return list(jieba_next_cut(text, HMM=HMM, cut_all=cut_all))
+118
View File
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
import importlib
import pkgutil
from pathlib import Path
from typing import List, Any, Callable
FilterFuncType = Callable[[str, Any], bool]
def _default_filter(name: str, obj: Any) -> bool:
"""
默认过滤器
"""
return True if name and obj else False
class ModuleHelper:
"""
模块动态加载
"""
@classmethod
def load(cls, package_path: str, filter_func: FilterFuncType = _default_filter) -> List[Any]:
"""
导入模块
:param package_path: 父包名
:param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入
:return: 导入的模块对象列表
"""
submodules: list = []
loaded_modules = set()
packages = importlib.import_module(package_path)
for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):
try:
if package_name.startswith('_'):
continue
full_package_name = f'{package_path}.{package_name}'
module = importlib.import_module(full_package_name)
importlib.reload(module)
for name, obj in module.__dict__.items():
if name.startswith('_'):
continue
if isinstance(obj, type) and filter_func(name, obj):
if name in loaded_modules:
continue
loaded_modules.add(name)
submodules.append(obj)
except Exception:
continue
return submodules
@classmethod
def load_with_pre_filter(cls, package_path: str, filter_func: FilterFuncType = _default_filter) -> List[Any]:
"""
导入子模块
:param package_path: 父包名
:param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入
:return: 导入的模块对象列表
"""
submodules: list = []
packages = importlib.import_module(package_path)
def reload_module_objects(target_module):
"""加载模块并返回对象"""
importlib.reload(target_module)
# reload后,重新过滤已经重新加载后的模块中的对象
return [
obj for name, obj in target_module.__dict__.items()
if not name.startswith('_') and isinstance(obj, type) and filter_func(name, obj)
]
def reload_sub_modules(parent_module, parent_module_name):
"""重新加载一级子模块"""
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__,
parent_module_name + '.'):
try:
full_sub_module = importlib.import_module(sub_module_name)
importlib.reload(full_sub_module)
except Exception:
continue
# 遍历包中的所有子模块
for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):
if package_name.startswith('_'):
continue
full_package_name = f'{package_path}.{package_name}'
try:
module = importlib.import_module(full_package_name)
# 预检查模块中的对象
candidates = [(name, obj) for name, obj in module.__dict__.items() if
not name.startswith('_') and isinstance(obj, type)]
# 确定是否需要重新加载
if any(filter_func(name, obj) for name, obj in candidates):
# 如果子模块是包,重新加载其子模块
if is_pkg:
reload_sub_modules(module, full_package_name)
submodules.extend(reload_module_objects(module))
except Exception:
continue
return submodules
@staticmethod
def dynamic_import_all_modules(base_path: Path, package_name: str):
"""
动态导入目录下所有模块
"""
modules = []
# 遍历文件夹,找到所有模块文件
for file in base_path.glob("*.py"):
file_name = file.stem
if file_name != "__init__":
modules.append(file_name)
full_module_name = f"{package_name}.{file_name}"
importlib.import_module(full_module_name)
+137
View File
@@ -0,0 +1,137 @@
import ast
import dis
import inspect
import textwrap
from types import FunctionType
from typing import Any, Callable, get_type_hints
class ObjectUtils:
"""提供对象类型、函数实现和签名检查能力。"""
@staticmethod
def is_obj(obj: Any):
"""判断值是否属于可展开的复合对象。"""
if isinstance(obj, list) \
or isinstance(obj, dict) \
or isinstance(obj, tuple):
return True
elif isinstance(obj, int) \
or isinstance(obj, float) \
or isinstance(obj, bool) \
or isinstance(obj, bytes) \
or isinstance(obj, str):
return False
return True
@staticmethod
def is_objstr(obj: Any):
"""判断字符串是否以常见复合对象字面量开头。"""
if not isinstance(obj, str):
return False
return str(obj).startswith("{") \
or str(obj).startswith("[") \
or str(obj).startswith("(")
@staticmethod
def arguments(func: Callable) -> int:
"""
返回函数的参数个数
"""
signature = inspect.signature(func)
parameters = signature.parameters
return len(list(parameters.keys()))
@staticmethod
def check_method(func: Callable[..., Any]) -> bool:
"""
检查函数是否已实现
"""
try:
src = inspect.getsource(func)
tree = ast.parse(textwrap.dedent(src))
node = tree.body[0]
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
return True
body = node.body
for stmt in body:
# 跳过 pass
if isinstance(stmt, ast.Pass):
continue
# 跳过 docstring 或 ...
if isinstance(stmt, ast.Expr):
expr = stmt.value
if isinstance(expr, ast.Constant):
if isinstance(expr.value, str) or expr.value is Ellipsis:
continue
# 检查 raise NotImplementedError
if isinstance(stmt, ast.Raise):
exc = stmt.exc
if isinstance(exc, ast.Call) and getattr(exc.func, "id", None) == "NotImplementedError":
continue
if isinstance(exc, ast.Name) and exc.id == "NotImplementedError":
continue
return True
return False
except Exception as err:
print(err)
# 源代码分析失败时,进行字节码分析
code_obj = func.__code__ # type: ignore[attr-defined]
instructions = list(dis.get_instructions(code_obj))
# 检查是否为仅返回None的简单结构
if len(instructions) == 2:
first, second = instructions
if (first.opname == 'LOAD_CONST' and
second.opname == 'RETURN_VALUE'):
# 验证加载的常量是否为None
const_index = first.arg
if (const_index < len(code_obj.co_consts) and
code_obj.co_consts[const_index] is None):
# 未实现的空函数
return False
# 其他情况认为已实现
return True
@staticmethod
def check_signature(func: FunctionType, *args) -> bool:
"""
检查输出与函数的参数类型是否一致
"""
# 获取函数的参数信息
signature = inspect.signature(func)
parameters = signature.parameters
if len(args) != len(parameters):
return False
try:
# 获取解析后的类型提示
type_hints = get_type_hints(func)
except TypeError:
type_hints = {}
for arg, (param_name, param) in zip(args, parameters.items()):
# 优先使用解析后的类型提示
param_type = type_hints.get(param_name, None)
if param_type is None:
# 处理原始注解(可能为字符串或Cython类型)
param_annotation = param.annotation
if param_annotation is inspect.Parameter.empty:
continue
# 处理字符串类型的注解
if isinstance(param_annotation, str):
# 尝试解析字符串为实际类型
module = inspect.getmodule(func)
global_vars = module.__dict__ if module else globals()
try:
param_type = eval(param_annotation, global_vars)
except Exception as err:
print(str(err))
continue
else:
param_type = param_annotation
if param_type is None:
continue
if not isinstance(arg, param_type):
return False
return True
+70
View File
@@ -0,0 +1,70 @@
import abc
import threading
import weakref
class Singleton(abc.ABCMeta, type):
"""
类单例模式(按参数)
"""
_instances: dict = {}
def get_existing_instance(cls, *args, **kwargs):
"""按相同参数返回已创建实例,不触发初始化"""
key = (cls, args, frozenset(kwargs.items()))
return cls._instances.get(key)
def __call__(cls, *args, **kwargs):
"""按类和构造参数创建或复用实例。"""
key = (cls, args, frozenset(kwargs.items()))
if key not in cls._instances:
cls._instances[key] = super().__call__(*args, **kwargs)
return cls._instances[key]
class AbstractSingleton(abc.ABC, metaclass=Singleton):
"""
抽像类单例模式
"""
pass
class SingletonClass(abc.ABCMeta, type):
"""
类单例模式(按类)
"""
_instances: dict = {}
def get_existing_instance(cls):
"""返回已创建实例,不触发初始化"""
return cls._instances.get(cls)
def __call__(cls, *args, **kwargs):
"""按类创建或复用唯一实例。"""
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class AbstractSingletonClass(abc.ABC, metaclass=SingletonClass):
"""
抽像类单例模式(按类)
"""
pass
class WeakSingleton(abc.ABCMeta, type):
"""
弱引用单例模式 - 当没有强引用时自动清理
"""
_instances: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
_lock = threading.RLock()
def __call__(cls, *args, **kwargs):
"""按类创建或复用仍有强引用的实例。"""
with cls._lock:
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
+82
View File
@@ -0,0 +1,82 @@
from typing import Dict, List, Set, TypeVar, Any, Union
K = TypeVar("K")
V = TypeVar("V")
class DictUtils:
"""提供字典键集合相关的基础操作。"""
@staticmethod
def filter_keys_to_subset(source: Dict[K, V], reference: Dict[K, V]) -> Dict[K, V]:
"""
过滤 source 字典,使其键成为 reference 字典键的子集
:param source: 要被过滤的字典
:param reference: 参考字典,定义允许的键
:return: 过滤后的字典,只包含在 reference 中存在的键
"""
if not isinstance(source, dict) or not isinstance(reference, dict):
return {}
return {key: value for key, value in source.items() if key in reference}
@staticmethod
def is_keys_subset(source: Dict[K, V], reference: Dict[K, V]) -> bool:
"""
判断 source 字典的键是否为 reference 字典键的子集
:param source: 要检查的字典
:param reference: 参考字典
:return: 如果 source 的键是 reference 的键子集,则返回 True,否则返回 False
"""
if not isinstance(source, dict) or not isinstance(reference, dict):
return False
return all(key in reference for key in source)
class ListUtils:
"""提供列表结构的基础转换能力。"""
@staticmethod
def flatten(nested_list: Union[List[List[Any]], List[Any]]) -> List[Any]:
"""
将嵌套的列表展平成单个列表
:param nested_list: 嵌套的列表
:return: 展平后的列表
"""
if not isinstance(nested_list, list):
return []
# 检查是否嵌套,若不嵌套直接返回
if not any(isinstance(sublist, list) for sublist in nested_list):
return nested_list
return [
item
for sublist in nested_list
for item in (sublist if isinstance(sublist, list) else [sublist])
]
class SetUtils:
"""提供集合结构的基础转换能力。"""
@staticmethod
def flatten(nested_sets: Union[Set[Set[Any]], Set[Any]]) -> Set[Any]:
"""
将嵌套的集合展开为单个集合
:param nested_sets: 嵌套的集合
:return: 展开的集合
"""
if not isinstance(nested_sets, set):
return set()
# 检查是否嵌套,若不嵌套直接返回
if not any(isinstance(subset, set) for subset in nested_sets):
return nested_sets
return {item for subset in nested_sets if isinstance(subset, set) for item in subset}
+137
View File
@@ -0,0 +1,137 @@
import mimetypes
from pathlib import Path
from typing import Optional, Union, Tuple
from urllib import parse
from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse
class UrlUtils:
"""提供不发起网络请求的 URL 解析与组合能力。"""
@staticmethod
def standardize_base_url(host: str) -> str:
"""
标准化提供的主机地址,确保它以http://或https://开头,并且以斜杠(/)结尾
:param host: 提供的主机地址字符串
:return: 标准化后的主机地址字符串
"""
if not host:
return host
if not host.endswith("/"):
host += "/"
if not host.startswith("http://") and not host.startswith("https://"):
host = "http://" + host
return host
@staticmethod
def adapt_request_url(host: str, endpoint: str) -> Optional[str]:
"""
基于传入的host,适配请求的URL,确保每个请求的URL是完整的,用于在发送请求前自动处理和修正请求的URL
:param host: 主机头
:param endpoint: 端点
:return: 完整的请求URL字符串
"""
if not host and not endpoint:
return None
if endpoint.startswith(("http://", "https://")):
return endpoint
host = UrlUtils.standardize_base_url(host)
return urljoin(host, endpoint) if host else endpoint
@staticmethod
def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]:
"""
使用给定的主机头、路径和查询参数组合生成完整的URL
:param host: str, 主机头,例如 https://example.com
:param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1
:param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"}
:return: str, 完整的请求URL字符串
"""
try:
# 如果路径为空,则默认为 '/'
if path is None:
path = '/'
host = UrlUtils.standardize_base_url(host)
# 使用 urljoin 合并 host 和 path
url = urljoin(host, path)
# 解析当前 URL 的组成部分
url_parts = urlparse(url)
# 解析已存在的查询参数,并与额外的查询参数合并
query_params = parse_qs(url_parts.query)
if query:
for key, value in query.items():
query_params[key] = value
# 重新构建查询字符串
query_string = urlencode(query_params, doseq=True)
# 构建完整的 URL
new_url_parts = url_parts._replace(query=query_string)
complete_url = urlunparse(new_url_parts)
return str(complete_url)
except Exception:
return None
@staticmethod
def get_mime_type(path_or_url: Union[str, Path], default_type: str = "application/octet-stream") -> str:
"""
根据文件路径或 URL 获取 MIME 类型,如果无法获取则返回默认类型
:param path_or_url: 文件路径 (Path) 或 URL (str)
:param default_type: 无法获取类型时返回的默认 MIME 类型
:return: 获取到的 MIME 类型或默认类型
"""
try:
# 如果是 Path 类型,转换为字符串
if isinstance(path_or_url, Path):
path_or_url = str(path_or_url)
# 尝试根据路径或 URL 获取 MIME 类型
mime_type, _ = mimetypes.guess_type(path_or_url)
# 如果无法推测到类型,返回默认类型
if not mime_type:
return default_type
return mime_type
except Exception:
return default_type
@staticmethod
def quote(s: str) -> str:
"""
将字符串编码为 URL 安全的格式
:param s: 要编码的字符串
:return: 编码后的字符串
"""
return parse.quote(s)
@staticmethod
def parse_url_params(url: str) -> Optional[Tuple[str, str, int, str]]:
"""
解析给定的 URL,并提取协议、主机名、端口和路径信息
:param url: str
需要解析的 URL 字符串
可以是完整的 URL(例如:"http://example.com:8080/path")或不带协议的地址(例如:"example.com:1234"
:return: Optional[Tuple[str, str, int, str]]
- str: 协议(例如:"http", "https"
- str: 主机名或 IP 地址(例如:"example.com", "192.168.1.1"
- int: 端口号(例如:80, 443
- str: URL 的路径部分(例如:"/", "/path"
如果输入地址无效或无法解析,则返回 None
"""
try:
if not url:
return None
url = UrlUtils.standardize_base_url(host=url)
parsed = urlparse(url)
if not parsed.hostname:
return None
protocol = parsed.scheme
hostname = parsed.hostname
port = parsed.port or (443 if protocol == "https" else 80)
path = parsed.path or "/"
return protocol, hostname, port, path
except Exception:
return None
+69
View File
@@ -0,0 +1,69 @@
import re
from typing import Optional, Tuple
_VERSION_LABELS = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
_UNKNOWN_VERSION_LABEL = -5
_COMPARISON_TYPES = {"ge", "gt", "le", "lt", "eq", "==", ">=", ">", "<=", "<"}
def _normalize_version(version: str) -> list[int]:
"""将历史版本格式转换为保持原有排序语义的整数序列。"""
parts = re.split(r"[.-]", version.strip().lstrip("vV"))
return [
int(part)
if part.isdigit()
else _VERSION_LABELS.get(part, _UNKNOWN_VERSION_LABEL)
for part in parts
]
def compare_version(
source: str,
comparison: str,
target: str,
verbose: bool = False,
) -> Optional[bool] | Tuple[Optional[bool], str | Exception]:
"""按 MoviePilot 历史规则比较版本号,并可返回可读比较结果。"""
try:
if not source or not target:
raise ValueError("要比较的版本号不全")
if not comparison:
raise ValueError("缺少比对模式,无法比对")
if comparison not in _COMPARISON_TYPES:
raise ValueError(f"设置的版本比对模式 {comparison} 不是有效的模式!")
source_parts = _normalize_version(source)
target_parts = _normalize_version(target)
max_length = max(len(source_parts), len(target_parts))
source_parts += [0] * (max_length - len(source_parts))
target_parts += [0] * (max_length - len(target_parts))
relation = "等于"
for source_value, target_value in zip(source_parts, target_parts):
if source_value > target_value:
relation = "大于"
break
if source_value < target_value:
relation = "小于"
break
matched = {
"eq": relation == "等于",
"==": relation == "等于",
"ge": relation in {"大于", "等于"},
">=": relation in {"大于", "等于"},
"gt": relation == "大于",
">": relation == "大于",
"le": relation in {"小于", "等于"},
"<=": relation in {"小于", "等于"},
"lt": relation == "小于",
"<": relation == "小于",
}[comparison]
display_relation = (
"不等于" if comparison in {"eq", "=="} and not matched else relation
)
message = f"版本号 {source} {display_relation} 目标版本号 {target} "
return (matched, message) if verbose else matched
except Exception as err:
return (None, err) if verbose else None
+10
View File
@@ -0,0 +1,10 @@
"""中文简繁转换工具。"""
from zhconv_rs import zhconv as _zhconv # pylint: disable=no-name-in-module
def convert(text: str, target: str) -> str:
"""
使用 zhconv-rs 执行中文简繁转换,并隔离第三方包的函数名差异。
"""
return _zhconv(text, target)