diff --git a/app/foundation/reflection.py b/app/foundation/reflection.py index 56ead5b0e..9bdd8f7f3 100644 --- a/app/foundation/reflection.py +++ b/app/foundation/reflection.py @@ -4,14 +4,48 @@ import importlib import inspect import pkgutil import textwrap +import threading +import tokenize +import weakref from pathlib import Path -from types import FunctionType -from typing import Any, Callable, List, get_type_hints +from types import CodeType, FunctionType +from typing import Any, Callable, List, Optional, get_type_hints FilterFuncType = Callable[[str, Any], bool] +# check_method 结果缓存:弱引用代码对象,模块或插件卸载后自动清理 +_implemented_cache: "weakref.WeakKeyDictionary[CodeType, bool]" = weakref.WeakKeyDictionary() +_implemented_cache_lock = threading.Lock() + + +def _read_function_source(code: CodeType) -> str: + """ + 读取函数定义源码。 + + 与 inspect.getsource 的定位方式一致,但直接读文件而不经 linecache: + getsource 会把整份源文件留在 linecache 中常驻,所有模块和插件累计约 10MB。 + """ + with tokenize.open(code.co_filename) as fh: + lines = fh.readlines() + return "".join(inspect.getblock(lines[code.co_firstlineno - 1:])) + + +def _is_placeholder_stmt(stmt: ast.stmt) -> bool: + """pass、docstring、... 与 raise NotImplementedError 视为未实现的占位语句。""" + if isinstance(stmt, ast.Pass): + return True + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant): + return isinstance(stmt.value.value, str) or stmt.value.value is Ellipsis + if isinstance(stmt, ast.Raise): + exc = stmt.exc + if isinstance(exc, ast.Call): + exc = exc.func + return isinstance(exc, ast.Name) and exc.id == "NotImplementedError" + return False + + def _default_filter(name: str, obj: Any) -> bool: """接受具有名称和值的动态加载对象。""" return bool(name and obj) @@ -151,35 +185,32 @@ class ObjectUtils: def check_method(func: Callable[..., Any]) -> bool: """ 检查函数是否已实现 + + 模块链每次分发都会调用,结果按代码对象缓存;插件热重载生成新代码对象后自然重新判定, + 旧代码对象回收时缓存项随之释放。 """ + target = inspect.unwrap(func) + code = getattr(getattr(target, "__func__", target), "__code__", None) + if code is None: + return ObjectUtils._check_method_uncached(func, None) + cached = _implemented_cache.get(code) + if cached is None: + cached = ObjectUtils._check_method_uncached(func, code) + with _implemented_cache_lock: + _implemented_cache[code] = cached + return cached + + @staticmethod + def _check_method_uncached(func: Callable[..., Any], code: Optional[CodeType]) -> bool: + """按源码语法判断函数体是否只有占位语句,源码不可得时退回字节码判断。""" try: - src = inspect.getsource(func) - tree = ast.parse(textwrap.dedent(src)) + if code is None: + raise OSError("callable without code object") + tree = ast.parse(textwrap.dedent(_read_function_source(code))) 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 + return not all(_is_placeholder_stmt(stmt) for stmt in node.body) except Exception: # 源代码分析失败时,进行字节码分析 code_obj = func.__code__ # type: ignore[attr-defined] diff --git a/docker/nginx.template.conf b/docker/nginx.template.conf index 20da300ad..f3a62a7f2 100644 --- a/docker/nginx.template.conf +++ b/docker/nginx.template.conf @@ -1,5 +1,6 @@ user moviepilot; -worker_processes auto; +# 只代理单用户 WebUI 与 API,固定 2 个 worker 足够,auto 会按 CPU 核数起进程,多核 NAS 上白占内存 +worker_processes 2; pid /var/run/nginx.pid; worker_cpu_affinity auto; diff --git a/tests/test_object.py b/tests/test_object.py index 2802fe317..ec3210f3a 100644 --- a/tests/test_object.py +++ b/tests/test_object.py @@ -1,41 +1,101 @@ -from unittest import TestCase +import functools +import linecache +from app.foundation import reflection from app.foundation.reflection import ObjectUtils -class ObjectUtilsTest(TestCase): +def test_check_method(): + def implemented_function(): + return "Hello" - def test_check_method(self): - def implemented_function(): - return "Hello" + def pass_function(): + pass + def docstring_function(): + """This is a docstring.""" + + def ellipsis_function(): + ... + + def not_implemented_function(): + raise NotImplementedError + + def not_implemented_function_with_call(): + raise NotImplementedError() + + async def multiple_lines_async_def(_param1: str, + _param2: str): + pass + + def empty_function(): + return + + assert ObjectUtils.check_method(implemented_function) + assert not ObjectUtils.check_method(pass_function) + assert not ObjectUtils.check_method(docstring_function) + assert not ObjectUtils.check_method(ellipsis_function) + assert not ObjectUtils.check_method(not_implemented_function) + assert not ObjectUtils.check_method(not_implemented_function_with_call) + assert not ObjectUtils.check_method(multiple_lines_async_def) + assert ObjectUtils.check_method(empty_function) + + +class _Module: + """模拟模块类:占位方法与已实现方法并存,并经装饰器包装。""" + + def placeholder(self): + """未实现""" + + @staticmethod + def _wrap(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + @_wrap + def decorated_placeholder(self): + pass + + def implemented(self): + return 1 + + +def test_check_method_handles_bound_and_decorated_methods(): + module = _Module() + + assert not ObjectUtils.check_method(module.placeholder) + assert not ObjectUtils.check_method(module.decorated_placeholder) + assert ObjectUtils.check_method(module.implemented) + + +def test_check_method_does_not_leave_source_in_linecache(): + linecache.clearcache() + + def pass_function(): + pass + + assert not ObjectUtils.check_method(pass_function) + assert __file__ not in linecache.cache + + +def test_check_method_caches_result_per_code_object(monkeypatch): + calls = [] + original = reflection._read_function_source + monkeypatch.setattr( + reflection, "_read_function_source", lambda code: calls.append(code) or original(code) + ) + + def make(): def pass_function(): pass - def docstring_function(): - """This is a docstring.""" + return pass_function - def ellipsis_function(): - ... - - def not_implemented_function(): - raise NotImplementedError - - def not_implemented_function_with_call(): - raise NotImplementedError() - - async def multiple_lines_async_def(_param1: str, - _param2: str): - pass - - def empty_function(): - return - - self.assertTrue(ObjectUtils.check_method(implemented_function)) - self.assertFalse(ObjectUtils.check_method(pass_function)) - self.assertFalse(ObjectUtils.check_method(docstring_function)) - self.assertFalse(ObjectUtils.check_method(ellipsis_function)) - self.assertFalse(ObjectUtils.check_method(not_implemented_function)) - self.assertFalse(ObjectUtils.check_method(not_implemented_function_with_call)) - self.assertFalse(ObjectUtils.check_method(multiple_lines_async_def)) - self.assertTrue(ObjectUtils.check_method(empty_function)) + # 两个闭包共享同一代码对象,只读取一次源码 + first, second = make(), make() + assert not ObjectUtils.check_method(first) + assert not ObjectUtils.check_method(second) + assert calls == [first.__code__]